diff --git a/src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs b/src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs
index 5fe4ea9..7f4c9fe 100644
--- a/src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs
+++ b/src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs
@@ -509,13 +509,27 @@ private static void RenderBoxCompartments(
// Compartments start below the title area (keyword + label), computed via shared metrics
var labelAreaHeight = BoxMetrics.TitleAreaHeight(theme, box.Label != null, box.Keyword != null);
+
+ // Deliberately based on the computed title-area height (labelAreaHeight > 0), not on
+ // "is Label/Keyword set": a theme with zero title padding/font-size would give
+ // labelAreaHeight == 0 even with a Label present, and the leading divider must still be
+ // skipped in that case to avoid the stray-line bug this fix addresses (see below).
+ var titleAreaOccupiesSpace = labelAreaHeight > 0;
var compartmentY = ResolveTitleAreaTop(box, theme) + box.ContentInsetTop + labelAreaHeight;
+ var isFirstCompartment = true;
foreach (var compartment in box.Compartments)
{
- // Draw a full-width horizontal divider at the top of this compartment
- using (var divPaint = new SKPaint())
+ // The divider above the first compartment only has something to separate from when the
+ // title area above it occupies real space. With no such space, this divider would sit
+ // in the un-reserved region near the box's own top edge — redundant for a plain
+ // rectangle (any coinciding lines are invisible duplicates) but a genuine visible defect
+ // for shapes whose top edge isn't a plain straight line across the full width (e.g.
+ // Note's folded corner cutout), where the full-width divider ignores the cut and renders
+ // as a stray line past the fold.
+ if (!isFirstCompartment || titleAreaOccupiesSpace)
{
+ using var divPaint = new SKPaint();
divPaint.Color = strokeColor;
divPaint.Style = SKPaintStyle.Stroke;
divPaint.StrokeWidth = (float)theme.StrokeWidth * scale;
@@ -527,6 +541,8 @@ private static void RenderBoxCompartments(
divPaint);
}
+ isFirstCompartment = false;
+
// Draw the optional bold compartment title
if (compartment.Title != null)
{
diff --git a/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs b/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs
index ea8cd90..62800bc 100644
--- a/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs
+++ b/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs
@@ -598,14 +598,33 @@ private static void RenderBoxCompartments(StringBuilder sb, LayoutBox box, Theme
{
// Compartments start below the title area (keyword + label), computed via shared metrics
var labelAreaHeight = BoxMetrics.TitleAreaHeight(theme, box.Label != null, box.Keyword != null);
+
+ // Deliberately based on the computed title-area height (labelAreaHeight > 0), not on
+ // "is Label/Keyword set": a theme with zero title padding/font-size would give
+ // labelAreaHeight == 0 even with a Label present, and the leading divider must still be
+ // skipped in that case to avoid the stray-line bug this fix addresses (see below).
+ var titleAreaOccupiesSpace = labelAreaHeight > 0;
var compartmentY = ResolveTitleAreaTop(box, theme) + box.ContentInsetTop + labelAreaHeight;
+ var isFirstCompartment = true;
foreach (var compartment in box.Compartments)
{
- // Full-width horizontal divider at the top of this compartment
- sb.Append(CultureInfo.InvariantCulture,
- $""" """);
- sb.AppendLine();
+ // The divider above the first compartment only has something to separate from when the
+ // title area above it occupies real space. With no such space, this divider would sit
+ // in the un-reserved region near the box's own top edge — redundant for a plain
+ // rectangle (any coinciding lines are invisible duplicates) but a genuine visible defect
+ // for shapes whose top edge isn't a plain straight line across the full width (e.g.
+ // Note's folded corner cutout), where the full-width divider ignores the cut and renders
+ // as a stray line past the fold.
+ if (!isFirstCompartment || titleAreaOccupiesSpace)
+ {
+ // Full-width horizontal divider at the top of this compartment
+ sb.Append(CultureInfo.InvariantCulture,
+ $""" """);
+ sb.AppendLine();
+ }
+
+ isFirstCompartment = false;
// Draw optional bold compartment title
if (compartment.Title != null)
diff --git a/test/DemaConsulting.Rendering.Skia.Tests/SkiaPortAndContentInsetTests.cs b/test/DemaConsulting.Rendering.Skia.Tests/SkiaPortAndContentInsetTests.cs
index db41b51..c9ed0dc 100644
--- a/test/DemaConsulting.Rendering.Skia.Tests/SkiaPortAndContentInsetTests.cs
+++ b/test/DemaConsulting.Rendering.Skia.Tests/SkiaPortAndContentInsetTests.cs
@@ -217,6 +217,57 @@ private static int RightmostForegroundX(SKBitmap bitmap, SKColor background, int
return -1;
}
+ ///
+ /// Proves that rendering a Note-shaped box with a compartment and no Label/Keyword does not
+ /// draw a stray divider line protruding past the note's folded-corner cutout. With no title
+ /// area, the first compartment's divider would land exactly on the box's own top edge; for a
+ /// plain rectangle that's an invisible duplicate, but for Note it must not extend past the
+ /// diagonal fold (from xFold to the right edge) as a visible artifact.
+ ///
+ [Fact]
+ public void PngRenderer_Render_NoteBoxWithCompartmentAndNoTitle_NoStrayLinePastFold()
+ {
+ // Arrange: a Note-shaped box with a compartment but no Label/Keyword
+ var renderer = new PngRenderer();
+ var compartment = new LayoutCompartment(null, ["Some body text"]);
+ var box = new LayoutBox(10, 10, 150, 80, null, 0, BoxShape.Note, [compartment], []);
+ var options = new RenderOptions(Themes.Light);
+ var background = SKColor.Parse(Themes.Light.BackgroundColor);
+
+ using var stream = new MemoryStream();
+ renderer.Render(new LayoutTree(200, 120, [box]), options, stream);
+ stream.Position = 0;
+ using var data = SKData.Create(stream);
+ using var bitmap = SKBitmap.Decode(data);
+ Assert.NotNull(bitmap);
+
+ // Compute the fold's scaled x-position (matching RenderNotePng's own geometry) and scan a
+ // vertical band just below the box's top edge. The divider (a ~1.5px stroke) can land
+ // partially on adjacent rows once anti-aliased, so a single row risked missing or flaking
+ // on the regression; scanning several rows makes the check reliable. The diagonal fold edge
+ // has a 1:1 slope (rise == run == fold size) starting at (xFold, yTop), so the scan's xStart
+ // is offset past where that diagonal (plus its anti-aliasing) could reach within the band,
+ // ensuring only a genuine stray horizontal divider - not the legitimate fold edge - would be
+ // detected. xFold uses Math.Ceiling (not truncation) so the scan never starts left of the
+ // true fold boundary, which would otherwise risk sampling the diagonal's anti-aliased edge.
+ var fold = Math.Min(Math.Min(box.Width, box.Height) * NotationMetrics.NoteFoldFraction, NotationMetrics.NoteFoldMaxSize);
+ var scale = options.Scale;
+ var xFold = (int)Math.Ceiling((box.X + box.Width - fold) * scale);
+ var yTop = (int)(box.Y * scale);
+ const int bandHeight = 4;
+ const int diagonalClearance = 3;
+
+ var rightmost = RightmostForegroundX(
+ bitmap,
+ background,
+ yStart: yTop,
+ yEnd: yTop + bandHeight,
+ xStart: xFold + bandHeight + diagonalClearance);
+
+ // Assert: no stray foreground pixel found past the fold at the top edge
+ Assert.Equal(-1, rightmost);
+ }
+
///
/// Proves that when 3+ parallel labeled connectors force the label placer to nudge labels
/// downward to avoid collisions, the raster renderer grows the bitmap (rather than sizing it
diff --git a/test/DemaConsulting.Rendering.Svg.Tests/SvgRendererPortedTests.cs b/test/DemaConsulting.Rendering.Svg.Tests/SvgRendererPortedTests.cs
index 4199512..7d153a9 100644
--- a/test/DemaConsulting.Rendering.Svg.Tests/SvgRendererPortedTests.cs
+++ b/test/DemaConsulting.Rendering.Svg.Tests/SvgRendererPortedTests.cs
@@ -284,6 +284,54 @@ public void SvgRenderer_Render_BoxWithCompartment_ProducesLineAndText()
Assert.Contains("+ radius : Real", svgText, StringComparison.Ordinal);
}
+ ///
+ /// Render a LayoutBox with a Note shape, a compartment, and no Label/Keyword does not emit a
+ /// divider line before the first compartment, since with no title area the divider would sit
+ /// on the box's own top edge and (unlike a plain rectangle) protrude past the Note shape's
+ /// folded-corner cutout as a stray line.
+ ///
+ [Fact]
+ public void SvgRenderer_Render_NoteBoxWithCompartmentAndNoTitle_OmitsLeadingDivider()
+ {
+ // Arrange: a Note-shaped box with a compartment but no Label/Keyword
+ var renderer = new SvgRenderer();
+ var compartment = new LayoutCompartment(null, ["Some body text"]);
+ var box = new LayoutBox(10, 10, 150, 80, null, 0, BoxShape.Note, [compartment], []);
+ var layout = new LayoutTree(200, 120, [box]);
+ var options = new RenderOptions(Themes.Light);
+ using var output = new MemoryStream();
+
+ // Act
+ renderer.Render(layout, options, output);
+
+ // Assert: no full-width divider element is drawn at the box's own top edge (the
+ // previously buggy behavior), but the compartment row text still renders. Parsing as XML
+ // and inspecting the element's attributes (rather than matching a literal substring) keeps
+ // this robust to harmless formatting/attribute-ordering changes in the SVG serialization.
+ // Expected coordinates are derived from box/options.Scale (rather than hard-coded) so the
+ // assertion stays correct if the fixture's geometry ever changes.
+ var expectedX1 = box.X * options.Scale;
+ var expectedY = box.Y * options.Scale;
+ var expectedX2 = (box.X + box.Width) * options.Scale;
+
+ output.Position = 0;
+ var svgText = ReadAllText(output);
+ var document = System.Xml.Linq.XDocument.Parse(svgText);
+ var hasStrayDivider = document.Descendants()
+ .Where(e => e.Name.LocalName == "line")
+ .Any(e =>
+ e.Attribute("x1") is { } x1 && IsClose(x1.Value, expectedX1) &&
+ e.Attribute("y1") is { } y1 && IsClose(y1.Value, expectedY) &&
+ e.Attribute("x2") is { } x2 && IsClose(x2.Value, expectedX2) &&
+ e.Attribute("y2") is { } y2 && IsClose(y2.Value, expectedY));
+ Assert.False(hasStrayDivider);
+ Assert.Contains("Some body text", svgText, StringComparison.Ordinal);
+ }
+
+ /// Parses an SVG numeric attribute value and compares it to an expected value within a small tolerance.
+ private static bool IsClose(string attributeValue, double expected) =>
+ Math.Abs(double.Parse(attributeValue, System.Globalization.CultureInfo.InvariantCulture) - expected) < 0.01;
+
///
/// Render a LayoutBox with RoundedRectangle shape produces SVG output containing an
/// rx attribute, confirming that rounded corners are applied via the rx/ry attributes.