909 lines
33 KiB
C#
909 lines
33 KiB
C#
|
|
using System;
|
||
|
|
using System.Collections.Generic;
|
||
|
|
using System.Drawing.Text;
|
||
|
|
using System.IO;
|
||
|
|
using System.Linq;
|
||
|
|
using DocumentFormat.OpenXml;
|
||
|
|
using DocumentFormat.OpenXml.Packaging;
|
||
|
|
using W = DocumentFormat.OpenXml.Wordprocessing;
|
||
|
|
|
||
|
|
namespace DCIT.Core.Services
|
||
|
|
{
|
||
|
|
public sealed class FontValidationService : IFontValidationService
|
||
|
|
{
|
||
|
|
private const string WordprocessingNamespace = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||
|
|
private const string DefaultEastAsiaFallbackFont = "宋体";
|
||
|
|
private const string DefaultLatinFallbackFont = "Times New Roman";
|
||
|
|
|
||
|
|
private static readonly string[] ThemeFontTokens =
|
||
|
|
{
|
||
|
|
"majorAscii",
|
||
|
|
"majorHAnsi",
|
||
|
|
"majorEastAsia",
|
||
|
|
"majorBidi",
|
||
|
|
"minorAscii",
|
||
|
|
"minorHAnsi",
|
||
|
|
"minorEastAsia",
|
||
|
|
"minorBidi",
|
||
|
|
};
|
||
|
|
|
||
|
|
private static readonly string[][] KnownFontAliasGroups =
|
||
|
|
{
|
||
|
|
new[] { "宋体", "SimSun" },
|
||
|
|
new[] { "新宋体", "NSimSun" },
|
||
|
|
new[] { "黑体", "SimHei" },
|
||
|
|
new[] { "仿宋", "FangSong" },
|
||
|
|
new[] { "楷体", "KaiTi" },
|
||
|
|
new[] { "微软雅黑", "Microsoft YaHei" },
|
||
|
|
new[] { "微软雅黑 Light", "Microsoft YaHei Light" },
|
||
|
|
new[] { "微软雅黑 Bold", "Microsoft YaHei Bold" },
|
||
|
|
new[] { "等线", "DengXian" },
|
||
|
|
new[] { "等线 Light", "DengXian Light" },
|
||
|
|
new[] { "等线 Bold", "DengXian Bold" },
|
||
|
|
new[] { "华文宋体", "STSong" },
|
||
|
|
new[] { "华文黑体", "STHeiti" },
|
||
|
|
new[] { "华文中宋", "STZhongsong" },
|
||
|
|
new[] { "华文仿宋", "STFangsong" },
|
||
|
|
new[] { "华文楷体", "STKaiti" },
|
||
|
|
new[] { "幼圆", "YouYuan" },
|
||
|
|
new[] { "隶书", "LiSu" },
|
||
|
|
};
|
||
|
|
|
||
|
|
private static readonly Lazy<Dictionary<string, HashSet<string>>> KnownFontAliases =
|
||
|
|
new Lazy<Dictionary<string, HashSet<string>>>(BuildKnownFontAliases);
|
||
|
|
|
||
|
|
private readonly Lazy<HashSet<string>> _installedFonts;
|
||
|
|
|
||
|
|
public FontValidationService()
|
||
|
|
: this(null)
|
||
|
|
{
|
||
|
|
}
|
||
|
|
|
||
|
|
public FontValidationService(IEnumerable<string> installedFonts)
|
||
|
|
{
|
||
|
|
_installedFonts = new Lazy<HashSet<string>>(
|
||
|
|
delegate
|
||
|
|
{
|
||
|
|
return installedFonts == null
|
||
|
|
? LoadInstalledFonts()
|
||
|
|
: new HashSet<string>(
|
||
|
|
installedFonts
|
||
|
|
.Select(NormalizeFontName)
|
||
|
|
.Where(name => !string.IsNullOrWhiteSpace(name)),
|
||
|
|
StringComparer.OrdinalIgnoreCase);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
public FontValidationResult Validate(string docxPath)
|
||
|
|
{
|
||
|
|
if (string.IsNullOrWhiteSpace(docxPath))
|
||
|
|
{
|
||
|
|
throw new ArgumentException("docxPath");
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!File.Exists(docxPath))
|
||
|
|
{
|
||
|
|
throw new FileNotFoundException("未找到待校验的文档。", docxPath);
|
||
|
|
}
|
||
|
|
|
||
|
|
using (var document = WordprocessingDocument.Open(docxPath, false))
|
||
|
|
{
|
||
|
|
var themeFonts = BuildThemeFontMap(document);
|
||
|
|
var styleIndex = BuildStyleIndex(document);
|
||
|
|
var defaultFonts = ReadDocumentDefaultFonts(document, themeFonts);
|
||
|
|
var documentFontAliases = BuildDocumentFontAliasMap(document);
|
||
|
|
var usedFonts = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
|
|
|
||
|
|
foreach (var root in EnumerateRelevantRoots(document))
|
||
|
|
{
|
||
|
|
CollectWordRunFonts(root, styleIndex, defaultFonts, themeFonts, usedFonts);
|
||
|
|
CollectDrawingFonts(root, usedFonts);
|
||
|
|
}
|
||
|
|
|
||
|
|
var missingFonts = usedFonts
|
||
|
|
.Select(NormalizeFontName)
|
||
|
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||
|
|
.Where(name => !IsFontAvailable(name, documentFontAliases))
|
||
|
|
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
|
||
|
|
.ToList();
|
||
|
|
|
||
|
|
return new FontValidationResult(
|
||
|
|
usedFonts.OrderBy(name => name, StringComparer.OrdinalIgnoreCase).ToList(),
|
||
|
|
missingFonts);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
public FontValidationResult EnsureFontsAvailable(string docxPath)
|
||
|
|
{
|
||
|
|
var result = Validate(docxPath);
|
||
|
|
if (result.MissingFonts.Count == 0)
|
||
|
|
{
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
ApplyMissingFontFallbacks(docxPath, result.MissingFonts);
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static void ApplyMissingFontFallbacks(string docxPath, IEnumerable<string> missingFonts)
|
||
|
|
{
|
||
|
|
var missingFontSet = new HashSet<string>(
|
||
|
|
(missingFonts ?? Array.Empty<string>())
|
||
|
|
.Select(NormalizeFontName)
|
||
|
|
.Where(name => !string.IsNullOrWhiteSpace(name)),
|
||
|
|
StringComparer.OrdinalIgnoreCase);
|
||
|
|
if (missingFontSet.Count == 0)
|
||
|
|
{
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
using (var document = WordprocessingDocument.Open(docxPath, true))
|
||
|
|
{
|
||
|
|
var themeFonts = BuildThemeFontMap(document);
|
||
|
|
foreach (var root in EnumerateFontFallbackRoots(document))
|
||
|
|
{
|
||
|
|
var changed = false;
|
||
|
|
foreach (var runFonts in root.Descendants<W.RunFonts>())
|
||
|
|
{
|
||
|
|
changed |= ApplyWordRunFontFallbacks(runFonts, missingFontSet, themeFonts);
|
||
|
|
}
|
||
|
|
|
||
|
|
foreach (var element in root.Descendants())
|
||
|
|
{
|
||
|
|
changed |= ApplyDrawingFontFallback(element, missingFontSet);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (changed)
|
||
|
|
{
|
||
|
|
root.Save();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private static IEnumerable<OpenXmlPartRootElement> EnumerateFontFallbackRoots(WordprocessingDocument document)
|
||
|
|
{
|
||
|
|
var main = document.MainDocumentPart;
|
||
|
|
if (main == null)
|
||
|
|
{
|
||
|
|
yield break;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (main.Document != null)
|
||
|
|
{
|
||
|
|
yield return main.Document;
|
||
|
|
}
|
||
|
|
|
||
|
|
var queue = new Queue<OpenXmlPartContainer>();
|
||
|
|
var visited = new HashSet<OpenXmlPart>();
|
||
|
|
queue.Enqueue(main);
|
||
|
|
|
||
|
|
while (queue.Count > 0)
|
||
|
|
{
|
||
|
|
var container = queue.Dequeue();
|
||
|
|
foreach (var pair in container.Parts)
|
||
|
|
{
|
||
|
|
var part = pair.OpenXmlPart;
|
||
|
|
if (part == null || !visited.Add(part))
|
||
|
|
{
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (part.RootElement != null)
|
||
|
|
{
|
||
|
|
yield return part.RootElement;
|
||
|
|
}
|
||
|
|
|
||
|
|
queue.Enqueue(part);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private static bool ApplyWordRunFontFallbacks(
|
||
|
|
W.RunFonts runFonts,
|
||
|
|
ISet<string> missingFonts,
|
||
|
|
IDictionary<string, string> themeFonts)
|
||
|
|
{
|
||
|
|
var changed = false;
|
||
|
|
changed |= ApplyWordFontFallback(
|
||
|
|
runFonts,
|
||
|
|
"ascii",
|
||
|
|
new[] { "asciiTheme" },
|
||
|
|
DefaultLatinFallbackFont,
|
||
|
|
missingFonts,
|
||
|
|
themeFonts);
|
||
|
|
changed |= ApplyWordFontFallback(
|
||
|
|
runFonts,
|
||
|
|
"hAnsi",
|
||
|
|
new[] { "hAnsiTheme" },
|
||
|
|
DefaultLatinFallbackFont,
|
||
|
|
missingFonts,
|
||
|
|
themeFonts);
|
||
|
|
changed |= ApplyWordFontFallback(
|
||
|
|
runFonts,
|
||
|
|
"eastAsia",
|
||
|
|
new[] { "eastAsiaTheme" },
|
||
|
|
DefaultEastAsiaFallbackFont,
|
||
|
|
missingFonts,
|
||
|
|
themeFonts);
|
||
|
|
changed |= ApplyWordFontFallback(
|
||
|
|
runFonts,
|
||
|
|
"cs",
|
||
|
|
new[] { "csTheme", "cstheme" },
|
||
|
|
DefaultLatinFallbackFont,
|
||
|
|
missingFonts,
|
||
|
|
themeFonts);
|
||
|
|
return changed;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static bool ApplyWordFontFallback(
|
||
|
|
OpenXmlElement element,
|
||
|
|
string directLocalName,
|
||
|
|
IEnumerable<string> themeLocalNames,
|
||
|
|
string fallbackFont,
|
||
|
|
ISet<string> missingFonts,
|
||
|
|
IDictionary<string, string> themeFonts)
|
||
|
|
{
|
||
|
|
var directFont = NormalizeFontName(GetAttributeValue(element, directLocalName));
|
||
|
|
var resolvedFont = directFont;
|
||
|
|
if (string.IsNullOrWhiteSpace(resolvedFont))
|
||
|
|
{
|
||
|
|
foreach (var themeLocalName in themeLocalNames)
|
||
|
|
{
|
||
|
|
resolvedFont = ResolveFontName(null, GetAttributeValue(element, themeLocalName), themeFonts);
|
||
|
|
if (!string.IsNullOrWhiteSpace(resolvedFont))
|
||
|
|
{
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (string.IsNullOrWhiteSpace(resolvedFont) || !missingFonts.Contains(resolvedFont))
|
||
|
|
{
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
var changed = false;
|
||
|
|
if (!string.Equals(directFont, fallbackFont, StringComparison.OrdinalIgnoreCase))
|
||
|
|
{
|
||
|
|
SetWordprocessingAttribute(element, directLocalName, fallbackFont);
|
||
|
|
changed = true;
|
||
|
|
}
|
||
|
|
|
||
|
|
changed |= RemoveAttributesByLocalName(element, themeLocalNames);
|
||
|
|
return changed;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static bool ApplyDrawingFontFallback(OpenXmlElement element, ISet<string> missingFonts)
|
||
|
|
{
|
||
|
|
if (!(string.Equals(element.LocalName, "latin", StringComparison.OrdinalIgnoreCase)
|
||
|
|
|| string.Equals(element.LocalName, "ea", StringComparison.OrdinalIgnoreCase)
|
||
|
|
|| string.Equals(element.LocalName, "cs", StringComparison.OrdinalIgnoreCase)
|
||
|
|
|| string.Equals(element.LocalName, "font", StringComparison.OrdinalIgnoreCase)))
|
||
|
|
{
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
var typeface = NormalizeFontName(GetAttributeValue(element, "typeface"));
|
||
|
|
if (string.IsNullOrWhiteSpace(typeface) || !missingFonts.Contains(typeface))
|
||
|
|
{
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
var fallbackFont = ResolveDrawingFallbackFont(element);
|
||
|
|
if (string.Equals(typeface, fallbackFont, StringComparison.OrdinalIgnoreCase))
|
||
|
|
{
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
element.SetAttribute(new OpenXmlAttribute(string.Empty, "typeface", string.Empty, fallbackFont));
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static string ResolveDrawingFallbackFont(OpenXmlElement element)
|
||
|
|
{
|
||
|
|
if (string.Equals(element.LocalName, "ea", StringComparison.OrdinalIgnoreCase))
|
||
|
|
{
|
||
|
|
return DefaultEastAsiaFallbackFont;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (string.Equals(element.LocalName, "font", StringComparison.OrdinalIgnoreCase)
|
||
|
|
&& IsEastAsiaScript(GetAttributeValue(element, "script")))
|
||
|
|
{
|
||
|
|
return DefaultEastAsiaFallbackFont;
|
||
|
|
}
|
||
|
|
|
||
|
|
return DefaultLatinFallbackFont;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static bool IsEastAsiaScript(string script)
|
||
|
|
{
|
||
|
|
var normalized = NormalizeFontName(script);
|
||
|
|
if (string.IsNullOrWhiteSpace(normalized))
|
||
|
|
{
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
return normalized.IndexOf("Hans", StringComparison.OrdinalIgnoreCase) >= 0
|
||
|
|
|| normalized.IndexOf("Hant", StringComparison.OrdinalIgnoreCase) >= 0
|
||
|
|
|| normalized.IndexOf("Jpan", StringComparison.OrdinalIgnoreCase) >= 0
|
||
|
|
|| normalized.IndexOf("Hang", StringComparison.OrdinalIgnoreCase) >= 0
|
||
|
|
|| normalized.IndexOf("Kore", StringComparison.OrdinalIgnoreCase) >= 0
|
||
|
|
|| normalized.IndexOf("Bopo", StringComparison.OrdinalIgnoreCase) >= 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static void SetWordprocessingAttribute(OpenXmlElement element, string localName, string value)
|
||
|
|
{
|
||
|
|
element.SetAttribute(new OpenXmlAttribute("w", localName, WordprocessingNamespace, value));
|
||
|
|
}
|
||
|
|
|
||
|
|
private static bool RemoveAttributesByLocalName(OpenXmlElement element, IEnumerable<string> localNames)
|
||
|
|
{
|
||
|
|
var names = new HashSet<string>(
|
||
|
|
(localNames ?? Array.Empty<string>()).Where(name => !string.IsNullOrWhiteSpace(name)),
|
||
|
|
StringComparer.OrdinalIgnoreCase);
|
||
|
|
if (names.Count == 0)
|
||
|
|
{
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
var changed = false;
|
||
|
|
foreach (var attribute in element.GetAttributes().Where(attribute => names.Contains(attribute.LocalName)).ToList())
|
||
|
|
{
|
||
|
|
element.RemoveAttribute(attribute.LocalName, attribute.NamespaceUri);
|
||
|
|
changed = true;
|
||
|
|
}
|
||
|
|
|
||
|
|
return changed;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static HashSet<string> LoadInstalledFonts()
|
||
|
|
{
|
||
|
|
var collection = new InstalledFontCollection();
|
||
|
|
return new HashSet<string>(
|
||
|
|
collection.Families
|
||
|
|
.Select(item => NormalizeFontName(item.Name))
|
||
|
|
.Where(name => !string.IsNullOrWhiteSpace(name)),
|
||
|
|
StringComparer.OrdinalIgnoreCase);
|
||
|
|
}
|
||
|
|
|
||
|
|
private static Dictionary<string, HashSet<string>> BuildDocumentFontAliasMap(WordprocessingDocument document)
|
||
|
|
{
|
||
|
|
var result = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
|
||
|
|
var fontTable = document.MainDocumentPart == null || document.MainDocumentPart.FontTablePart == null
|
||
|
|
? null
|
||
|
|
: document.MainDocumentPart.FontTablePart.Fonts;
|
||
|
|
if (fontTable == null)
|
||
|
|
{
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
foreach (var font in fontTable.Elements<W.Font>())
|
||
|
|
{
|
||
|
|
var primary = NormalizeFontName(font.Name == null ? null : font.Name.Value);
|
||
|
|
var alternate = NormalizeFontName(font.AltName == null || font.AltName.Val == null ? null : font.AltName.Val.Value);
|
||
|
|
AddAliasPair(result, primary, alternate);
|
||
|
|
}
|
||
|
|
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static IEnumerable<OpenXmlPartRootElement> EnumerateRelevantRoots(WordprocessingDocument document)
|
||
|
|
{
|
||
|
|
var main = document.MainDocumentPart;
|
||
|
|
if (main == null)
|
||
|
|
{
|
||
|
|
yield break;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (main.Document != null)
|
||
|
|
{
|
||
|
|
yield return main.Document;
|
||
|
|
}
|
||
|
|
|
||
|
|
var queue = new Queue<OpenXmlPartContainer>();
|
||
|
|
var visited = new HashSet<OpenXmlPart>();
|
||
|
|
queue.Enqueue(main);
|
||
|
|
|
||
|
|
while (queue.Count > 0)
|
||
|
|
{
|
||
|
|
var container = queue.Dequeue();
|
||
|
|
foreach (var pair in container.Parts)
|
||
|
|
{
|
||
|
|
var part = pair.OpenXmlPart;
|
||
|
|
if (part == null || !visited.Add(part))
|
||
|
|
{
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (part.RootElement != null && IsRelevantPart(part))
|
||
|
|
{
|
||
|
|
yield return part.RootElement;
|
||
|
|
}
|
||
|
|
|
||
|
|
queue.Enqueue(part);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private static bool IsRelevantPart(OpenXmlPart part)
|
||
|
|
{
|
||
|
|
return part is HeaderPart
|
||
|
|
|| part is FooterPart
|
||
|
|
|| part is WordprocessingCommentsPart
|
||
|
|
|| part is FootnotesPart
|
||
|
|
|| part is EndnotesPart
|
||
|
|
|| part.GetType().Name.IndexOf("ChartPart", StringComparison.OrdinalIgnoreCase) >= 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static Dictionary<string, StyleFontSet> BuildStyleIndex(WordprocessingDocument document)
|
||
|
|
{
|
||
|
|
var result = new Dictionary<string, StyleFontSet>(StringComparer.OrdinalIgnoreCase);
|
||
|
|
var styles = document.MainDocumentPart == null || document.MainDocumentPart.StyleDefinitionsPart == null
|
||
|
|
? null
|
||
|
|
: document.MainDocumentPart.StyleDefinitionsPart.Styles;
|
||
|
|
|
||
|
|
if (styles == null)
|
||
|
|
{
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
foreach (var style in styles.Elements<W.Style>())
|
||
|
|
{
|
||
|
|
var styleId = style.StyleId == null ? null : style.StyleId.Value;
|
||
|
|
if (string.IsNullOrWhiteSpace(styleId))
|
||
|
|
{
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
result[styleId] = new StyleFontSet
|
||
|
|
{
|
||
|
|
StyleId = styleId,
|
||
|
|
BasedOnStyleId = style.BasedOn == null ? null : style.BasedOn.Val == null ? null : style.BasedOn.Val.Value,
|
||
|
|
Fonts = ExtractWordFontSet(style.StyleRunProperties == null ? null : style.StyleRunProperties.RunFonts, null),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static FontReferenceSet ReadDocumentDefaultFonts(WordprocessingDocument document, IDictionary<string, string> themeFonts)
|
||
|
|
{
|
||
|
|
var styles = document.MainDocumentPart == null || document.MainDocumentPart.StyleDefinitionsPart == null
|
||
|
|
? null
|
||
|
|
: document.MainDocumentPart.StyleDefinitionsPart.Styles;
|
||
|
|
var docDefaults = styles == null ? null : styles.DocDefaults;
|
||
|
|
var runDefaults = docDefaults == null ? null : docDefaults.RunPropertiesDefault;
|
||
|
|
var baseStyle = runDefaults == null ? null : runDefaults.RunPropertiesBaseStyle;
|
||
|
|
return ExtractWordFontSet(baseStyle == null ? null : baseStyle.RunFonts, themeFonts);
|
||
|
|
}
|
||
|
|
|
||
|
|
private static IDictionary<string, string> BuildThemeFontMap(WordprocessingDocument document)
|
||
|
|
{
|
||
|
|
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||
|
|
var themePart = document.MainDocumentPart == null ? null : document.MainDocumentPart.ThemePart;
|
||
|
|
var theme = themePart == null ? null : themePart.Theme;
|
||
|
|
if (theme == null)
|
||
|
|
{
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
var majorFont = theme.Descendants().FirstOrDefault(item => string.Equals(item.LocalName, "majorFont", StringComparison.OrdinalIgnoreCase));
|
||
|
|
var minorFont = theme.Descendants().FirstOrDefault(item => string.Equals(item.LocalName, "minorFont", StringComparison.OrdinalIgnoreCase));
|
||
|
|
|
||
|
|
AddThemeSlot(result, "majorAscii", majorFont, "latin");
|
||
|
|
AddThemeSlot(result, "majorHAnsi", majorFont, "latin");
|
||
|
|
AddThemeSlot(result, "majorEastAsia", majorFont, "ea");
|
||
|
|
AddThemeSlot(result, "majorBidi", majorFont, "cs");
|
||
|
|
AddThemeSlot(result, "minorAscii", minorFont, "latin");
|
||
|
|
AddThemeSlot(result, "minorHAnsi", minorFont, "latin");
|
||
|
|
AddThemeSlot(result, "minorEastAsia", minorFont, "ea");
|
||
|
|
AddThemeSlot(result, "minorBidi", minorFont, "cs");
|
||
|
|
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static void AddThemeSlot(IDictionary<string, string> themeFonts, string token, OpenXmlElement schemeRoot, string localName)
|
||
|
|
{
|
||
|
|
if (schemeRoot == null)
|
||
|
|
{
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
var element = schemeRoot.Elements().FirstOrDefault(item => string.Equals(item.LocalName, localName, StringComparison.OrdinalIgnoreCase));
|
||
|
|
if (element == null)
|
||
|
|
{
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
var typeface = GetAttributeValue(element, "typeface");
|
||
|
|
var normalized = NormalizeFontName(typeface);
|
||
|
|
if (!string.IsNullOrWhiteSpace(normalized))
|
||
|
|
{
|
||
|
|
themeFonts[token] = normalized;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private static void CollectWordRunFonts(
|
||
|
|
OpenXmlElement root,
|
||
|
|
IDictionary<string, StyleFontSet> styleIndex,
|
||
|
|
FontReferenceSet defaultFonts,
|
||
|
|
IDictionary<string, string> themeFonts,
|
||
|
|
ISet<string> usedFonts)
|
||
|
|
{
|
||
|
|
foreach (var run in root.Descendants<W.Run>())
|
||
|
|
{
|
||
|
|
if (!ContainsVisibleText(run))
|
||
|
|
{
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
var effectiveFonts = ResolveEffectiveWordFonts(run, styleIndex, defaultFonts, themeFonts);
|
||
|
|
foreach (var font in effectiveFonts.GetDefinedFonts())
|
||
|
|
{
|
||
|
|
usedFonts.Add(font);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private static void CollectDrawingFonts(OpenXmlElement root, ISet<string> usedFonts)
|
||
|
|
{
|
||
|
|
foreach (var element in root.Descendants())
|
||
|
|
{
|
||
|
|
if (!(string.Equals(element.LocalName, "latin", StringComparison.OrdinalIgnoreCase)
|
||
|
|
|| string.Equals(element.LocalName, "ea", StringComparison.OrdinalIgnoreCase)
|
||
|
|
|| string.Equals(element.LocalName, "cs", StringComparison.OrdinalIgnoreCase)
|
||
|
|
|| string.Equals(element.LocalName, "font", StringComparison.OrdinalIgnoreCase)))
|
||
|
|
{
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
var typeface = NormalizeFontName(GetAttributeValue(element, "typeface"));
|
||
|
|
if (!string.IsNullOrWhiteSpace(typeface))
|
||
|
|
{
|
||
|
|
usedFonts.Add(typeface);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private static bool ContainsVisibleText(W.Run run)
|
||
|
|
{
|
||
|
|
if (run == null || string.IsNullOrWhiteSpace(run.InnerText))
|
||
|
|
{
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (run.Ancestors<W.DeletedRun>().Any())
|
||
|
|
{
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
return run.RunProperties == null || run.RunProperties.Vanish == null;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static FontReferenceSet ResolveEffectiveWordFonts(
|
||
|
|
W.Run run,
|
||
|
|
IDictionary<string, StyleFontSet> styleIndex,
|
||
|
|
FontReferenceSet defaultFonts,
|
||
|
|
IDictionary<string, string> themeFonts)
|
||
|
|
{
|
||
|
|
var effectiveFonts = new FontReferenceSet();
|
||
|
|
effectiveFonts.ApplyOverride(defaultFonts);
|
||
|
|
|
||
|
|
var paragraph = run.Ancestors<W.Paragraph>().FirstOrDefault();
|
||
|
|
if (paragraph != null && paragraph.ParagraphProperties != null)
|
||
|
|
{
|
||
|
|
var paragraphStyleId = paragraph.ParagraphProperties.ParagraphStyleId == null
|
||
|
|
? null
|
||
|
|
: paragraph.ParagraphProperties.ParagraphStyleId.Val == null
|
||
|
|
? null
|
||
|
|
: paragraph.ParagraphProperties.ParagraphStyleId.Val.Value;
|
||
|
|
effectiveFonts.ApplyOverride(ResolveStyleFonts(paragraphStyleId, styleIndex));
|
||
|
|
}
|
||
|
|
|
||
|
|
var table = run.Ancestors<W.Table>().FirstOrDefault();
|
||
|
|
if (table != null && table.TableProperties != null && table.TableProperties.TableStyle != null)
|
||
|
|
{
|
||
|
|
var tableStyleId = table.TableProperties.TableStyle.Val == null ? null : table.TableProperties.TableStyle.Val.Value;
|
||
|
|
effectiveFonts.ApplyOverride(ResolveStyleFonts(tableStyleId, styleIndex));
|
||
|
|
}
|
||
|
|
|
||
|
|
if (run.RunProperties != null && run.RunProperties.RunStyle != null)
|
||
|
|
{
|
||
|
|
var runStyleId = run.RunProperties.RunStyle.Val == null ? null : run.RunProperties.RunStyle.Val.Value;
|
||
|
|
effectiveFonts.ApplyOverride(ResolveStyleFonts(runStyleId, styleIndex));
|
||
|
|
}
|
||
|
|
|
||
|
|
effectiveFonts.ApplyOverride(ExtractWordFontSet(run.RunProperties == null ? null : run.RunProperties.RunFonts, themeFonts));
|
||
|
|
return effectiveFonts;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static FontReferenceSet ResolveStyleFonts(string styleId, IDictionary<string, StyleFontSet> styleIndex)
|
||
|
|
{
|
||
|
|
var result = new FontReferenceSet();
|
||
|
|
if (string.IsNullOrWhiteSpace(styleId) || styleIndex == null || !styleIndex.TryGetValue(styleId, out var style))
|
||
|
|
{
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
var resolved = ResolveStyleFonts(styleId, styleIndex, new HashSet<string>(StringComparer.OrdinalIgnoreCase));
|
||
|
|
result.ApplyOverride(resolved);
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static FontReferenceSet ResolveStyleFonts(
|
||
|
|
string styleId,
|
||
|
|
IDictionary<string, StyleFontSet> styleIndex,
|
||
|
|
ISet<string> visited)
|
||
|
|
{
|
||
|
|
var result = new FontReferenceSet();
|
||
|
|
if (string.IsNullOrWhiteSpace(styleId)
|
||
|
|
|| styleIndex == null
|
||
|
|
|| !styleIndex.TryGetValue(styleId, out var style)
|
||
|
|
|| !visited.Add(styleId))
|
||
|
|
{
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
result.ApplyOverride(ResolveStyleFonts(style.BasedOnStyleId, styleIndex, visited));
|
||
|
|
result.ApplyOverride(style.Fonts);
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static FontReferenceSet ExtractWordFontSet(W.RunFonts runFonts, IDictionary<string, string> themeFonts)
|
||
|
|
{
|
||
|
|
var result = new FontReferenceSet();
|
||
|
|
if (runFonts == null)
|
||
|
|
{
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
result.Ascii = ResolveFontName(GetAttributeValue(runFonts, "ascii"), GetAttributeValue(runFonts, "asciiTheme"), themeFonts);
|
||
|
|
result.HighAnsi = ResolveFontName(GetAttributeValue(runFonts, "hAnsi"), GetAttributeValue(runFonts, "hAnsiTheme"), themeFonts);
|
||
|
|
result.EastAsia = ResolveFontName(GetAttributeValue(runFonts, "eastAsia"), GetAttributeValue(runFonts, "eastAsiaTheme"), themeFonts);
|
||
|
|
result.ComplexScript = ResolveFontName(GetAttributeValue(runFonts, "cs"), GetAttributeValue(runFonts, "cstheme"), themeFonts);
|
||
|
|
if (string.IsNullOrWhiteSpace(result.ComplexScript))
|
||
|
|
{
|
||
|
|
result.ComplexScript = ResolveFontName(null, GetAttributeValue(runFonts, "csTheme"), themeFonts);
|
||
|
|
}
|
||
|
|
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static string ResolveFontName(string directValue, string themeToken, IDictionary<string, string> themeFonts)
|
||
|
|
{
|
||
|
|
var normalizedDirect = NormalizeFontName(directValue);
|
||
|
|
if (!string.IsNullOrWhiteSpace(normalizedDirect))
|
||
|
|
{
|
||
|
|
return normalizedDirect;
|
||
|
|
}
|
||
|
|
|
||
|
|
var normalizedToken = NormalizeFontName(themeToken);
|
||
|
|
if (!string.IsNullOrWhiteSpace(normalizedToken)
|
||
|
|
&& themeFonts != null
|
||
|
|
&& themeFonts.TryGetValue(normalizedToken, out var themeFont))
|
||
|
|
{
|
||
|
|
return NormalizeFontName(themeFont);
|
||
|
|
}
|
||
|
|
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static string NormalizeFontName(string value)
|
||
|
|
{
|
||
|
|
if (string.IsNullOrWhiteSpace(value))
|
||
|
|
{
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
var normalized = value.Trim().Trim('"', '\'');
|
||
|
|
if (normalized.StartsWith("@", StringComparison.Ordinal))
|
||
|
|
{
|
||
|
|
normalized = normalized.Substring(1);
|
||
|
|
}
|
||
|
|
|
||
|
|
return normalized.Length == 0 ? null : normalized;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static string GetAttributeValue(OpenXmlElement element, string localName)
|
||
|
|
{
|
||
|
|
if (element == null || string.IsNullOrWhiteSpace(localName))
|
||
|
|
{
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
foreach (var attribute in element.GetAttributes())
|
||
|
|
{
|
||
|
|
if (string.Equals(attribute.LocalName, localName, StringComparison.OrdinalIgnoreCase))
|
||
|
|
{
|
||
|
|
return attribute.Value;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
private bool IsFontAvailable(string fontName, IDictionary<string, HashSet<string>> documentFontAliases)
|
||
|
|
{
|
||
|
|
if (string.IsNullOrWhiteSpace(fontName))
|
||
|
|
{
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
var installedFonts = _installedFonts.Value;
|
||
|
|
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
|
|
var queue = new Queue<string>();
|
||
|
|
queue.Enqueue(fontName);
|
||
|
|
|
||
|
|
while (queue.Count > 0)
|
||
|
|
{
|
||
|
|
var current = NormalizeFontName(queue.Dequeue());
|
||
|
|
if (string.IsNullOrWhiteSpace(current) || !visited.Add(current))
|
||
|
|
{
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (installedFonts.Contains(current))
|
||
|
|
{
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
EnqueueAliases(current, documentFontAliases, queue);
|
||
|
|
EnqueueAliases(current, KnownFontAliases.Value, queue);
|
||
|
|
}
|
||
|
|
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static Dictionary<string, HashSet<string>> BuildKnownFontAliases()
|
||
|
|
{
|
||
|
|
var result = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
|
||
|
|
foreach (var group in KnownFontAliasGroups)
|
||
|
|
{
|
||
|
|
if (group == null || group.Length < 2)
|
||
|
|
{
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
for (var index = 0; index < group.Length; index++)
|
||
|
|
{
|
||
|
|
var current = NormalizeFontName(group[index]);
|
||
|
|
if (string.IsNullOrWhiteSpace(current))
|
||
|
|
{
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
for (var inner = 0; inner < group.Length; inner++)
|
||
|
|
{
|
||
|
|
if (inner == index)
|
||
|
|
{
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
AddAliasPair(result, current, group[inner]);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static void EnqueueAliases(string fontName, IDictionary<string, HashSet<string>> aliases, Queue<string> queue)
|
||
|
|
{
|
||
|
|
if (queue == null || aliases == null || string.IsNullOrWhiteSpace(fontName))
|
||
|
|
{
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!aliases.TryGetValue(fontName, out var values) || values == null)
|
||
|
|
{
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
foreach (var value in values)
|
||
|
|
{
|
||
|
|
var normalized = NormalizeFontName(value);
|
||
|
|
if (!string.IsNullOrWhiteSpace(normalized))
|
||
|
|
{
|
||
|
|
queue.Enqueue(normalized);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private static void AddAliasPair(IDictionary<string, HashSet<string>> aliases, string left, string right)
|
||
|
|
{
|
||
|
|
var normalizedLeft = NormalizeFontName(left);
|
||
|
|
var normalizedRight = NormalizeFontName(right);
|
||
|
|
if (string.IsNullOrWhiteSpace(normalizedLeft)
|
||
|
|
|| string.IsNullOrWhiteSpace(normalizedRight)
|
||
|
|
|| string.Equals(normalizedLeft, normalizedRight, StringComparison.OrdinalIgnoreCase))
|
||
|
|
{
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
AddAliasValue(aliases, normalizedLeft, normalizedRight);
|
||
|
|
AddAliasValue(aliases, normalizedRight, normalizedLeft);
|
||
|
|
}
|
||
|
|
|
||
|
|
private static void AddAliasValue(IDictionary<string, HashSet<string>> aliases, string key, string value)
|
||
|
|
{
|
||
|
|
if (!aliases.TryGetValue(key, out var values))
|
||
|
|
{
|
||
|
|
values = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
|
|
aliases[key] = values;
|
||
|
|
}
|
||
|
|
|
||
|
|
values.Add(value);
|
||
|
|
}
|
||
|
|
|
||
|
|
private sealed class StyleFontSet
|
||
|
|
{
|
||
|
|
public string StyleId { get; set; }
|
||
|
|
|
||
|
|
public string BasedOnStyleId { get; set; }
|
||
|
|
|
||
|
|
public FontReferenceSet Fonts { get; set; }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
public sealed class FontValidationResult
|
||
|
|
{
|
||
|
|
public FontValidationResult(IList<string> usedFonts, IList<string> missingFonts)
|
||
|
|
{
|
||
|
|
UsedFonts = usedFonts ?? Array.Empty<string>();
|
||
|
|
MissingFonts = missingFonts ?? Array.Empty<string>();
|
||
|
|
}
|
||
|
|
|
||
|
|
public IList<string> UsedFonts { get; private set; }
|
||
|
|
|
||
|
|
public IList<string> MissingFonts { get; private set; }
|
||
|
|
}
|
||
|
|
|
||
|
|
internal sealed class FontReferenceSet
|
||
|
|
{
|
||
|
|
public string Ascii { get; set; }
|
||
|
|
|
||
|
|
public string HighAnsi { get; set; }
|
||
|
|
|
||
|
|
public string EastAsia { get; set; }
|
||
|
|
|
||
|
|
public string ComplexScript { get; set; }
|
||
|
|
|
||
|
|
public IEnumerable<string> GetDefinedFonts()
|
||
|
|
{
|
||
|
|
return new[] { Ascii, HighAnsi, EastAsia, ComplexScript }
|
||
|
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||
|
|
.Distinct(StringComparer.OrdinalIgnoreCase);
|
||
|
|
}
|
||
|
|
|
||
|
|
public void ApplyOverride(FontReferenceSet other)
|
||
|
|
{
|
||
|
|
if (other == null)
|
||
|
|
{
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!string.IsNullOrWhiteSpace(other.Ascii))
|
||
|
|
{
|
||
|
|
Ascii = other.Ascii;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!string.IsNullOrWhiteSpace(other.HighAnsi))
|
||
|
|
{
|
||
|
|
HighAnsi = other.HighAnsi;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!string.IsNullOrWhiteSpace(other.EastAsia))
|
||
|
|
{
|
||
|
|
EastAsia = other.EastAsia;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!string.IsNullOrWhiteSpace(other.ComplexScript))
|
||
|
|
{
|
||
|
|
ComplexScript = other.ComplexScript;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|