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