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
40 changes: 35 additions & 5 deletions src/Morph/OpenXml/Parsing/DocumentParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6332,8 +6332,20 @@ static bool HasRenderableContent(List<DocumentElement> elements)
}
}

// If no fill, skip this shape
if (fillColorHex == null)
// Resolve the outline once. A shape can be stroke-only (no fill) — e.g. the thin accent
// rules down the left and across the top of the Agenda template, which are stroked
// custom-geometry line segments (moveTo + lnTo) with no fill. Those must still render, so
// don't bail purely because there's no fill; only bail when there's neither fill nor stroke.
var (lineColorHex, lineWidthPoints) = ShapeParser.ExtractLineStyle(wsp, shapeProps, currentThemeColors);
// A dashed outline would render as a solid line here (this path draws no dash pattern),
// which looks worse than not drawing it — so a stroke-only shape only counts as strokeable
// when its dash is solid. Matches the prstGeom-line policy in ParseLineShape; the Agenda
// accent rules are prstDash="solid" and still render, while dashed decorations (e.g. the
// letterhead rules in letters/05) stay unrendered rather than becoming solid bars.
var strokeDash = shapeProps.GetFirstChild<A.Outline>()?.GetFirstChild<A.PresetDash>()?.Val;
var isSolidStroke = strokeDash is null || strokeDash.Value == A.PresetLineDashValues.Solid;
var hasStroke = lineColorHex != null && lineWidthPoints is > 0 && isSolidStroke;
if (fillColorHex == null && !hasStroke)
{
return null;
}
Expand Down Expand Up @@ -6373,6 +6385,22 @@ static bool HasRenderableContent(List<DocumentElement> elements)
return null;
}

// Without this, custGeom shapes (e.g. half-circle decorations with cubic-bezier arcs)
// render as their bounding rect instead of the actual curved silhouette. Stroke-only
// shapes allow an open two-point line segment through (minContourPoints: 2) so the
// renderer strokes the actual rule rather than the bounding box; filled shapes keep the
// 3-point minimum, so their geometry is unchanged.
var subpaths = ShapeParser.ExtractSubpaths(
shapeProps,
minContourPoints: fillColorHex == null ? 2 : 3);

// A stroke-only shape with no custom-geometry path to stroke would fall back to a
// bounding-box outline — a border Word doesn't draw. Skip it.
if (fillColorHex == null && subpaths == null)
{
return null;
}

return new()
{
WidthPoints = widthPt,
Expand All @@ -6386,10 +6414,12 @@ static bool HasRenderableContent(List<DocumentElement> elements)
BehindText = behindText,
FillColorHex = fillColorHex,
FillAlpha = fillAlpha,
// Only stroke-only shapes render their outline here — a filled decorative shape's
// <a:ln> was never drawn by this path, so leave that behavior untouched.
LineColorHex = fillColorHex == null ? lineColorHex : null,
LineWidthPoints = fillColorHex == null ? lineWidthPoints : null,
Preset = ShapeParser.ExtractPresetShape(shapeProps),
// Without this, custGeom shapes (e.g. half-circle decorations with cubic-bezier
// arcs) render as their bounding rect instead of the actual curved silhouette.
Subpaths = ShapeParser.ExtractSubpaths(shapeProps)
Subpaths = subpaths
};
}

Expand Down
23 changes: 14 additions & 9 deletions src/Morph/OpenXml/Parsing/ShapeParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -329,10 +329,13 @@ public static bool IsLineShape(WPS.ShapeProperties? shapeProps)
/// holes stay separate rather than being joined by connector lines. Cubic and quadratic
/// beziers are flattened into <see cref="bezierFlattenSegments"/> line segments. Returns
/// null when the geometry is missing, uses ArcTo (whose parameters the de Casteljau
/// flattening can't consume), or yields no contour with at least three points — those cases
/// fall back to the bounding rect.
/// flattening can't consume), or yields no contour with at least
/// <paramref name="minContourPoints"/> points — those cases fall back to the bounding rect.
/// <paramref name="minContourPoints"/> defaults to 3 (a fillable polygon); pass 2 to keep an
/// open two-point line segment (<c>moveTo</c> + <c>lnTo</c>) that will be stroked rather than
/// filled — e.g. the thin accent rules in the Agenda template.
/// </summary>
public static IReadOnlyList<IReadOnlyList<(double X, double Y)>>? ExtractSubpaths(WPS.ShapeProperties shapeProps)
public static IReadOnlyList<IReadOnlyList<(double X, double Y)>>? ExtractSubpaths(WPS.ShapeProperties shapeProps, int minContourPoints = 3)
{
var custGeom = shapeProps.GetFirstChild<A.CustomGeometry>();
var pathList = custGeom?.GetFirstChild<A.PathList>();
Expand All @@ -349,17 +352,18 @@ public static bool IsLineShape(WPS.ShapeProperties? shapeProps)
var subpaths = new List<IReadOnlyList<(double X, double Y)>>();
foreach (var path in pathList.Elements<A.Path>())
{
AppendPathContours(path, subpaths);
AppendPathContours(path, subpaths, minContourPoints);
}

return subpaths.Count > 0 ? subpaths : null;
}

