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
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 22 additions & 7 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<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.
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 `<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 (issue 98). `SvgMarkup.Rewrite` moves the clip, and the image's transform with
it, onto a `<g>` 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 `<image>` 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`.

---

Expand Down
135 changes: 106 additions & 29 deletions src/SQLBI.Whiteboard.Core/Import/SvgMarkup.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Globalization;
using System.Xml;
using System.Xml.Linq;

Expand All @@ -12,15 +13,11 @@ 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.
/// 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.
/// </summary>
public static byte[] HoistImageClips(byte[] bytes)
public static byte[] Rewrite(byte[] bytes)
{
ArgumentNullException.ThrowIfNull(bytes);

Expand All @@ -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
// <text>, which SVG renders as spaces.
using var output = new MemoryStream();
Expand All @@ -69,4 +48,102 @@ public static byte[] HoistImageClips(byte[] bytes)
return bytes;
}
}

/// <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.
/// </summary>
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;
}

/// <summary>
/// Removes <c>letter-spacing</c> 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 <c>text-anchor="middle"</c> every glyph is centred on the
/// pen and the pen advances half a glyph, and with <c>end</c> 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.
/// </summary>
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;
}

/// <summary>
/// The <c>text-anchor</c> in force on an element: its own, or the nearest ancestor's,
/// whether written as an attribute or inside <c>style</c>.
/// </summary>
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";
}
}
78 changes: 77 additions & 1 deletion src/SQLBI.Whiteboard/SvgImageCodec.cs
Original file line number Diff line number Diff line change
@@ -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;

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

Expand All @@ -44,4 +47,77 @@ public static DrawingImage Decode(byte[] bytes)

return image;
}

/// <summary>
/// Decodes the bitmaps an SVG embeds so that one bitmap pixel is one user unit.
/// SharpVectors fits an embedded bitmap into its <c>&lt;image&gt;</c> by the WPF
/// <c>Width</c> and <c>Height</c> 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.
/// </summary>
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;
}
}
}
35 changes: 29 additions & 6 deletions tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"<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));
string hoisted = Encoding.UTF8.GetString(SvgMarkup.Rewrite(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.");
Assert(ReferenceEquals(SvgMarkup.Rewrite(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.");
Assert(ReferenceEquals(SvgMarkup.Rewrite(brokenSvg), brokenSvg), "Markup that does not parse is left for the renderer.");

byte[] spacedSvg = Encoding.UTF8.GetBytes(
"<svg xmlns=\"http://www.w3.org/2000/svg\">" +
"<g text-anchor=\"middle\"><text x=\"1\" letter-spacing=\"0.8\">A</text><text letter-spacing=\"0\">B</text></g>" +
"<text style=\"fill:red; text-anchor : end\"><tspan letter-spacing=\"1\">C</tspan></text>" +
"<text text-anchor=\"middle\" letter-spacing=\"0.1em\">D</text>" +
"<text letter-spacing=\"1.5\">E</text>" +
"<g text-anchor=\"middle\"><text text-anchor=\"start\" letter-spacing=\"1.5\">F</text></g></svg>");
string unspaced = Encoding.UTF8.GetString(SvgMarkup.Rewrite(spacedSvg));
Assert(
unspaced.Contains("<text x=\"1\">A</text>", StringComparison.Ordinal) &&
unspaced.Contains("<tspan>C</tspan>", StringComparison.Ordinal),
"Letter-spacing comes off text whose anchor, inherited or in a style, is middle or end.");
Assert(
unspaced.Contains("<text letter-spacing=\"0\">B</text>", StringComparison.Ordinal) &&
unspaced.Contains("<text text-anchor=\"middle\" letter-spacing=\"0.1em\">D</text>", StringComparison.Ordinal) &&
unspaced.Contains("<text letter-spacing=\"1.5\">E</text>", StringComparison.Ordinal) &&
unspaced.Contains("<text text-anchor=\"start\" letter-spacing=\"1.5\">F</text>", StringComparison.Ordinal),
"Spacing the renderer ignores, and spacing on start-anchored text, is kept.");
byte[] startSpacedSvg = Encoding.UTF8.GetBytes("<svg xmlns=\"http://www.w3.org/2000/svg\"><text letter-spacing=\"2\">E</text></svg>");
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
Expand Down