diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 1aa5789c0..b26bbb4bc 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -51,6 +51,11 @@ jobs:
- name: Build
run: dotnet build Source/HtmlRenderer.sln --configuration Release --no-restore
+ - name: Run Tests
+ run: dotnet test Source/HtmlRenderer.sln --configuration Release --no-build --logger trx --results-directory ${{ github.workspace }}/TestResults
+ env:
+ PDF_OUTPUT_DIRECTORY: ${{ github.workspace }}/pdf-artifacts/${{ matrix.platform.name }}-${{ matrix.dotnet.name }}
+
- name: Create HtmlRenderer.Core NuGet package
run: dotnet pack Source/HtmlRenderer/HtmlRenderer.csproj --configuration Release --include-symbols -p:SymbolPackageFormat=snupkg --no-build --verbosity normal --output ${{ env.NuGetDirectory }}
@@ -69,6 +74,14 @@ jobs:
name: "HTML Renderer (${{ matrix.platform.name }} ${{ matrix.dotnet.name }})"
path: ${{ env.NuGetDirectory }}/*.*nupkg
+ - name: Upload PDF artifacts
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: "PDF artifacts (${{ matrix.platform.name }} ${{ matrix.dotnet.name }})"
+ path: ${{ github.workspace }}/pdf-artifacts/**/*.pdf
+ if-no-files-found: ignore
+
- name: NuGet Login
if: startsWith(github.ref, 'refs/tags/') && matrix.dotnet.name == '.NET 8' && runner.os == 'Windows'
uses: NuGet/login@v1
diff --git a/Source/Directory.Build.props b/Source/Directory.Build.props
index dfc83f317..3cb376ff9 100644
--- a/Source/Directory.Build.props
+++ b/Source/Directory.Build.props
@@ -4,7 +4,7 @@
Arthur Teplitzki
Arthur Teplitzki
Open source hosted on GitHub
- Copyright © 2008-2025
+ Copyright © 2008-2026
1.6.0-dev
1.6.0.0
1.6.0.0
@@ -16,7 +16,7 @@
See https://github.com/ArthurHub/HTML-Renderer/releases.
false
-
+
diff --git a/Source/HtmlRenderer.PdfSharp/Adapters/PdfSharpAdapter.cs b/Source/HtmlRenderer.PdfSharp/Adapters/PdfSharpAdapter.cs
index e5fa309c0..16652f550 100644
--- a/Source/HtmlRenderer.PdfSharp/Adapters/PdfSharpAdapter.cs
+++ b/Source/HtmlRenderer.PdfSharp/Adapters/PdfSharpAdapter.cs
@@ -12,11 +12,11 @@
using PdfSharp.Drawing;
using PdfSharp.Pdf;
-using System.Drawing;
-using System.Drawing.Text;
+using System;
using System.IO;
using TheArtOfDev.HtmlRenderer.Adapters;
using TheArtOfDev.HtmlRenderer.Adapters.Entities;
+using TheArtOfDev.HtmlRenderer.PdfSharp.FontResolution;
using TheArtOfDev.HtmlRenderer.PdfSharp.Utilities;
namespace TheArtOfDev.HtmlRenderer.PdfSharp.Adapters
@@ -41,14 +41,16 @@ internal sealed class PdfSharpAdapter : RAdapter
///
private PdfSharpAdapter()
{
+ var fontResolver = FontResolver.Register();
+
AddFontFamilyMapping("monospace", "Courier New");
AddFontFamilyMapping("Helvetica", "Arial");
- var families = new InstalledFontCollection();
-
- foreach (var family in families.Families)
+ var fontFamilies = fontResolver.DiscoverFontFamilies();
+
+ foreach (var fontFamily in fontFamilies)
{
- AddFontFamily(new FontFamilyAdapter(new XFontFamily(family.Name)));
+ AddFontFamily(new FontFamilyAdapter(new XFontFamily(fontFamily)));
}
}
@@ -64,8 +66,23 @@ protected override RColor GetColorInt(string colorName)
{
try
{
- var color = Color.FromKnownColor((KnownColor)System.Enum.Parse(typeof(KnownColor), colorName, true));
- return Utils.Convert(color);
+ var colorResourceManager = new XColorResourceManager();
+
+ var knownColors = XColorResourceManager.GetKnownColors(true);
+
+ foreach (var knownColor in knownColors)
+ {
+ var name = colorResourceManager.ToColorName(knownColor);
+ if (!string.Equals(name, colorName, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var xColor = XColor.FromKnownColor(knownColor);
+ return xColor.IsEmpty ? RColor.Empty : Utils.Convert(xColor);
+ }
+
+ return RColor.Empty;
}
catch
{
@@ -119,14 +136,14 @@ protected override RImage ImageFromStreamInt(Stream memoryStream)
protected override RFont CreateFontInt(string family, double size, RFontStyle style)
{
- var fontStyle = (XFontStyle)((int)style);
+ var fontStyle = Utils.Convert(style);
var xFont = new XFont(family, size, fontStyle, new XPdfFontOptions(PdfFontEncoding.Unicode));
return new FontAdapter(xFont);
}
protected override RFont CreateFontInt(RFontFamily family, double size, RFontStyle style)
{
- var fontStyle = (XFontStyle)((int)style);
+ var fontStyle = Utils.Convert(style);
var xFont = new XFont(((FontFamilyAdapter)family).FontFamily.Name, size, fontStyle, new XPdfFontOptions(PdfFontEncoding.Unicode));
return new FontAdapter(xFont);
}
diff --git a/Source/HtmlRenderer.PdfSharp/FontResolution/Discovery/DirectoryFontDiscovery.cs b/Source/HtmlRenderer.PdfSharp/FontResolution/Discovery/DirectoryFontDiscovery.cs
new file mode 100644
index 000000000..c36b2292a
--- /dev/null
+++ b/Source/HtmlRenderer.PdfSharp/FontResolution/Discovery/DirectoryFontDiscovery.cs
@@ -0,0 +1,67 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices;
+using TheArtOfDev.HtmlRenderer.PdfSharp.FontResolution.Parsing;
+
+namespace TheArtOfDev.HtmlRenderer.PdfSharp.FontResolution.Discovery
+{
+ internal static class DirectoryFontDiscovery
+ {
+ public static List DiscoverFontFilesFromDirectories(List customFontDirectories)
+ {
+ var fontDirectories = new List();
+
+ // Add platform-specific directories
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ fontDirectories.Add(Environment.GetFolderPath(Environment.SpecialFolder.Fonts));
+ }
+ else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+ {
+ fontDirectories.AddRange(new[]
+ {
+ "/usr/share/fonts",
+ "/usr/local/share/fonts",
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".fonts"),
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local/share/fonts")
+ });
+ }
+
+ // Add custom directories
+ fontDirectories.AddRange(customFontDirectories);
+
+ return DiscoverFontFilesInDirectories(fontDirectories);
+ }
+
+ public static List DiscoverFontFilesInDirectories(List directories)
+ {
+ var fontInfos = new List();
+
+ foreach (var directory in directories)
+ {
+ if (!Directory.Exists(directory))
+ {
+ continue;
+ }
+
+ try
+ {
+ fontInfos.AddRange(FontDiscoveryService.SupportedFontExtensions.SelectMany(e => Directory.GetFiles(directory, $"*{e}", SearchOption.AllDirectories))
+ .Select(f => new FontInfo
+ {
+ Name = Path.GetFileNameWithoutExtension(f),
+ FilePath = f
+ }));
+ }
+ catch
+ {
+ // Silently continue on directory access errors
+ }
+ }
+
+ return fontInfos;
+ }
+ }
+}
diff --git a/Source/HtmlRenderer.PdfSharp/FontResolution/Discovery/FontDiscoveryService.cs b/Source/HtmlRenderer.PdfSharp/FontResolution/Discovery/FontDiscoveryService.cs
new file mode 100644
index 000000000..81b793cc8
--- /dev/null
+++ b/Source/HtmlRenderer.PdfSharp/FontResolution/Discovery/FontDiscoveryService.cs
@@ -0,0 +1,46 @@
+using System.Collections.Generic;
+using System.Runtime.InteropServices;
+
+namespace TheArtOfDev.HtmlRenderer.PdfSharp.FontResolution.Discovery
+{
+ internal class FontDiscoveryService
+ {
+ private readonly List _customFontDirectories = new List();
+
+ public static string[] SupportedFontExtensions { get; } = new[] { ".ttf", ".otf" };
+
+ public List DiscoverFontInfos()
+ {
+ var fontInfos = new List();
+
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ fontInfos = WindowsFontDiscovery.DiscoverFontInfos();
+ }
+ else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+ {
+ fontInfos = LinuxFontDiscovery.DiscoverFontFiles();
+ }
+ else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+ {
+ fontInfos = MacOsFontDiscovery.DiscoverFontFiles();
+ }
+
+ // Always use directory scan as fallback
+ fontInfos.AddRange(DirectoryFontDiscovery.DiscoverFontFilesFromDirectories(_customFontDirectories));
+
+ return fontInfos;
+ }
+
+ public void RegisterCustomFontDirectory(string fontDirectory)
+ {
+ if (_customFontDirectories.Contains(fontDirectory))
+ {
+ return;
+ }
+
+ _customFontDirectories.Add(fontDirectory);
+ }
+ }
+}
+
diff --git a/Source/HtmlRenderer.PdfSharp/FontResolution/Discovery/LinuxFontDiscovery.cs b/Source/HtmlRenderer.PdfSharp/FontResolution/Discovery/LinuxFontDiscovery.cs
new file mode 100644
index 000000000..9bfee68eb
--- /dev/null
+++ b/Source/HtmlRenderer.PdfSharp/FontResolution/Discovery/LinuxFontDiscovery.cs
@@ -0,0 +1,129 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+
+namespace TheArtOfDev.HtmlRenderer.PdfSharp.FontResolution.Discovery
+{
+ internal static class LinuxFontDiscovery
+ {
+ public static List DiscoverFontFiles()
+ {
+ var fontInfos = new List();
+
+ if (!IsFontConfigAvailable())
+ {
+ return fontInfos;
+ }
+
+ try
+ {
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = "fc-list",
+ Arguments = ":",
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+
+ using (var process = Process.Start(startInfo))
+ {
+ if (process == null)
+ {
+ return fontInfos;
+ }
+
+ var output = process.StandardOutput.ReadToEnd();
+ process.WaitForExit();
+
+ if (process.ExitCode != 0 || string.IsNullOrWhiteSpace(output))
+ {
+ return fontInfos;
+ }
+
+ var lines = output.Split(new[] { '\n' }, StringSplitOptions.RemoveEmptyEntries);
+ foreach (var line in lines)
+ {
+ var fontFamilies = DiscoverFontFile(line);
+
+ if (fontFamilies.Count > 0)
+ {
+ fontInfos.AddRange(fontFamilies);
+ }
+ }
+ }
+ }
+ catch
+ {
+ // Silently continue on errors
+ }
+
+ return fontInfos;
+ }
+
+ private static bool IsFontConfigAvailable()
+ {
+ try
+ {
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = "which",
+ Arguments = "fc-list",
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+
+ using (var process = Process.Start(startInfo))
+ {
+ if (process == null)
+ {
+ return false;
+ }
+
+ process.WaitForExit();
+ return process.ExitCode == 0;
+ }
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static List DiscoverFontFile(string line)
+ {
+ var fonts = new List();
+
+ // Parse fc-list output: "path: family1,family2:style=style"
+ var parts = line.Split(new[] { ':' }, 2, StringSplitOptions.None);
+ if (parts.Length < 2)
+ {
+ return fonts;
+ }
+
+ var fontFilePath = parts[0].Trim();
+ var families = parts[1].Trim().Split(',');
+
+ if (!File.Exists(fontFilePath) ||
+ !FontDiscoveryService.SupportedFontExtensions.Any(e => fontFilePath.EndsWith(e, StringComparison.InvariantCultureIgnoreCase)))
+ {
+ return fonts;
+ }
+
+ foreach (var family in families)
+ {
+ var familyName = family.Trim();
+ if (!string.IsNullOrEmpty(familyName) && File.Exists(fontFilePath))
+ {
+ fonts.Add(new FontInfo { Name = familyName, FilePath = fontFilePath });
+ }
+ }
+ return fonts;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Source/HtmlRenderer.PdfSharp/FontResolution/Discovery/MacOsFontDiscovery.cs b/Source/HtmlRenderer.PdfSharp/FontResolution/Discovery/MacOsFontDiscovery.cs
new file mode 100644
index 000000000..aae467b64
--- /dev/null
+++ b/Source/HtmlRenderer.PdfSharp/FontResolution/Discovery/MacOsFontDiscovery.cs
@@ -0,0 +1,21 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+
+namespace TheArtOfDev.HtmlRenderer.PdfSharp.FontResolution.Discovery
+{
+ internal static class MacOsFontDiscovery
+ {
+ public static List DiscoverFontFiles()
+ {
+ var fontDirectories = new List
+ {
+ "/System/Library/Fonts",
+ "/Library/Fonts",
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library/Fonts")
+ };
+
+ return DirectoryFontDiscovery.DiscoverFontFilesInDirectories(fontDirectories);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Source/HtmlRenderer.PdfSharp/FontResolution/Discovery/WindowsFontDiscovery.cs b/Source/HtmlRenderer.PdfSharp/FontResolution/Discovery/WindowsFontDiscovery.cs
new file mode 100644
index 000000000..7885b6a54
--- /dev/null
+++ b/Source/HtmlRenderer.PdfSharp/FontResolution/Discovery/WindowsFontDiscovery.cs
@@ -0,0 +1,76 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Linq;
+using Microsoft.Win32;
+
+namespace TheArtOfDev.HtmlRenderer.PdfSharp.FontResolution.Discovery
+{
+ [SuppressMessage("Interoperability", "CA1416:Validate platform compatibility", Justification = "Platform switching in calling class")]
+ internal static class WindowsFontDiscovery
+ {
+ public static List DiscoverFontInfos()
+ {
+ var fontInfos = new List();
+ try
+ {
+ using (var machineRegistryKey = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts", false))
+ using (var userRegistryKey = Registry.CurrentUser.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts", false))
+ {
+ foreach (var registryKey in new[] { machineRegistryKey, userRegistryKey })
+ {
+ if (registryKey == null)
+ {
+ continue;
+ }
+
+ foreach (var fontName in registryKey.GetValueNames())
+ {
+ var fontFilePath = registryKey.GetValue(fontName)?.ToString();
+
+ var fontFile = DiscoverFontFile(fontFilePath);
+
+ if (fontFile != null)
+ {
+ fontInfos.Add(new FontInfo
+ {
+ Name = fontName,
+ FilePath = fontFile
+ });
+ }
+ }
+ }
+ }
+ }
+ catch
+ {
+ // Silently continue on errors
+ }
+
+ return fontInfos;
+ }
+
+ private static string DiscoverFontFile(string fontFilePath)
+ {
+ if (fontFilePath == null ||
+ !FontDiscoveryService.SupportedFontExtensions.Any(e => fontFilePath.EndsWith(e, StringComparison.InvariantCultureIgnoreCase)))
+ {
+ return null;
+ }
+
+ // If the path is not absolute, the font is in the Windows Fonts folder
+ if (!Path.IsPathRooted(fontFilePath))
+ {
+ fontFilePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), fontFilePath);
+ }
+
+ if (!File.Exists(fontFilePath))
+ {
+ return null;
+ }
+
+ return fontFilePath;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Source/HtmlRenderer.PdfSharp/FontResolution/Fallback/Tuffy Bold Italic.ttf b/Source/HtmlRenderer.PdfSharp/FontResolution/Fallback/Tuffy Bold Italic.ttf
new file mode 100644
index 000000000..7be4c4155
Binary files /dev/null and b/Source/HtmlRenderer.PdfSharp/FontResolution/Fallback/Tuffy Bold Italic.ttf differ
diff --git a/Source/HtmlRenderer.PdfSharp/FontResolution/Fallback/Tuffy Bold.ttf b/Source/HtmlRenderer.PdfSharp/FontResolution/Fallback/Tuffy Bold.ttf
new file mode 100644
index 000000000..33fcc8af6
Binary files /dev/null and b/Source/HtmlRenderer.PdfSharp/FontResolution/Fallback/Tuffy Bold.ttf differ
diff --git a/Source/HtmlRenderer.PdfSharp/FontResolution/Fallback/Tuffy Italic.ttf b/Source/HtmlRenderer.PdfSharp/FontResolution/Fallback/Tuffy Italic.ttf
new file mode 100644
index 000000000..e34a3bee1
Binary files /dev/null and b/Source/HtmlRenderer.PdfSharp/FontResolution/Fallback/Tuffy Italic.ttf differ
diff --git a/Source/HtmlRenderer.PdfSharp/FontResolution/Fallback/Tuffy.ttf b/Source/HtmlRenderer.PdfSharp/FontResolution/Fallback/Tuffy.ttf
new file mode 100644
index 000000000..ade553a60
Binary files /dev/null and b/Source/HtmlRenderer.PdfSharp/FontResolution/Fallback/Tuffy.ttf differ
diff --git a/Source/HtmlRenderer.PdfSharp/FontResolution/FontAttributes.cs b/Source/HtmlRenderer.PdfSharp/FontResolution/FontAttributes.cs
new file mode 100644
index 000000000..c1c2ccfb4
--- /dev/null
+++ b/Source/HtmlRenderer.PdfSharp/FontResolution/FontAttributes.cs
@@ -0,0 +1,42 @@
+namespace TheArtOfDev.HtmlRenderer.PdfSharp.FontResolution
+{
+ public enum FontWeight
+ {
+ Thin = 100,
+ ExtraLight = 200,
+ Light = 300,
+ Normal = 400,
+ Medium = 500,
+ SemiBold = 600,
+ Bold = 700,
+ ExtraBold = 800,
+ Black = 900,
+ }
+
+ public enum FontWidth
+ {
+ UltraCondensed = 50,
+ ExtraCondensed = 62,
+ Condensed = 75,
+ SemiCondensed = 87,
+ Medium = 100,
+ SemiExpanded = 112,
+ Expanded = 125,
+ ExtraExpanded = 150,
+ UltraExpanded = 200,
+ }
+
+ public enum FontStyle
+ {
+ Normal,
+ Italic,
+ Oblique,
+ }
+
+ public class FontAttributes
+ {
+ public FontWeight Weight { get; set; } = FontWeight.Normal;
+ public FontStyle Style { get; set; } = FontStyle.Normal;
+ public FontWidth Width { get; set; } = FontWidth.Medium;
+ }
+}
diff --git a/Source/HtmlRenderer.PdfSharp/FontResolution/FontInfo.cs b/Source/HtmlRenderer.PdfSharp/FontResolution/FontInfo.cs
new file mode 100644
index 000000000..cceb0ae97
--- /dev/null
+++ b/Source/HtmlRenderer.PdfSharp/FontResolution/FontInfo.cs
@@ -0,0 +1,8 @@
+namespace TheArtOfDev.HtmlRenderer.PdfSharp.FontResolution
+{
+ public class FontInfo
+ {
+ public string Name { get; set; }
+ public string FilePath { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/Source/HtmlRenderer.PdfSharp/FontResolution/FontMetadata.cs b/Source/HtmlRenderer.PdfSharp/FontResolution/FontMetadata.cs
new file mode 100644
index 000000000..d452b9f0e
--- /dev/null
+++ b/Source/HtmlRenderer.PdfSharp/FontResolution/FontMetadata.cs
@@ -0,0 +1,18 @@
+namespace TheArtOfDev.HtmlRenderer.PdfSharp.FontResolution
+{
+ public class FontMetadata
+ {
+ public string FilePath { get; set; }
+
+ public string Family { get; set; }
+ public string Subfamily { get; set; }
+
+ public string PreferredFamily { get; set; }
+ public string PreferredSubfamily { get; set; }
+
+ public string FullName { get; set; }
+ public string PostScriptName { get; set; }
+
+ public FontAttributes Attributes { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/Source/HtmlRenderer.PdfSharp/FontResolution/FontNameResolver.cs b/Source/HtmlRenderer.PdfSharp/FontResolution/FontNameResolver.cs
new file mode 100644
index 000000000..7faced10b
--- /dev/null
+++ b/Source/HtmlRenderer.PdfSharp/FontResolution/FontNameResolver.cs
@@ -0,0 +1,165 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+
+namespace TheArtOfDev.HtmlRenderer.PdfSharp.FontResolution
+{
+ public static class FontNameResolver
+ {
+ public static List ResolveFontNames(string fontName, FontAttributes fontAttributes, FontResolveStrategy resolveStrategy)
+ {
+ var fontNames = new List();
+
+ if (resolveStrategy == FontResolveStrategy.Strict)
+ {
+ fontNames.Add(StylizeFontNameStrict(fontName, fontAttributes));
+ }
+ else
+ {
+ fontNames.AddRange(StylizeFontNameClosest(fontName, fontAttributes));
+ }
+
+ return fontNames;
+ }
+
+ public static string StylizeFontNameFamily(FontMetadata metadata)
+ {
+ if (metadata.Family is null || metadata.Attributes is null)
+ {
+ return string.Empty;
+ }
+
+ return BuildStylizedFontName(metadata.Family, MatchWeightStrict(metadata.Attributes.Weight), MatchStyleStrict(metadata.Attributes.Style));
+ }
+
+ public static string StylizeFontNamePreferredFamily(FontMetadata metadata)
+ {
+ if (metadata.PreferredFamily is null || metadata.PreferredSubfamily is null)
+ {
+ return string.Empty;
+ }
+
+ return string.IsNullOrEmpty(metadata.PreferredSubfamily)
+ ? metadata.PreferredFamily
+ : $"{metadata.PreferredFamily} {metadata.PreferredSubfamily}";
+ }
+
+ public static string StylizeFontNameStrict(string fontName, FontAttributes fontAttributes)
+ {
+ return BuildStylizedFontName(fontName, MatchWeightStrict(fontAttributes.Weight), MatchStyleStrict(fontAttributes.Style));
+ }
+
+ private static string[] StylizeFontNameClosest(string fontName, FontAttributes fontAttributes)
+ {
+ var fontNames = new List
+ {
+ StylizeFontNameStrict(fontName, fontAttributes),
+ BuildStylizedFontName(fontName, MatchWeightStrict(fontAttributes.Weight), MatchStyleObliqueAndItalic(fontAttributes.Style)),
+ BuildStylizedFontName(fontName, MatchWeightMiddle(fontAttributes.Weight), MatchStyleStrict(fontAttributes.Style)),
+ BuildStylizedFontName(fontName, MatchWeightMiddle(fontAttributes.Weight), MatchStyleObliqueAndItalic(fontAttributes.Style)),
+ BuildStylizedFontName(fontName, MatchWeightOutward(fontAttributes.Weight), MatchStyleStrict(fontAttributes.Style)),
+ BuildStylizedFontName(fontName, MatchWeightOutward(fontAttributes.Weight), MatchStyleObliqueAndItalic(fontAttributes.Style)),
+ fontName
+ };
+
+ return fontNames.Distinct().Where(x => !string.IsNullOrEmpty(x)).ToArray();
+ }
+
+ private static string MatchStyleStrict(FontStyle fontStyle)
+ {
+ if (fontStyle == FontStyle.Oblique || fontStyle == FontStyle.Italic)
+ {
+ return fontStyle.ToString();
+ }
+
+ return string.Empty;
+ }
+
+ private static string MatchStyleObliqueAndItalic(FontStyle fontStyle)
+ {
+ if (fontStyle == FontStyle.Oblique || fontStyle == FontStyle.Italic)
+ {
+ return nameof(FontStyle.Italic);
+ }
+
+ return string.Empty;
+ }
+
+ private static string MatchWeightStrict(FontWeight fontWeight)
+ {
+ if (fontWeight == FontWeight.Normal)
+ {
+ return string.Empty;
+ }
+
+ return fontWeight.ToString();
+ }
+
+ private static string MatchWeightMiddle(FontWeight fontWeight)
+ {
+ if (fontWeight == FontWeight.Light)
+ {
+ return nameof(FontWeight.Light);
+ }
+
+ if (fontWeight == FontWeight.Medium)
+ {
+ return nameof(FontWeight.Medium);
+ }
+
+ if (fontWeight < FontWeight.Light)
+ {
+ return $"{fontWeight + 100}";
+ }
+
+ if (fontWeight < FontWeight.Medium)
+ {
+ return $"{fontWeight - 100}";
+ }
+
+ return string.Empty;
+ }
+
+ private static string MatchWeightOutward(FontWeight fontWeight)
+ {
+ if (fontWeight == FontWeight.Thin)
+ {
+ return nameof(FontWeight.Thin);
+ }
+
+ if (fontWeight == FontWeight.Black)
+ {
+ return nameof(FontWeight.Black);
+ }
+
+ if (fontWeight < FontWeight.Normal)
+ {
+ return $"{fontWeight - 100}";
+ }
+
+ if (fontWeight > FontWeight.Normal)
+ {
+ return $"{fontWeight + 100}";
+ }
+
+ return string.Empty;
+ }
+
+ private static string BuildStylizedFontName(string fontName, string fontWeight, string fontStyle)
+ {
+ var stylizedFontName = new StringBuilder(fontName);
+
+ if (!string.IsNullOrEmpty(fontWeight))
+ {
+ stylizedFontName.Append($" {fontWeight}");
+ }
+
+ if (!string.IsNullOrEmpty(fontStyle))
+ {
+ stylizedFontName.Append($" {fontStyle}");
+ }
+
+ return stylizedFontName.ToString();
+ }
+ }
+}
\ No newline at end of file
diff --git a/Source/HtmlRenderer.PdfSharp/FontResolution/FontResolveStrategy.cs b/Source/HtmlRenderer.PdfSharp/FontResolution/FontResolveStrategy.cs
new file mode 100644
index 000000000..ac7d9c126
--- /dev/null
+++ b/Source/HtmlRenderer.PdfSharp/FontResolution/FontResolveStrategy.cs
@@ -0,0 +1,8 @@
+namespace TheArtOfDev.HtmlRenderer.PdfSharp.FontResolution
+{
+ public enum FontResolveStrategy
+ {
+ Strict,
+ Closest
+ }
+}
\ No newline at end of file
diff --git a/Source/HtmlRenderer.PdfSharp/FontResolution/FontResolver.cs b/Source/HtmlRenderer.PdfSharp/FontResolution/FontResolver.cs
new file mode 100644
index 000000000..373f9a1c7
--- /dev/null
+++ b/Source/HtmlRenderer.PdfSharp/FontResolution/FontResolver.cs
@@ -0,0 +1,149 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using PdfSharp.Fonts;
+using TheArtOfDev.HtmlRenderer.PdfSharp.FontResolution.Discovery;
+using TheArtOfDev.HtmlRenderer.PdfSharp.FontResolution.Parsing;
+
+namespace TheArtOfDev.HtmlRenderer.PdfSharp.FontResolution
+{
+ public class FontResolver : IFontResolver
+ {
+ private readonly FontDiscoveryService _fontDiscoveryService = new FontDiscoveryService();
+ private readonly Dictionary _fontCache = new Dictionary();
+
+ private List _fontMetadataCache = new List();
+
+ public static string FallbackFont => "Tuffy";
+ public FontResolveStrategy ResolveStrategy { get; set; } = FontResolveStrategy.Strict;
+
+ public FontResolverInfo ResolveTypeface(string familyName, bool bold, bool italic)
+ {
+ if (_fontMetadataCache.Count == 0)
+ {
+ DiscoverFonts();
+ }
+
+ var fontAttributes = new FontAttributes
+ {
+ Style = italic ? FontStyle.Italic : FontStyle.Normal,
+ Weight = bold ? FontWeight.Bold : FontWeight.Normal
+ };
+
+ var fontNames = FontNameResolver.ResolveFontNames(familyName, fontAttributes, ResolveStrategy);
+
+ foreach (var name in fontNames)
+ {
+ foreach (var metadata in _fontMetadataCache)
+ {
+ var stylizedPreferredFamilyName = FontNameResolver.StylizeFontNamePreferredFamily(metadata);
+ var stylizedFamilyName = FontNameResolver.StylizeFontNameFamily(metadata);
+
+ if (stylizedPreferredFamilyName == name || stylizedFamilyName == name)
+ {
+ if (!_fontCache.ContainsKey(name))
+ {
+ _fontCache[name] = metadata;
+ }
+
+ return new FontResolverInfo(name);
+ }
+ }
+ }
+
+ // Return fallback if no matching font is found
+ var stylizedFallbackFontName = FontNameResolver.StylizeFontNameStrict(FallbackFont, fontAttributes);
+ return new FontResolverInfo(stylizedFallbackFontName);
+ }
+
+ public byte[] GetFont(string faceName)
+ {
+ if (_fontCache.TryGetValue(faceName, out var font))
+ {
+ if (File.Exists(font.FilePath))
+ {
+ return File.ReadAllBytes(font.FilePath);
+ }
+ }
+
+ if (!faceName.StartsWith(FallbackFont))
+ {
+ return null;
+ }
+
+ // Try to load embedded fallback font
+ var assembly = Assembly.GetExecutingAssembly();
+
+ var resourceName = assembly.GetManifestResourceNames()
+ .FirstOrDefault(r => r.EndsWith($"{faceName}.ttf", StringComparison.OrdinalIgnoreCase));
+ if (resourceName == null)
+ {
+ return null;
+ }
+
+ using (var stream = assembly.GetManifestResourceStream(resourceName))
+ {
+ if (stream == null)
+ {
+ return null;
+ }
+
+ using (var memoryStream = new MemoryStream())
+ {
+ stream.CopyTo(memoryStream);
+ return memoryStream.ToArray();
+ }
+ }
+ }
+
+ public void RegisterCustomFontDirectory(string fontDirectory)
+ {
+ _fontDiscoveryService.RegisterCustomFontDirectory(fontDirectory);
+ }
+
+ public List DiscoverFontFamilies()
+ {
+ if (_fontMetadataCache.Count == 0)
+ {
+ DiscoverFonts();
+ }
+
+ var fontFamilies = new List();
+
+ foreach (var metadata in _fontMetadataCache)
+ {
+ var stylizedPreferredFamilyName = FontNameResolver.StylizeFontNamePreferredFamily(metadata);
+ var stylizedFamilyName = FontNameResolver.StylizeFontNameFamily(metadata);
+
+ if (!string.IsNullOrEmpty(stylizedPreferredFamilyName))
+ {
+ fontFamilies.Add(stylizedFamilyName);
+ }
+
+ fontFamilies.Add(stylizedFamilyName);
+ }
+
+ return fontFamilies;
+ }
+
+ public static FontResolver Register()
+ {
+ var fontResolver = new FontResolver();
+ GlobalFontSettings.FontResolver = fontResolver;
+ return fontResolver;
+ }
+
+ private void DiscoverFonts()
+ {
+ var fontInfos = _fontDiscoveryService.DiscoverFontInfos();
+
+ _fontMetadataCache = fontInfos.Select(fontInfo => fontInfo.FilePath)
+ .Distinct()
+ .Select(FontParser.ExtractFontMetadata)
+ .Where(fontMetadata => fontMetadata != null)
+ .ToList();
+ }
+ }
+}
\ No newline at end of file
diff --git a/Source/HtmlRenderer.PdfSharp/FontResolution/Parsing/BinaryParser.cs b/Source/HtmlRenderer.PdfSharp/FontResolution/Parsing/BinaryParser.cs
new file mode 100644
index 000000000..b67434a40
--- /dev/null
+++ b/Source/HtmlRenderer.PdfSharp/FontResolution/Parsing/BinaryParser.cs
@@ -0,0 +1,69 @@
+using System;
+using System.Buffers;
+using System.Text;
+
+namespace TheArtOfDev.HtmlRenderer.PdfSharp.FontResolution.Parsing
+{
+ internal static class BinaryParser
+ {
+ public static ushort ReadUint16BigEndian(Span data, int offset)
+ {
+ if (offset + 1 >= data.Length)
+ {
+ return 0;
+ }
+
+ return (ushort)((data[offset] << 8) | data[offset + 1]);
+ }
+
+ public static uint ReadUint32BigEndian(Span data, int offset)
+ {
+ if (offset + 3 >= data.Length)
+ {
+ return 0;
+ }
+
+ return ((uint)data[offset] << 24) | ((uint)data[offset + 1] << 16) |
+ ((uint)data[offset + 2] << 8) | data[offset + 3];
+ }
+
+ public static string ReadAsciiString(Span data, int offset, int length)
+ {
+ return Encoding.ASCII.GetString(data.Slice(offset, length).ToArray());
+ }
+
+ public static string ReadAsciiString(Span data)
+ {
+ return ReadAsciiString(data, 0, data.Length);
+ }
+
+ public static string ReadUtf16StringBigEndian(Span data)
+ {
+ var chars = ArrayPool.Shared.Rent(data.Length);
+
+ try
+ {
+ var index = 0;
+
+ for (var i = 0; i < data.Length - 1; i += 2)
+ {
+ var c = (char)((data[i] << 8) | data[i + 1]);
+
+ if (c == '\0')
+ {
+ continue;
+ }
+
+ chars[index] = c;
+ ++index;
+ }
+
+ return new string(chars, 0, index);
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(chars);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Source/HtmlRenderer.PdfSharp/FontResolution/Parsing/FontParser.cs b/Source/HtmlRenderer.PdfSharp/FontResolution/Parsing/FontParser.cs
new file mode 100644
index 000000000..db01ae555
--- /dev/null
+++ b/Source/HtmlRenderer.PdfSharp/FontResolution/Parsing/FontParser.cs
@@ -0,0 +1,467 @@
+using System;
+using System.Buffers;
+using System.Collections.Generic;
+using System.IO;
+
+namespace TheArtOfDev.HtmlRenderer.PdfSharp.FontResolution.Parsing
+{
+ internal static class FontParser
+ {
+ public static FontMetadata ExtractFontMetadata(string fontFilePath)
+ {
+ try
+ {
+ using (var fs = new FileStream(fontFilePath, FileMode.Open, FileAccess.Read))
+ {
+ if (fs.Length > int.MaxValue)
+ {
+ return null;
+ }
+
+ var pooledBytes = ArrayPool.Shared.Rent((int)fs.Length);
+
+ try
+ {
+ var bytesRead = fs.Read(pooledBytes, 0, (int)fs.Length);
+ var bytes = pooledBytes.AsSpan(0, bytesRead); // Fixed size span view
+
+ var metadata = ParseFontMetadata(bytes);
+ if (metadata != null)
+ {
+ metadata.FilePath = fontFilePath;
+ }
+ return metadata;
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(pooledBytes);
+ }
+ }
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static FontMetadata ParseFontMetadata(Span bytes)
+ {
+ // Read the font header (big-endian)
+ var scalarType = BinaryParser.ReadUint32BigEndian(bytes, 0);
+
+ // Verify it's a valid TTF/OTF file
+ if (scalarType != 0x00010000 && // TrueType version 1.0
+ scalarType != 0x74727565 && // "true" - TrueType
+ scalarType != 0x4F54544F) // "OTTO" - OpenType with CFF outline
+ {
+ return null;
+ }
+
+ var tableCount = BinaryParser.ReadUint16BigEndian(bytes, 4);
+
+ // Find the "name" and "os/2" tables
+ long nameTableOffset = -1;
+ uint? os2TableOffset = null;
+ var tableRecordOffset = 12;
+
+ for (var i = 0; i < tableCount; i++)
+ {
+ var tag = BinaryParser.ReadAsciiString(bytes, tableRecordOffset, 4);
+ var offset = BinaryParser.ReadUint32BigEndian(bytes, tableRecordOffset + 8);
+
+ if (tag == "name")
+ {
+ nameTableOffset = offset;
+ }
+ else if (tag == "OS/2")
+ {
+ os2TableOffset = offset;
+ }
+
+ tableRecordOffset += 16;
+ }
+
+ if (nameTableOffset == -1)
+ {
+ return null;
+ }
+
+ var metadata = new FontMetadata();
+
+ ParseNameTable(bytes, nameTableOffset, metadata);
+
+ // Extract OS/2 table data if available for more accurate weight/width/style info
+ var hasOs2Data = ParseOs2Table(bytes, os2TableOffset, metadata);
+
+ // Parse style from subfamily if not already set from OS/2
+ if (!hasOs2Data)
+ {
+ ParseStyleFromSubfamily(metadata, metadata.Subfamily);
+ }
+
+ // Return metadata if we at least have a family name
+ return !string.IsNullOrEmpty(metadata.Family) ? metadata : null;
+ }
+
+ private static void ParseNameTable(Span bytes, long nameTableOffset, FontMetadata metadata)
+ {
+ var nameTablePos = (int)nameTableOffset;
+ var count = BinaryParser.ReadUint16BigEndian(bytes, nameTablePos + 2);
+ var stringDataOffset = BinaryParser.ReadUint16BigEndian(bytes, nameTablePos + 4);
+
+ // Track which values have been set with English entries
+ var hasEnglishValue = new HashSet();
+
+ // First pass: Look for English entries
+ var nameRecordOffset = nameTablePos + 6;
+ for (var i = 0; i < count; i++, nameRecordOffset += 12)
+ {
+ var platformId = BinaryParser.ReadUint16BigEndian(bytes, nameRecordOffset);
+ var encodingId = BinaryParser.ReadUint16BigEndian(bytes, nameRecordOffset + 2);
+ var languageId = BinaryParser.ReadUint16BigEndian(bytes, nameRecordOffset + 4);
+ var nameId = BinaryParser.ReadUint16BigEndian(bytes, nameRecordOffset + 6);
+ var length = BinaryParser.ReadUint16BigEndian(bytes, nameRecordOffset + 8);
+ var offset = BinaryParser.ReadUint16BigEndian(bytes, nameRecordOffset + 10);
+
+ // Filter for English language entries:
+ // Platform 3 (Windows): languageID 0x0409 (1033) = US English
+ // Platform 1 (Macintosh): languageID 0 = English
+ var isEnglish = (platformId == 3 && languageId == 0x0409) ||
+ (platformId == 1 && languageId == 0);
+
+ if ((platformId != 3 && platformId != 1) || !IsRelevantNameId(nameId))
+ {
+ continue;
+ }
+
+ var stringPos = (int)nameTableOffset + stringDataOffset + offset;
+
+ if (stringPos + length > bytes.Length)
+ {
+ continue;
+ }
+
+ var nameBytes = bytes.Slice(stringPos, length);
+
+ var decodedString = DecodeNameTableString(nameBytes, platformId, encodingId);
+
+ if (string.IsNullOrEmpty(decodedString) || decodedString == null)
+ {
+ continue;
+ }
+
+ // If English, set the value and mark as having English
+ if (isEnglish)
+ {
+ if (SetMetadataValue(metadata, nameId, decodedString))
+ {
+ hasEnglishValue.Add(nameId);
+ }
+ }
+ // If not English but we haven't found an English entry yet, use it as fallback
+ else if (!hasEnglishValue.Contains(nameId))
+ {
+ SetMetadataValue(metadata, nameId, decodedString);
+ }
+ }
+ }
+
+ private static bool IsRelevantNameId(ushort nameId)
+ {
+ return nameId is 1 || nameId is 2 || nameId is 4 || nameId is 6 || nameId is 16 || nameId is 17;
+ }
+
+ private static bool SetMetadataValue(FontMetadata metadata, ushort nameId, string value)
+ {
+ switch (nameId)
+ {
+ case 1: // Legacy Family name
+ if (metadata.Family == null)
+ {
+ metadata.Family = value;
+ return true;
+ }
+ return false;
+ case 2: // Legacy Subfamily
+ if (metadata.Subfamily == null)
+ {
+ metadata.Subfamily = value;
+ ParseStyleFromSubfamily(metadata, value);
+ return true;
+ }
+ return false;
+ case 4: // Full font name
+ if (metadata.FullName == null)
+ {
+ metadata.FullName = value;
+ return true;
+ }
+ return false;
+ case 6: // PostScript name
+ if (metadata.PostScriptName == null)
+ {
+ metadata.PostScriptName = value;
+ return true;
+ }
+ return false;
+ case 16: // Preferred Family (Typographic Family)
+ if (metadata.PreferredFamily == null)
+ {
+ metadata.PreferredFamily = value;
+ return true;
+ }
+ return false;
+ case 17: // Preferred Subfamily (Typographic Subfamily)
+ if (metadata.PreferredSubfamily == null)
+ {
+ metadata.PreferredSubfamily = value;
+ return true;
+ }
+ return false;
+ default:
+ return false;
+ }
+ }
+
+ private static void ParseStyleFromSubfamily(FontMetadata metadata, string subfamily)
+ {
+ if (subfamily is null)
+ {
+ return;
+ }
+
+ var lower = subfamily.ToLowerInvariant();
+
+ // Parse weight, width, and style
+ var weight = ParseWeight(lower);
+ var width = ParseWidth(lower);
+ var style = ParseStyle(lower);
+
+ // Update metadata with parsed attributes (note: FontAttributes order is Weight, Style, Width)
+ metadata.Attributes = new FontAttributes
+ {
+ Weight = weight,
+ Style = style,
+ Width = width
+ };
+ }
+
+ private static FontStyle ParseStyle(string lowerSubfamily)
+ {
+ if (lowerSubfamily.Contains("oblique"))
+ {
+ return FontStyle.Oblique;
+ }
+
+ if (lowerSubfamily.Contains("italic"))
+ {
+ return FontStyle.Italic;
+ }
+
+ return FontStyle.Normal;
+ }
+
+ private static FontWeight ParseWeight(string lowerSubfamily)
+ {
+ // Map weight keywords to enum values
+ if (lowerSubfamily.Contains("thin") || lowerSubfamily.Contains("hairline"))
+ {
+ return FontWeight.Thin;
+ }
+
+ if (lowerSubfamily.Contains("extralight") || lowerSubfamily.Contains("ultra light"))
+ {
+ return FontWeight.ExtraLight;
+ }
+
+ if (lowerSubfamily.Contains("light"))
+ {
+ return FontWeight.Light;
+ }
+
+ if (lowerSubfamily.Contains("medium"))
+ {
+ return FontWeight.Medium;
+ }
+
+ if (lowerSubfamily.Contains("semibold") || lowerSubfamily.Contains("demibold"))
+ {
+ return FontWeight.SemiBold;
+ }
+
+ if (lowerSubfamily.Contains("extrabold") || lowerSubfamily.Contains("ultra bold"))
+ {
+ return FontWeight.ExtraBold;
+ }
+
+ if (lowerSubfamily.Contains("bold"))
+ {
+ return FontWeight.Bold;
+ }
+
+ if (lowerSubfamily.Contains("black") || lowerSubfamily.Contains("heavy"))
+ {
+ return FontWeight.Black;
+ }
+
+ return FontWeight.Normal;
+ }
+
+ private static FontWidth ParseWidth(string lowerSubfamily)
+ {
+ if (lowerSubfamily.Contains("ultracondensed") || lowerSubfamily.Contains("ultra condensed"))
+ {
+ return FontWidth.UltraCondensed;
+ }
+
+ if (lowerSubfamily.Contains("extracondensed") || lowerSubfamily.Contains("extra condensed"))
+ {
+ return FontWidth.ExtraCondensed;
+ }
+
+ if (lowerSubfamily.Contains("condensed"))
+ {
+ return FontWidth.Condensed;
+ }
+
+ if (lowerSubfamily.Contains("semicondensed"))
+ {
+ return FontWidth.SemiCondensed;
+ }
+
+ if (lowerSubfamily.Contains("semiexpanded"))
+ {
+ return FontWidth.SemiExpanded;
+ }
+
+ if (lowerSubfamily.Contains("extraexpanded") || lowerSubfamily.Contains("extra expanded"))
+ {
+ return FontWidth.ExtraExpanded;
+ }
+
+ if (lowerSubfamily.Contains("ultraexpanded") || lowerSubfamily.Contains("ultra expanded"))
+ {
+ return FontWidth.UltraExpanded;
+ }
+
+ if (lowerSubfamily.Contains("expanded"))
+ {
+ return FontWidth.Expanded;
+ }
+
+ return FontWidth.Medium;
+ }
+
+ private static bool ParseOs2Table(Span bytes, uint? os2TableOffset, FontMetadata metadata)
+ {
+ if (!os2TableOffset.HasValue)
+ {
+ return false;
+ }
+
+ var offset = (int)os2TableOffset.Value;
+
+ try
+ {
+ // OS/2 table structure (v0+):
+ // Offset 4: usWeightClass (USHORT) - font weight (100-900)
+ // Offset 6: usWidthClass (USHORT) - font width (1-9)
+ // Offset 8: fsType (USHORT) - embedding permissions
+ // Offset 62: fsSelection (USHORT) - contains style bits
+
+ // Check if we have enough bytes for OS/2 table
+ if (offset + 64 > bytes.Length)
+ {
+ return false;
+ }
+
+ // Read usWeightClass (offset 4 in OS/2)
+ var usWeightClass = BinaryParser.ReadUint16BigEndian(bytes, offset + 4);
+ var weight = FontWeight.Normal;
+ if (usWeightClass >= 100 && usWeightClass <= 900)
+ {
+ weight = (FontWeight)usWeightClass;
+ }
+
+ // Read usWidthClass (offset 6 in OS/2)
+ var usWidthClass = BinaryParser.ReadUint16BigEndian(bytes, offset + 6);
+ var width = FontWidth.Medium;
+ if (usWidthClass >= 1 && usWidthClass <= 9)
+ {
+ width = WidthClassToEnum(usWidthClass);
+ }
+
+ // Read fsSelection for style bits (offset 62 in OS/2)
+ var fsSelection = BinaryParser.ReadUint16BigEndian(bytes, offset + 62);
+
+ // Bit 9: Oblique, Bit 6: Regular, Bit 0: Italic
+ var style = ((fsSelection & 0x0200) != 0) ? FontStyle.Oblique :
+ ((fsSelection & 0x0001) != 0) ? FontStyle.Italic :
+ FontStyle.Normal;
+
+ // Update attributes with extracted OS/2 data (note: FontAttributes order is Weight, Style, Width)
+ metadata.Attributes = new FontAttributes
+ {
+ Weight = weight,
+ Style = style,
+ Width = width
+ };
+ return true;
+ }
+ catch
+ {
+ // Silently continue if OS/2 parsing fails
+ return false;
+ }
+ }
+
+ private static FontWidth WidthClassToEnum(int widthClass)
+ {
+ switch (widthClass)
+ {
+ case 1: return FontWidth.UltraCondensed;
+ case 2: return FontWidth.ExtraCondensed;
+ case 3: return FontWidth.Condensed;
+ case 4: return FontWidth.SemiCondensed;
+ case 5: return FontWidth.Medium;
+ case 6: return FontWidth.SemiExpanded;
+ case 7: return FontWidth.Expanded;
+ case 8: return FontWidth.ExtraExpanded;
+ case 9: return FontWidth.UltraExpanded;
+ default: return FontWidth.Medium;
+ }
+ }
+
+ private static string DecodeNameTableString(Span data, ushort platformId, ushort encodingId)
+ {
+ try
+ {
+ // Windows platform
+ if (platformId == 3)
+ {
+ if (encodingId == 1)
+ {
+ return BinaryParser.ReadUtf16StringBigEndian(data);
+ }
+
+ return null;
+ }
+
+ // Macintosh platform
+ if (platformId == 1)
+ {
+ if (encodingId == 0)
+ {
+ return BinaryParser.ReadAsciiString(data).TrimEnd('\0');
+ }
+ }
+
+ return null;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+ }
+}
diff --git a/Source/HtmlRenderer.PdfSharp/HtmlRenderer.PdfSharp.csproj b/Source/HtmlRenderer.PdfSharp/HtmlRenderer.PdfSharp.csproj
index e2c036703..3379bcc34 100644
--- a/Source/HtmlRenderer.PdfSharp/HtmlRenderer.PdfSharp.csproj
+++ b/Source/HtmlRenderer.PdfSharp/HtmlRenderer.PdfSharp.csproj
@@ -1,6 +1,6 @@
- net8.0-windows
+ netstandard2.0;net8.0
Library
TheArtOfDev.HtmlRenderer.PdfSharp
true
@@ -10,20 +10,20 @@
HtmlRenderer.PdfSharp
- HTML Renderer for PDF using PdfSharp
+ HTML Renderer for PDF using PDFsharp
html render renderer draw pdfsharp
- PDF document generator from HTML snippet, 100% managed (C#), High performance library using PdfSharp.
+ PDF document generator from HTML snippet, 100% managed (C#), High performance library using PDFsharp.
Features and Benefits:
---
-* 100% managed code depends only on PdfSharp library, no ActiveX, no MSHTML.
+* 100% managed code depends only on the PDFsharp library, no ActiveX, no MSHTML.
* Extensive HTML 4.01 and CSS level 2 specifications support.
* Support separating CSS from HTML by loading stylesheet code separately.
* Handles "real world" malformed HTML, it doesn't have to be XHTML.
* Lightweight, only two DLLs (~300K).
* High performance and low memory footprint.
* Extendable and configurable.
- PDF document generator from HTML snippet, 100% managed (C#), High performance library using PdfSharp.
+ PDF document generator from HTML snippet, 100% managed (C#), High performance library using PDFsharp.
@@ -32,7 +32,18 @@ Features and Benefits:
-
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Source/HtmlRenderer.PdfSharp/PdfGenerator.cs b/Source/HtmlRenderer.PdfSharp/PdfGenerator.cs
index 0f15c5a7a..3e63ab1cc 100644
--- a/Source/HtmlRenderer.PdfSharp/PdfGenerator.cs
+++ b/Source/HtmlRenderer.PdfSharp/PdfGenerator.cs
@@ -171,13 +171,13 @@ public static void AddPdfPages(PdfDocument document, string html, PdfGenerateCon
while (scrollOffset > -container.ActualSize.Height)
{
var page = document.AddPage();
- page.Height = orgPageSize.Height;
- page.Width = orgPageSize.Width;
+ page.Height = XUnit.FromPoint(orgPageSize.Height);
+ page.Width = XUnit.FromPoint(orgPageSize.Width);
using (var g = XGraphics.FromPdfPage(page))
{
//g.IntersectClip(new XRect(config.MarginLeft, config.MarginTop, pageSize.Width, pageSize.Height));
- g.IntersectClip(new XRect(0, 0, page.Width, page.Height));
+ g.IntersectClip(new XRect(0, 0, page.Width.Point, page.Height.Point));
container.ScrollOffset = new XPoint(0, scrollOffset);
container.PerformPaint(g);
diff --git a/Source/HtmlRenderer.PdfSharp/Utilities/Utils.cs b/Source/HtmlRenderer.PdfSharp/Utilities/Utils.cs
index 59ac2c4b5..c92191908 100644
--- a/Source/HtmlRenderer.PdfSharp/Utilities/Utils.cs
+++ b/Source/HtmlRenderer.PdfSharp/Utilities/Utils.cs
@@ -11,7 +11,6 @@
// "The Art of War"
using PdfSharp.Drawing;
-using System.Drawing;
using TheArtOfDev.HtmlRenderer.Adapters.Entities;
namespace TheArtOfDev.HtmlRenderer.PdfSharp.Utilities
@@ -89,11 +88,33 @@ public static XColor Convert(RColor c)
}
///
- /// Convert from color to WinForms color.
+ /// Convert from PDFsharp color to WinForms color.
///
- public static RColor Convert(Color c)
+ public static RColor Convert(XColor c)
{
- return RColor.FromArgb(c.A, c.R, c.G, c.B);
+ return RColor.FromArgb((byte)(c.A * 255.0), c.R, c.G, c.B);
+ }
+
+ ///
+ /// Convert from core font style to PDFsharp font style.
+ ///
+ public static XFontStyleEx Convert(RFontStyle style)
+ {
+ var fontStyle = XFontStyleEx.Regular;
+
+ if ((style & RFontStyle.Bold) == RFontStyle.Bold)
+ fontStyle |= XFontStyleEx.Bold;
+
+ if ((style & RFontStyle.Italic) == RFontStyle.Italic)
+ fontStyle |= XFontStyleEx.Italic;
+
+ if ((style & RFontStyle.Underline) == RFontStyle.Underline)
+ fontStyle |= XFontStyleEx.Underline;
+
+ if ((style & RFontStyle.Strikeout) == RFontStyle.Strikeout)
+ fontStyle |= XFontStyleEx.Strikeout;
+
+ return fontStyle;
}
}
diff --git a/Source/HtmlRenderer.sln b/Source/HtmlRenderer.sln
index c6594f4d7..56fea4960 100644
--- a/Source/HtmlRenderer.sln
+++ b/Source/HtmlRenderer.sln
@@ -19,6 +19,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HtmlRenderer", "HtmlRendere
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HtmlRenderer.PdfSharp", "HtmlRenderer.PdfSharp\HtmlRenderer.PdfSharp.csproj", "{CA249F5D-9285-40A6-B217-5889EF79FD7E}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Test", "Test", "{E508FA78-D57E-492F-9BF7-23640001848A}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HtmlRenderer.PdfSharp.Test", "Test\HtmlRenderer.PdfSharp.Test\HtmlRenderer.PdfSharp.Test.csproj", "{AF39851E-37B9-409E-A34D-CE091A098C86}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -99,6 +103,18 @@ Global
{CA249F5D-9285-40A6-B217-5889EF79FD7E}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
{CA249F5D-9285-40A6-B217-5889EF79FD7E}.Release|Mixed Platforms.Build.0 = Release|Any CPU
{CA249F5D-9285-40A6-B217-5889EF79FD7E}.Release|x86.ActiveCfg = Release|Any CPU
+ {AF39851E-37B9-409E-A34D-CE091A098C86}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {AF39851E-37B9-409E-A34D-CE091A098C86}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {AF39851E-37B9-409E-A34D-CE091A098C86}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
+ {AF39851E-37B9-409E-A34D-CE091A098C86}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU
+ {AF39851E-37B9-409E-A34D-CE091A098C86}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {AF39851E-37B9-409E-A34D-CE091A098C86}.Debug|x86.Build.0 = Debug|Any CPU
+ {AF39851E-37B9-409E-A34D-CE091A098C86}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {AF39851E-37B9-409E-A34D-CE091A098C86}.Release|Any CPU.Build.0 = Release|Any CPU
+ {AF39851E-37B9-409E-A34D-CE091A098C86}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
+ {AF39851E-37B9-409E-A34D-CE091A098C86}.Release|Mixed Platforms.Build.0 = Release|Any CPU
+ {AF39851E-37B9-409E-A34D-CE091A098C86}.Release|x86.ActiveCfg = Release|Any CPU
+ {AF39851E-37B9-409E-A34D-CE091A098C86}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -107,6 +123,7 @@ Global
{2390B71F-9400-47F4-B23A-7F2649C87D35} = {E263EA16-2E6A-4269-A319-AA2F97ADA8E1}
{8AD34FE8-8382-4A8A-B3AA-A0392ED42423} = {E263EA16-2E6A-4269-A319-AA2F97ADA8E1}
{F02E0216-4AE3-474F-9381-FCB93411CDB0} = {E263EA16-2E6A-4269-A319-AA2F97ADA8E1}
+ {AF39851E-37B9-409E-A34D-CE091A098C86} = {E508FA78-D57E-492F-9BF7-23640001848A}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {902788D6-3165-491D-B860-DF2F74E66C1D}
diff --git a/Source/HtmlRenderer/HtmlRenderer.csproj b/Source/HtmlRenderer/HtmlRenderer.csproj
index 273dcccf8..6d3de45aa 100644
--- a/Source/HtmlRenderer/HtmlRenderer.csproj
+++ b/Source/HtmlRenderer/HtmlRenderer.csproj
@@ -26,6 +26,6 @@ For existing implementations see: HtmlRenderer.WinForms, HtmlRenderer.WPF and Ht
-
+
\ No newline at end of file
diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/HtmlRenderer.PdfSharp.Test.csproj b/Source/Test/HtmlRenderer.PdfSharp.Test/HtmlRenderer.PdfSharp.Test.csproj
new file mode 100644
index 000000000..e43631950
--- /dev/null
+++ b/Source/Test/HtmlRenderer.PdfSharp.Test/HtmlRenderer.PdfSharp.Test.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net8.0
+ latest
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/MSTestSettings.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/MSTestSettings.cs
new file mode 100644
index 000000000..4ef96cd3d
--- /dev/null
+++ b/Source/Test/HtmlRenderer.PdfSharp.Test/MSTestSettings.cs
@@ -0,0 +1 @@
+[assembly: Parallelize(Scope = ExecutionScope.ClassLevel)]
\ No newline at end of file
diff --git a/Source/Test/HtmlRenderer.PdfSharp.Test/PdfGeneratorTests.cs b/Source/Test/HtmlRenderer.PdfSharp.Test/PdfGeneratorTests.cs
new file mode 100644
index 000000000..0fd72ab1e
--- /dev/null
+++ b/Source/Test/HtmlRenderer.PdfSharp.Test/PdfGeneratorTests.cs
@@ -0,0 +1,150 @@
+using System.Runtime.InteropServices;
+using PdfSharp;
+using PdfSharp.Drawing;
+using TheArtOfDev.HtmlRenderer.Core.Entities;
+using TheArtOfDev.HtmlRenderer.Demo.Common;
+using TheArtOfDev.HtmlRenderer.PdfSharp;
+
+namespace HtmlRenderer.PdfSharp.Test;
+
+[TestClass]
+public sealed class PdfGeneratorTests
+{
+ [TestMethod]
+ public void GeneratePdf_FromHtml_CreatesPdfDocument()
+ {
+ // Arrange
+ var config = new PdfGenerateConfig
+ {
+ PageSize = PageSize.A4
+ };
+ config.SetMargins(20);
+
+ const string html = """
+
+
+
+
+
+ PdfSharp
+ This is html rendered text 
+
+
+ """;
+
+ // Act
+ using var document = PdfGenerator.GeneratePdf(html, config, null, DemoUtils.OnStylesheetLoad, OnImageLoadPdfSharp);
+
+ // Assert
+ Assert.AreEqual(1, document.Pages.Count);
+
+ using var stream = new MemoryStream();
+ document.Save(stream, false);
+
+ var pdf = stream.ToArray();
+ Assert.IsGreaterThan(4, pdf.Length);
+ Assert.AreEqual("%PDF", System.Text.Encoding.ASCII.GetString(pdf, 0, 4));
+
+ var pdfOutputDirectory = Environment.GetEnvironmentVariable("PDF_OUTPUT_DIRECTORY");
+ if (string.IsNullOrWhiteSpace(pdfOutputDirectory))
+ {
+ pdfOutputDirectory = Path.Combine(Path.GetTempPath(), "html-renderer-pdf-artifacts");
+ }
+
+ Directory.CreateDirectory(pdfOutputDirectory);
+
+ var pdfPath = Path.Combine(
+ pdfOutputDirectory,
+ $"{nameof(GeneratePdf_FromHtml_CreatesPdfDocument)}-{GetPlatformName()}.pdf");
+ File.WriteAllBytes(pdfPath, pdf);
+ }
+
+ [TestMethod]
+ public void GeneratePdf_FromHtml_WithMultipleFonts_CreatesPdfDocument()
+ {
+ // Arrange
+ var config = new PdfGenerateConfig
+ {
+ PageSize = PageSize.A4
+ };
+ config.SetMargins(20);
+
+ const string html = """
+
+
+
+
+
+
+ Helvetica text
+ Monospace text
+
+
+ """;
+
+ // Act
+ using var document = PdfGenerator.GeneratePdf(html, config, null, DemoUtils.OnStylesheetLoad, null);
+
+ // Assert
+ Assert.AreEqual(1, document.Pages.Count);
+
+ using var stream = new MemoryStream();
+ document.Save(stream, false);
+
+ var pdf = stream.ToArray();
+ Assert.IsGreaterThan(4, pdf.Length);
+ Assert.AreEqual("%PDF", System.Text.Encoding.ASCII.GetString(pdf, 0, 4));
+
+ var pdfOutputDirectory = Environment.GetEnvironmentVariable("PDF_OUTPUT_DIRECTORY");
+ if (string.IsNullOrWhiteSpace(pdfOutputDirectory))
+ {
+ pdfOutputDirectory = Path.Combine(Path.GetTempPath(), "html-renderer-pdf-artifacts");
+ }
+
+ Directory.CreateDirectory(pdfOutputDirectory);
+
+ var pdfPath = Path.Combine(
+ pdfOutputDirectory,
+ $"{nameof(GeneratePdf_FromHtml_WithMultipleFonts_CreatesPdfDocument)}-{GetPlatformName()}.pdf");
+ File.WriteAllBytes(pdfPath, pdf);
+ }
+
+ private static void OnImageLoadPdfSharp(object? sender, HtmlImageLoadEventArgs e)
+ {
+ if (!string.Equals(e.Src, "ImageIcon", StringComparison.OrdinalIgnoreCase))
+ {
+ return;
+ }
+
+ using var imageStream = DemoUtils.GetImageStream(e.Src);
+ if (imageStream == null)
+ {
+ throw new InvalidOperationException("Expected demo image resource was not found.");
+ }
+
+ e.Callback(XImage.FromStream(imageStream));
+ }
+
+ private static string GetPlatformName()
+ {
+ if (OperatingSystem.IsWindows())
+ {
+ return "windows";
+ }
+
+ if (OperatingSystem.IsMacOS())
+ {
+ return "macos";
+ }
+
+ if (OperatingSystem.IsLinux())
+ {
+ return "linux";
+ }
+
+ return RuntimeInformation.RuntimeIdentifier;
+ }
+}
\ No newline at end of file