Skip to content

Commit

Permalink
Merge pull request #263 from 0xced/MacSystemFonts
Browse files Browse the repository at this point in the history
Enumerate available fonts through the native API on macOS
  • Loading branch information
JimBobSquarePants authored May 16, 2022
2 parents 5359ec7 + 7f953ed commit 2832534
Show file tree
Hide file tree
Showing 9 changed files with 429 additions and 30 deletions.
24 changes: 24 additions & 0 deletions src/SixLabors.Fonts/Native/CFStringEncoding.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright (c) Six Labors.
// Licensed under the Apache License, Version 2.0.

using System.Diagnostics.CodeAnalysis;

namespace SixLabors.Fonts.Native
{
/// <summary>
/// An integer type for constants used to specify supported string encodings in various CFString functions.
/// </summary>
[SuppressMessage("StyleCop.CSharp.NamingRules", "SA1300:Element should begin with upper-case letter", Justification = "Verbatim constants from the macOS SDK")]
internal enum CFStringEncoding : uint
{
/// <summary>
/// An encoding constant that identifies the UTF 8 encoding.
/// </summary>
kCFStringEncodingUTF8 = 0x08000100,

/// <summary>
/// An encoding constant that identifies kTextEncodingUnicodeDefault + kUnicodeUTF16LEFormat encoding. This constant specifies little-endian byte order.
/// </summary>
kCFStringEncodingUTF16LE = 0x14000100,
}
}
19 changes: 19 additions & 0 deletions src/SixLabors.Fonts/Native/CFURLPathStyle.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright (c) Six Labors.
// Licensed under the Apache License, Version 2.0.

using System.Diagnostics.CodeAnalysis;

namespace SixLabors.Fonts.Native
{
/// <summary>
/// Options you can use to determine how CFURL functions parse a file system path name.
/// </summary>
[SuppressMessage("StyleCop.CSharp.NamingRules", "SA1300:Element should begin with upper-case letter", Justification = "Verbatim constants from the macOS SDK")]
internal enum CFURLPathStyle : long
{
/// <summary>
/// Indicates a POSIX style path name. Components are slash delimited. A leading slash indicates an absolute path; a trailing slash is not significant.
/// </summary>
kCFURLPOSIXPathStyle = 0,
}
}
116 changes: 116 additions & 0 deletions src/SixLabors.Fonts/Native/CoreFoundation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Copyright (c) Six Labors.
// Licensed under the Apache License, Version 2.0.

using System;
using System.Runtime.InteropServices;

