Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<image>`'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 `<g>` 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
Expand Down
72 changes: 72 additions & 0 deletions src/SQLBI.Whiteboard.Core/Import/SvgMarkup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using System.Xml;
using System.Xml.Linq;

namespace SQLBI.Whiteboard.Core.Import;

/// <summary>
/// Rewrites SVG markup around the renderer's blind spots before it is drawn. The
/// markup is otherwise stored and drawn as it arrived.
/// </summary>
public static class SvgMarkup
{
private static readonly XNamespace Svg = "http://www.w3.org/2000/svg";

/// <summary>
/// Moves an image's own <c>clip-path</c>, and its <c>transform</c> 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.
/// </summary>
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
// <text>, which SVG renders as spaces.
using var output = new MemoryStream();
document.Save(output, SaveOptions.DisableFormatting);
return output.ToArray();
}
catch (XmlException)
{
return bytes;
}
}
}
3 changes: 2 additions & 1 deletion src/SQLBI.Whiteboard/SvgImageCodec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using SharpVectors.Converters;
using SharpVectors.Dom;
using SharpVectors.Renderers.Wpf;
using SQLBI.Whiteboard.Core.Import;

namespace SQLBI.Whiteboard;

Expand Down Expand Up @@ -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.");

Expand Down
18 changes: 18 additions & 0 deletions tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"<svg xmlns=\"http://www.w3.org/2000/svg\"><defs><clipPath id=\"c\"><rect width=\"1\" height=\"1\"/></clipPath></defs>" +
"<image x=\"1\" clip-path=\"url(#c)\" transform=\"scale(2)\" href=\"data:image/png;base64,AA==\"/><rect width=\"2\" height=\"2\"/></svg>");
string hoisted = Encoding.UTF8.GetString(SvgMarkup.HoistImageClips(clippedSvg));
Assert(
hoisted.Contains("<g clip-path=\"url(#c)\" transform=\"scale(2)\"><image x=\"1\" href=\"data:image/png;base64,AA==\" /></g>", StringComparison.Ordinal) &&
hoisted.Contains("<rect width=\"2\" height=\"2\" />", StringComparison.Ordinal),
"An image's clip-path and transform move to a group around it; the rest is untouched.");
byte[] plainSvg = Encoding.UTF8.GetBytes("<svg xmlns=\"http://www.w3.org/2000/svg\"><image href=\"data:image/png;base64,AA==\"/></svg>");
Assert(ReferenceEquals(SvgMarkup.HoistImageClips(plainSvg), plainSvg), "Markup with no clipped image is the same bytes.");
byte[] brokenSvg = Encoding.UTF8.GetBytes("<svg><image clip-path='u'");
Assert(ReferenceEquals(SvgMarkup.HoistImageClips(brokenSvg), brokenSvg), "Markup that does not parse is left for the renderer.");
}

// Export areas: the board is cut only where it is empty, a container keeps its
// linked ink, a bridging stroke glues its neighbours, and the two orders differ.
{
Expand Down