Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
64 changes: 64 additions & 0 deletions docs/design/canvas-net-pptx/pptx-document.md
Original file line number Diff line number Diff line change
Expand Up @@ -2766,6 +2766,70 @@ A before/after visual repro (the bug report's own exact geometry, rendered and v
confirmed the fix: before, a plain pale ellipse with no red anywhere on its perimeter; after, a
complete, correctly-closed red ellipse outline fully framing the same pale ellipse.

#### Phase 2 Follow-Up: Run Text Outline Rendering (`<a:ln>` on `<a:rPr>`/`<a:defRPr>`)

A user-reported real-world corpus fixture ("ERF IWF Breadboard Peer Review.pptx", slide 3
"Objective") contains two large numbered-badge runs whose own `<a:rPr>` declares `<a:ln
w="9525"><a:solidFill><a:schemeClr val="accent1"/></a:solidFill></a:ln>` (a solid purple stroke)
alongside a `<a:solidFill>` of `<a:srgbClr val="70AD47"><a:tint val="1000"/></a:srgbClr>` - a
~1%-tint, near-white fill - deliberately constructing PowerPoint's "hollow outlined numeral"
effect, where the stroke is the only visible ink. CanvasNet rendered these numerals as nearly
invisible near-white shapes: `PptxEffectiveRunProperties`/`PaintTextLayout` resolved and painted
only a run's own `<a:solidFill>` fill color, with no concept at all of a run's own `<a:ln>` (as
distinct from a shape's `<p:spPr>/<a:ln>`, which strokes the shape's geometry, not its text) - an
undocumented gap until this investigation (triggered by, but distinct from, a separately-reported
font-fallback suspicion that this investigation disproved: both this fixture's and a second
real-world fixture's own theme fonts were confirmed, by direct test, to resolve correctly).

**Scope**: solid-color-only, mirroring the pre-existing run fill color's own scope (`Color` is
`Rgba32`, not a general `PptxPaint`) - a run `<a:ln>` with a gradient/image/pattern paint, or one
that resolves to "no stroke" (see `ResolveLineStyle`'s own documented cases), is treated as "no
outline declared at this tier", falling through the same attribute-level inheritance chain used
for every other run property. The companion `<a:effectLst><a:glow>` PowerPoint also applies to
Comment thread
Copilot marked this conversation as resolved.
Resolved
this fixture's same badges remains out of scope, consistent with this unit's already-documented
`<a:effectRef>` shape-effects deferral - only the `<a:ln>` stroke itself is implemented.

**Model/inheritance** (`PptxEffectiveTextProperties.cs`/`PptxDocument.TextInheritance.cs`):
`PptxEffectiveRunProperties` gains two trailing optional fields, `OutlineWidthEmu` (`float?`,
`null` meaning "no outline") and `OutlineColor` (`Rgba32`, meaningful only when
`OutlineWidthEmu` is non-null) - optional, so this record's one existing construction call site
needed no other change. A new `GetRunOutline(XElement?, PptxTheme, PptxColorMap?)` reuses
`ResolveLineStyle` (the exact resolver `RenderShape`/`RenderPicture`/`RenderConnector` already use
for shape/picture/connector strokes) against the element's own `<a:ln>` child, returning
`(WidthEmu, Color)` only when `ResolveLineStyle` resolves a stroke whose paint is a plain
`PptxSolidFill`; `ResolveEffectiveRunProperties` chains `GetRunOutline` across the same four tiers
(run/paragraph-defRPr/placeholder-lstStyle-level/master-txStyles) as every other run property.

**Layout plumbing** (`PptxTextLayout.cs`/`PptxDocument.TextLayout.cs`): `LineGlyph` and
`PptxGlyphPlacement` each gain the same trailing optional `OutlineWidthEmu`/`OutlineColor` pair
(pre-existing direct-construction call sites, including tests, keep compiling unchanged);
`BuildLines` additionally autofit-scales `OutlineWidthEmu` by the same `fontScale` factor applied
to `SizeEmu`, so a shrunk-to-fit outline stays proportional to its own shrunk text.

**Painting** (`PptxDocument.TextRender.cs`): `PaintTextLayout`'s glyph matrix is now built in two
stages instead of one - a `localGlyphMatrix` (scale + baseline-origin translation only, no
`shapeToSurfaceTransform`) builds the glyph's outline in shape-local EMU space, which is then
`.Transform(shapeToSurfaceTransform)`-ed for the existing fill paint (mathematically identical to
the prior single-step composed matrix, by matrix-multiplication associativity - the pre-existing
fill-painting tests needed no changes and continue to pass unmodified). When a glyph's own
`OutlineWidthEmu` is non-null, its `localGlyphMatrix`-built path is additionally stroked via
`ResolveStrokeOutline` (constructing an ad hoc single-use `PptxLineStyle(OutlineWidthEmu, new
PptxSolidFill(OutlineColor), DashArray: null)`) before being transformed and filled with
`OutlineColor` - mirroring `RenderShape`'s own fill-then-stroke pattern, and, by stroking in
local/native-EMU space before transforming (exactly like every existing shape/picture/connector
stroke), correctly picking up `ResolveFlattenTolerance`'s coordinate-scale-aware tessellation.

**Test coverage**: `PptxTextTests.cs` gained `ResolveEffectiveRunProperties_RunLn_
ResolvesOutlineWidthAndColor` (a run-level `<a:ln>` resolves both fields) and
`ResolveEffectiveRunProperties_NoLnDeclared_OutlineWidthEmuIsNull` (absent at every tier, a
regression guard that the chain still defaults to "no outline"). `PptxTextRenderTests.cs` gained
`PaintTextLayout_GlyphWithOutline_PaintsOutlineStrokeAroundInterior` (a synthetic square glyph with
a near-white fill and a strongly-colored outline: the interior samples the fill color, the path
edge samples the outline color, and the exterior remains untouched). A before/after visual repro
(the bug report's own fixture, slide 3, rendered end-to-end through the public `PptxDocument.Render`
API and saved to PNG) confirmed the fix: before, the two numeral badges appeared as near-invisible
pale shapes; after, both render as clearly visible purple-outlined hollow numerals.

#### Phase 2 Follow-Up: Table Style/Banding Resolution (`<a:tableStyleId>`)

**Bug**: a `<a:tbl>` whose `<a:tblPr>` declares `<a:tableStyleId>{GUID}</a:tableStyleId>`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,15 @@ internal static PptxEffectiveRunProperties ResolveEffectiveRunProperties(
GetUnderlineColor(masterLevelDefRPr, theme, colorMap) ??
color;

return new PptxEffectiveRunProperties(typeface, sizeEmu, bold, italic, underlineStyle, underlineColor, color);
var outline =
GetRunOutline(runRPr, theme, colorMap) ??
GetRunOutline(paragraphDefRPr, theme, colorMap) ??
GetRunOutline(placeholderLevelDefRPr, theme, colorMap) ??
GetRunOutline(masterLevelDefRPr, theme, colorMap);
Comment thread
Malcolmnixon marked this conversation as resolved.
Resolved

return new PptxEffectiveRunProperties(
typeface, sizeEmu, bold, italic, underlineStyle, underlineColor, color,
outline?.WidthEmu, outline?.Color ?? default);
}

/// <summary>
Expand Down Expand Up @@ -605,6 +613,42 @@ private static string DefaultTypeface(PptxTheme theme, string placeholderType) =
return colorElement is null ? null : ResolveColor(colorElement, theme, colorMap: colorMap);
}

/// <summary>
/// Resolves an <c>&lt;a:rPr&gt;</c>/<c>&lt;a:defRPr&gt;</c>-shaped element's run-level
/// <c>&lt;a:ln&gt;</c> text-outline/stroke child (Phase 2 Follow-Up: Run Text Outline),
/// reusing the exact same <see cref="ResolveLineStyle"/> a shape's own
/// <c>&lt;p:spPr&gt;/&lt;a:ln&gt;</c> is resolved with - PowerPoint lets a run stroke its
/// own glyph outlines (for example a hollow/outlined numeral effect: a near-transparent
/// fill paired with a solid-colored stroke), a schema position entirely distinct from a
/// shape's geometry outline.
/// </summary>
/// <returns>
/// The resolved width/color pair, or <see langword="null"/> when <paramref name="rPrLikeElement"/>
/// has no <c>&lt;a:ln&gt;</c> child at all, that child resolves to "no stroke" (see
/// <see cref="ResolveLineStyle"/>'s own remarks - for example an explicit
/// <c>&lt;a:noFill/&gt;</c> line or a non-positive <c>w</c>), or its resolved paint is not
/// a plain <see cref="PptxSolidFill"/> - a gradient/image/pattern text-outline paint is a
/// documented, out-of-scope simplification (this unit's run <em>fill</em> color is already
/// solid-only; its outline color is kept at that same fidelity). In every one of these
/// cases the attribute-level inheritance chain keeps falling through to a shallower tier,
/// exactly like every other run attribute this file resolves - including the case where a
/// run's own <c>&lt;a:ln&gt;</c> explicitly cancels an outline a shallower tier declares;
/// a document relying on that explicit-cancellation nuance is not expected in practice.
/// </returns>
private static (float WidthEmu, Rgba32 Color)? GetRunOutline(XElement? rPrLikeElement, PptxTheme theme, PptxColorMap? colorMap = null)
{
var lnElement = rPrLikeElement?.Element(DrawingNamespace + "ln");
if (lnElement is null)
{
return null;
}

var lineStyle = ResolveLineStyle(lnElement, theme, colorMap);
return lineStyle is { Paint: PptxSolidFill solidFill }
? (lineStyle.WidthEmu, solidFill.Color)
: null;
}

/// <summary>
/// Normalizes a resolved <c>algn</c> value: <c>"just"</c>/<c>"justLow"</c> (full
/// justification) is deliberately resolved to <c>"l"</c>, a documented simplification (see
Expand Down
23 changes: 19 additions & 4 deletions src/DemaConsulting.CanvasNet.Pptx/PptxDocument.TextLayout.cs
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,14 @@ private readonly record struct ResolvedToken(
bool IsTab = false);

/// <summary>A single laid-out glyph, positioned relative to its own line's start (alignment/margin not yet applied).</summary>
private readonly record struct LineGlyph(TrueTypeFont Font, int GlyphIndex, float XInLineEmu, float SizeEmu, Rgba32 Color);
private readonly record struct LineGlyph(
TrueTypeFont Font,
int GlyphIndex,
float XInLineEmu,
float SizeEmu,
Rgba32 Color,
float? OutlineWidthEmu = null,
Rgba32 OutlineColor = default);

/// <summary>
/// A single contiguous underlined span on one line (Phase 2 Follow-Up: Underline
Expand Down Expand Up @@ -493,7 +500,11 @@ private static List<LineBox> BuildLines(
runIndex++;
var scaledSizeEmu = runProps.SizeEmu * fontScale;
var font = resolveFont(runProps.FontFamily, runProps.Bold, runProps.Italic);
var scaledRunProps = runProps with { SizeEmu = scaledSizeEmu };
var scaledRunProps = runProps with
{
SizeEmu = scaledSizeEmu,
OutlineWidthEmu = runProps.OutlineWidthEmu is { } outlineWidthEmu ? outlineWidthEmu * fontScale : null,
};

foreach (var (text, isWhitespace, isTab) in Tokenize(run.Text))
{
Expand Down Expand Up @@ -572,7 +583,9 @@ private static List<LineBox> BuildLines(
var advance = resolvedFont.GetAdvanceWidth(glyphIndex) / (float)resolvedFont.UnitsPerEm * token.RunProperties.SizeEmu;
if (!token.IsWhitespace)
{
glyphs.Add(new LineGlyph(resolvedFont, glyphIndex, cursorX, token.RunProperties.SizeEmu, token.RunProperties.Color));
glyphs.Add(new LineGlyph(
resolvedFont, glyphIndex, cursorX, token.RunProperties.SizeEmu, token.RunProperties.Color,
token.RunProperties.OutlineWidthEmu, token.RunProperties.OutlineColor));
}

cursorX += advance;
Expand Down Expand Up @@ -1017,7 +1030,9 @@ private static (List<PptxGlyphPlacement> Glyphs, List<PptxUnderlineSegment> Unde

foreach (var glyph in line.Glyphs)
{
glyphs.Add(new PptxGlyphPlacement(glyph.Font, glyph.GlyphIndex, startX + glyph.XInLineEmu, baselineY, glyph.SizeEmu, glyph.Color));
glyphs.Add(new PptxGlyphPlacement(
glyph.Font, glyph.GlyphIndex, startX + glyph.XInLineEmu, baselineY, glyph.SizeEmu, glyph.Color,
glyph.OutlineWidthEmu, glyph.OutlineColor));
}

foreach (var span in line.UnderlineSpans)
Expand Down
49 changes: 35 additions & 14 deletions src/DemaConsulting.CanvasNet.Pptx/PptxDocument.TextRender.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,22 @@ public sealed partial class PptxDocument
/// A glyph whose outline has no subpaths (a space or control character - though
/// <see cref="ResolveTextLayout"/> already omits whitespace glyphs from
/// <see cref="PptxTextLayout.Glyphs"/>, a defensive, resolver-agnostic check) is skipped
/// entirely rather than painting an empty fill. After every glyph is painted, every
/// <see cref="PptxTextLayout.Underlines"/> segment (Phase 2 Follow-Up: Underline
/// Rendering) is painted as one or two thin filled rectangles (see
/// <see cref="UnderlineThicknessRatio"/>/<see cref="UnderlineOffsetRatio"/>'s own remarks
/// for the documented thickness/offset approximation) - <see cref="PptxUnderlineStyle.Double"/>
/// paints two thinner, gapped rectangles; every other non-<see cref="PptxUnderlineStyle.None"/>
/// style (<see cref="PptxUnderlineStyle.Single"/>/<see cref="PptxUnderlineStyle.Other"/>)
/// paints exactly one - never throwing for an unrecognized style.
/// entirely rather than painting an empty fill. A glyph with a non-null
/// <see cref="PptxGlyphPlacement.OutlineWidthEmu"/> (Phase 2 Follow-Up: Run Text Outline)
/// additionally has its outline stroked and filled with
/// <see cref="PptxGlyphPlacement.OutlineColor"/>, on top of its own fill - mirroring
/// <c>PptxDocument.Render.cs</c>'s shape-outline pattern: the glyph's own local-space
/// outline path is stroked via <see cref="ResolveStrokeOutline"/> (so
/// <see cref="ResolveFlattenTolerance"/>'s coordinate-scale-aware tessellation applies
/// correctly) and only then transformed into surface space, exactly like the fill path.
/// After every glyph is painted, every <see cref="PptxTextLayout.Underlines"/> segment
/// (Phase 2 Follow-Up: Underline Rendering) is painted as one or two thin filled
/// rectangles (see <see cref="UnderlineThicknessRatio"/>/<see cref="UnderlineOffsetRatio"/>'s
/// own remarks for the documented thickness/offset approximation) -
/// <see cref="PptxUnderlineStyle.Double"/> paints two thinner, gapped rectangles; every
/// other non-<see cref="PptxUnderlineStyle.None"/> style
/// (<see cref="PptxUnderlineStyle.Single"/>/<see cref="PptxUnderlineStyle.Other"/>) paints
/// exactly one - never throwing for an unrecognized style.
/// </summary>
/// <param name="surface">The surface to paint onto.</param>
/// <param name="layout">The resolved text layout to paint.</param>
Expand All @@ -81,16 +89,29 @@ internal static void PaintTextLayout(Surface surface, PptxTextLayout layout, Mat
// y, relative to the baseline), not below it. A positive Y scale here would paint
// every glyph upside-down and offset below the baseline instead of above it.
var glyphScaleEmu = glyph.SizeEmu / glyph.Font.UnitsPerEm;
var glyphMatrix =

// Built in the glyph's own shape-local (native-EMU) space first - shapeToSurfaceTransform
// is applied afterward, separately for the fill path and (when present) the stroked
// outline path, so ResolveStrokeOutline's own flattenTolerance derivation sees the
// correct pre-transform coordinate magnitude (mirroring the shape-outline pattern in
// PptxDocument.Render.cs). Matrix multiplication associativity means the fill path's
// own rendered geometry is unchanged from composing the full transform in one step.
var localGlyphMatrix =
Matrix3x2.CreateScale(glyphScaleEmu, -glyphScaleEmu) *
Matrix3x2.CreateTranslation(glyph.OriginXEmu, glyph.OriginYEmu) *
shapeToSurfaceTransform;
Matrix3x2.CreateTranslation(glyph.OriginXEmu, glyph.OriginYEmu);

var builder = new PathBuilder();
AppendTransformedGlyphOutline(builder, outline, glyphMatrix);
var path = builder.Build();
AppendTransformedGlyphOutline(builder, outline, localGlyphMatrix);
var localPath = builder.Build();

PathFiller.Fill(surface, localPath.Transform(shapeToSurfaceTransform), glyph.Color, FillRule.NonZero);

PathFiller.Fill(surface, path, glyph.Color, FillRule.NonZero);
if (glyph.OutlineWidthEmu is { } outlineWidthEmu)
{
var outlineLineStyle = new PptxLineStyle(outlineWidthEmu, new PptxSolidFill(glyph.OutlineColor), DashArray: null);
Comment thread
Malcolmnixon marked this conversation as resolved.
Resolved
var strokedOutline = ResolveStrokeOutline(localPath, outlineLineStyle, shapeToSurfaceTransform);
PathFiller.Fill(surface, strokedOutline.Transform(shapeToSurfaceTransform), glyph.OutlineColor, FillRule.NonZero);
}
}

foreach (var segment in layout.Underlines)
Expand Down
15 changes: 14 additions & 1 deletion src/DemaConsulting.CanvasNet.Pptx/PptxEffectiveTextProperties.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,27 @@ namespace DemaConsulting.CanvasNet.Pptx;
/// meaningful when <paramref name="UnderlineStyle"/> is not <see cref="PptxUnderlineStyle.None"/>.
/// </param>
/// <param name="Color">The run's resolved color.</param>
/// <param name="OutlineWidthEmu">
/// The run's resolved text-outline (stroke) width, in EMU, from a run-level <c>&lt;a:ln&gt;</c>
/// child of <c>&lt;a:rPr&gt;</c>/<c>&lt;a:defRPr&gt;</c> (Phase 2 Follow-Up: Run Text Outline) -
/// <see langword="null"/> when no tier in the attribute-level inheritance chain declares one,
/// meaning "no outline is painted for this run". Distinct from a shape's own
/// <c>&lt;p:spPr&gt;/&lt;a:ln&gt;</c>, which strokes the shape's geometry, not its text.
Comment thread
Copilot marked this conversation as resolved.
Resolved
/// </param>
/// <param name="OutlineColor">
/// The run's resolved text-outline ink color, only meaningful when
/// <paramref name="OutlineWidthEmu"/> is non-<see langword="null"/>.
/// </param>
internal sealed record PptxEffectiveRunProperties(
string FontFamily,
float SizeEmu,
bool Bold,
bool Italic,
PptxUnderlineStyle UnderlineStyle,
Rgba32 UnderlineColor,
Rgba32 Color);
Rgba32 Color,
float? OutlineWidthEmu = null,
Rgba32 OutlineColor = default);

/// <summary>
/// The discriminator for a run's resolved <c>&lt;a:rPr u="..."/&gt;</c> underline style
Expand Down
12 changes: 11 additions & 1 deletion src/DemaConsulting.CanvasNet.Pptx/PptxTextLayout.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,20 @@ internal readonly record struct PptxUnderlineSegment(
/// <param name="OriginYEmu">The glyph's baseline origin Y, in EMU, in the owning shape's own local coordinate space.</param>
/// <param name="SizeEmu">The glyph's resolved (already autofit-scaled) font size, in EMU.</param>
/// <param name="Color">The glyph's resolved ink color.</param>
/// <param name="OutlineWidthEmu">
/// The glyph's resolved text-outline (stroke) width, in EMU (Phase 2 Follow-Up: Run Text
/// Outline), already scaled by the same autofit factor as <paramref name="SizeEmu"/> - see
/// <see cref="PptxEffectiveRunProperties.OutlineWidthEmu"/>. <see langword="null"/> means "no
/// outline is painted for this glyph", the default for every pre-existing direct-construction
/// call site (for example in tests) that predates outline support.
/// </param>
/// <param name="OutlineColor">The glyph's resolved outline ink color, only meaningful when <paramref name="OutlineWidthEmu"/> is non-<see langword="null"/>.</param>
internal sealed record PptxGlyphPlacement(
TrueTypeFont Font,
int GlyphIndex,
float OriginXEmu,
float OriginYEmu,
float SizeEmu,
Rgba32 Color);
Rgba32 Color,
float? OutlineWidthEmu = null,
Rgba32 OutlineColor = default);
Loading
Loading