From 2b1150414d7e65a65d35e4d490cf6134aa928e56 Mon Sep 17 00:00:00 2001 From: niksedk Date: Sat, 11 Jul 2026 22:13:24 +0200 Subject: [PATCH] Fix missing thin outlines in image-based subtitle export At small outline widths (1-2) the outline vanished on horizontal glyph edges in BDN XML / Blu-ray SUP export, depending on font and font size. Two causes: the stroke was centered on the glyph edge, so only half the requested width was outside the glyph and the fill drawn on top covered the rest; and stroking through the hinted glyph rasterizer snapped horizontal edges to the pixel grid, letting the fill wash out what little outline remained. Stroke the raw shaped glyph path with doubled width instead (the fill covers the inner half, so the visible outline matches the requested width), and disable hinting on export fonts so fill and outline stay registered. Fixes #12206 Co-Authored-By: Claude Fable 5 --- src/libuilogic/Export/ImageRenderer.cs | 55 +++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/src/libuilogic/Export/ImageRenderer.cs b/src/libuilogic/Export/ImageRenderer.cs index 37c360c5347..29f30548ebf 100644 --- a/src/libuilogic/Export/ImageRenderer.cs +++ b/src/libuilogic/Export/ImageRenderer.cs @@ -88,6 +88,16 @@ public static SKBitmap GenerateBitmap(ImageParameter ip) using var italicFont = new SKFont(ip.IsBold ? boldItalicTypeface : italicTypeface, fontSize); using var boldItalicFont = new SKFont(boldItalicTypeface, fontSize); + // Hinting snaps glyph edges to the pixel grid, which breaks the registration + // between the fill and the outline stroked from the raw glyph path - thin + // outlines then vanish on horizontal edges (issue #12206). Export renders at + // video resolution, so hinting is not needed. + foreach (var font in new[] { regularFont, boldFont, italicFont, boldItalicFont }) + { + font.Hinting = SKFontHinting.None; + font.Subpixel = true; + } + // Split segments into lines var lines = SplitIntoSegments(segments); @@ -320,25 +330,35 @@ private static void RenderTextToCanvas( var segment = segmentsToRender[i]; var currentFont = GetFont(segment, regularFont, boldFont, italicFont, boldItalicFont); + // Outlines are stroked on the raw glyph path with doubled width: the fill + // drawn on top covers the inner half, so the visible outline matches the + // requested width. Stroking through the glyph rasterizer instead loses thin + // horizontal outline edges entirely (issue #12206). + using var textPath = outlineWidth > 0 + ? GetShapedTextPath(segment.Text, currentX, textStartY + currentY, currentFont) + : null; + // Draw shadow first (if shadow width > 0) if (shadowWidth > 0) { var shadowOffsetX = currentX + (float)shadowWidth; var shadowOffsetY = textStartY + currentY + (float)shadowWidth; - if (outlineWidth > 0) + if (textPath != null) { using var shadowOutlinePaint = new SKPaint { Color = shadowColor, IsAntialias = true, Style = SKPaintStyle.Stroke, - StrokeWidth = (float)outlineWidth, + StrokeWidth = (float)outlineWidth * 2, StrokeJoin = SKStrokeJoin.Round, StrokeCap = SKStrokeCap.Round, }; - DrawShapedText(canvas, segment.Text, shadowOffsetX, shadowOffsetY, currentFont, shadowOutlinePaint); + using var shadowPath = new SKPath(textPath); + shadowPath.Offset((float)shadowWidth, (float)shadowWidth); + canvas.DrawPath(shadowPath, shadowOutlinePaint); } using var shadowTextPaint = new SKPaint @@ -352,19 +372,19 @@ private static void RenderTextToCanvas( } // Draw outline second (if outline width > 0) - if (outlineWidth > 0) + if (textPath != null) { using var outlinePaint = new SKPaint { Color = outlineColor, IsAntialias = true, Style = SKPaintStyle.Stroke, - StrokeWidth = (float)outlineWidth, + StrokeWidth = (float)outlineWidth * 2, StrokeJoin = SKStrokeJoin.Round, StrokeCap = SKStrokeCap.Round, }; - DrawShapedText(canvas, segment.Text, currentX, textStartY + currentY, currentFont, outlinePaint); + canvas.DrawPath(textPath, outlinePaint); } // Draw the main text on top @@ -413,6 +433,29 @@ private static void DrawShapedText(SKCanvas canvas, string text, float x, float canvas.DrawShapedText(shaper, text, x, y, SKTextAlign.Left, font, paint); } + // Builds the combined glyph outline path for shaped text, positioned like + // DrawShapedText draws it. Glyphs without an outline (whitespace, color emoji) + // are skipped - they get no stroke, but the fill still renders them. + private static SKPath GetShapedTextPath(string text, float x, float y, SKFont font) + { + using var shaper = new SKShaper(font.Typeface); + var result = shaper.Shape(text, x, y, font); + + var path = new SKPath(); + for (var i = 0; i < result.Codepoints.Length; i++) + { + using var glyphPath = font.GetGlyphPath((ushort)result.Codepoints[i]); + if (glyphPath == null || glyphPath.IsEmpty) + { + continue; + } + + path.AddPath(glyphPath, result.Points[i].X, result.Points[i].Y); + } + + return path; + } + private static SKFont GetFont(TextSegment segment, SKFont regular, SKFont bold, SKFont italic, SKFont boldItalic) { if (segment.IsBold && segment.IsItalic)