From 51c022357bcf1aae1a2882fa3ba4b23aef98efe4 Mon Sep 17 00:00:00 2001 From: Marco Russo Date: Sat, 5 Sep 2026 23:12:58 +0200 Subject: [PATCH] Draw an SVG's embedded bitmaps at pixel size and keep centred spaced labels readable Two SharpVectors defects showed up importing an infographic: logos embedded as 72-DPI and 216-DPI bitmaps came out a third larger or less than half the size of their box, and letter-spaced labels with text-anchor="middle" piled up in half their width. The renderer fits a bitmap by its WPF Width and Height, which follow the file's DPI; an image visitor now decodes base64 data URIs and hands back the same pixels at 96 DPI. Spaced text is drawn glyph by glyph with the text's own alignment, so SvgMarkup.Rewrite drops letter-spacing from text not anchored at its start. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 13 +- docs/decisions.md | 29 +++- src/SQLBI.Whiteboard.Core/Import/SvgMarkup.cs | 135 ++++++++++++++---- src/SQLBI.Whiteboard/SvgImageCodec.cs | 78 +++++++++- .../Program.cs | 35 ++++- 5 files changed, 246 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96cb196..aae73c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,13 +54,24 @@ everything in it stays reachable; F2 renames it. A board with a frame is saved i format version, so it needs this release to open; a board without one still opens in the releases before it. -### An SVG with a picture inside draws the picture where it belongs +### An SVG with pictures inside draws them where, and as large as, the author placed them An SVG that embeds a bitmap and clips it, the way an illustration frames a screenshot, drew the bitmap shifted and partly missing. The renderer applied the clip inside the scaling it builds for the picture, so a clip written in page coordinates moved with the picture. The clip is now lifted onto a group around the picture before drawing, which is what the markup means, and the picture lands where the author put it. +An embedded logo saved at 72 or 216 DPI came out a third larger, or less than half the +size, of the box the SVG gave it, because the renderer measured the picture in screen +units rather than pixels. The pictures are now measured in pixels, as browsers do, so a +logo fills exactly the box it was given. + +### A centred, letter-spaced label no longer collapses +A heading such as `THE INTERFACE` set with `letter-spacing` and centred with +`text-anchor="middle"` piled its letters up in half the width, or all on one spot when +anchored at the end. The letter-spacing is dropped for such text before drawing, so the +label appears where it was placed, set a little tighter than the author asked. + ## 1.2.2 - 2 September 2026 ### The Eraser, for a pen that has none diff --git a/docs/decisions.md b/docs/decisions.md index a47dda1..1677948 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -382,13 +382,28 @@ The renderer is given `ExternalResourcesAccessModes.Ignore`. Its default is to f the markup names, which would let a pasted or dropped file turn opening a board into an outbound request. -One rendering defect is worked around in the markup rather than in the drawing it -produces (issue 98): SharpVectors puts an ``'s own `clip-path` on the same drawing -group as the scale and offset it builds for the image's size, so the clip is transformed -along with the bitmap. `SvgMarkup.HoistImageClips` moves the clip, and the image's -transform with it, onto a `` around the image before decoding, which the renderer -handles as the author meant. The stored asset is untouched; only what is handed to the -renderer changes. +Three rendering defects are worked around before the drawing is produced rather than in +the drawing itself. The stored asset is untouched in every case; only what is handed to +the renderer changes. + +- SharpVectors puts an ``'s own `clip-path` on the same drawing group as the scale + and offset it builds for the image's size, so the clip is transformed along with the + bitmap (issue 98). `SvgMarkup.Rewrite` moves the clip, and the image's transform with + it, onto a `` around the image, which the renderer handles as the author meant. +- Text with `letter-spacing` is drawn one glyph at a time, and each glyph is given the + text's own anchor, so with `text-anchor="middle"` every glyph is centred on the pen and + the pen advances half a glyph; with `end` it does not advance at all. A centred label + piles up in half its width. `SvgMarkup.Rewrite` removes the spacing from text that is + not anchored at its start, so the whole string is measured and placed at once. The + label is set a little tighter than the author asked, and where they asked. A tracked + heading anchored at its start is left as written, since that path draws correctly. +- An embedded bitmap is fitted into its `` by the WPF `Width` and `Height` of the + decoded picture, which are device-independent units and scale with the DPI the file + declares. A 72-DPI PNG comes out a third larger than its pixels, a 216-DPI logo less + than half its size, while the browsers measure pixels. `SvgImageCodec` gives the + renderer an image visitor that decodes base64 data URIs itself and hands back the same + pixels at 96 DPI, so a unit is a pixel. Anything else is left to the renderer's reader, + still under `ExternalResourcesAccessModes.Ignore`. --- diff --git a/src/SQLBI.Whiteboard.Core/Import/SvgMarkup.cs b/src/SQLBI.Whiteboard.Core/Import/SvgMarkup.cs index 78109e6..fcbbedf 100644 --- a/src/SQLBI.Whiteboard.Core/Import/SvgMarkup.cs +++ b/src/SQLBI.Whiteboard.Core/Import/SvgMarkup.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Xml; using System.Xml.Linq; @@ -12,15 +13,11 @@ public static class SvgMarkup private static readonly XNamespace Svg = "http://www.w3.org/2000/svg"; /// - /// Moves an image's own clip-path, and its transform with it, onto a - /// group around the image. SharpVectors applies the clip on the same drawing group - /// as the scale and offset it builds for the image's width, height, and aspect ratio, - /// so a clip written in page coordinates is scaled and shifted along with the bitmap - /// and lands somewhere else (issue 98). On a group the clip is honored where the - /// author put it. Markup with nothing to move comes back as the same bytes; markup - /// that does not parse is left for the renderer to reject in its own words. + /// Applies every rewrite the renderer needs and returns the markup to hand it. + /// Markup with nothing to change comes back as the same bytes; markup that does + /// not parse is left for the renderer to reject in its own words. /// - public static byte[] HoistImageClips(byte[] bytes) + public static byte[] Rewrite(byte[] bytes) { ArgumentNullException.ThrowIfNull(bytes); @@ -33,31 +30,13 @@ public static byte[] HoistImageClips(byte[] bytes) XmlResolver = null, }); var document = XDocument.Load(reader, LoadOptions.PreserveWhitespace); - var clipped = document - .Descendants(Svg + "image") - .Where(image => image.Attribute("clip-path") is not null) - .ToArray(); - if (clipped.Length == 0) + var changed = HoistImageClips(document); + changed |= DropAnchoredLetterSpacing(document); + if (!changed) { return bytes; } - foreach (var image in clipped) - { - var group = new XElement(Svg + "g"); - foreach (var name in new[] { "clip-path", "transform" }) - { - if (image.Attribute(name) is { } attribute) - { - attribute.Remove(); - group.Add(new XAttribute(name, attribute.Value)); - } - } - - image.ReplaceWith(group); - group.Add(image); - } - // Formatting off: re-indenting would put whitespace between the runs of a // , which SVG renders as spaces. using var output = new MemoryStream(); @@ -69,4 +48,102 @@ public static byte[] HoistImageClips(byte[] bytes) return bytes; } } + + /// + /// Moves an image's own clip-path, and its transform with it, onto a + /// group around the image. SharpVectors applies the clip on the same drawing group + /// as the scale and offset it builds for the image's width, height, and aspect ratio, + /// so a clip written in page coordinates is scaled and shifted along with the bitmap + /// and lands somewhere else (issue 98). On a group the clip is honored where the + /// author put it. + /// + private static bool HoistImageClips(XDocument document) + { + var clipped = document + .Descendants(Svg + "image") + .Where(image => image.Attribute("clip-path") is not null) + .ToArray(); + + foreach (var image in clipped) + { + var group = new XElement(Svg + "g"); + foreach (var name in new[] { "clip-path", "transform" }) + { + if (image.Attribute(name) is { } attribute) + { + attribute.Remove(); + group.Add(new XAttribute(name, attribute.Value)); + } + } + + image.ReplaceWith(group); + group.Add(image); + } + + return clipped.Length > 0; + } + + /// + /// Removes letter-spacing from text that is not anchored at its start. + /// SharpVectors draws spaced text one glyph at a time and gives each glyph the text's + /// own alignment, so with text-anchor="middle" every glyph is centred on the + /// pen and the pen advances half a glyph, and with end it does not advance at + /// all: the label piles up in half its width or less. Without the spacing the whole + /// string is measured and placed at once, which the renderer gets right; the label + /// is set a little tighter than the author asked, and where they asked. Spacing the + /// renderer already ignores, such as a length with a unit, is left alone. + /// + private static bool DropAnchoredLetterSpacing(XDocument document) + { + var spaced = document + .Descendants() + .Where(element => + element.Name.Namespace == Svg && + (element.Name.LocalName == "text" || element.Name.LocalName == "tspan") && + element.Attribute("letter-spacing") is { } spacing && + double.TryParse(spacing.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var amount) && + amount != 0 && + TextAnchor(element) is "middle" or "end") + .ToArray(); + + foreach (var element in spaced) + { + element.Attribute("letter-spacing")!.Remove(); + } + + return spaced.Length > 0; + } + + /// + /// The text-anchor in force on an element: its own, or the nearest ancestor's, + /// whether written as an attribute or inside style. + /// + private static string TextAnchor(XElement element) + { + for (var current = element; current is not null; current = current.Parent) + { + if (current.Attribute("text-anchor")?.Value.Trim() is { Length: > 0 } attribute && + attribute != "inherit") + { + return attribute; + } + + if (current.Attribute("style")?.Value is { } style) + { + foreach (var declaration in style.Split(';')) + { + var colon = declaration.IndexOf(':'); + if (colon > 0 && + declaration[..colon].Trim() == "text-anchor" && + declaration[(colon + 1)..].Trim() is { Length: > 0 } value && + value != "inherit") + { + return value; + } + } + } + } + + return "start"; + } } diff --git a/src/SQLBI.Whiteboard/SvgImageCodec.cs b/src/SQLBI.Whiteboard/SvgImageCodec.cs index 3f3fda8..821f0c8 100644 --- a/src/SQLBI.Whiteboard/SvgImageCodec.cs +++ b/src/SQLBI.Whiteboard/SvgImageCodec.cs @@ -1,7 +1,9 @@ using System.IO; using System.Windows.Media; +using System.Windows.Media.Imaging; using SharpVectors.Converters; using SharpVectors.Dom; +using SharpVectors.Dom.Svg; using SharpVectors.Renderers.Wpf; using SQLBI.Whiteboard.Core.Import; @@ -30,9 +32,10 @@ public static DrawingImage Decode(byte[] bytes) // opening a board into an outbound request. ExternalResourcesAccessMode = ExternalResourcesAccessModes.Ignore, }; + settings.Visitors.ImageVisitor = PixelSizedBitmapVisitor.Instance; using var reader = new FileSvgReader(settings); - using var stream = new MemoryStream(SvgMarkup.HoistImageClips(bytes), writable: false); + using var stream = new MemoryStream(SvgMarkup.Rewrite(bytes), writable: false); var drawing = reader.Read(stream) ?? throw new InvalidDataException("The SVG has nothing to draw."); @@ -44,4 +47,77 @@ public static DrawingImage Decode(byte[] bytes) return image; } + + /// + /// Decodes the bitmaps an SVG embeds so that one bitmap pixel is one user unit. + /// SharpVectors fits an embedded bitmap into its <image> by the WPF + /// Width and Height of the decoded picture, which are device-independent + /// units and so scale with the DPI the file declares: a 72-DPI PNG comes out a third + /// larger than the pixels the author placed, and a 216-DPI logo less than half the + /// size. The browsers measure the pixels. Handing the renderer the same pixels at + /// 96 DPI makes the two agree. Anything that is not a base64 data URI, or that WPF + /// cannot decode, is left to the renderer's own reader. + /// + private sealed class PixelSizedBitmapVisitor : WpfEmbeddedImageVisitor + { + public static PixelSizedBitmapVisitor Instance { get; } = new(); + + private const double ScreenDpi = 96; + + public override ImageSource? Visit(SvgImageElement element, WpfDrawingContext context) + { + var href = element.Href?.AnimVal; + if (href is null || !href.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var comma = href.IndexOf(','); + if (comma < 0 || !href[..comma].Contains(";base64", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + try + { + var bytes = Convert.FromBase64String(href[(comma + 1)..]); + using var stream = new MemoryStream(bytes, writable: false); + var decoder = BitmapDecoder.Create( + stream, + BitmapCreateOptions.PreservePixelFormat, + BitmapCacheOption.OnLoad); + if (decoder.Frames.Count == 0) + { + return null; + } + + BitmapSource frame = decoder.Frames[0]; + return Math.Abs(frame.DpiX - ScreenDpi) < 0.01 && Math.Abs(frame.DpiY - ScreenDpi) < 0.01 + ? frame + : AtScreenDpi(frame); + } + catch (Exception exception) when (exception is FormatException or IOException or NotSupportedException or ArgumentException) + { + return null; + } + } + + private static BitmapSource AtScreenDpi(BitmapSource frame) + { + var stride = (frame.PixelWidth * frame.Format.BitsPerPixel + 7) / 8; + var pixels = new byte[stride * frame.PixelHeight]; + frame.CopyPixels(pixels, stride, 0); + var bitmap = BitmapSource.Create( + frame.PixelWidth, + frame.PixelHeight, + ScreenDpi, + ScreenDpi, + frame.Format, + frame.Palette, + pixels, + stride); + bitmap.Freeze(); + return bitmap; + } + } } diff --git a/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs b/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs index d59f046..e6cfc13 100644 --- a/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs +++ b/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs @@ -1176,22 +1176,45 @@ string SqlText(SqlServerClassifiedSpan span) => TextLanguageIds.Normalize("SQLSERVER") == TextLanguageIds.SqlServer, "The SQL Server text language identifier should normalize for persistence."); -// An image's own clip-path is hoisted onto a group around it before the SVG is -// drawn, with its transform, so that the renderer's clip lands where the author -// put it (issue 98). Markup with nothing to hoist is passed through untouched. +// The SVG handed to the renderer is rewritten around its blind spots. An image's own +// clip-path is hoisted onto a group around it, with its transform, so the clip lands +// where the author put it (issue 98); letter-spacing comes off text that is anchored at +// its middle or end, which the renderer would otherwise pile up in half its width. +// Markup with nothing to rewrite is passed through untouched. { byte[] clippedSvg = Encoding.UTF8.GetBytes( "" + ""); - string hoisted = Encoding.UTF8.GetString(SvgMarkup.HoistImageClips(clippedSvg)); + string hoisted = Encoding.UTF8.GetString(SvgMarkup.Rewrite(clippedSvg)); Assert( hoisted.Contains("", StringComparison.Ordinal) && hoisted.Contains("", StringComparison.Ordinal), "An image's clip-path and transform move to a group around it; the rest is untouched."); byte[] plainSvg = Encoding.UTF8.GetBytes(""); - Assert(ReferenceEquals(SvgMarkup.HoistImageClips(plainSvg), plainSvg), "Markup with no clipped image is the same bytes."); + Assert(ReferenceEquals(SvgMarkup.Rewrite(plainSvg), plainSvg), "Markup with no clipped image is the same bytes."); byte[] brokenSvg = Encoding.UTF8.GetBytes("" + + "AB" + + "C" + + "D" + + "E" + + "F"); + string unspaced = Encoding.UTF8.GetString(SvgMarkup.Rewrite(spacedSvg)); + Assert( + unspaced.Contains("A", StringComparison.Ordinal) && + unspaced.Contains("C", StringComparison.Ordinal), + "Letter-spacing comes off text whose anchor, inherited or in a style, is middle or end."); + Assert( + unspaced.Contains("B", StringComparison.Ordinal) && + unspaced.Contains("D", StringComparison.Ordinal) && + unspaced.Contains("E", StringComparison.Ordinal) && + unspaced.Contains("F", StringComparison.Ordinal), + "Spacing the renderer ignores, and spacing on start-anchored text, is kept."); + byte[] startSpacedSvg = Encoding.UTF8.GetBytes("E"); + Assert(ReferenceEquals(SvgMarkup.Rewrite(startSpacedSvg), startSpacedSvg), "Start-anchored spaced text leaves the bytes as they were."); } // Export areas: the board is cut only where it is empty, a container keeps its