namespace SixLabors.Fonts.Native
{
// ReSharper disable InconsistentNaming
internal static class CoreFoundation
{
private const string CoreFoundationFramework = "/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation";

/// <summary>
/// Returns the number of values currently in an array.
/// </summary>
/// <param name="theArray">The array to examine.</param>
/// <returns>The number of values in <paramref name="theArray"/>.</returns>
[DllImport(CoreFoundationFramework, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)]
public static extern long CFArrayGetCount(IntPtr theArray);

/// <summary>
/// Returns the type identifier for the CFArray opaque type.
/// </summary>
/// <returns>The type identifier for the CFArray opaque type.</returns>
/// <remarks>CFMutableArray objects have the same type identifier as CFArray objects.</remarks>
[DllImport(CoreFoundationFramework, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)]
public static extern ulong CFArrayGetTypeID();

/// <summary>
/// Retrieves a value at a given index.
/// </summary>
/// <param name="theArray">The array to examine.</param>
/// <param name="idx">The index of the value to retrieve. If the index is outside the index space of <paramref name="theArray"/> (<c>0</c> to <c>N-1</c> inclusive where <c>N</c> is the count of <paramref name="theArray"/>), the behavior is undefined.</param>
/// <returns>The value at the <paramref name="idx"/> index in <paramref name="theArray"/>. If the return value is a Core Foundation Object, ownership follows The Get Rule.</returns>
[DllImport(CoreFoundationFramework, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)]
public static extern IntPtr CFArrayGetValueAtIndex(IntPtr theArray, long idx);

/// <summary>
/// Returns the unique identifier of an opaque type to which a Core Foundation object belongs.
/// </summary>
/// <param name="cf">The CFType object to examine.</param>
/// <returns>A value of type <c>CFTypeID</c> that identifies the opaque type of <paramref name="cf"/>.</returns>
/// <remarks>
/// This function returns a value that uniquely identifies the opaque type of any Core Foundation object.
/// You can compare this value with the known <c>CFTypeID</c> identifier obtained with a “GetTypeID” function specific to a type, for example CFDateGetTypeID.
/// These values might change from release to release or platform to platform.
/// </remarks>
[DllImport(CoreFoundationFramework, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)]
public static extern ulong CFGetTypeID(IntPtr cf);

/// <summary>
/// Returns the number (in terms of UTF-16 code pairs) of Unicode characters in a string.
/// </summary>
/// <param name="theString">The string to examine.</param>
/// <returns>The number (in terms of UTF-16 code pairs) of characters stored in <paramref name="theString"/>.</returns>
[DllImport(CoreFoundationFramework, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)]
public static extern long CFStringGetLength(IntPtr theString);

/// <summary>
/// Copies the character contents of a string to a local C string buffer after converting the characters to a given encoding.
/// </summary>
/// <param name="theString">The string whose contents you wish to access.</param>
/// <param name="buffer">
/// The C string buffer into which to copy the string. On return, the buffer contains the converted characters. If there is an error in conversion, the buffer contains only partial results.
/// The buffer must be large enough to contain the converted characters and a NUL terminator. For example, if the string is <c>Toby</c>, the buffer must be at least 5 bytes long.
/// </param>
/// <param name="bufferSize">The length of <paramref name="buffer"/> in bytes.</param>
/// <param name="encoding">The string encoding to which the character contents of <paramref name="theString"/> should be converted. The encoding must specify an 8-bit encoding.</param>
/// <returns><see langword="true"/> upon success or <see langword="false"/> if the conversion fails or the provided buffer is too small.</returns>
/// <remarks>This function is useful when you need your own copy of a string’s character data as a C string. You also typically call it as a “backup” when a prior call to the <see cref="CFStringGetCStringPtr"/> function fails.</remarks>
[DllImport(CoreFoundationFramework, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)]
public static extern bool CFStringGetCString(IntPtr theString, byte[] buffer, long bufferSize, CFStringEncoding encoding);

/// <summary>
/// Quickly obtains a pointer to a C-string buffer containing the characters of a string in a given encoding.
/// </summary>
/// <param name="theString">The string whose contents you wish to access.</param>
/// <param name="encoding">The string encoding to which the character contents of <paramref name="theString"/> should be converted. The encoding must specify an 8-bit encoding.</param>
/// <returns>A pointer to a C string or <c>NULL</c> if the internal storage of <paramref name="theString"/> does not allow this to be returned efficiently.</returns>
/// <remarks>
/// <para>
/// This function either returns the requested pointer immediately, with no memory allocations and no copying, in constant time, or returns <c>NULL</c>. If the latter is the result, call an alternative function such as the <see cref="CFStringGetCString"/> function to extract the characters.
/// </para>
/// <para>
/// Whether or not this function returns a valid pointer or <c>NULL</c> depends on many factors, all of which depend on how the string was created and its properties. In addition, the function result might change between different releases and on different platforms. So do not count on receiving a non-<c>NULL</c> result from this function under any circumstances.
/// </para>
/// </remarks>
[DllImport(CoreFoundationFramework, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)]
public static extern IntPtr CFStringGetCStringPtr(IntPtr theString, CFStringEncoding encoding);

/// <summary>
/// Releases a Core Foundation object.
/// </summary>
/// <param name="cf">A CFType object to release. This value must not be <c>NULL</c>.</param>
[DllImport(CoreFoundationFramework, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)]
public static extern void CFRelease(IntPtr cf);

/// <summary>
/// Returns the path portion of a given URL.
/// </summary>
/// <param name="anURL">The <c>CFURL</c> object whose path you want to obtain.</param>
/// <param name="pathStyle">The operating system path style to be used to create the path. See <see cref="CFURLPathStyle"/> for a list of possible values.</param>
/// <returns>The URL's path in the format specified by <paramref name="pathStyle"/>. Ownership follows the create rule. See The Create Rule.</returns>
/// <remarks>This function returns the URL's path as a file system path for a given path style.</remarks>
[DllImport(CoreFoundationFramework, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)]
public static extern IntPtr CFURLCopyFileSystemPath(IntPtr anURL, CFURLPathStyle pathStyle);

/// <summary>
/// Returns the type identifier for the CFURL opaque type.
/// </summary>
/// <returns>The type identifier for the CFURL opaque type.</returns>
[DllImport(CoreFoundationFramework, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)]
public static extern ulong CFURLGetTypeID();
}
}
20 changes: 20 additions & 0 deletions src/SixLabors.Fonts/Native/CoreText.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright (c) Six Labors.
// Licensed under the Apache License, Version 2.0.

