From dc73368ba06a26da41f1be3437c4264db6c1a3e5 Mon Sep 17 00:00:00 2001 From: Marco Russo Date: Fri, 4 Sep 2026 00:18:49 +0200 Subject: [PATCH] Draw an SVG's clipped picture where the author put it SharpVectors puts an image's own clip-path 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 transformed along with the bitmap and lands elsewhere: the picture came out shifted and partly blank. The clip, and the image's transform with it, is now hoisted onto a group around the image before decoding, which is what the markup means and what the renderer handles correctly. The stored asset is untouched; only what is handed to the renderer changes. Fixes #98. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 7 ++ docs/decisions.md | 8 +++ src/SQLBI.Whiteboard.Core/Import/SvgMarkup.cs | 72 +++++++++++++++++++ src/SQLBI.Whiteboard/SvgImageCodec.cs | 3 +- .../Program.cs | 18 +++++ 5 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 src/SQLBI.Whiteboard.Core/Import/SvgMarkup.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index e4c8b2d..96cb196 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,13 @@ 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 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. + ## 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 7f4878d..a47dda1 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -382,6 +382,14 @@ 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. + --- ## 22. Pen ink is collected from the pen, not from the InkCanvas diff --git a/src/SQLBI.Whiteboard.Core/Import/SvgMarkup.cs b/src/SQLBI.Whiteboard.Core/Import/SvgMarkup.cs new file mode 100644 index 0000000..78109e6 --- /dev/null +++ b/src/SQLBI.Whiteboard.Core/Import/SvgMarkup.cs @@ -0,0 +1,72 @@ +using System.Xml; +using System.Xml.Linq; + +namespace SQLBI.Whiteboard.Core.Import; + +/// +/// Rewrites SVG markup around the renderer's blind spots before it is drawn. The +/// markup is otherwise stored and drawn as it arrived. +/// +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. + /// + public static byte[] HoistImageClips(byte[] bytes) + { + ArgumentNullException.ThrowIfNull(bytes); + + try + { + using var stream = new MemoryStream(bytes, writable: false); + using var reader = XmlReader.Create(stream, new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Ignore, + 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) + { + 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(); + document.Save(output, SaveOptions.DisableFormatting); + return output.ToArray(); + } + catch (XmlException) + { + return bytes; + } + } +} diff --git a/src/SQLBI.Whiteboard/SvgImageCodec.cs b/src/SQLBI.Whiteboard/SvgImageCodec.cs index e862c6a..3f3fda8 100644 --- a/src/SQLBI.Whiteboard/SvgImageCodec.cs +++ b/src/SQLBI.Whiteboard/SvgImageCodec.cs @@ -3,6 +3,7 @@ using SharpVectors.Converters; using SharpVectors.Dom; using SharpVectors.Renderers.Wpf; +using SQLBI.Whiteboard.Core.Import; namespace SQLBI.Whiteboard; @@ -31,7 +32,7 @@ public static DrawingImage Decode(byte[] bytes) }; using var reader = new FileSvgReader(settings); - using var stream = new MemoryStream(bytes, writable: false); + using var stream = new MemoryStream(SvgMarkup.HoistImageClips(bytes), writable: false); var drawing = reader.Read(stream) ?? throw new InvalidDataException("The SVG has nothing to draw."); diff --git a/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs b/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs index b1b91d2..d59f046 100644 --- a/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs +++ b/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs @@ -1176,6 +1176,24 @@ 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. +{ + byte[] clippedSvg = Encoding.UTF8.GetBytes( + "" + + ""); + string hoisted = Encoding.UTF8.GetString(SvgMarkup.HoistImageClips(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."); + byte[] brokenSvg = Encoding.UTF8.GetBytes("