diff --git a/src/officecli/Core/FontMetricsReader.cs b/src/officecli/Core/FontMetricsReader.cs index 7d6141228..326c3efee 100644 --- a/src/officecli/Core/FontMetricsReader.cs +++ b/src/officecli/Core/FontMetricsReader.cs @@ -13,6 +13,32 @@ namespace OfficeCli.Core; /// internal static class FontMetricsReader { + // MOD(#12): see docs/cove-desktop-mods.md + // Word documents often specify Windows logical CJK family names such as + // "宋体" / "黑体", while macOS ships the actual font files under stems like + // Songti.ttc / STHeiti.ttc. Metrics lookup must follow the same alias/fallback + // path as the preview CSS, otherwise GetRatio() falls back to 1.0 and the + // rendered line-height becomes much tighter than the browser's real glyph box. + private static readonly Dictionary s_fontSearchAliases = new(StringComparer.OrdinalIgnoreCase) + { + ["宋体"] = ["Songti", "Songti SC", "STSong", "SimSun", "NSimSun"], + ["宋体-简"] = ["Songti", "Songti SC", "STSong"], + ["宋體-簡"] = ["Songti", "Songti SC", "STSong"], + ["SimSun"] = ["Songti", "Songti SC", "STSong", "宋体"], + ["NSimSun"] = ["Songti", "Songti SC", "STSong", "宋体"], + ["黑体"] = ["STHeiti", "Heiti SC", "Heiti TC", "SimHei"], + ["SimHei"] = ["STHeiti", "Heiti SC", "Heiti TC", "黑体"], + ["仿宋_GB2312"] = ["STFangsong", "FangSong", "仿宋"], + ["仿宋"] = ["STFangsong", "FangSong", "仿宋_GB2312"], + ["楷体_GB2312"] = ["STKaiti", "KaiTi", "楷体"], + ["楷体"] = ["STKaiti", "KaiTi", "楷体_GB2312"], + ["长城小标宋体"] = ["STZhongsong", "Songti", "STSong"], + ["Songti SC"] = ["Songti", "STSong", "宋体"], + ["STSong"] = ["Songti", "Songti SC", "宋体"], + ["Heiti SC"] = ["STHeiti", "黑体"], + ["STHeiti"] = ["Heiti SC", "黑体"], + }; + /// /// Line-height ratio = (usWinAscent + usWinDescent + hheaLineGap) / unitsPerEm. /// Returns 1.0 if the font file cannot be read. @@ -152,8 +178,12 @@ private static uint ReadUInt32BE(BinaryReader r) dirs.Add("/usr/local/share/fonts"); } - // Normalize: remove spaces, try exact match and lowercase - var normalized = fontFamily.Replace(" ", ""); + // Normalize: remove spaces, try exact match and platform fallback aliases. + var candidates = ExpandFontSearchAliases(fontFamily) + .Select(NormalizeFontSearchToken) + .Where(token => !string.IsNullOrWhiteSpace(token)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); foreach (var dir in dirs) { if (!Directory.Exists(dir)) continue; @@ -161,15 +191,31 @@ private static uint ReadUInt32BE(BinaryReader r) { var ext = Path.GetExtension(file); if (ext is not (".ttf" or ".otf" or ".ttc")) continue; - var stem = Path.GetFileNameWithoutExtension(file); - if (stem.Equals(normalized, StringComparison.OrdinalIgnoreCase) - || stem.Equals(fontFamily, StringComparison.OrdinalIgnoreCase)) + var stem = NormalizeFontSearchToken(Path.GetFileNameWithoutExtension(file)); + if (candidates.Any(candidate => stem.Equals(candidate, StringComparison.OrdinalIgnoreCase))) return file; } } return null; } + private static IEnumerable ExpandFontSearchAliases(string fontFamily) + { + yield return fontFamily; + + if (s_fontSearchAliases.TryGetValue(fontFamily, out var aliases)) + { + foreach (var alias in aliases) + yield return alias; + } + } + + private static string NormalizeFontSearchToken(string value) => + value + .Replace(" ", "", StringComparison.Ordinal) + .Replace("-", "", StringComparison.Ordinal) + .Replace("_", "", StringComparison.Ordinal); + // ==================== Cached Ratio Lookup ==================== private static readonly Dictionary s_ratioCache = new(StringComparer.OrdinalIgnoreCase); diff --git a/src/officecli/Core/SpacingConverter.cs b/src/officecli/Core/SpacingConverter.cs index 5fbedb60a..e71055099 100644 --- a/src/officecli/Core/SpacingConverter.cs +++ b/src/officecli/Core/SpacingConverter.cs @@ -32,7 +32,7 @@ internal static class SpacingConverter private const double PointsPerCm = 72.0 / 2.54; // ~28.3465 private const double PointsPerInch = 72.0; private const int TwipsPerPoint = 20; // 1 pt = 20 twips - private const int WordAutoLineSpacingUnit = 240; // 240 twips = single line in Auto mode + private const int WordAutoLineSpacingUnit = 288; // MOD: 288 twips (14.4pt) better matches WPS/Chinese Word default row height for 16pt fonts // 240 twips = single line in Auto mode // ──────────────────────────────────────────────────────────────── // spaceBefore / spaceAfter → Word twips diff --git a/src/officecli/Handlers/Word/WordHandler.Helpers.cs b/src/officecli/Handlers/Word/WordHandler.Helpers.cs index 19a8e9253..99bed491e 100644 --- a/src/officecli/Handlers/Word/WordHandler.Helpers.cs +++ b/src/officecli/Handlers/Word/WordHandler.Helpers.cs @@ -1391,7 +1391,7 @@ private static PageMargin EnsureSectPrPageMargin(SectionProperties sectPr) var existing = sectPr.GetFirstChild(); if (existing != null) return existing; - var pm = new PageMargin(); + var pm = new PageMargin { Top = 1440, Bottom = 1440, Left = 1800, Right = 1800, Header = 851, Footer = 992 }; // Insert after PageSize if present, after SectionType, after last headerRef/footerRef, or prepend var pageSize = sectPr.GetFirstChild(); if (pageSize != null) diff --git a/src/officecli/Handlers/Word/WordHandler.HtmlPreview.Css.cs b/src/officecli/Handlers/Word/WordHandler.HtmlPreview.Css.cs index 414464276..d41d2a25c 100644 --- a/src/officecli/Handlers/Word/WordHandler.HtmlPreview.Css.cs +++ b/src/officecli/Handlers/Word/WordHandler.HtmlPreview.Css.cs @@ -225,6 +225,7 @@ private static long GetLongAttr(OpenXmlElement? el, string attrName, long defaul private string GetParagraphInlineCss(Paragraph para, bool isListItem = false) { var parts = new List(); + var docDefaults = ReadDocDefaults(); // Set paragraph font-size to match the first run's resolved font-size. // This prevents the CSS "strut" (block container's anonymous inline box) from inflating @@ -243,6 +244,8 @@ private string GetParagraphInlineCss(Paragraph para, bool isListItem = false) if (pProps == null) { var styleCss = ResolveParagraphStyleCss(para); + if (!styleCss.Contains("line-height:", StringComparison.Ordinal)) + parts.Add(BuildDefaultParagraphLineHeightCss(para, null, docDefaults)); if (parts.Count > 0 && !string.IsNullOrEmpty(styleCss)) return string.Join(";", parts) + ";" + styleCss; if (parts.Count > 0) return string.Join(";", parts); @@ -253,24 +256,18 @@ private string GetParagraphInlineCss(Paragraph para, bool isListItem = false) var styleId = pProps.ParagraphStyleId?.Val?.Value; // Alignment (direct or from style chain) - var jc = pProps.Justification?.Val; - if (jc == null) jc = ResolveJustificationFromStyle(styleId); - if (jc != null) + var jc = pProps.Justification?.Val?.InnerText; + if (string.IsNullOrWhiteSpace(jc)) jc = ResolveJustificationFromStyle(styleId); + if (!string.IsNullOrWhiteSpace(jc)) { - var jcVal = jc.InnerText; - var align = jcVal switch - { - "center" => "center", - "right" or "end" => "right", - "both" or "distribute" => "justify", - _ => (string?)null - }; + var jcVal = jc.Trim(); + var align = MapJustificationToCss(jcVal); if (align != null) parts.Add($"text-align:{align}"); // w:jc="distribute" stretches EVERY line (including single/last) // to full width with inter-character spacing. Plain CSS justify // leaves the last line unstretched, so add text-align-last // and text-justify hints for closer fidelity. - if (jcVal == "distribute") + if (jcVal.Equals("distribute", StringComparison.OrdinalIgnoreCase)) parts.Add("text-align-last:justify;text-justify:inter-character"); } @@ -393,92 +390,25 @@ private string GetParagraphInlineCss(Paragraph para, bool isListItem = false) parts.Add($"{vSpacingPropAfter}:{Units.TwipsToPt(afterTwips):0.##}pt"); else if (afterLinesVal is int afterLines) parts.Add($"{vSpacingPropAfter}:{afterLines / 100.0:0.##}em"); - - // Line: try direct, then style fallback var lineVal = pProps.SpacingBetweenLines?.Line?.Value ?? styleSpacing?.Line?.Value; - if (lineVal is string lv) - { - var rule = pProps.SpacingBetweenLines?.LineRule?.InnerText + var lineRule = pProps.SpacingBetweenLines?.LineRule?.InnerText ?? styleSpacing?.LineRule?.InnerText; - if (rule == "auto" || rule == null) - { - if (int.TryParse(lv, out var lvNum)) - { - // Correct for font metrics ratio - var paraFont = ResolveParaFontForLineHeight(para); - var ratio = FontMetricsReader.GetRatio(paraFont); - parts.Add($"line-height:{lvNum / 240.0 * ratio:0.##}"); - } - } - else if (rule == "exact" || rule == "atLeast") - { - var linePt = Units.TwipsToPt(lv); - parts.Add($"line-height:{linePt:0.##}pt"); - // #7b0001: when lineRule=exact pins the line box below - // ~120% of the paragraph's font size, Word clips - // over-tall glyphs. Emit overflow:hidden so tall glyphs - // don't leak into neighboring lines. - if (rule == "exact") - { - var sizeStr = ResolveStyleFontSize( - para.ParagraphProperties?.ParagraphStyleId?.Val?.Value ?? "") - ?? $"{ReadDocDefaults().SizePt}pt"; - // ResolveStyleFontSize returns "Npt"; strip suffix. - if (sizeStr.EndsWith("pt", StringComparison.Ordinal) - && double.TryParse(sizeStr[..^2], - System.Globalization.NumberStyles.Float, - System.Globalization.CultureInfo.InvariantCulture, - out var runSizePt) - && runSizePt > 0 && linePt < runSizePt * 1.2) - parts.Add("overflow:hidden"); - } - } - } - - // If no explicit line-height was set, use font metrics ratio - if (!parts.Any(p => p.StartsWith("line-height"))) + if (lineRule == "exact" && lineVal is string exactLine) { - var paraFont = ResolveParaFontForLineHeight(para); - var ratio = FontMetricsReader.GetRatio(paraFont); - if (ratio > 1.01 || ratio < 0.99) // only if meaningfully different from 1.0 - parts.Add($"line-height:{ratio:0.##}"); + var linePt = Units.TwipsToPt(exactLine); + var paraFontSizePt = ResolveParaFontSizePt(para, docDefaults); + if (paraFontSizePt > 0 && linePt < paraFontSizePt * 1.2) + parts.Add("overflow:hidden"); } - } - // docGrid snap: when type="lines" and paragraph doesn't opt out via snapToGrid=false, - // snap line-height to the nearest multiple of linePitch that fits the text. - { - var snapToGrid = pProps.SnapToGrid?.Val?.Value ?? true; - if (snapToGrid) - { - var sectPr = _doc.MainDocumentPart?.Document?.Body?.GetFirstChild(); - var dg = sectPr?.GetFirstChild(); - if ((dg?.Type?.Value == DocGridValues.Lines || dg?.Type?.Value == DocGridValues.LinesAndChars) - && dg.LinePitch?.Value is int lp && lp > 0) - { - double gridPitchPt = lp / 20.0; - var gFont = ResolveParaFontForLineHeight(para); - var gRatio = FontMetricsReader.GetRatio(gFont); - double gSizePt = 0; - var gFirstRun = para.Elements().FirstOrDefault(r => - r.ChildElements.Any(c => c is Text t && !string.IsNullOrEmpty(t.Text))); - if (gFirstRun != null) - { - var grProps = ResolveEffectiveRunProperties(gFirstRun, para); - if (grProps.FontSize?.Val?.Value is string gsz && int.TryParse(gsz, out var ghp)) - gSizePt = ghp / 2.0; - } - if (gSizePt <= 0) gSizePt = 12.0; - - double fontHeightPt = gSizePt * gRatio; - double snappedPt = Math.Ceiling(fontHeightPt / gridPitchPt) * gridPitchPt; - parts.RemoveAll(p => p.StartsWith("line-height")); - parts.Add($"line-height:{snappedPt:0.##}pt"); - } - } - } + // MOD(#6): see cove-desktop-mods.md + // Always emit an inline line-height derived from the paragraph's + // effective spacing + actual font metrics. This path also folds in the + // section docGrid linePitch when the body text snaps to grid, so WPS / + // Word previews do not collapse back to the browser's tighter default. + parts.Add(BuildDefaultParagraphLineHeightCss(para, styleSpacing, docDefaults)); // Shading / background (direct or from style) var shading = pProps.Shading; @@ -545,6 +475,75 @@ private string GetParagraphInlineCss(Paragraph para, bool isListItem = false) return string.Join(";", parts); } + private string BuildDefaultParagraphLineHeightCss( + Paragraph para, + SpacingBetweenLines? styleSpacing, + DocDef docDefaults) + { + var lineVal = para.ParagraphProperties?.SpacingBetweenLines?.Line?.Value + ?? styleSpacing?.Line?.Value; + var rule = para.ParagraphProperties?.SpacingBetweenLines?.LineRule?.InnerText + ?? styleSpacing?.LineRule?.InnerText; + var paraFont = ResolveParaFontForLineHeight(para); + var ratio = FontMetricsReader.GetRatio(paraFont); + var paraFontSizePt = ResolveParaFontSizePt(para, docDefaults); + + if (lineVal is string explicitLine) + { + if ((rule == "auto" || rule == null) && int.TryParse(explicitLine, out var autoLine)) + { + var autoLineHeightPt = paraFontSizePt * (autoLine / 240.0) * ratio; + autoLineHeightPt = ClampBodyParagraphToDocGrid(para, autoLineHeightPt, docDefaults); + return $"line-height:{autoLineHeightPt:0.##}pt"; + } + if (rule == "exact" || rule == "atLeast") + return $"line-height:{Units.TwipsToPt(explicitLine):0.##}pt"; + } + + var defaultLineHeightPt = paraFontSizePt * docDefaults.LineHeight * ratio; + defaultLineHeightPt = ClampBodyParagraphToDocGrid(para, defaultLineHeightPt, docDefaults); + return $"line-height:{defaultLineHeightPt:0.##}pt"; + } + + private double ResolveParaFontSizePt(Paragraph para, DocDef docDefaults) + { + var firstRun = para.Elements().FirstOrDefault(r => + r.ChildElements.Any(c => c is Text t && !string.IsNullOrEmpty(t.Text))); + if (firstRun != null) + { + var rProps = ResolveEffectiveRunProperties(firstRun, para); + if (rProps.FontSize?.Val?.Value is string runSize && double.TryParse(runSize, out var halfPts)) + return halfPts / 2.0; + } + + var styleId = para.ParagraphProperties?.ParagraphStyleId?.Val?.Value; + if (!string.IsNullOrWhiteSpace(styleId)) + { + var styleFontSize = ResolveStyleFontSize(styleId); + if (!string.IsNullOrWhiteSpace(styleFontSize) + && styleFontSize.EndsWith("pt", StringComparison.OrdinalIgnoreCase) + && double.TryParse(styleFontSize[..^2], out var stylePt)) + return stylePt; + } + + return docDefaults.SizePt; + } + + private double ClampBodyParagraphToDocGrid(Paragraph para, double lineHeightPt, DocDef docDefaults) + { + // MOD(#11): see cove-desktop-mods.md + // Section docGrid linePitch is measured from the page edge grid, not + // from the browser's anonymous line box. When preview paragraphs emit + // their own inline line-height they override the page-level grid + // fallback, so we clamp body text here to keep the rendered baseline + // spacing aligned with WPS / Word. + if (_ctx?.RenderingHeaderFooter == true) return lineHeightPt; + var snapToGrid = para.ParagraphProperties?.SnapToGrid?.Val?.Value ?? true; + return snapToGrid && docDefaults.GridLinePitchPt > 0 + ? Math.Max(lineHeightPt, docDefaults.GridLinePitchPt) + : lineHeightPt; + } + /// /// Resolve paragraph background shading from the style chain. /// @@ -573,7 +572,7 @@ private string GetParagraphInlineCss(Paragraph para, bool isListItem = false) /// /// Resolve Justification from the style chain. /// - private JustificationValues? ResolveJustificationFromStyle(string? styleId) + private string? ResolveJustificationFromStyle(string? styleId) { if (styleId == null) return null; var visited = new HashSet(); @@ -583,13 +582,24 @@ private string GetParagraphInlineCss(Paragraph para, bool isListItem = false) var style = _doc.MainDocumentPart?.StyleDefinitionsPart?.Styles ?.Elements