using System;
using System.Runtime.InteropServices;

namespace SixLabors.Fonts.Native
{
internal static class CoreText
{
private const string CoreTextFramework = "/System/Library/Frameworks/CoreText.framework/Versions/A/CoreText";

/// <summary>
/// Returns an array of font URLs.
/// </summary>
/// <returns>This function returns a retained reference to a <c>CFArray</c> of <c>CFURLRef</c> objects representing the URLs of the available fonts, or <c>NULL</c> on error. The caller is responsible for releasing the array.</returns>
[DllImport(CoreTextFramework, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)]
public static extern IntPtr CTFontManagerCopyAvailableFontURLs();
}
}
105 changes: 105 additions & 0 deletions src/SixLabors.Fonts/Native/MacSystemFontsEnumerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// Copyright (c) Six Labors.
// Licensed under the Apache License, Version 2.0.

using System;
using System.Buffers;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
using static SixLabors.Fonts.Native.CoreFoundation;
using static SixLabors.Fonts.Native.CoreText;

namespace SixLabors.Fonts.Native
{
/// <summary>
/// An enumerator that enumerates over available macOS system fonts.
/// The enumerated strings are the absolute paths to the font files.
/// </summary>
/// <remarks>
/// Internally, it calls the native CoreText's <see cref="CTFontManagerCopyAvailableFontURLs"/> method to retrieve
/// the list of fonts so using this class must be guarded by <c>RuntimeInformation.IsOSPlatform(OSPlatform.OSX)</c>.
/// </remarks>
internal class MacSystemFontsEnumerator : IEnumerable<string>, IEnumerator<string>
{
private static readonly ArrayPool<byte> BytePool = ArrayPool<byte>.Shared;

private readonly IntPtr fontUrls;
private readonly bool releaseFontUrls;
private int fontIndex;

public MacSystemFontsEnumerator()
: this(CTFontManagerCopyAvailableFontURLs(), releaseFontUrls: true, fontIndex: 0)
{
}

private MacSystemFontsEnumerator(IntPtr fontUrls, bool releaseFontUrls, int fontIndex)
{
if (fontUrls == IntPtr.Zero)
{
throw new ArgumentException($"The {nameof(fontUrls)} must not be NULL.", nameof(fontUrls));
}

this.fontUrls = fontUrls;
this.releaseFontUrls = releaseFontUrls;
this.fontIndex = fontIndex;

this.Current = null!;
}

public string Current { get; private set; }

object IEnumerator.Current => this.Current;

public bool MoveNext()
{
Debug.Assert(CFGetTypeID(this.fontUrls) == CFArrayGetTypeID(), "The fontUrls array must be a CFArrayRef");
if (this.fontIndex < CFArrayGetCount(this.fontUrls))
{
IntPtr fontUrl = CFArrayGetValueAtIndex(this.fontUrls, this.fontIndex);
Debug.Assert(CFGetTypeID(fontUrl) == CFURLGetTypeID(), "The elements of the fontUrls array must be a CFURLRef");
IntPtr fontPath = CFURLCopyFileSystemPath(fontUrl, CFURLPathStyle.kCFURLPOSIXPathStyle);

#if !NETSTANDARD2_0
string? current = Marshal.PtrToStringUTF8(CFStringGetCStringPtr(fontPath, CFStringEncoding.kCFStringEncodingUTF8));
if (current is not null)
{
this.Current = current;
}
else
#endif
{
int fontPathLength = (int)CFStringGetLength(fontPath);
int fontPathBufferSize = (fontPathLength + 1) * 2; // +1 for the NULL byte and *2 for UTF-16
byte[] fontPathBuffer = BytePool.Rent(fontPathBufferSize);
CFStringGetCString(fontPath, fontPathBuffer, fontPathBufferSize, CFStringEncoding.kCFStringEncodingUTF16LE);
this.Current = Encoding.Unicode.GetString(fontPathBuffer, 0, fontPathBufferSize - 2); // -2 for the UTF-16 NULL
BytePool.Return(fontPathBuffer);
}

CFRelease(fontPath);

this.fontIndex++;

return true;
}

return false;
}

public void Reset() => this.fontIndex = 0;

public void Dispose()
{
if (this.releaseFontUrls)
{
CFRelease(this.fontUrls);
}
}

public IEnumerator<string> GetEnumerator() => new MacSystemFontsEnumerator(this.fontUrls, releaseFontUrls: false, this.fontIndex);

IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator();
}
}
78 changes: 50 additions & 28 deletions src/SixLabors.Fonts/SystemFontCollection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,41 +63,37 @@ static SystemFontCollection()
}

