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