Browse Source

Feature: support for OpenType font features in TextStyle

Marcin Ziąbek 1 year ago
parent
commit
245da65c9d

+ 29 - 0
Source/QuestPDF.Examples/TextExamples.cs

@@ -1169,5 +1169,34 @@ namespace QuestPDF.Examples
                          .ClampLines(3, " [...]");
                  });
          }
+         
+         [Test]
+         public void FontFeaturesTest()
+         {
+             RenderingTest
+                 .Create()
+                 .PageSize(500, 150)
+                 .ProducePdf()
+                 .ShowResults()
+                 .Render(container =>
+                 {
+                     container.Padding(25).Row(row =>
+                     {
+                         row.Spacing(25);
+                         
+                         row.RelativeItem().Column(column =>
+                         {
+                             column.Item().Text("Without ligatures").FontSize(16);
+                             column.Item().Text("fly and fight").FontSize(32).DisableFontFeature(FontFeatures.StandardLigatures);
+                         });
+                         
+                         row.RelativeItem().Column(column =>
+                         {
+                             column.Item().Text("With ligatures").FontSize(16);
+                             column.Item().Text("fly and fight").FontSize(32).EnableFontFeature(FontFeatures.StandardLigatures);
+                         });
+                     });
+                 });
+         }
     }
 }

+ 18 - 0
Source/QuestPDF/Fluent/TextSpanDescriptorExtensions.cs

@@ -311,5 +311,23 @@ namespace QuestPDF.Fluent
         }
 
         #endregion
+        
+        #region Font Features
+        
+        /// <include file='../Resources/Documentation.xml' path='documentation/doc[@for="text.fontFeatures"]/*' />
+        public static T EnableFontFeature<T>(this T descriptor, string featureName) where T : TextSpanDescriptor
+        {
+            descriptor.MutateTextStyle(TextStyleExtensions.EnableFontFeature, featureName);
+            return descriptor;
+        }
+        
+        /// <include file='../Resources/Documentation.xml' path='documentation/doc[@for="text.fontFeatures"]/*' />
+        public static T DisableFontFeature<T>(this T descriptor, string featureName) where T : TextSpanDescriptor
+        {
+            descriptor.MutateTextStyle(TextStyleExtensions.DisableFontFeature, featureName);
+            return descriptor;
+        }
+        
+        #endregion
     }
 }

+ 16 - 0
Source/QuestPDF/Fluent/TextStyleExtensions.cs

@@ -323,5 +323,21 @@ namespace QuestPDF.Fluent
         }
 
         #endregion
+
+        #region Font Features
+        
+        /// <include file='../Resources/Documentation.xml' path='documentation/doc[@for="text.fontFeatures"]/*' />
+        public static TextStyle EnableFontFeature(this TextStyle style, string featureName)
+        {
+            return style.Mutate(TextStyleProperty.FontFeatures, new[] { (featureName, true) });
+        }
+
+        /// <include file='../Resources/Documentation.xml' path='documentation/doc[@for="text.fontFeatures"]/*' />
+        public static TextStyle DisableFontFeature(this TextStyle style, string featureName)
+        {
+            return style.Mutate(TextStyleProperty.FontFeatures, new[] { (featureName, false) });
+        }
+        
+        #endregion
     }
 }

+ 153 - 0
Source/QuestPDF/Helpers/FontFeatures.cs