public SystemFontCollection()
: this(StandardFontLocations)
{
}
IEnumerable<string> paths;
Native.MacSystemFontsEnumerator? nativeEnumerator = null;

public SystemFontCollection(IEnumerable<string> paths)
{
string[] expanded = paths.Select(x => Environment.ExpandEnvironmentVariables(x)).ToArray();
this.searchDirectories = expanded.Where(x => Directory.Exists(x)).ToArray();
bool forceDirectoryEnumeration = AppContext.TryGetSwitch("Switch.SixLabors.Fonts.DoNotUseNativeSystemFontsEnumeration", out bool isEnabled) && isEnabled;
if (!forceDirectoryEnumeration && RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
nativeEnumerator = new Native.MacSystemFontsEnumerator();

this.collection = new FontCollection(this.searchDirectories);
// The CTFontManagerCopyAvailableFontURLs method might return duplicate paths, hence the call to Distinct()
paths = nativeEnumerator.Distinct();

// We do this to provide a consistent experience with case sensitive file systems.
IEnumerable<string> files = this.searchDirectories
.SelectMany(x => Directory.EnumerateFiles(x, "*.*", SearchOption.AllDirectories))
.Where(x => Path.GetExtension(x).Equals(".ttf", StringComparison.OrdinalIgnoreCase)
this.searchDirectories = Array.Empty<string>();
}
else
{
string[] expanded = StandardFontLocations.Select(x => Environment.ExpandEnvironmentVariables(x)).ToArray();
string[] existingDirectories = expanded.Where(x => Directory.Exists(x)).ToArray();

// We do this to provide a consistent experience with case sensitive file systems.
paths = existingDirectories
.SelectMany(x => Directory.EnumerateFiles(x, "*.*", SearchOption.AllDirectories))
.Where(x => Path.GetExtension(x).Equals(".ttf", StringComparison.OrdinalIgnoreCase)
|| Path.GetExtension(x).Equals(".ttc", StringComparison.OrdinalIgnoreCase));

foreach (string path in files)
{
try
{
if (path.EndsWith(".ttc", StringComparison.OrdinalIgnoreCase))
{
this.collection.AddCollection(path);
}
else
{
this.collection.Add(path);
}
}
catch
{
// We swallow exceptions installing system fonts as we hold no guarantees about permissions etc.
}
this.searchDirectories = existingDirectories;
}

this.collection = CreateSystemFontCollection(paths, this.searchDirectories);

nativeEnumerator?.Dispose();
}

/// <inheritdoc/>
Expand Down Expand Up @@ -140,5 +136,31 @@ IEnumerable<FontStyle> IReadOnlyFontMetricsCollection.GetAllStyles(string name,
/// <inheritdoc/>
IEnumerator<FontMetrics> IReadOnlyFontMetricsCollection.GetEnumerator()
=> ((IReadOnlyFontMetricsCollection)this.collection).GetEnumerator();

private static FontCollection CreateSystemFontCollection(IEnumerable<string> paths, IReadOnlyCollection<string> searchDirectories)
{
var collection = new FontCollection(searchDirectories);

foreach (string path in paths)
{
try
{
if (path.EndsWith(".ttc", StringComparison.OrdinalIgnoreCase))
{
collection.AddCollection(path);
}
else
{
collection.Add(path);
}
}
catch
{
// We swallow exceptions installing system fonts as we hold no guarantees about permissions etc.
}
}

return collection;
}
}
}
Loading

0 comments on commit 2832534

Please sign in to comment.