diff --git a/CADability.Forms/GdiFontProvider.cs b/CADability.Forms/GdiFontProvider.cs new file mode 100644 index 00000000..846e3d9e --- /dev/null +++ b/CADability.Forms/GdiFontProvider.cs @@ -0,0 +1,225 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Linq; +using System.Runtime.InteropServices; + +namespace CADability.Forms +{ + /// + /// A that gets the fonts from GDI and GDI+, as CADability did before issue #412: the glyph + /// outlines from , the widths and + /// the kerning from gdi32. It knows every font Windows knows (also OpenType fonts with CFF outlines) and synthesizes bold + /// and italic faces a family does not have. Set to an instance of this class to use it; + /// the default is the . + /// + public class GdiFontProvider : FontProvider + { + private readonly Dictionary<(string family, bool bold, bool italic), GdiFontFace> faces = new Dictionary<(string, bool, bool), GdiFontFace>(); + private HashSet familyNames; // upper case + + /// + public override string[] GetFamilyNames() + { + return FontFamily.Families.Select(f => f.Name).ToArray(); + } + /// + public override FontFace GetFace(string familyName, bool bold, bool italic) + { + if (string.IsNullOrEmpty(familyName)) return null; + lock (faces) + { + if (familyNames == null) familyNames = new HashSet(GetFamilyNames().Select(n => n.ToUpperInvariant())); + if (!familyNames.Contains(familyName.ToUpperInvariant())) return null; + if (!faces.TryGetValue((familyName.ToUpperInvariant(), bold, italic), out GdiFontFace face)) + { + face = new GdiFontFace(new FontFamily(familyName), bold, italic); + faces[(familyName.ToUpperInvariant(), bold, italic)] = face; + } + return face; + } + } + /// + /// The generic sans serif family of GDI+, as before issue #412. + /// + public override FontFace GetDefaultFace(bool bold, bool italic) + { + using (FontFamily sansSerif = new FontFamily(System.Drawing.Text.GenericFontFamilies.SansSerif)) + { + return GetFace(sansSerif.Name, bold, italic); + } + } + } + + /// + /// A face of the . + /// + internal sealed class GdiFontFace : FontFace + { + private readonly FontFamily family; + private readonly FontStyle style; // bold and italic only + private readonly int em; + private readonly Dictionary widths = new Dictionary(); + private Dictionary<(int, int), double> kerning; + + public GdiFontFace(FontFamily family, bool bold, bool italic) + { + this.family = family; + FontStyle requested = (bold ? FontStyle.Bold : FontStyle.Regular) | (italic ? FontStyle.Italic : FontStyle.Regular); + style = requested; + if (!family.IsStyleAvailable(style)) + { + if (family.IsStyleAvailable(FontStyle.Regular)) style = FontStyle.Regular; + else if (family.IsStyleAvailable(FontStyle.Bold)) style = FontStyle.Bold; + else if (family.IsStyleAvailable(FontStyle.Italic)) style = FontStyle.Italic; + } + em = family.GetEmHeight(style); + if (em == 0) em = 1000; + } + public override string FamilyName => family.Name; + public override bool IsBold => (style & FontStyle.Bold) != 0; + public override bool IsItalic => (style & FontStyle.Italic) != 0; + public override int EmHeight => em; + public override int CellAscent => family.GetCellAscent(style); + public override int CellDescent => family.GetCellDescent(style); + public override int LineSpacing => family.GetLineSpacing(style); + + public override double GetAdvanceWidth(int codePoint) + { + lock (widths) + { + if (widths.TryGetValue(codePoint, out double width)) return width; + WithSelectedFont(hdc => + { + if (codePoint <= 0xFFFF) + { + ABC[] abc = new ABC[1]; + GetCharABCWidths(hdc, (uint)codePoint, (uint)codePoint, abc); + width = (abc[0].abcA + abc[0].abcB + abc[0].abcC) / (double)em; + } + else + { // GetCharABCWidths only takes UTF-16 chars: measure the surrogate pair as a string + string s = char.ConvertFromUtf32(codePoint); + GetTextExtentPoint32(hdc, s, s.Length, out SIZE size); + width = size.cx / (double)em; + } + }); + widths[codePoint] = width; + return width; + } + } + public override double GetKerning(int left, int right) + { + lock (widths) + { + if (kerning == null) + { + Dictionary<(int, int), double> pairs = new Dictionary<(int, int), double>(); + WithSelectedFont(hdc => + { + int num = GetKerningPairs(hdc, 0, null); + if (num > 0) + { + KERNINGPAIR[] kp = new KERNINGPAIR[num]; + GetKerningPairs(hdc, num, kp); + for (int i = 0; i < kp.Length; ++i) pairs[((ushort)kp[i].wFirst, (ushort)kp[i].wSecond)] = kp[i].iKernAmount / (double)em; + } + }); + kerning = pairs; + } + kerning.TryGetValue((left, right), out double res); + return res; + } + } + public override void GetGlyphPath(int codePoint, bool underline, bool strikeout, out PointF[] points, out byte[] types) + { + FontStyle fs = style | (underline ? FontStyle.Underline : FontStyle.Regular) | (strikeout ? FontStyle.Strikeout : FontStyle.Regular); + using (GraphicsPath path = new GraphicsPath()) + using (StringFormat sf = (StringFormat)StringFormat.GenericTypographic.Clone()) + { + sf.LineAlignment = StringAlignment.Near; + sf.Alignment = StringAlignment.Near; + path.AddString(AsString(codePoint), family, (int)fs, 1.0f, new PointF(0.0f, 0.0f), sf); + if (path.PointCount == 0) + { + points = new PointF[0]; + types = new byte[0]; + } + else + { + points = path.PathPoints; + types = path.PathTypes; + } + } + } + + // a lone surrogate (not a valid code point) is passed on as the char it is + private static string AsString(int codePoint) => codePoint >= 0xD800 && codePoint <= 0xDFFF ? ((char)codePoint).ToString() : char.ConvertFromUtf32(codePoint); + + /// + /// Selects this face with the em height as pixel size into a device context for the duration of . + /// The device context only lives during the call, so nothing is left to a finalizer. + /// + private void WithSelectedFont(Action action) + { + IntPtr hdc = CreateCompatibleDC(IntPtr.Zero); + try + { + using (Font font = new Font(family, em, style, GraphicsUnit.Pixel)) + { + IntPtr hfont = font.ToHfont(); + IntPtr oldFont = SelectObject(hdc, hfont); + try + { + action(hdc); + } + finally + { + SelectObject(hdc, oldFont); + DeleteObject(hfont); + } + } + } + finally + { + DeleteDC(hdc); + } + } + + [StructLayout(LayoutKind.Sequential)] + private struct ABC + { + public int abcA; + public uint abcB; + public int abcC; + } + [StructLayout(LayoutKind.Sequential)] + private struct KERNINGPAIR + { + public short wFirst; + public short wSecond; + public int iKernAmount; + } + [DllImport("gdi32.dll", EntryPoint = "GetCharABCWidthsW")] + private static extern bool GetCharABCWidths(IntPtr hdc, uint uFirstChar, uint uLastChar, [Out] ABC[] lpabc); + [StructLayout(LayoutKind.Sequential)] + private struct SIZE + { + public int cx; + public int cy; + } + [DllImport("gdi32.dll", EntryPoint = "GetTextExtentPoint32W", CharSet = CharSet.Unicode)] + private static extern bool GetTextExtentPoint32(IntPtr hdc, string lpString, int c, out SIZE psizl); + [DllImport("gdi32.dll", EntryPoint = "GetKerningPairsW")] + private static extern int GetKerningPairs(IntPtr hdc, int cPairs, [Out] KERNINGPAIR[] lpkrnpair); + [DllImport("gdi32.dll")] + private static extern IntPtr CreateCompatibleDC(IntPtr hdc); + [DllImport("gdi32.dll")] + private static extern bool DeleteDC(IntPtr hdc); + [DllImport("gdi32.dll")] + private static extern IntPtr SelectObject(IntPtr hdc, IntPtr hgdiobj); + [DllImport("gdi32.dll")] + private static extern bool DeleteObject(IntPtr hObject); + } +} diff --git a/CADability/DimensionStyle.cs b/CADability/DimensionStyle.cs index a0d7e421..41fd15c1 100644 --- a/CADability/DimensionStyle.cs +++ b/CADability/DimensionStyle.cs @@ -1,6 +1,7 @@ using CADability.GeoObject; using CADability.UserInterface; using System; +using System.Linq; using System.Runtime.Serialization; namespace CADability.Attribute @@ -2056,9 +2057,7 @@ public override IPropertyEntry[] SubItems options.AddSubEntry(new CheckProperty(this, "DimRadiusBend", "DimensionStyle.DimRadiusBend")); ShowPropertyGroup font = new ShowPropertyGroup("DimensionStyle.Font"); - System.Drawing.FontFamily[] families = System.Drawing.FontFamily.Families; - string[] choices = new string[families.Length]; - for (int i = 0; i < families.Length; i++) choices[i] = families[i].Name; + string[] choices = FontProvider.Current.GetFamilyNames().OrderBy(n => n, StringComparer.CurrentCultureIgnoreCase).ToArray(); MultipleChoiceProperty multipropfont = new MultipleChoiceProperty("DimensionStyle.TextFont", choices, TextFont); multipropfont.ValueChangedEvent += new ValueChangedDelegate(FontValueChanged); font.AddSubEntry(multipropfont); diff --git a/CADability/FontProvider.cs b/CADability/FontProvider.cs new file mode 100644 index 00000000..e0c00708 --- /dev/null +++ b/CADability/FontProvider.cs @@ -0,0 +1,320 @@ +using CADability.GeoObject; +using System; +using System.Collections.Generic; +using System.Drawing; +using System.IO; +using System.Linq; +using System.Threading; +using Path = System.IO.Path; + +namespace CADability +{ + /// + /// Provides the fonts for and dimensions: the names of the font families, their metrics and the outlines + /// of the glyphs. The kernel gets all fonts from , which works without any user interface. The default + /// is a , which reads the TrueType font files of the system itself and therefore works on + /// every platform. CADability.Forms contains a provider based on GDI (GdiFontProvider) for applications that want the + /// fonts exactly as Windows renders them. + /// + public abstract class FontProvider + { + private static FontProvider current; + /// + /// The font provider the kernel uses. Setting it clears the cached glyphs, so that texts are rebuilt with the new + /// provider, like : set it before texts are painted, or rebuild the display lists of + /// the views afterwards (). + /// + public static FontProvider Current + { + get => LazyInitializer.EnsureInitialized(ref current, () => new ManagedFontProvider()); + set + { + current = value ?? throw new ArgumentNullException(nameof(value)); + FontCache.GlobalFontCache.Clear(); + Text.ResetFontFamilyNames(); + } + } + /// + /// The names of all font families this provider knows, e.g. for a list to choose from. + /// + public abstract string[] GetFamilyNames(); + /// + /// Returns the face of the font family (not case sensitive) that matches the requested + /// style best, or null if the provider does not know this family. + /// + /// The name of the font family, e.g. "Arial" + /// Bold requested + /// Italic requested + public abstract FontFace GetFace(string familyName, bool bold, bool italic); + /// + /// Returns the face to use for a family that does not know. The default implementation tries some + /// common sans serif fonts and then takes the first family. Returns null when there is no font at all. + /// + public virtual FontFace GetDefaultFace(bool bold, bool italic) + { + foreach (string name in new[] { "Microsoft Sans Serif", "Arial", "Liberation Sans", "DejaVu Sans", "Helvetica", "Noto Sans", "FreeSans" }) + { + FontFace face = GetFace(name, bold, italic); + if (face != null) return face; + } + string first = GetFamilyNames().OrderBy(n => n, StringComparer.OrdinalIgnoreCase).FirstOrDefault(); + return first == null ? null : GetFace(first, bold, italic); + } + } + + /// + /// A face of a font family (e.g. "Arial Bold") as a provides it. Metrics are in font units, + /// widths and kerning in units of the em height, and the glyph outlines are Bezier curves, so that all providers produce + /// the same exact geometry. + /// + public abstract class FontFace + { + /// + /// The name of the font family. + /// + public abstract string FamilyName { get; } + /// + /// True if this face is bold. + /// + public abstract bool IsBold { get; } + /// + /// True if this face is italic. + /// + public abstract bool IsItalic { get; } + /// + /// The height of the em square in font units (e.g. 2048 for most TrueType fonts). + /// + public abstract int EmHeight { get; } + /// + /// The ascent of the character cell in font units. + /// + public abstract int CellAscent { get; } + /// + /// The descent of the character cell in font units, positive downwards. + /// + public abstract int CellDescent { get; } + /// + /// The distance between the baselines of two lines in font units. + /// + public abstract int LineSpacing { get; } + /// + /// The advance width of the character in units of the em height. Characters are Unicode + /// code points, so that characters outside the Basic Multilingual Plane (e.g. most emoji), which take two UTF-16 + /// chars (a surrogate pair) in a string, are one character here. + /// + public abstract double GetAdvanceWidth(int codePoint); + /// + /// The kerning between two characters (Unicode code points) in units of the em height, usually negative; 0 if there + /// is none. + /// + public abstract double GetKerning(int left, int right); + /// + /// Returns the outline of the character (Unicode code point) in the layout of a GDI+ GraphicsPath after + /// AddString with an em size of 1 and StringFormat.GenericTypographic: in units of the em height, y + /// downwards, the top of the character cell at 0 and the baseline at /. + /// The point types are those of GDI+ (0 start, 1 line, 3 cubic Bezier, 0x80 closes the figure). Curves must be returned + /// as Bezier segments and not be flattened. Underline and strikeout are added as rectangles when requested. + /// + public abstract void GetGlyphPath(int codePoint, bool underline, bool strikeout, out PointF[] points, out byte[] types); + } + + /// + /// The default : reads TrueType font files (.ttf, .ttc) itself, so it needs neither GDI nor any + /// other part of the operating system and works on Windows, Linux, macOS and in the browser. By default it uses the font + /// directories of the system; more files or directories can be added. OpenType fonts with CFF outlines are not supported + /// and are left out. A bold or italic face that a family does not have is not synthesized: the closest face is used. + /// + public class ManagedFontProvider : FontProvider + { + private sealed class Entry + { + public string FileName; // or + public byte[] Data; + public int Index; // in a collection + public string Family; + public bool Bold, Italic; + public ManagedFontFace Face; // loaded on demand + } + private readonly object locker = new object(); + private readonly List directories = new List(); + private readonly List added = new List(); + private Dictionary> families; // upper case family name -> faces, built on demand + + /// + /// Creates a provider for the fonts installed on the system. + /// + public ManagedFontProvider() : this(null, true) + { + } + /// + /// Creates a provider for the fonts in (with their subdirectories) and, if + /// is true, for the fonts installed on the system. + /// + public ManagedFontProvider(IEnumerable fontDirectories, bool includeSystemFonts) + { + if (fontDirectories != null) directories.AddRange(fontDirectories); + if (includeSystemFonts) directories.AddRange(SystemFontDirectories()); + } + /// + /// The directories where the fonts of the system are installed on the current platform. + /// + public static IEnumerable SystemFontDirectories() + { + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string fonts = Environment.GetFolderPath(Environment.SpecialFolder.Fonts); // Windows + if (!string.IsNullOrEmpty(fonts)) yield return fonts; + string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (!string.IsNullOrEmpty(localAppData)) yield return Path.Combine(localAppData, "Microsoft", "Windows", "Fonts"); // fonts installed per user + yield return "/usr/share/fonts"; + yield return "/usr/local/share/fonts"; + yield return "/System/Library/Fonts"; + yield return "/Library/Fonts"; + if (!string.IsNullOrEmpty(home)) + { + yield return Path.Combine(home, ".fonts"); + yield return Path.Combine(home, ".local", "share", "fonts"); + yield return Path.Combine(home, "Library", "Fonts"); + } + } + /// + /// Adds the fonts of a font file (.ttf or .ttc). + /// + public void AddFontFile(string fileName) + { + TrueTypeFont[] fonts = TrueTypeFont.ReadHeaders(fileName); + lock (locker) + { + for (int i = 0; i < fonts.Length; ++i) AddEntry(new Entry { FileName = fileName, Index = i }, fonts[i], added); + families = null; + } + } + /// + /// Adds the fonts of a font file given as its content, e.g. an embedded resource. + /// + public void AddFontData(byte[] data) + { + TrueTypeFont[] fonts = TrueTypeFont.Load(data); + lock (locker) + { + for (int i = 0; i < fonts.Length; ++i) AddEntry(new Entry { Data = data, Index = i }, fonts[i], added); + families = null; + } + } + /// + public override string[] GetFamilyNames() + { + lock (locker) + { + return Families().Values.Select(l => l[0].Family).ToArray(); + } + } + /// + public override FontFace GetFace(string familyName, bool bold, bool italic) + { + if (string.IsNullOrEmpty(familyName)) return null; + Entry best; + lock (locker) + { + if (!Families().TryGetValue(familyName.ToUpperInvariant(), out List faces)) return null; + // the exact style, then the same weight, then regular, then any face + best = faces.FirstOrDefault(f => f.Bold == bold && f.Italic == italic) + ?? faces.FirstOrDefault(f => f.Bold == bold) + ?? faces.FirstOrDefault(f => !f.Bold && !f.Italic) + ?? faces[0]; + if (best.Face == null) + { + TrueTypeFont[] fonts = best.Data != null ? TrueTypeFont.Load(best.Data) : TrueTypeFont.Load(best.FileName); + best.Face = new ManagedFontFace(fonts[best.Index]); + } + } + return best.Face; + } + + private static void AddEntry(Entry entry, TrueTypeFont font, List list) + { + if (!font.HasTrueTypeOutlines || string.IsNullOrEmpty(font.FamilyName)) return; // CFF or bitmap fonts cannot be used + entry.Family = font.FamilyName; + entry.Bold = font.IsBold; + entry.Italic = font.IsItalic; + list.Add(entry); + } + private Dictionary> Families() + { + if (families != null) return families; + List all = new List(); + HashSet seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (string directory in directories) + { + if (!Directory.Exists(directory)) continue; + IEnumerable files; + try + { + files = Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories).ToArray(); + } + catch (Exception e) when (e is IOException || e is UnauthorizedAccessException) + { + continue; + } + foreach (string file in files) + { + string ext = Path.GetExtension(file).ToLowerInvariant(); + if (ext != ".ttf" && ext != ".ttc") continue; + if (!seen.Add(Path.GetFullPath(file))) continue; + try + { + TrueTypeFont[] fonts = TrueTypeFont.ReadHeaders(file); + for (int i = 0; i < fonts.Length; ++i) AddEntry(new Entry { FileName = file, Index = i }, fonts[i], all); + } + catch (Exception e) when (e is IOException || e is UnauthorizedAccessException || e is InvalidDataException || e is IndexOutOfRangeException || e is ArgumentException) + { // a broken or unreadable font file is left out + } + } + } + all.AddRange(added); + families = new Dictionary>(); + foreach (Entry e in all) + { + string key = e.Family.ToUpperInvariant(); + if (!families.TryGetValue(key, out List list)) families[key] = list = new List(); + if (!list.Any(f => f.Bold == e.Bold && f.Italic == e.Italic)) list.Add(e); // the first file of a style wins + } + return families; + } + } + + /// + /// A face read by the . + /// + internal sealed class ManagedFontFace : FontFace + { + private readonly TrueTypeFont font; + private readonly object locker = new object(); + public ManagedFontFace(TrueTypeFont font) + { + this.font = font; + } + internal TrueTypeFont Font => font; + public override string FamilyName => font.FamilyName; + public override bool IsBold => font.IsBold; + public override bool IsItalic => font.IsItalic; + public override int EmHeight => font.UnitsPerEm; + public override int CellAscent => font.CellAscent; + public override int CellDescent => font.CellDescent; + public override int LineSpacing => font.LineSpacing; + public override double GetAdvanceWidth(int codePoint) + { + return font.GetAdvanceWidth(font.GetGlyphIndex(codePoint)) / (double)font.UnitsPerEm; + } + public override double GetKerning(int left, int right) + { + lock (locker) + { // the kerning pairs are read on the first call + return font.GetKerning(font.GetGlyphIndex(left), font.GetGlyphIndex(right)) / (double)font.UnitsPerEm; + } + } + public override void GetGlyphPath(int codePoint, bool underline, bool strikeout, out PointF[] points, out byte[] types) + { + font.GetPathData(font.GetGlyphIndex(codePoint), underline, strikeout, out points, out types); + } + } +} diff --git a/CADability/Text.cs b/CADability/Text.cs index 1488360b..0e171b09 100644 --- a/CADability/Text.cs +++ b/CADability/Text.cs @@ -6,7 +6,6 @@ using System; using System.Collections.Generic; using System.Drawing; -using System.Drawing.Drawing2D; using System.Linq; using System.Runtime.Serialization; using System.Threading; @@ -36,15 +35,7 @@ public ShowPropertyFont(Text t) private MultipleChoiceProperty InitFontList() { - FontFamily[] families = FontFamily.Families; - string[] choices = new string[families.Length]; - for (int i = 0; i < families.Length; i++) - { - choices[i] = families[i].Name; - int emS = families[i].GetEmHeight(FontStyle.Regular); - int asc = families[i].GetCellAscent(FontStyle.Regular); - int desc = families[i].GetCellDescent(FontStyle.Regular); - } + string[] choices = FontProvider.Current.GetFamilyNames().OrderBy(n => n, StringComparer.CurrentCultureIgnoreCase).ToArray(); MultipleChoiceProperty res = new MultipleChoiceProperty("Text.Font", choices, text.Font); res.ValueChangedEvent += FontChanged; return res; @@ -404,9 +395,8 @@ string IGeoObjectShowProperty.GetContextMenuId() internal class FontCache { - // Holds CompoundShapes for all characters requested so far - // and a kerning table for all fonts used so far - // Maintains a DeviceContext throughout its lifetime, where only one font is switched. + // Holds CompoundShapes for all characters requested so far + // and the font faces used so far, which come from FontProvider.Current (issue #412: no GDI here). // The unit of the font is size 1.0, allowing easy work without scaling. // However, the faces are only generated when a string is created. // It might also be possible to keep a list of faces for each character in different sizes, @@ -418,8 +408,8 @@ struct DictKey { public readonly string fontName; public readonly int fontStyle; - public readonly char c; - public DictKey(string fontName, int fontStyle, char c) + public readonly int c; // a Unicode code point + public DictKey(string fontName, int fontStyle, int c) { this.fontName = fontName; this.fontStyle = fontStyle; @@ -427,7 +417,7 @@ public DictKey(string fontName, int fontStyle, char c) } public override int GetHashCode() { - return fontName.GetHashCode() ^ fontStyle ^ (int)c; + return fontName.GetHashCode() ^ fontStyle ^ c; } public override bool Equals(object obj) { @@ -462,28 +452,6 @@ public override bool Equals(object obj) return false; } } - struct KerningKey - { - public readonly string fontName; - public readonly int fontStyle; - public KerningKey(string fontName, int fontStyle) - { - this.fontName = fontName; - this.fontStyle = fontStyle; - } - public override int GetHashCode() - { - return fontName.GetHashCode() ^ fontStyle; - } - public override bool Equals(object obj) - { - if (obj is KerningKey other) - { - return other.fontName == fontName && other.fontStyle == fontStyle; - } - return false; - } - } struct DictVal { public CompoundShape shape; // hat die Splines statt der Polygone @@ -508,8 +476,7 @@ struct FontCharacteristics readonly Dictionary cache; // Cache von CompundShapes und Breiten für einzelne zeichen readonly Dictionary centerLineCache; // Cache für die CenterLines, Größe 1 readonly Dictionary fontCharacteristicsCache; // Cache für die FontCharacteristics - readonly Dictionary> kerning; // Kerningtabellen - readonly IntPtr hDC; // fester DeviceContext (für die ganze Lebensdauer) + readonly Dictionary faces; // the faces of FontProvider.Current, per font name and bold/italic public FontCache() { FontPrecision = Settings.GlobalSettings.GetIntValue("Font.Precision", 1); // |grob|mittel|fein @@ -517,76 +484,58 @@ public FontCache() cache = new Dictionary(); centerLineCache = new Dictionary(); fontCharacteristicsCache = new Dictionary(); - kerning = new Dictionary>(); - hDC = Gdi.CreateCompatibleDC(IntPtr.Zero); + faces = new Dictionary(); } - ~FontCache() + /// + /// Returns the face of for a font name and a font style (the flags of + /// System.Drawing.FontStyle: 1 bold, 2 italic, 4 underline, 8 strikeout). An unknown font name gives the default face of + /// the provider. + /// + /// when the provider has no font at all + internal FontFace GetFace(string fontName, int fontStyle) { - // the constructor throws where there is no GDI (e.g. on Linux), and an exception in a finalizer ends the process - if (hDC != IntPtr.Zero) Gdi.DeleteDC(hDC); + bool bold = (fontStyle & 1) != 0, italic = (fontStyle & 2) != 0; + FontKey key = new FontKey(fontName ?? "", fontStyle & 3); + lock (faces) + { + if (faces.TryGetValue(key, out FontFace face)) return face; + FontProvider provider = FontProvider.Current; + face = provider.GetFace(fontName, bold, italic) ?? provider.GetDefaultFace(bold, italic); + if (face == null) throw new InvalidOperationException("No font is available for texts. Install TrueType fonts, add font files to a ManagedFontProvider or set FontProvider.Current."); + faces[key] = face; + return face; + } } - // The caches and the device context are shared by all threads that measure or paint text, and Clear may empty the - // caches at any time: every access goes through lock (this). The lock is reentrant, Get and GetCenterLines call GetOutline2D. - public Path2D[] GetOutline2D(string fontName, int fontStyle, char c, out double width) + /// + /// Like , but returns null instead of throwing when there is no font at all: painting and the + /// extent of a text leave the text out then, instead of failing. + /// + internal FontFace TryGetFace(string fontName, int fontStyle) + { + try + { + return GetFace(fontName, fontStyle); + } + catch (InvalidOperationException) + { + return null; + } + } + // The caches are shared by all threads that measure or paint text, and Clear may empty them at any time: every access + // goes through lock (this). The lock is reentrant, Get and GetCenterLines call GetOutline2D. The faces are locked + // separately and always after this, so the two locks are taken in the same order everywhere. + public Path2D[] GetOutline2D(string fontName, int fontStyle, int c, out double width) { lock (this) { - return ReadOutline2D(fontName, fontStyle, c, out width); - } - } - private Path2D[] ReadOutline2D(string fontName, int fontStyle, char c, out double width) - { - using GraphicsPath path = new GraphicsPath(); - // FontFamily wraps an unmanaged GDI+ object: dispose it at the end of the block instead of - // leaving it to the finalizer (this code runs for every text on every repaint) - using FontFamily ff = Text.FontFamilyNames.Contains(fontName.ToUpper()) ? new FontFamily(fontName) : new FontFamily(System.Drawing.Text.GenericFontFamilies.SansSerif); - StringFormat sf = StringFormat.GenericTypographic.Clone() as StringFormat; - sf.LineAlignment = StringAlignment.Near; - sf.Alignment = StringAlignment.Near; - if (!ff.IsStyleAvailable((FontStyle)fontStyle)) - { - if (ff.IsStyleAvailable(FontStyle.Regular)) fontStyle = (int)FontStyle.Regular; - if (ff.IsStyleAvailable(FontStyle.Bold)) fontStyle = (int)FontStyle.Bold; - if (ff.IsStyleAvailable(FontStyle.Italic)) fontStyle = (int)FontStyle.Italic; - if (ff.IsStyleAvailable(FontStyle.Strikeout)) fontStyle = (int)FontStyle.Strikeout; - if (ff.IsStyleAvailable(FontStyle.Underline)) fontStyle = (int)FontStyle.Underline; - } - int fs = fontStyle; - int em = ff.GetEmHeight((FontStyle)fs); - if (em == 0) em = 1000; - using Font font = new Font(ff, em, (FontStyle)fs, GraphicsUnit.Pixel); - IntPtr hfont = font.ToHfont(); - IntPtr oldfont = Gdi.SelectObject(hDC, hfont); - Gdi.ABC[] abc = new Gdi.ABC[1]; - Gdi.GetCharABCWidths(hDC, (uint)c, (uint)c, abc); - //int[] widths = new int[1]; // liefert das selbe wie GetCharABCWidths, nur die Summe halt. - //Gdi.GetCharWidth32(hDC, (uint)c, (uint)c, widths); - if (!kerning.ContainsKey(new KerningKey(fontName, fontStyle))) - { - KerningKey kk = new KerningKey(fontName, fontStyle); - Dictionary<(char Left, char Right), double> pairs = new Dictionary<(char Left, char Right), double>(); - kerning[kk] = pairs; - int num = Gdi.GetKerningPairs(hDC, 0, null); - if (num > 0) - { - Gdi.KERNINGPAIR[] kp = new Gdi.KERNINGPAIR[num]; - int ok = Gdi.GetKerningPairs(hDC, num, kp); - for (int i = 0; i < kp.Length; ++i) - { - pairs[((char)kp[i].wFirst, (char)kp[i].wSecond)] = kp[i].iKernAmount / (double)em; - } - } + FontFace face = GetFace(fontName, fontStyle); + width = face.GetAdvanceWidth(c); + face.GetGlyphPath(c, (fontStyle & 4) != 0, (fontStyle & 8) != 0, out PointF[] points, out byte[] types); + if (points.Length == 0) return new Path2D[0]; + return GlyphShapeBuilder.PathDataToPath2D(points, types, FontPrecision); } - Gdi.SelectObject(hDC, oldfont); - Gdi.DeleteObject(hfont); - - width = (abc[0].abcA + abc[0].abcB + abc[0].abcC) / (double)em; - - path.AddString(c.ToString(), ff, fs, 1.0f, new PointF(0.0f, 0.0f), sf); - if (path.PointCount == 0) return new Path2D[0]; - return GlyphShapeBuilder.PathDataToPath2D(path.PathPoints, path.PathTypes, FontPrecision); } - public CompoundShape Get(string font, int fontStyle, char c, out double width) + public CompoundShape Get(string font, int fontStyle, int c, out double width) { // soll verschwinden!! DictVal found; lock (this) @@ -605,20 +554,14 @@ public CompoundShape Get(string font, int fontStyle, char c, out double width) width = found.width; return found.shape; } - public double GetKerning(string font, int fontStyle, char a, char b) + public double GetKerning(string font, int fontStyle, int a, int b) { - Dictionary<(char Left, char Right), double> pairs; - double res = 0.0; lock (this) { - if (kerning.TryGetValue(new KerningKey(font, fontStyle), out pairs)) - { - pairs.TryGetValue((a, b), out res); - } + return GetFace(font, fontStyle).GetKerning(a, b); } - return res; } - public IPaintTo3DList Get(string font, int fontStyle, char c, out double width, IPaintTo3D paintTo3D) + public IPaintTo3DList Get(string font, int fontStyle, int c, out double width, IPaintTo3D paintTo3D) { bool useLists = true; if (paintTo3D != null) useLists = !paintTo3D.IsBitmap; @@ -811,17 +754,17 @@ public void Clear() cache.Clear(); centerLineCache.Clear(); fontCharacteristicsCache.Clear(); - kerning.Clear(); + lock (faces) faces.Clear(); } } - public GeoObjectList GetCenterLines(string font, int fontStyle, char c, out double width) + public GeoObjectList GetCenterLines(string font, int fontStyle, int c, out double width) { lock (this) { return ReadCenterLines(font, fontStyle, c, out width); } } - private GeoObjectList ReadCenterLines(string font, int fontStyle, char c, out double width) + private GeoObjectList ReadCenterLines(string font, int fontStyle, int c, out double width) { CenterLineVal found; if (!centerLineCache.TryGetValue(new DictKey(font, fontStyle, c), out found)) @@ -996,9 +939,16 @@ public enum LineAlignMode private LineAlignMode lineAlignment; // private IGeoObject[] cachedDisplayItems; private static HashSet fontFamilyNames; + /// + /// The upper case names of the font families of . + /// internal static HashSet FontFamilyNames => LazyInitializer.EnsureInitialized(ref fontFamilyNames, () => - new HashSet(FontFamily.Families.Select(f => f.Name.ToUpper()))); + new HashSet(FontProvider.Current.GetFamilyNames().Select(f => f.ToUpper()))); + internal static void ResetFontFamilyNames() + { + fontFamilyNames = null; + } #region polymorph construction /// @@ -1057,37 +1007,29 @@ private void calcExtent() { try { - // FontFamily wraps an unmanaged GDI+ object: dispose it at the end of the block instead of - // leaving it to the finalizer (this code runs for every text on every repaint) - using FontFamily ff = FontFamilyNames.Contains(fontName.ToUpper()) ? new FontFamily(fontName) : new FontFamily(System.Drawing.Text.GenericFontFamilies.SansSerif); - FontStyle fs = fontStyle; - if (!ff.IsStyleAvailable(fs)) - { - if (ff.IsStyleAvailable(FontStyle.Regular)) fs = FontStyle.Regular; - else if (ff.IsStyleAvailable(FontStyle.Bold)) fs = FontStyle.Bold; - else if (ff.IsStyleAvailable(FontStyle.Italic)) fs = FontStyle.Italic; - else if (ff.IsStyleAvailable(FontStyle.Strikeout)) fs = FontStyle.Strikeout; - else if (ff.IsStyleAvailable(FontStyle.Underline)) fs = FontStyle.Underline; // irgend einen muss es ja wohl geben, oder? - } - int em = ff.GetEmHeight(fs); - int asc = ff.GetCellAscent(fs); - int desc = ff.GetCellDescent(fs); - int lsp = ff.GetLineSpacing(fs); - double dbg = (double)(asc + desc) / (double)lsp; + int fs = (int)fontStyle; + FontFace ff = FontCache.GlobalFontCache.GetFace(fontName, fs); // FontProvider.Current, see issue #412 + int em = ff.EmHeight; + int asc = ff.CellAscent; + int desc = ff.CellDescent; + int lsp = ff.LineSpacing; // asc+desc kann mehr als em sein. ich nehme jedoch an, dass em für die Größe ausschlaggebend ist // wenn man em als 1 annimmt, dann muss man um desc/em nach unten verschieben um auf die Grundlinie zu kommen emSizeFactor = (double)(asc + desc) / (double)em; emAscDescFactor = (double)(desc) / (double)asc; emDescDiff = (double)(em - asc) / (double)em; this.desc = (double)(desc) / (double)em; - // the bitmap only serves as a device for measuring; it must be disposed as well, otherwise - // 400 KB of unmanaged GDI+ memory stay alive per measured text until the finalizer runs - using (Bitmap measureBitmap = new Bitmap(1000, 100)) - using (Graphics graphics = Graphics.FromImage(measureBitmap)) - using (Font font = new Font(fontName, (float)(glyphDirection.Length), GraphicsUnit.Pixel)) + // the size of the text string with the font size glyphDirection.Length: the advance widths and the kerning + // of the characters and the line spacing (this used to be Graphics.MeasureString of GDI+) + double width = 0.0; + int[] chars = CodePoints(textString); + for (int i = 0; i < chars.Length; ++i) { - sizeExtent = graphics.MeasureString(textString, font); + width += ff.GetAdvanceWidth(chars[i]); + if (i > 0) width += ff.GetKerning(chars[i - 1], chars[i]); } + double size = glyphDirection.Length; + sizeExtent = new SizeF((float)(width * size), (float)(size * lsp / em)); isValidExtent = true; } catch (Exception e) @@ -1223,6 +1165,24 @@ private void AddToPath2D(List addto, List points, bool spl } points.RemoveRange(0, points.Count - 1); // den letzten als ersten drinlassen } + /// + /// The characters of as Unicode code points: a surrogate pair (e.g. an emoji) is one character. + /// A lone surrogate is kept as the char it is. + /// + internal static int[] CodePoints(string s) + { + List res = new List(s.Length); + for (int i = 0; i < s.Length; ++i) + { + if (char.IsHighSurrogate(s[i]) && i + 1 < s.Length && char.IsLowSurrogate(s[i + 1])) + { + res.Add(char.ConvertToUtf32(s[i], s[i + 1])); + ++i; + } + else res.Add(s[i]); + } + return res.ToArray(); + } // Returns the string to display: textString, with additional line breaks inserted at word // boundaries when ColumnWidth demands wrapping. Character widths are measured with the same // FontCache the layout loops use, so the wrapped lines fit the column exactly. @@ -1255,9 +1215,10 @@ private string GetDisplayString(int fs) } private void AppendWrappedParagraph(System.Text.StringBuilder result, string paragraph, double normalizedWidth, int fs) { - double MeasureWord(string w) + double MeasureWord(string word) { double total = 0.0; + int[] w = CodePoints(word); for (int i = 0; i < w.Length; i++) { FontCache.GlobalFontCache.Get(fontName, fs, w[i], out double cw, null); @@ -1306,22 +1267,12 @@ public virtual GeoObjectList GetOutline() if (Bold) fs |= (int)FontStyle.Bold; if (Strikeout) fs |= (int)FontStyle.Strikeout; PlaneSurface pls = new PlaneSurface(location, lineDirection, glyphDirection, lineDirection ^ glyphDirection); - // FontFamily wraps an unmanaged GDI+ object: dispose it at the end of the block instead of - // leaving it to the finalizer (this code runs for every text on every repaint) - using FontFamily ff = FontFamilyNames.Contains(fontName.ToUpper()) ? new FontFamily(fontName) : new FontFamily(System.Drawing.Text.GenericFontFamilies.SansSerif); - if (!ff.IsStyleAvailable((FontStyle)fs)) - { - if (ff.IsStyleAvailable(FontStyle.Regular)) fs = (int)FontStyle.Regular; - else if (ff.IsStyleAvailable(FontStyle.Bold)) fs = (int)FontStyle.Bold; - else if (ff.IsStyleAvailable(FontStyle.Italic)) fs = (int)FontStyle.Italic; - else if (ff.IsStyleAvailable(FontStyle.Strikeout)) fs = (int)FontStyle.Strikeout; - else if (ff.IsStyleAvailable(FontStyle.Underline)) fs = (int)FontStyle.Underline; // irgend einen muss es ja wohl geben, oder? - } - string txt = GetDisplayString(fs); - int em = ff.GetEmHeight((FontStyle)fs); - int ls = ff.GetLineSpacing((FontStyle)fs); - int dc = ff.GetCellDescent((FontStyle)fs); - int ac = ff.GetCellAscent((FontStyle)fs); + FontFace ff = FontCache.GlobalFontCache.GetFace(fontName, fs); // FontProvider.Current, see issue #412 + int[] txt = CodePoints(GetDisplayString(fs)); + int em = ff.EmHeight; + int ls = ff.LineSpacing; + int dc = ff.CellDescent; + int ac = ff.CellAscent; double lsp = ls / em; if (lineSpacing > 0.0) lsp = lineSpacing; // explicit baseline distance as factor of the text size double dx = 0.0; @@ -1463,22 +1414,12 @@ public CompoundShape[] GetShapes() if (Bold) fs |= (int)FontStyle.Bold; if (Strikeout) fs |= (int)FontStyle.Strikeout; PlaneSurface pls = new PlaneSurface(location, lineDirection, glyphDirection, lineDirection ^ glyphDirection); - // FontFamily wraps an unmanaged GDI+ object: dispose it at the end of the block instead of - // leaving it to the finalizer (this code runs for every text on every repaint) - using FontFamily ff = FontFamilyNames.Contains(fontName.ToUpper()) ? new FontFamily(fontName) : new FontFamily(System.Drawing.Text.GenericFontFamilies.SansSerif); - if (!ff.IsStyleAvailable((FontStyle)fs)) - { - if (ff.IsStyleAvailable(FontStyle.Regular)) fs = (int)FontStyle.Regular; - else if (ff.IsStyleAvailable(FontStyle.Bold)) fs = (int)FontStyle.Bold; - else if (ff.IsStyleAvailable(FontStyle.Italic)) fs = (int)FontStyle.Italic; - else if (ff.IsStyleAvailable(FontStyle.Strikeout)) fs = (int)FontStyle.Strikeout; - else if (ff.IsStyleAvailable(FontStyle.Underline)) fs = (int)FontStyle.Underline; // irgend einen muss es ja wohl geben, oder? - } - string txt = GetDisplayString(fs); - int em = ff.GetEmHeight((FontStyle)fs); - int ls = ff.GetLineSpacing((FontStyle)fs); - int dc = ff.GetCellDescent((FontStyle)fs); - int ac = ff.GetCellAscent((FontStyle)fs); + FontFace ff = FontCache.GlobalFontCache.GetFace(fontName, fs); // FontProvider.Current, see issue #412 + int[] txt = CodePoints(GetDisplayString(fs)); + int em = ff.EmHeight; + int ls = ff.LineSpacing; + int dc = ff.CellDescent; + int ac = ff.CellAscent; double lsp = ls / em; if (lineSpacing > 0.0) lsp = lineSpacing; // explicit baseline distance as factor of the text size double dx = 0.0; @@ -1606,22 +1547,12 @@ public virtual GeoObjectList GetCenterline() if (Bold) fs |= (int)FontStyle.Bold; if (Strikeout) fs |= (int)FontStyle.Strikeout; PlaneSurface pls = new PlaneSurface(location, lineDirection, glyphDirection, lineDirection ^ glyphDirection); - // FontFamily wraps an unmanaged GDI+ object: dispose it at the end of the block instead of - // leaving it to the finalizer (this code runs for every text on every repaint) - using FontFamily ff = FontFamilyNames.Contains(fontName.ToUpper()) ? new FontFamily(fontName) : new FontFamily(System.Drawing.Text.GenericFontFamilies.SansSerif); - if (!ff.IsStyleAvailable((FontStyle)fs)) - { - if (ff.IsStyleAvailable(FontStyle.Regular)) fs = (int)FontStyle.Regular; - else if (ff.IsStyleAvailable(FontStyle.Bold)) fs = (int)FontStyle.Bold; - else if (ff.IsStyleAvailable(FontStyle.Italic)) fs = (int)FontStyle.Italic; - else if (ff.IsStyleAvailable(FontStyle.Strikeout)) fs = (int)FontStyle.Strikeout; - else if (ff.IsStyleAvailable(FontStyle.Underline)) fs = (int)FontStyle.Underline; // irgend einen muss es ja wohl geben, oder? - } - string txt = GetDisplayString(fs); - int em = ff.GetEmHeight((FontStyle)fs); - int ls = ff.GetLineSpacing((FontStyle)fs); - int dc = ff.GetCellDescent((FontStyle)fs); - int ac = ff.GetCellAscent((FontStyle)fs); + FontFace ff = FontCache.GlobalFontCache.GetFace(fontName, fs); // FontProvider.Current, see issue #412 + int[] txt = CodePoints(GetDisplayString(fs)); + int em = ff.EmHeight; + int ls = ff.LineSpacing; + int dc = ff.CellDescent; + int ac = ff.CellAscent; double lsp = ls / em; if (lineSpacing > 0.0) lsp = lineSpacing; // explicit baseline distance as factor of the text size double dx = 0.0; @@ -2010,22 +1941,13 @@ private void CalcExtent() { // bei sehr kleinen lineDirection u.s.w. gibts hier ein Problem pls = new PlaneSurface(location, GeoVector.XAxis, GeoVector.YAxis, GeoVector.ZAxis); } - // FontFamily wraps an unmanaged GDI+ object: dispose it at the end of the block instead of - // leaving it to the finalizer (this code runs for every text on every repaint) - using FontFamily ff = FontFamilyNames.Contains(fontName.ToUpper()) ? new FontFamily(fontName) : new FontFamily(System.Drawing.Text.GenericFontFamilies.SansSerif); - if (!ff.IsStyleAvailable((FontStyle)fs)) - { - if (ff.IsStyleAvailable(FontStyle.Regular)) fs = (int)FontStyle.Regular; - else if (ff.IsStyleAvailable(FontStyle.Bold)) fs = (int)FontStyle.Bold; - else if (ff.IsStyleAvailable(FontStyle.Italic)) fs = (int)FontStyle.Italic; - else if (ff.IsStyleAvailable(FontStyle.Strikeout)) fs = (int)FontStyle.Strikeout; - else if (ff.IsStyleAvailable(FontStyle.Underline)) fs = (int)FontStyle.Underline; // irgend einen muss es ja wohl geben, oder? - } - string txt = GetDisplayString(fs); - int em = ff.GetEmHeight((FontStyle)fs); - int ls = ff.GetLineSpacing((FontStyle)fs); - int dc = ff.GetCellDescent((FontStyle)fs); - int ac = ff.GetCellAscent((FontStyle)fs); + FontFace ff = FontCache.GlobalFontCache.TryGetFace(fontName, fs); // FontProvider.Current, see issue #412 + if (ff == null) return; // no font at all: nothing to paint or measure + int[] txt = CodePoints(GetDisplayString(fs)); + int em = ff.EmHeight; + int ls = ff.LineSpacing; + int dc = ff.CellDescent; + int ac = ff.CellAscent; double lsp; if (em > 0) lsp = (double)ls / em; else lsp = 1.0; @@ -2650,25 +2572,15 @@ public override void PrePaintTo3D(IPaintTo3D paintTo3D) if (Strikeout) fs |= (int)FontStyle.Strikeout; List res = new List(); PlaneSurface pls = new PlaneSurface(location, lineDirection, glyphDirection, lineDirection ^ glyphDirection); - // FontFamily wraps an unmanaged GDI+ object: dispose it at the end of the block instead of - // leaving it to the finalizer (this code runs for every text on every repaint) - using FontFamily ff = FontFamilyNames.Contains(fontName.ToUpper()) ? new FontFamily(fontName) : new FontFamily(System.Drawing.Text.GenericFontFamilies.SansSerif); - if (!ff.IsStyleAvailable((FontStyle)fs)) - { - if (ff.IsStyleAvailable(FontStyle.Regular)) fs = (int)FontStyle.Regular; - else if (ff.IsStyleAvailable(FontStyle.Bold)) fs = (int)FontStyle.Bold; - else if (ff.IsStyleAvailable(FontStyle.Italic)) fs = (int)FontStyle.Italic; - else if (ff.IsStyleAvailable(FontStyle.Strikeout)) fs = (int)FontStyle.Strikeout; - else if (ff.IsStyleAvailable(FontStyle.Underline)) fs = (int)FontStyle.Underline; // there must be one - } - int em = ff.GetEmHeight((FontStyle)fs); - int ls = ff.GetLineSpacing((FontStyle)fs); - int dc = ff.GetCellDescent((FontStyle)fs); - int ac = ff.GetCellAscent((FontStyle)fs); - for (int i = 0; i < textString.Length; ++i) + FontFace ff = FontCache.GlobalFontCache.TryGetFace(fontName, fs); // FontProvider.Current, see issue #412 + if (ff == null) return; // no font at all: nothing to paint or measure + int em = ff.EmHeight; + int ls = ff.LineSpacing; + int dc = ff.CellDescent; + int ac = ff.CellAscent; + foreach (int c in CodePoints(textString)) { - double width; - FontCache.GlobalFontCache.Get(fontName, fs, textString[i], out width, paintTo3D); + FontCache.GlobalFontCache.Get(fontName, fs, c, out double width, paintTo3D); } } else @@ -2719,22 +2631,13 @@ public override void PaintTo3D(IPaintTo3D paintTo3D) if (Strikeout) fs |= (int)FontStyle.Strikeout; List res = new List(); PlaneSurface pls = new PlaneSurface(location, lineDirection, glyphDirection, lineDirection ^ glyphDirection); - // FontFamily wraps an unmanaged GDI+ object: dispose it at the end of the block instead of - // leaving it to the finalizer (this code runs for every text on every repaint) - using FontFamily ff = FontFamilyNames.Contains(fontName.ToUpper()) ? new FontFamily(fontName) : new FontFamily(System.Drawing.Text.GenericFontFamilies.SansSerif); - if (!ff.IsStyleAvailable((FontStyle)fs)) - { - if (ff.IsStyleAvailable(FontStyle.Regular)) fs = (int)FontStyle.Regular; - else if (ff.IsStyleAvailable(FontStyle.Bold)) fs = (int)FontStyle.Bold; - else if (ff.IsStyleAvailable(FontStyle.Italic)) fs = (int)FontStyle.Italic; - else if (ff.IsStyleAvailable(FontStyle.Strikeout)) fs = (int)FontStyle.Strikeout; - else if (ff.IsStyleAvailable(FontStyle.Underline)) fs = (int)FontStyle.Underline; // irgend einen muss es ja wohl geben, oder? - } - string txt = GetDisplayString(fs); - int em = ff.GetEmHeight((FontStyle)fs); - int ls = ff.GetLineSpacing((FontStyle)fs); - int dc = ff.GetCellDescent((FontStyle)fs); - int ac = ff.GetCellAscent((FontStyle)fs); + FontFace ff = FontCache.GlobalFontCache.TryGetFace(fontName, fs); // FontProvider.Current, see issue #412 + if (ff == null) return; // no font at all: nothing to paint or measure + int[] txt = CodePoints(GetDisplayString(fs)); + int em = ff.EmHeight; + int ls = ff.LineSpacing; + int dc = ff.CellDescent; + int ac = ff.CellAscent; double lsp = ls / em; if (lineSpacing > 0.0) lsp = lineSpacing; // explicit baseline distance as factor of the text size if (lineSpacing > 0.0) lsp = lineSpacing; // explicit baseline distance as factor of the text size diff --git a/CADability/TrueTypeFont.cs b/CADability/TrueTypeFont.cs index 9ad4c236..7428e83d 100644 --- a/CADability/TrueTypeFont.cs +++ b/CADability/TrueTypeFont.cs @@ -64,6 +64,20 @@ internal sealed class TrueTypeFont public int WinAscent { get; } public int WinDescent { get; } /// + /// The top of the underline above the baseline in font units (negative: below), from the "post" table. + /// + public int UnderlinePosition { get; } + public int UnderlineThickness { get; } + /// + /// The top of the strikeout line above the baseline in font units, from the "OS/2" table. + /// + public int StrikeoutPosition { get; } + public int StrikeoutSize { get; } + /// + /// True for a font read by : it knows its names, style and metrics, but has no glyphs. + /// + public bool IsHeaderOnly { get; private set; } + /// /// False for an OpenType font with CFF outlines and for a bitmap font, whose glyphs cannot be read. /// public bool HasTrueTypeOutlines { get; } @@ -123,14 +137,26 @@ private TrueTypeFont(byte[] data, int offset) bool bold = (macStyle & 1) != 0, italic = (macStyle & 2) != 0; WinAscent = Ascender; WinDescent = -Descender; + // defaults like the ones GDI uses when the tables are missing + UnderlineThickness = Math.Max(1, UnitsPerEm / 14); + UnderlinePosition = -UnitsPerEm / 10; + StrikeoutSize = UnderlineThickness; + StrikeoutPosition = UnitsPerEm / 4; if (tables.TryGetValue("OS/2", out var os2) && os2.length >= 78) { + StrikeoutSize = S16(os2.offset + 26); + StrikeoutPosition = S16(os2.offset + 28); int fsSelection = U16(os2.offset + 62); italic = (fsSelection & 1) != 0; bold = (fsSelection & 0x20) != 0; WinAscent = U16(os2.offset + 74); WinDescent = U16(os2.offset + 76); } + if (tables.TryGetValue("post", out var post) && post.length >= 12) + { + UnderlinePosition = S16(post.offset + 8); + UnderlineThickness = S16(post.offset + 10); + } IsBold = bold; IsItalic = italic; ReadNames(out string family, out string subfamily, out string full, out string typographicFamily); @@ -160,6 +186,91 @@ public static TrueTypeFont[] Load(byte[] data) return new TrueTypeFont[] { new TrueTypeFont(data, 0) }; } /// + /// Reads the names, styles and metrics of all fonts of a font file without reading the whole file: only the tables + /// "head", "hhea", "maxp", "OS/2", "post" and "name" are read. This is how a directory with hundreds of fonts is indexed + /// quickly. The fonts returned have set; load the file with to + /// get the glyphs. + /// + public static TrueTypeFont[] ReadHeaders(string fileName) + { + using (FileStream stream = new FileStream(fileName, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + byte[] head = ReadBytes(stream, 0, 12); + if (head[0] == 't' && head[1] == 't' && head[2] == 'c' && head[3] == 'f') + { + int count = (int)ReadU32(head, 8); + if (count <= 0 || count > 10000) throw new InvalidDataException("invalid font collection"); + byte[] offsets = ReadBytes(stream, 12, 4 * count); + TrueTypeFont[] res = new TrueTypeFont[count]; + for (int i = 0; i < count; ++i) res[i] = ReadHeader(stream, ReadU32(offsets, 4 * i)); + return res; + } + return new TrueTypeFont[] { ReadHeader(stream, 0) }; + } + } + private static TrueTypeFont ReadHeader(FileStream stream, uint offset) + { + // a small font made of the needed tables only, with the same table directory layout, so that the constructor reads it + byte[] directory = ReadBytes(stream, offset, 12); + int numTables = (directory[4] << 8) | directory[5]; + if (numTables > 1000) throw new InvalidDataException("invalid table directory"); + byte[] records = ReadBytes(stream, offset + 12, 16 * numTables); + string[] needed = { "head", "hhea", "maxp", "OS/2", "post", "name" }; + List<(byte[] record, byte[] content)> copied = new List<(byte[], byte[])>(); + for (int i = 0; i < numTables; ++i) + { + string tag = Encoding.ASCII.GetString(records, 16 * i, 4); + byte[] record = new byte[16]; + Array.Copy(records, 16 * i, record, 0, 16); + if (Array.IndexOf(needed, tag) >= 0) + { + uint length = ReadU32(records, 16 * i + 12); + if (length > stream.Length) throw new InvalidDataException("table " + tag + " exceeds the file"); + copied.Add((record, ReadBytes(stream, ReadU32(records, 16 * i + 8), (int)length))); + } + else if (tag == "glyf" || tag == "loca") + { // only their presence matters (HasTrueTypeOutlines) + for (int k = 8; k < 16; ++k) record[k] = 0; + copied.Add((record, new byte[0])); + } + } + int size = 12 + 16 * copied.Count; + foreach (var (_, content) in copied) size += content.Length; + byte[] data = new byte[size]; + Array.Copy(directory, data, 12); + data[4] = (byte)(copied.Count >> 8); + data[5] = (byte)copied.Count; + int pos = 12 + 16 * copied.Count; + for (int i = 0; i < copied.Count; ++i) + { + byte[] record = copied[i].record, content = copied[i].content; + if (content.Length > 0) + { + record[8] = (byte)(pos >> 24); record[9] = (byte)(pos >> 16); record[10] = (byte)(pos >> 8); record[11] = (byte)pos; + } + Array.Copy(record, 0, data, 12 + 16 * i, 16); + Array.Copy(content, 0, data, pos, content.Length); + pos += content.Length; + } + TrueTypeFont res = new TrueTypeFont(data, 0); + res.IsHeaderOnly = true; + return res; + } + private static byte[] ReadBytes(FileStream stream, long offset, int count) + { + if (count < 0 || offset + count > stream.Length) throw new InvalidDataException("unexpected end of the font file"); + byte[] res = new byte[count]; + stream.Seek(offset, SeekOrigin.Begin); + int read = 0; + while (read < count) + { + int n = stream.Read(res, read, count - read); + if (n <= 0) throw new InvalidDataException("unexpected end of the font file"); + read += n; + } + return res; + } + /// /// Reads all fonts of the font file , see . /// public static TrueTypeFont[] Load(string fileName) @@ -213,6 +324,7 @@ public int GetKerning(int leftGlyph, int rightGlyph) public List GetContours(int glyph) { if (!HasTrueTypeOutlines) throw new NotSupportedException("The outlines of the font \"" + FullName + "\" cannot be read: they are in CFF format or the font has bitmaps only"); + if (IsHeaderOnly) throw new InvalidOperationException("The font \"" + FullName + "\" was read by ReadHeaders and has no glyphs, load it with Load"); List res = new List(); AddContours(glyph, res, 0); return res; @@ -226,6 +338,14 @@ public List GetContours(int glyph) /// 0 start, 1 line, 3 Bezier, 0x80 closes the figure. This is the input of GlyphShapeBuilder.PathDataToPath2D. /// public void GetPathData(int glyph, out PointF[] points, out byte[] types) + { + GetPathData(glyph, false, false, out points, out types); + } + /// + /// Like , and adds the underline and the strikeout line as + /// rectangles over the advance width of the glyph, as GDI+ does for an underlined or struck out font. + /// + public void GetPathData(int glyph, bool underline, bool strikeout, out PointF[] points, out byte[] types) { List pts = new List(); List tps = new List(); @@ -308,6 +428,17 @@ public void GetPathData(int glyph, out PointF[] points, out byte[] types) } tps[tps.Count - 1] |= 0x80; } + void AddBar(int top, int thickness) + { + if (thickness <= 0) return; + double width = GetAdvanceWidth(glyph); + pts.Add(Map(0, top)); tps.Add(0); + pts.Add(Map(width, top)); tps.Add(1); + pts.Add(Map(width, top - thickness)); tps.Add(1); + pts.Add(Map(0, top - thickness)); tps.Add(0x81); + } + if (underline) AddBar(UnderlinePosition, UnderlineThickness); + if (strikeout) AddBar(StrikeoutPosition, StrikeoutSize); points = pts.ToArray(); types = tps.ToArray(); } diff --git a/tests/CADability.ImportTests/CADability.ImportTests.csproj b/tests/CADability.ImportTests/CADability.ImportTests.csproj index 3ab5a792..bdea3af5 100644 --- a/tests/CADability.ImportTests/CADability.ImportTests.csproj +++ b/tests/CADability.ImportTests/CADability.ImportTests.csproj @@ -13,6 +13,13 @@ + + + + PreserveNewest + + + PreserveNewest diff --git a/tests/CADability.ImportTests/DxfDimensionTest.cs b/tests/CADability.ImportTests/DxfDimensionTest.cs index 32b4b706..4a37aa85 100644 --- a/tests/CADability.ImportTests/DxfDimensionTest.cs +++ b/tests/CADability.ImportTests/DxfDimensionTest.cs @@ -367,7 +367,6 @@ public void import_dxf_dimension_with_missing_block_is_drawn_from_its_definition [TestMethod] public void import_dxf_dimension_without_block_shows_its_measurement() { - RequireGdi(); string dxf = Header + Tables + EntitiesStart + AlignedDimension(null) + Epilogue; Block block = AssertRegeneratedDimension(ImportDxf(dxf).GetActiveModel()); Text text = block.Children.OfType().SingleOrDefault(); @@ -759,7 +758,6 @@ public void export_dimension_writes_its_xdata() [TestMethod] public void export_dimension_round_trips_as_a_dxf_dimension() { - RequireGdi(); Project project = MakeProjectWithDimension(new GeoPoint(0, 0, 0), new GeoPoint(100, 0, 0)); Model reimported = WithChoice("DxfDwg.ExportDimension", 1, () => ExportAndImport(project)); @@ -1613,13 +1611,6 @@ private static Block SingleDimensionBlock(Model model) return block; } - // Adding a Text to a model computes its extent through GDI, which only exists on Windows. - private static void RequireGdi() - { - if (!System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows)) - Assert.Inconclusive("text needs GDI, which is only available on Windows"); - } - private Project ImportDxf(string dxf) { string file = this.TestContext.TestName + ".dxf"; diff --git a/tests/CADability.ImportTests/DxfOcsExportTest.cs b/tests/CADability.ImportTests/DxfOcsExportTest.cs index 5e436801..3f81fd85 100644 --- a/tests/CADability.ImportTests/DxfOcsExportTest.cs +++ b/tests/CADability.ImportTests/DxfOcsExportTest.cs @@ -90,7 +90,6 @@ public void export_dxf_tilted_circle_and_arc_keep_position() [TestMethod] public void export_dxf_flipped_text_keeps_position() { - RequireGdi(); foreach (bool aligned in new[] { false, true }) { Text text = MakeText("Flipped", new GeoPoint(10, 5, 2)); @@ -119,7 +118,6 @@ public void export_dxf_flipped_text_keeps_position() [TestMethod] public void export_dxf_tilted_text_keeps_position() { - RequireGdi(); Text text = MakeText("Tilted", new GeoPoint(-3, 8, 1)); text.Modify(ModOp.Rotate(new GeoPoint(2, 3, 4), new GeoVector(1, 1, 0.3), new SweepAngle(0.65))); AssertSameText(text, ReimportSingle(Export(text))); @@ -130,7 +128,6 @@ public void export_dxf_tilted_text_keeps_position() [TestMethod] public void export_dxf_flipped_and_tilted_mtext_keep_position() { - RequireGdi(); Text text = MakeText("first line\nsecond line", new GeoPoint(10, 5, 2)); text.Modify(ModOp.Rotate(GeoPoint.Origin, GeoVector.XAxis, new SweepAngle(Math.PI))); string file = Export(text); @@ -151,13 +148,6 @@ public void export_dxf_flipped_and_tilted_mtext_keep_position() // --- helpers ------------------------------------------------------------------------- - // Adding a Text to a model computes its extent through GDI, which only exists on Windows. - private static void RequireGdi() - { - if (!System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows)) - Assert.Inconclusive("text needs GDI, which is only available on Windows"); - } - private static Text MakeText(string value, GeoPoint location) { Text text = Text.Construct(); diff --git a/tests/CADability.ImportTests/TestFonts.cs b/tests/CADability.ImportTests/TestFonts.cs new file mode 100644 index 00000000..1be6eb08 --- /dev/null +++ b/tests/CADability.ImportTests/TestFonts.cs @@ -0,0 +1,20 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace CADability.ImportTests +{ + /// + /// Texts get their fonts from FontProvider.Current. The tests add Liberation Sans (Files/Fonts) to the fonts of the system, + /// so that texts have a font on every machine, also on a build server without any fonts installed (issue #412). + /// + [TestClass] + public static class TestFonts + { + public static string Directory => System.IO.Path.Combine(AppContext.BaseDirectory, "Files", "Fonts"); + + [AssemblyInitialize] + public static void UseTestFonts(TestContext context) + { + FontProvider.Current = new ManagedFontProvider(new[] { Directory }, true); + } + } +} diff --git a/tests/CADability.Tests/FontCacheGlyphTests.cs b/tests/CADability.Tests/FontCacheGlyphTests.cs index d0eaf115..04f64367 100644 --- a/tests/CADability.Tests/FontCacheGlyphTests.cs +++ b/tests/CADability.Tests/FontCacheGlyphTests.cs @@ -7,7 +7,8 @@ namespace CADability.Tests { /// /// Sends all printable ASCII characters of installed fonts through - /// and checks that every glyph with a closed outline is painted with triangles. Needs Windows (GDI) and the font. + /// and checks that every glyph with a closed outline is painted with triangles. Needs the font: Liberation Sans comes with + /// the tests (see ), the others are Windows fonts. /// [TestClass] public class FontCacheGlyphTests @@ -105,7 +106,6 @@ public void SetClip(Rectangle clipRectangle) { } private static string CheckFontAvailable(string fontName) { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return "glyph outlines need GDI, which is only available on Windows"; if (!GeoObject.Text.FontFamilyNames.Contains(fontName.ToUpper())) { // name the installed families that start with the same word, which shows a misspelled or a wrong instance name @@ -145,6 +145,7 @@ private static (List withoutTriangles, List withoutList) CheckFont(s } [DataTestMethod] + [DataRow("Liberation Sans")] [DataRow("Arial")] [DataRow("Calibri")] [DataRow("Bahnschrift")] @@ -179,18 +180,17 @@ public void ReportGlyphsWithoutTrianglesForAllInstalledFonts() { if (!System.Diagnostics.Debugger.IsAttached && string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CADABILITY_FONT_SURVEY"))) Assert.Inconclusive("survey over all installed fonts, takes a few minutes: start it with Debug Test or set CADABILITY_FONT_SURVEY=1"); - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) Assert.Inconclusive("glyph outlines need GDI, which is only available on Windows"); int fontsWithProblems = 0; - foreach (FontFamily ff in FontFamily.Families) + foreach (string family in FontProvider.Current.GetFamilyNames()) { try { - var (withoutTriangles, withoutList) = CheckFont(ff.Name, (int)FontStyle.Regular, TestContext); + var (withoutTriangles, withoutList) = CheckFont(family, (int)FontStyle.Regular, TestContext); if (withoutTriangles.Count + withoutList.Count > 0) ++fontsWithProblems; } catch (Exception ex) { - TestContext.WriteLine("{0}: exception {1}", ff.Name, ex.Message); + TestContext.WriteLine("{0}: exception {1}", family, ex.Message); ++fontsWithProblems; } } diff --git a/tests/CADability.Tests/FontCodePointTests.cs b/tests/CADability.Tests/FontCodePointTests.cs new file mode 100644 index 00000000..045025aa --- /dev/null +++ b/tests/CADability.Tests/FontCodePointTests.cs @@ -0,0 +1,115 @@ +using CADability.GeoObject; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Drawing; + +namespace CADability.Tests +{ + /// + /// takes Unicode code points, so that characters outside the Basic Multilingual Plane, e.g. most + /// emoji, which are a surrogate pair of two chars in a string, reach the font as one character (issue #412). + /// + [TestClass] + public class FontCodePointTests + { + private const int Emoji = 0x1F600; // GRINNING FACE, "😀" in a string + + /// + /// A face that records the characters it is asked for. Every character is a square from 0.1 to 0.4 with an advance + /// width of 0.5 (in units of the em height). + /// + private sealed class RecordingFace : FontFace + { + public readonly List Glyphs = new List(), Widths = new List(); + public readonly List<(int, int)> KerningPairs = new List<(int, int)>(); + public override string FamilyName => "Recording"; + public override bool IsBold => false; + public override bool IsItalic => false; + public override int EmHeight => 1000; + public override int CellAscent => 800; + public override int CellDescent => 200; + public override int LineSpacing => 1000; + public override double GetAdvanceWidth(int codePoint) + { + lock (Widths) Widths.Add(codePoint); + return 0.5; + } + public override double GetKerning(int left, int right) + { + lock (KerningPairs) KerningPairs.Add((left, right)); + return 0.0; + } + public override void GetGlyphPath(int codePoint, bool underline, bool strikeout, out PointF[] points, out byte[] types) + { + lock (Glyphs) Glyphs.Add(codePoint); + points = new[] { new PointF(0.1f, 0.2f), new PointF(0.4f, 0.2f), new PointF(0.4f, 0.6f), new PointF(0.1f, 0.6f) }; + types = new byte[] { 0, 1, 1, 0x81 }; + } + } + private sealed class RecordingProvider : FontProvider + { + public readonly RecordingFace Face = new RecordingFace(); + public override string[] GetFamilyNames() => new[] { "Recording" }; + public override FontFace GetFace(string familyName, bool bold, bool italic) => string.Equals(familyName, "Recording", StringComparison.OrdinalIgnoreCase) ? Face : null; + } + + private static bool IsSurrogate(int c) => c >= 0xD800 && c <= 0xDFFF; + + [TestMethod] + public void ASurrogatePairIsOneCodePoint() + { + CollectionAssert.AreEqual(new[] { 'a', Emoji, 'b' }, Text.CodePoints("a😀b")); + // a lone surrogate is no code point, it is kept as the char it is + CollectionAssert.AreEqual(new[] { 0xD83D, 'x' }, Text.CodePoints("\uD83Dx")); + CollectionAssert.AreEqual(new[] { 'x', 0xDE00 }, Text.CodePoints("x\uDE00")); + CollectionAssert.AreEqual(new int[0], Text.CodePoints("")); + } + + [TestMethod] + [DoNotParallelize] + public void AnEmojiReachesTheFontAsOneCharacter() + { + RecordingProvider provider = new RecordingProvider(); + try + { + FontProvider.Current = provider; + Text text = Text.Construct(); + text.Font = "Recording"; + text.TextString = "a😀b"; + text.Location = GeoPoint.Origin; + text.LineDirection = GeoVector.XAxis; + text.GlyphDirection = GeoVector.YAxis; + text.TextSize = 1.0; + + // three characters, not four as with the two halves of the surrogate pair as separate characters: the extent + // is the sum of the advance widths, the outline goes from the first square at 0.1 to the third at 2 * 0.5 + 0.4 + BoundingCube extent = text.GetBoundingCube(); + Assert.AreEqual(1.5, extent.Xmax - extent.Xmin, 1e-6, "the advance widths of three characters"); + BoundingCube outline = text.GetOutline().GetExtent(); + Assert.AreEqual(0.1, outline.Xmin, 1e-6); + Assert.AreEqual(1.4, outline.Xmax, 1e-6, "the outlines of three characters"); + + RecordingFace face = provider.Face; + CollectionAssert.AreEquivalent(new[] { 'a', Emoji, 'b' }, face.Glyphs.Distinct().ToArray(), "the outlines asked for"); + Assert.IsTrue(face.Widths.Contains(Emoji), "the advance width of the emoji"); + Assert.IsTrue(face.KerningPairs.Contains(('a', Emoji)) && face.KerningPairs.Contains((Emoji, 'b')), "the kerning around the emoji"); + Assert.IsFalse(face.Glyphs.Concat(face.Widths).Any(IsSurrogate), "no half of a surrogate pair reaches the font"); + } + finally + { + TestFonts.UseTestFonts(null); + } + } + + [TestMethod] + public void TheManagedReaderMapsCodePointsAboveTheBmp() + { + // the face of the managed reader passes the code point on to the cmap, which supports format 12 (beyond U+FFFF); + // a font without the character gives the missing glyph, but no exception + FontFace face = new ManagedFontProvider(new[] { TestFonts.Directory }, false).GetFace("Liberation Sans", false, false); + Assert.IsNotNull(face); + Assert.AreEqual(face.GetAdvanceWidth(0xFFFF + 1), face.GetAdvanceWidth(0x10FFFF), 1e-12, "both are the missing glyph"); + face.GetGlyphPath(Emoji, false, false, out PointF[] points, out byte[] types); + Assert.AreEqual(points.Length, types.Length); + } + } +} diff --git a/tests/CADability.Tests/FontProviderTests.cs b/tests/CADability.Tests/FontProviderTests.cs new file mode 100644 index 00000000..8e2b7be0 --- /dev/null +++ b/tests/CADability.Tests/FontProviderTests.cs @@ -0,0 +1,170 @@ +using CADability.Curve2D; +using CADability.GeoObject; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Runtime.CompilerServices; +using Path = System.IO.Path; + +namespace CADability.Tests +{ + /// + /// Texts get their fonts from , a static service of the kernel, and no longer from GDI or + /// the frame (issue #412, step 6): they work without a frame and on every platform. The tests that replace the provider + /// do not run in parallel with other tests and restore the provider of . + /// + [TestClass] + public class FontProviderTests + { + private static string FontDirectory([CallerFilePath] string thisFile = "") + => Path.Combine(Path.GetDirectoryName(thisFile), "Files", "Fonts"); + + private static Text MakeText(string font, string textString, double size) + { + Text text = Text.Construct(); + text.Font = font; + text.TextString = textString; + text.Location = GeoPoint.Origin; + text.LineDirection = GeoVector.XAxis; + text.GlyphDirection = GeoVector.YAxis; + text.TextSize = size; + return text; + } + private static void WithProvider(FontProvider provider, Action action) + { + try + { + FontProvider.Current = provider; + action(); + } + finally + { + TestFonts.UseTestFonts(null); + } + } + + [TestMethod] + [DoNotParallelize] + public void TextWorksWithoutFrameAndWithoutGdi() + { + Assert.IsNull(FrameImpl.MainFrame, "the tests run without a frame"); + WithProvider(new ManagedFontProvider(new[] { FontDirectory() }, false), () => + { + Text text = MakeText("Liberation Sans", "Hello", 10.0); + GeoObjectList outline = text.GetOutline(); + Assert.IsTrue(outline.Count > 0, "the text has an outline"); + BoundingCube ext = text.GetBoundingCube(); + Assert.IsFalse(ext.IsEmpty); + + // the width of the text is the sum of the advance widths and the kerning of the font + TrueTypeFont font = TrueTypeFont.Load(Path.Combine(FontDirectory(), "LiberationSans-Regular.ttf"))[0]; + double advance = 0.0; + string s = "Hello"; + for (int i = 0; i < s.Length; ++i) + { + advance += font.GetAdvanceWidth(font.GetGlyphIndex(s[i])); + if (i > 0) advance += font.GetKerning(font.GetGlyphIndex(s[i - 1]), font.GetGlyphIndex(s[i])); + } + double width = 10.0 * advance / font.UnitsPerEm; + Assert.AreEqual(width, text.GetBoundingCube().Xmax - text.GetBoundingCube().Xmin, 0.15 * 10.0, "the extent is about the advance width (the glyphs do not fill their whole advance)"); + }); + } + + [TestMethod] + [DoNotParallelize] + public void UnknownFontUsesTheDefaultFace() + { + WithProvider(new ManagedFontProvider(new[] { FontDirectory() }, false), () => + { + FontFace face = FontCache.GlobalFontCache.GetFace("A Font That Does Not Exist", 0); + Assert.AreEqual("Liberation Sans", face.FamilyName); + Text known = MakeText("Liberation Sans", "Ag", 1.0), unknown = MakeText("A Font That Does Not Exist", "Ag", 1.0); + Assert.AreEqual(known.GetOutline().GetExtent().Size, unknown.GetOutline().GetExtent().Size, 1e-9); + }); + } + + [TestMethod] + [DoNotParallelize] + public void WithoutAnyFontTheExceptionSaysWhatToDo() + { + string empty = Path.Combine(Path.GetTempPath(), "CADability-no-fonts-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(empty); + try + { + WithProvider(new ManagedFontProvider(new[] { empty }, false), () => + { + InvalidOperationException ex = Assert.ThrowsException(() => FontCache.GlobalFontCache.GetFace("Arial", 0)); + StringAssert.Contains(ex.Message, "FontProvider.Current"); + // the extent and the glyphs for painting are empty instead of failing, an explicit request for the outline throws + FontCache.GlobalFontCache.Get("Arial", 0, 'A', out double width, null); + Text text = MakeText("Arial", "no font", 1.0); + text.GetBoundingCube(); + Assert.ThrowsException(() => text.GetOutline()); + }); + } + finally + { + Directory.Delete(empty, true); + } + } + + [TestMethod] + [DoNotParallelize] + public void UnderlineAndStrikeoutAddBars() + { + WithProvider(new ManagedFontProvider(new[] { FontDirectory() }, false), () => + { + Path2D[] plain = FontCache.GlobalFontCache.GetOutline2D("Liberation Sans", 0, 'o', out double w1); + Path2D[] underlined = FontCache.GlobalFontCache.GetOutline2D("Liberation Sans", 4, 'o', out double w2); + Path2D[] both = FontCache.GlobalFontCache.GetOutline2D("Liberation Sans", 4 | 8, 'o', out double w3); + Assert.AreEqual(plain.Length + 1, underlined.Length); + Assert.AreEqual(plain.Length + 2, both.Length); + Assert.AreEqual(w1, w2); + // the underline is below the baseline (y = 1 - CellAscent/em after PathDataToPath2D) + FontFace face = FontCache.GlobalFontCache.GetFace("Liberation Sans", 0); + double baseline = 1.0 - face.CellAscent / (double)face.EmHeight; + Assert.IsTrue(underlined.Last().GetExtent().Top < baseline); + }); + } + + [TestMethod] + [DoNotParallelize] + public void MissingStyleFallsBackToAnExistingFace() + { + ManagedFontProvider provider = new ManagedFontProvider(new[] { FontDirectory() }, false); + CollectionAssert.AreEqual(new[] { "Liberation Sans" }, provider.GetFamilyNames()); + FontFace bold = provider.GetFace("liberation sans", true, false); + Assert.IsNotNull(bold, "the family name is not case sensitive"); + Assert.IsFalse(bold.IsBold, "only the regular face is there"); + Assert.IsNull(provider.GetFace("Arial", false, false)); + Assert.AreSame(bold, provider.GetFace("Liberation Sans", false, false), "faces are loaded once"); + } + + [TestMethod] + public void HeadersAreReadWithoutLoadingTheGlyphs() + { + string file = Path.Combine(FontDirectory(), "LiberationSans-Regular.ttf"); + TrueTypeFont header = TrueTypeFont.ReadHeaders(file).Single(), full = TrueTypeFont.Load(file).Single(); + Assert.IsTrue(header.IsHeaderOnly); + Assert.IsFalse(full.IsHeaderOnly); + Assert.AreEqual(full.FamilyName, header.FamilyName); + Assert.AreEqual(full.SubfamilyName, header.SubfamilyName); + Assert.AreEqual(full.IsBold, header.IsBold); + Assert.AreEqual(full.IsItalic, header.IsItalic); + Assert.AreEqual(full.UnitsPerEm, header.UnitsPerEm); + Assert.AreEqual(full.CellAscent, header.CellAscent); + Assert.AreEqual(full.UnderlinePosition, header.UnderlinePosition); + Assert.IsTrue(header.HasTrueTypeOutlines); + Assert.ThrowsException(() => header.GetContours(5)); + } + + [TestMethod] + public void FontCacheHasNoFinalizerThatCanCrashTheProcess() + { + // the FontCache used to create a GDI device context in its constructor and delete it in its finalizer; without GDI + // the finalizer threw and ended the process (issue #412) + for (int i = 0; i < 10; ++i) new FontCache(); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + } +} diff --git a/tests/CADability.Tests/ProjectTest.cs b/tests/CADability.Tests/ProjectTest.cs index 9923a8a3..03274956 100644 --- a/tests/CADability.Tests/ProjectTest.cs +++ b/tests/CADability.Tests/ProjectTest.cs @@ -15,16 +15,6 @@ public class ProjectTest public TestContext TestContext { get; set; } - /// - /// Text entities are measured and triangulated with GDI (System.Drawing and gdi32 via FontCache), - /// which only exists on Windows. Tests that import or create text are inconclusive elsewhere. - /// - private static void RequiresGdiText() - { - if (!System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows)) - Assert.Inconclusive("text needs GDI, which is only available on Windows"); - } - /// /// Renders with OpenGL and compares it with the reference bitmap. /// Rendering needs CADability.Forms (WinForms, OpenGL), so the check only runs on Windows. @@ -103,7 +93,6 @@ public void import_step_issue101_succeds() [TestMethod] public void export_dxf_issue_129_succeds() { - RequiresGdiText(); // in master@82ffb34 exporting a text object to txt does not set the location, // so all texts are all not in the correct location / orientation @@ -172,7 +161,6 @@ public void import_dxf_issue171_succeds() [DeploymentItem(@"Files/Dxf/Z273.dxf", nameof(import_dxf_Z273_succeeds))] public void import_dxf_Z273_succeeds() { - RequiresGdiText(); // AC1009 (R12) DXF with no *Paper_Space block — importing this used to throw // KeyNotFoundException when FillPaperSpace accessed the missing block record. var file = Path.Combine(this.TestContext.DeploymentDirectory, this.TestContext.TestName, "Z273.dxf"); @@ -323,7 +311,6 @@ public void import_dxf_arc_crossing_zero_sweep_is_correct() [TestMethod] public void import_dxf_text_rotation_correct() { - RequiresGdiText(); // Regression: TextEntity.Rotation from ACadSharp is in RADIANS. // Old code called Angle.Deg() on it, making 90° text appear at ~1.57° (nearly horizontal). // A text at DXF rotation=90° must import with LineDirection ≈ (0, 1, 0). @@ -509,7 +496,6 @@ public void import_dxf_0583667_succeeds() [TestMethod] public void import_dxf_mtext_topcenter_placement_correct() { - RequiresGdiText(); // Regression: CreateMText ignored AttachmentPoint so all MTEXT rendered as // Left/Baseline. For table cells with attachment TopCenter (71=2) the text // appears shifted right and down of where it should be. @@ -567,7 +553,6 @@ public void import_dxf_mtext_topcenter_placement_correct() [TestMethod] public void import_dxf_mtext_bottomright_placement_correct() { - RequiresGdiText(); // MTEXT with attachment BottomRight (71=9) must have Right+Bottom alignment. const string dxf = @" 0 SECTION @@ -710,7 +695,6 @@ public void import_dxf_spline_degenerate_does_not_crash() [TestMethod] public void import_dxf_tolerance_fcf_imported_as_text() { - RequiresGdiText(); // DXF TOLERANCE (AcDbFcf / Feature Control Frame) entities were silently dropped // because there was no case for ACadSharp.Entities.Tolerance in GeoObjectFromEntity. // They must now be imported as Text objects at the insertion point. diff --git a/tests/CADability.Tests/RendererRecoveryTests.cs b/tests/CADability.Tests/RendererRecoveryTests.cs index 4487e7e8..5e7fdd3a 100644 --- a/tests/CADability.Tests/RendererRecoveryTests.cs +++ b/tests/CADability.Tests/RendererRecoveryTests.cs @@ -1,6 +1,5 @@ using System.Collections; using System.Reflection; -using System.Runtime.InteropServices; using CADability.GeoObject; using CADability.Substitutes; // with UseWindowsForms (the Windows build of the tests), System.Windows.Forms has a PaintEventArgs as well @@ -74,9 +73,9 @@ public void ViewPaintsAgainAfterAnException() [TestMethod] public void ClearingTheFontCacheDisposesTheGlyphLists() { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) Assert.Inconclusive("the font cache needs GDI, which is only available on Windows"); + // the font cache gets its fonts from FontProvider.Current and no longer needs GDI (issue #412) FontCache fontCache = new FontCache(); - string[] dictionaries = { "cache", "centerLineCache", "fontCharacteristicsCache", "kerning" }; + string[] dictionaries = { "cache", "centerLineCache", "fontCharacteristicsCache", "faces" }; IDictionary cache = Dictionary(fontCache, "cache"); Type dictKey = typeof(FontCache).GetNestedType("DictKey", BindingFlags.NonPublic); Type dictVal = typeof(FontCache).GetNestedType("DictVal", BindingFlags.NonPublic); @@ -84,12 +83,12 @@ public void ClearingTheFontCacheDisposesTheGlyphLists() object withList = Activator.CreateInstance(dictVal); dictVal.GetField("list").SetValue(withList, list); dictVal.GetField("listCreated").SetValue(withList, true); - cache[Activator.CreateInstance(dictKey, "Arial", 0, 'A')] = withList; + cache[Activator.CreateInstance(dictKey, "Arial", 0, (int)'A')] = withList; object withoutList = Activator.CreateInstance(dictVal); // e.g. a space: the list is null dictVal.GetField("listCreated").SetValue(withoutList, true); - cache[Activator.CreateInstance(dictKey, "Arial", 0, ' ')] = withoutList; - Type kerningKey = typeof(FontCache).GetNestedType("KerningKey", BindingFlags.NonPublic); - Dictionary(fontCache, "kerning")[Activator.CreateInstance(kerningKey, "Arial", 0)] = new Dictionary<(char Left, char Right), double>(); + cache[Activator.CreateInstance(dictKey, "Arial", 0, (int)' ')] = withoutList; + Type fontKey = typeof(FontCache).GetNestedType("FontKey", BindingFlags.NonPublic); + Dictionary(fontCache, "faces")[Activator.CreateInstance(fontKey, "Arial", 0)] = null; fontCache.Clear(); diff --git a/tests/CADability.Tests/TestFonts.cs b/tests/CADability.Tests/TestFonts.cs new file mode 100644 index 00000000..ea18ad0f --- /dev/null +++ b/tests/CADability.Tests/TestFonts.cs @@ -0,0 +1,20 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace CADability.Tests +{ + /// + /// Texts get their fonts from FontProvider.Current. The tests add Liberation Sans (Files/Fonts) to the fonts of the system, + /// so that texts have a font on every machine, also on a build server without any fonts installed (issue #412). + /// + [TestClass] + public static class TestFonts + { + public static string Directory => System.IO.Path.Combine(AppContext.BaseDirectory, "Files", "Fonts"); + + [AssemblyInitialize] + public static void UseTestFonts(TestContext context) + { + FontProvider.Current = new ManagedFontProvider(new[] { Directory }, true); + } + } +}