@@ -0,0 +1,153 @@
+using System;
+
+#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
+
+namespace QuestPDF.Helpers;
+
+public static class FontFeatures
+{
+    public const string AccessAllAlternates = "aalt";
+    public const string AboveBaseForms = "abvf";
+    public const string AboveBaseMarkPositioning = "abvm";
+    public const string AboveBaseSubstitutions = "abvs";
+    public const string AlternativeFractions = "afrc";
+    public const string Akhand = "akhn";
+    public const string KerningForAlternateProportionalWidths = "apkn";
+    public const string BelowBaseForms = "blwf";
+    public const string BelowBaseMarkPositioning = "blwm";
+    public const string BelowBaseSubstitutions = "blws";
+    public const string ContextualAlternates = "calt";
+    public const string CaseSensitiveForms = "case";
+    public const string GlyphCompositionDecomposition = "ccmp";
+    public const string ConjunctFormAfterRo = "cfar";
+    public const string ContextualHalfWidthSpacing = "chws";
+    public const string ConjunctForms = "cjct";
+    public const string ContextualLigatures = "clig";
+    public const string CenteredCjkPunctuation = "cpct";
+    public const string CapitalSpacing = "cpsp";
+    public const string ContextualSwash = "cswh";
+    public const string CursivePositioning = "curs";
+    public const string PetiteCapitalsFromCapitals = "c2pc";
+    public const string SmallCapitalsFromCapitals = "c2sc";
+    public const string Distances = "dist";
+    public const string DiscretionaryLigatures = "dlig";
+    public const string Denominators = "dnom";
+    public const string DotlessForms = "dtls";
+    public const string ExpertForms = "expt";
+    public const string FinalGlyphOnLineAlternates = "falt";
+    public const string TerminalForms2 = "fin2";
+    public const string TerminalForms3 = "fin3";
+    public const string TerminalForms = "fina";
+    public const string FlattenedAccentForms = "flac";
+    public const string Fractions = "frac";
+    public const string FullWidths = "fwid";
+    public const string HalfForms = "half";
+    public const string HalantForms = "haln";
+    public const string AlternateHalfWidths = "halt";
+    public const string HistoricalForms = "hist";
+    public const string HorizontalKanaAlternates = "hkna";
+    public const string HistoricalLigatures = "hlig";
+    public const string Hangul = "hngl";
+    public const string HalfWidths = "hwid";
+    public const string InitialForms = "init";
+    public const string IsolatedForms = "isol";
+    public const string Italics = "ital";
+    public const string JustificationAlternates = "jalt";
+    public const string JIS78Forms = "jp78";
+    public const string JIS83Forms = "jp83";
+    public const string JIS90Forms = "jp90";
+    public const string JIS2004Forms = "jp04";
+    public const string Kerning = "kern";
+    public const string LeftBounds = "lfbd";
+    public const string StandardLigatures = "liga";
+    public const string LeadingJamoForms = "ljmo";
+    public const string LiningFigures = "lnum";
+    public const string LocalizedForms = "locl";
+    public const string LeftToRightAlternates = "ltra";
+    public const string LeftToRightMirroredForms = "ltrm";
+    public const string MarkPositioning = "mark";
+    public const string MedialForms2 = "med2";
+    public const string MedialForms = "medi";
+    public const string MathematicalGreek = "mgrk";
+    public const string MarkToMarkPositioning = "mkmk";
+    public const string MarkPositioningViaSubstitution = "mset";
+    public const string AlternateAnnotationForms = "nalt";
+    public const string NlcKanjiForms = "nlck";
+    public const string NuktaForms = "nukt";
+    public const string Numerators = "numr";
+    public const string OldstyleFigures = "onum";
+    public const string OpticalBounds = "opbd";
+    public const string Ordinals = "ordn";
+    public const string Ornaments = "ornm";
+    public const string ProportionalAlternateWidths = "palt";
+    public const string PetiteCapitals = "pcap";
+    public const string ProportionalKana = "pkna";
+    public const string ProportionalFigures = "pnum";
+    public const string PreBaseForms = "pref";
+    public const string PreBaseSubstitutions = "pres";
+    public const string PostBaseForms = "pstf";
+    public const string PostBaseSubstitutions = "psts";
+    public const string ProportionalWidths = "pwid";
+    public const string QuarterWidths = "qwid";
+    public const string Randomize = "rand";
+    public const string RequiredContextualAlternates = "rclt";
+    public const string RakarForms = "rkrf";
+    public const string RequiredLigatures = "rlig";
+    public const string RephForm = "rphf";
+    public const string RightBounds = "rtbd";
+    public const string RightToLeftAlternates = "rtla";
+    public const string RightToLeftMirroredForms = "rtlm";
+    public const string RubyNotationForms = "ruby";
+    public const string RequiredVariationAlternates = "rvrn";
+    public const string StylisticAlternates = "salt";
+    public const string ScientificInferiors = "sinf";
+    public const string OpticalSize = "size";
+    public const string SmallCapitals = "smcp";
+    public const string SimplifiedForms = "smpl";
+    public const string MathScriptStyleAlternates = "ssty";
+    public const string StretchingGlyphDecomposition = "stch";
+    public const string Subscript = "subs";
+    public const string Superscript = "sups";
+    public const string Swash = "swsh";
+    public const string Titling = "titl";
+    public const string TrailingJamoForms = "tjmo";
+    public const string TraditionalNameForms = "tnam";
+    public const string TabularFigures = "tnum";
+    public const string TraditionalForms = "trad";
+    public const string ThirdWidths = "twid";
+    public const string Unicase = "unic";
+    public const string AlternateVerticalMetrics = "valt";
+    public const string KerningForAlternateProportionalVerticalMetrics = "vapk";
+    public const string VattuVariants = "vatu";
+    public const string VerticalContextualHalfWidthSpacing = "vchw";
+    public const string VerticalAlternates = "vert";
+    public const string AlternateVerticalHalfMetrics = "vhal";
+    public const string VowelJamoForms = "vjmo";
+    public const string VerticalKanaAlternates = "vkna";
+    public const string VerticalKerning = "vkrn";
+    public const string ProportionalAlternateVerticalMetrics = "vpal";
+    public const string VerticalAlternatesAndRotation = "vrt2";
+    public const string VerticalAlternatesForRotation = "vrtr";
+    public const string SlashedZero = "zero";
+
+    /// <summary>
+    /// (JIS X 0212-1990 Kanji Forms)
+    /// </summary>
+    public const string HojoKanjiForms  = "hojo";
+    
+    public static string CharacterVariant(int value)
+    {
+        if (value < 1 || value > 99)
+            throw new ArgumentOutOfRangeException(nameof(value), "Character Variant value must be between 1 and 99.");
+        
+        return $"cv{value:00}";
+    }
+    
+    public static string StylisticSet(int value)
+    {
+        if (value < 1 || value > 20)
+            throw new ArgumentOutOfRangeException(nameof(value), "Character Variant value must be between 1 and 20.");
+        
+        return $"ss{value:00}";
+    }
+}