/// <summary>
/// Walks a single <c>a:path</c>, banking each <c>moveTo</c>…<c>close</c> run as its own
/// contour. Points are normalized into the unit square of the path's <c>w</c>/<c>h</c>.
/// Walks a single <c>a:path</c>, banking each <c>moveTo</c>…<c>close</c> run whose point
/// count reaches <paramref name="minContourPoints"/> as its own contour. Points are
/// normalized into the unit square of the path's <c>w</c>/<c>h</c>.
/// </summary>
static void AppendPathContours(A.Path path, List<IReadOnlyList<(double X, double Y)>> subpaths)
static void AppendPathContours(A.Path path, List<IReadOnlyList<(double X, double Y)>> subpaths, int minContourPoints)
{
var pathW = path.Width?.Value ?? 0;
var pathH = path.Height?.Value ?? 0;
Expand Down Expand Up @@ -392,10 +396,11 @@ bool TryReadPoint(A.Point? point, out double x, out double y)
return true;
}

// Bank the in-progress contour (if it has enough points to fill) and reset.
// Bank the in-progress contour (if it has enough points) and reset.
void Flush()
{
if (contour is { Count: >= 3 })
if (contour != null &&
contour.Count >= minContourPoints)
{
subpaths.Add(contour);
}
Expand Down
9 changes: 6 additions & 3 deletions src/Morph/Parsing/FloatingShapeElement.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,13 @@ sealed class FloatingShapeElement : DocumentElement
public PresetShape Preset { get; init; } = PresetShape.Rect;

/// <summary>
/// Custom geometry from <c>a:custGeom</c>, as one or more closed sub-path contours — each a
/// Custom geometry from <c>a:custGeom</c>, as one or more sub-path contours — each a
/// flattened polyline of points in the unit square (0..1) of the shape's pre-rotation
/// bounding box. When non-null, takes precedence over <see cref="Preset"/> and the shape is
/// rendered as a filled path (each contour implicitly closed, nonzero winding). Keeping the
/// bounding box. When non-null, takes precedence over <see cref="Preset"/>. A shape with a
/// fill (<see cref="FillColorHex"/>/<see cref="Gradient"/>) renders these as a filled path
/// (each contour implicitly closed, nonzero winding); a stroke-only shape
/// (<see cref="LineColorHex"/> with no fill) strokes them instead — e.g. the thin accent
/// rules in the Agenda template, which are single two-point line segments. Keeping the
/// contours separate preserves holes and disjoint pieces — e.g. the outlines in a leaf
/// cluster — that collapsing every <c>moveTo</c> into one polyline would fuse together with
/// spurious connector lines. Null for shapes without a custom geometry.
Expand Down
2 changes: 1 addition & 1 deletion src/Tests/Inputs/agendas-minutes/10/compare.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@ ArcTo-based custGeom is still unsupported and falls back to the bounding rect.

| Expected (Word) | Skia | ImageSharp |
| --- | --- | --- |
| **Page 1** | **Page 1. ErrorMetric: 0.0520** | **Page 1. ErrorMetric: 0.0554** |
| **Page 1** | **Page 1. ErrorMetric: 0.0516** | **Page 1. ErrorMetric: 0.0561** |
| <img src="expected_0001.png" width="500"> | <img src="skia_result%23page_0001.verified.png" width="500"> | <img src="imagesharp_result%23page_0001.verified.png" width="500"> |
2 changes: 2 additions & 0 deletions src/Tests/Inputs/agendas-minutes/10/html_result.verified.html

Large diffs are not rendered by default.

Binary file modified src/Tests/Inputs/agendas-minutes/10/html_result.verified.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"PageDiffs": [
{
"Page": 1,
"ErrorMetric": 0.0554,
"ErrorMetric": 0.0561,
"ExpectedFile": "expected_0001.png",
"VerifiedFile": "imagesharp_result#page_0001.verified.png",
"ReceivedFile": "imagesharp_result#page_0001.received.png"
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src/Tests/Inputs/agendas-minutes/10/pdf_result.verified.pdf
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"PageDiffs": [
{
"Page": 1,
"ErrorMetric": 0.052,
"ErrorMetric": 0.0516,
"ExpectedFile": "expected_0001.png",
"VerifiedFile": "skia_result#page_0001.verified.png",
"ReceivedFile": "skia_result#page_0001.received.png"
Expand Down
2 changes: 1 addition & 1 deletion src/Tests/Inputs/compare-all-images.md
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,7 @@ ArcTo-based custGeom is still unsupported and falls back to the bounding rect.

| Expected (Word) | Skia | ImageSharp |
| --- | --- | --- |
| **Page 1** | **Page 1. ErrorMetric: 0.0520** | **Page 1. ErrorMetric: 0.0554** |
| **Page 1** | **Page 1. ErrorMetric: 0.0516** | **Page 1. ErrorMetric: 0.0561** |
| <img src="agendas-minutes/10/expected_0001.png" width="500"> | <img src="agendas-minutes/10/skia_result%23page_0001.verified.png" width="500"> | <img src="agendas-minutes/10/imagesharp_result%23page_0001.verified.png" width="500"> |

## agendas-minutes/11
Expand Down
Loading