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>> KnownFontAliases = new Lazy>>(BuildKnownFontAliases); private readonly Lazy> _installedFonts; public FontValidationService() : this(null) { } public FontValidationService(IEnumerable installedFonts) { _installedFonts = new Lazy>( delegate { return installedFonts == null ? LoadInstalledFonts() : new HashSet( 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(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 missingFonts) { var missingFontSet = new HashSet( (missingFonts ?? Array.Empty()) .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()) { changed |= ApplyWordRunFontFallbacks(runFonts, missingFontSet, themeFonts); } foreach (var element in root.Descendants()) { changed |= ApplyDrawingFontFallback(element, missingFontSet); } if (changed) { root.Save(); } } } } private static IEnumerable EnumerateFontFallbackRoots(WordprocessingDocument document) { var main = document.MainDocumentPart; if (main == null) { yield break; } if (main.Document != null) { yield return main.Document; } var queue = new Queue(); var visited = new HashSet(); 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 missingFonts, IDictionary 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 themeLocalNames, string fallbackFont, ISet missingFonts, IDictionary 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 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 localNames) { var names = new HashSet( (localNames ?? Array.Empty()).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 LoadInstalledFonts() { var collection = new InstalledFontCollection(); return new HashSet( collection.Families .Select(item => NormalizeFontName(item.Name)) .Where(name => !string.IsNullOrWhiteSpace(name)), StringComparer.OrdinalIgnoreCase); } private static Dictionary> BuildDocumentFontAliasMap(WordprocessingDocument document) { var result = new Dictionary>(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()) { 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 EnumerateRelevantRoots(WordprocessingDocument document) { var main = document.MainDocumentPart; if (main == null) { yield break; } if (main.Document != null) { yield return main.Document; } var queue = new Queue(); var visited = new HashSet(); 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 BuildStyleIndex(WordprocessingDocument document) { var result = new Dictionary(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()) { 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 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 BuildThemeFontMap(WordprocessingDocument document) { var result = new Dictionary(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 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 styleIndex, FontReferenceSet defaultFonts, IDictionary themeFonts, ISet usedFonts) { foreach (var run in root.Descendants()) { 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 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().Any()) { return false; } return run.RunProperties == null || run.RunProperties.Vanish == null; } private static FontReferenceSet ResolveEffectiveWordFonts( W.Run run, IDictionary styleIndex, FontReferenceSet defaultFonts, IDictionary themeFonts) { var effectiveFonts = new FontReferenceSet(); effectiveFonts.ApplyOverride(defaultFonts); var paragraph = run.Ancestors().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().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 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(StringComparer.OrdinalIgnoreCase)); result.ApplyOverride(resolved); return result; } private static FontReferenceSet ResolveStyleFonts( string styleId, IDictionary styleIndex, ISet 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 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 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> documentFontAliases) { if (string.IsNullOrWhiteSpace(fontName)) { return true; } var installedFonts = _installedFonts.Value; var visited = new HashSet(StringComparer.OrdinalIgnoreCase); var queue = new Queue(); 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> BuildKnownFontAliases() { var result = new Dictionary>(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> aliases, Queue 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> 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> aliases, string key, string value) { if (!aliases.TryGetValue(key, out var values)) { values = new HashSet(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 usedFonts, IList missingFonts) { UsedFonts = usedFonts ?? Array.Empty(); MissingFonts = missingFonts ?? Array.Empty(); } public IList UsedFonts { get; private set; } public IList 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 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; } } } }