+ 20 - 1
Source/QuestPDF/Infrastructure/TextStyle.cs

@@ -5,6 +5,8 @@ using QuestPDF.Helpers;
 using QuestPDF.Skia;
 using QuestPDF.Skia.Text;
 
+using TextStyleFontFeature = (string Name, bool Enabled);
+
 namespace QuestPDF.Infrastructure
 {
     public record TextStyle
@@ -17,6 +19,7 @@ namespace QuestPDF.Infrastructure
         internal Color? BackgroundColor { get; set; }
         internal Color? DecorationColor { get; set; }
         internal string[]? FontFamilies { get; set; }
+        internal TextStyleFontFeature[]? FontFeatures { get; set; }
         internal float? Size { get; set; }
         internal float? LineHeight { get; set; }
         internal float? LetterSpacing { get; set; }
@@ -43,6 +46,7 @@ namespace QuestPDF.Infrastructure
             BackgroundColor = Colors.Transparent,
             DecorationColor = Colors.Black,
             FontFamilies = new[] { Fonts.Lato },
+            FontFeatures = {},
             Size = 12,
             LineHeight = NormalLineHeightCalculatedFromFontMetrics,
             LetterSpacing = 0,
@@ -78,9 +82,11 @@ namespace QuestPDF.Infrastructure
             {
                 FontSize = CalculateTargetFontSize(),
                 FontWeight = (TextStyleConfiguration.FontWeights?)FontWeight ?? TextStyleConfiguration.FontWeights.Normal,
-                
                 IsItalic = IsItalic ?? false,
+                
                 FontFamilies = GetFontFamilyPointers(fontFamilyTexts),
+                FontFeatures = GetFontFeatures(),
+                
                 ForegroundColor = Color ?? Colors.Black,
                 BackgroundColor = BackgroundColor ?? Colors.Transparent,
                 DecorationColor = DecorationColor ?? Colors.Black,
@@ -108,6 +114,19 @@ namespace QuestPDF.Infrastructure
                 
                 return result;
             }
+            
+            TextStyleConfiguration.FontFeature[] GetFontFeatures(params (string name, int value)[] features)
+            {
+                var result = new TextStyleConfiguration.FontFeature[TextStyleConfiguration.FONT_FEATURES_LENGTH];
+
+                foreach (var (feature, index) in FontFeatures.Take(TextStyleConfiguration.FONT_FEATURES_LENGTH).Select((x, i) => (x, i)))
+                {
+                    result[index].Name = feature.Name;
+                    result[index].Value = feature.Enabled ? 1 : 0;
+                }
+                
+                return result;
+            }
 
             TextStyleConfiguration.TextDecoration CreateDecoration()
             {

+ 48 - 10
Source/QuestPDF/Infrastructure/TextStyleManager.cs

@@ -5,6 +5,8 @@ using System.Diagnostics;
 using System.Linq;
 using System.Reflection;
 
+using TextStyleFontFeature = (string Name, bool Enabled);
+
 namespace QuestPDF.Infrastructure
 {
     internal enum TextStyleProperty
@@ -13,6 +15,7 @@ namespace QuestPDF.Infrastructure
         BackgroundColor,
         DecorationColor,
         FontFamilies,
+        FontFeatures,
         Size,
         LineHeight,
         LetterSpacing,
@@ -30,23 +33,23 @@ namespace QuestPDF.Infrastructure
 
     // C# does not have proper equality members for arrays
     // this struct is a wrapper that allows to use an array as part of dictionary key
-    internal struct FontFamiliesValue
+    internal struct ArrayContainer<T>
     {
-        public string[] Items { get; }
+        public T[] Items { get; }
 
-        public FontFamiliesValue(string[]? array)
+        public ArrayContainer(object array)
         {
-            Items = array ?? Array.Empty<string>();
+            Items = (array as T[]) ?? Array.Empty<T>();
         }
         
-        public bool Equals(FontFamiliesValue other)
+        public bool Equals(ArrayContainer<T> other)
         {
             return Items.SequenceEqual(other.Items);
         }
         
         public override bool Equals(object obj)
         {
-            return obj is FontFamiliesValue other && Equals(other);
+            return obj is ArrayContainer<T> other && Equals(other);
         }
         
         public override int GetHashCode()
@@ -84,8 +87,11 @@ namespace QuestPDF.Infrastructure
 
         public static TextStyle Mutate(this TextStyle origin, TextStyleProperty property, object value)
         {
-            if (property == TextStyleProperty.FontFamilies)
-                value = new FontFamiliesValue((string[]?)value);
+            if (property is TextStyleProperty.FontFamilies)
+                value = new ArrayContainer<string>(value);
+            
+            if (property is TextStyleProperty.FontFamilies or TextStyleProperty.FontFeatures)
+                value = new ArrayContainer<TextStyleFontFeature>(value);
             
             var cacheKey = (origin.Id, property, value);
             
@@ -93,8 +99,11 @@ namespace QuestPDF.Infrastructure
             {
                 var newValue = x.value;
                 
-                if (x.value is FontFamiliesValue fontFamiliesValue)
-                    newValue = fontFamiliesValue.Items;
+                if (x.value is ArrayContainer<string> fontFamilies)
+                    newValue = fontFamilies.Items;
+                
+                if (x.value is ArrayContainer<TextStyleFontFeature> fontFeatures)
+                    newValue = fontFeatures.Items;
    
                 return MutateStyle(TextStyles[x.originId], x.property, newValue, overrideValue: true);
             });
@@ -105,6 +114,9 @@ namespace QuestPDF.Infrastructure
             if (targetProperty == TextStyleProperty.FontFamilies)
                 return MutateFontFamily(origin, newValue as string[], overrideValue);
             
+            if (targetProperty == TextStyleProperty.FontFeatures)
+                return MutateFontFeatures(origin, newValue as TextStyleFontFeature[], overrideValue);
+ 
             lock (MutationLock)
             {
                 if (overrideValue && newValue is null)
@@ -157,6 +169,32 @@ namespace QuestPDF.Infrastructure
             }
         }
         
+        private static TextStyle MutateFontFeatures(this TextStyle origin, TextStyleFontFeature[]? newValue, bool overrideValue)
+        {
+            lock (MutationLock)
+            {
+                if (overrideValue && newValue is null)
+                    return origin;
+                
+                var newIndex = TextStyles.Count;
+                var newTextStyle = origin with { Id = newIndex };
+                newTextStyle.Id = newIndex;
+                
+                newValue ??= [];
+                var oldValue = origin.FontFeatures ?? [];
+                
+                if (origin.FontFeatures?.SequenceEqual(newValue) == true)
+                    return origin;
+                
+                newTextStyle.FontFeatures = overrideValue 
+                    ? newValue 
+                    : oldValue.Concat(newValue).GroupBy(x => x.Name).Select(x => x.First()).ToArray();
+
+                TextStyles.Add(newTextStyle);
+                return newTextStyle;
+            }
+        }
+        
         internal static TextStyle ApplyInheritedStyle(this TextStyle style, TextStyle parent)
         {
             return TextStyleApplyInheritedCache.GetOrAdd((style.Id, parent.Id), key => ApplyStyleProperties(key.originId, key.parentId, overrideStyle: false));

+ 1 - 1
Source/QuestPDF/QuestPDF.csproj

@@ -6,7 +6,7 @@
         <Version>2024.6.2</Version>
         <PackageDescription>QuestPDF is an open-source, modern and battle-tested library that can help you with generating PDF documents by offering friendly, discoverable and predictable C# fluent API. Easily generate PDF reports, invoices, exports, etc.</PackageDescription>
         <PackageReleaseNotes>$([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/Resources/ReleaseNotes.txt"))</PackageReleaseNotes>
-        <LangVersion>10</LangVersion>
+        <LangVersion>12</LangVersion>
         <GeneratePackageOnBuild>true</GeneratePackageOnBuild>
         <EnablePackageValidation>true</EnablePackageValidation>
         <PackageIcon>Logo.png</PackageIcon>

+ 24 - 0
Source/QuestPDF/Resources/Documentation.xml

@@ -221,6 +221,30 @@
         
         <param name="value">Actual font family (e.g. "Times New Roman", "Calibri", "Lato") or custom identifier used when invoking the <see cref="Drawing.FontManager.RegisterFontWithCustomName">FontManager</see> method.</param>
     </doc>
+
+    <doc for="text.fontFeatures">
+        <summary>
+            <para>Enables or disables font features defined by the OpenType standard, e.g. kernig, ligatures.</para>
+            
+            <para>
+                Font features are always encoded as 4-character long strings. For example, the ligatures feature is encode as <c>liga</c>, while the kernig feature as <c>kern</c>
+                For a list of available features, refer to the <see cref="Helpers.FontFeatures">FontFeatures</see> class.
+            </para>
+        </summary>
+
+        <remarks>
+            <para>To better understand what font features are, please consider example definitions. Please note that there are many more various font features. Most fonts support only a handful of them, having some of them enabled by default:</para>
+            <para>Ligatures in typography are specific character combinations that are designed to improve the aesthetics and readability of certain letter pairs. For example, in some fonts, when you type certain combinations of letters like 'fi' or 'fl', they will be replaced with a single, joined glyph.</para>
+            <para>Kerning in typography refers to the adjustment of space between characters in a proportional font. It's used to achieve a visually pleasing result by adjusting the spacing of specific character pairs. For example, in many fonts, the pair 'AV' is kerned so that the 'A' and 'V' are closer together than they would be by default.</para>
+        </remarks>
+
+        <example>
+            <para>TextStyle.Default.EnableFontFeature(FontFeatures.StandardLigatures);</para>
+            <para>TextStyle.Default.DisableFontFeature(FontFeatures.Kerning);</para>
+        </example>
+        
+        <param name="value">Provide font feature name or use the <see cref="Helpers.FontFeatures">FontFeatures</see> class.</param>
+    </doc>
     
     <doc for="text.fontSize">
         <summary>

BIN
Source/QuestPDF/Runtimes/osx-arm64/native/libQuestPdfSkia.dylib


+ 10 - 0
Source/QuestPDF/Skia/Text/SkTextStyle.cs

@@ -13,6 +13,9 @@ internal struct TextStyleConfiguration
     public const int FONT_FAMILIES_LENGTH = 16;
     [MarshalAs(UnmanagedType.ByValArray, SizeConst = FONT_FAMILIES_LENGTH)] public IntPtr[] FontFamilies;
     
+    public const int FONT_FEATURES_LENGTH = 16;
+    [MarshalAs(UnmanagedType.ByValArray, SizeConst = FONT_FEATURES_LENGTH)] public FontFeature[] FontFeatures;
+    
     public uint ForegroundColor;
     public uint BackgroundColor;
     
@@ -27,6 +30,13 @@ internal struct TextStyleConfiguration
     public float WordSpacing;
     public float BaselineOffset;
     
+    [StructLayout(LayoutKind.Sequential)]
+    public struct FontFeature
+    {
+        [MarshalAs(UnmanagedType.LPStr)] public string Name;
+        public int Value;
+    }
+    
     public enum FontWeights
     {
         Invisible = 0,