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.