diff --git a/docs/design/canvas-net-pptx/pptx-document.md b/docs/design/canvas-net-pptx/pptx-document.md index 3d849a45..4c6ffc43 100644 --- a/docs/design/canvas-net-pptx/pptx-document.md +++ b/docs/design/canvas-net-pptx/pptx-document.md @@ -2766,6 +2766,157 @@ 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 (`` on ``/``) + +A user-reported real-world corpus fixture ("ERF IWF Breadboard Peer Review.pptx", slide 3 +"Objective") contains two large numbered-badge runs whose own `` declares `` (a solid purple stroke) +alongside a `` of `` - 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 `` fill color, with no concept at all of a run's own `` (as +distinct from a shape's `/`, 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 `` that declares a `` picture-fill +line paint is short-circuited before `ResolveLineStyle` is ever called (see the picture-fill +correction below), and one that resolves to "no stroke" or to a gradient/pattern paint (see +`ResolveLineStyle`'s own documented cases) is treated as "no outline for this run" - but, per the +explicit-cancellation tier-selection correction below, none of these cases fall through to a +shallower tier once the first tier declaring an `` at all has been selected; tier selection +and tier resolution are two separate steps, and only the former walks the attribute-level +inheritance chain. The companion `` PowerPoint also applies to +this fixture's same badges remains out of scope, consistent with this unit's already-documented +`` shape-effects deferral - only the `` stroke itself is implemented. + +**Model/inheritance** (`PptxEffectiveTextProperties.cs`/`PptxDocument.TextInheritance.cs`): +`PptxEffectiveRunProperties` gains three trailing optional fields, `OutlineWidthEmu` (`float?`, +`null` meaning "no outline"), `OutlineColor` (`Rgba32`, meaningful only when `OutlineWidthEmu` is +non-null), and `OutlineDashArray` (`IReadOnlyList?`, meaningful only when `OutlineWidthEmu` +is non-null - see the dash-array correction below) - all optional, so this record's one existing +construction call site needed no other change. A `GetRunOutline(XElement?, PptxTheme, +PptxColorMap?)` reuses `ResolveLineStyle` (the exact resolver `RenderShape`/`RenderPicture`/ +`RenderConnector` already use for shape/picture/connector strokes) against an already-selected +`` element, returning `(WidthEmu, Color, DashArray)` only when `ResolveLineStyle` resolves a +stroke whose paint is a plain `PptxSolidFill`. + +**Correction - explicit-cancellation tier selection (a post-merge code-review finding)**: the +original implementation chained `GetRunOutline(rPrLikeElement, ...)` itself across the four tiers +with `??` (`GetRunOutline(runRPr) ?? GetRunOutline(paragraphDefRPr) ?? ...`), exactly like every +other run attribute's own chain - but unlike every other attribute, `GetRunOutline`'s own `null` +result is ambiguous between two completely different situations: "this tier's `rPrLikeElement` has +no `` child at all" (the ordinary "undeclared here, keep searching a shallower tier" case +every other attribute's chain correctly handles) and "this tier's `` is present but +explicitly resolves to no stroke" (for example ``, or an explicit +non-positive `w`, or a non-solid-fill line paint) - a deliberate cancellation that must stop the +chain outright, not fall through to a shallower tier's own outline. The original `??`-chain-over- +`GetRunOutline`'s-own-return-value could not distinguish these two cases, so a run that explicitly +cancelled its own outline (for example inheriting a near-white fill but an explicit +`` to suppress a paragraph-level stroke) incorrectly inherited the +paragraph/placeholder/master tier's own outline instead of painting none at all. The fix splits +tier selection from tier resolution into two distinct steps: first, a plain `??` chain over each +tier's own raw `rPrLikeElement?.Element(DrawingNamespace + "ln")` picks the first tier whose +`` child is present at all, regardless of what it resolves to (mirroring exactly how +`GetTypeface`/`GetFontSizeEmu`/every other attribute's own tier-selection already behaves, since +those helpers return the raw parsed value, not a doubly-overloaded "absent-or-cancelled" `null`); +second, that single, already-selected `` element is resolved exactly once via `GetRunOutline` +(now taking the `` element directly, not an `rPrLikeElement` to search within). Because tier +selection happens entirely before resolution, a run's own explicit cancellation is now honored +correctly - the chain never even looks at the paragraph/placeholder/master tiers once the run's +own `` is selected, whatever it resolves to. `GetRunOutline`'s own XmlDoc previously +documented the old, buggy fallthrough as a deliberate, accepted simplification ("a document relying +on that explicit-cancellation nuance is not expected in practice") - that remark has been removed +now that the behavior it excused is implemented correctly. + +**Layout plumbing** (`PptxTextLayout.cs`/`PptxDocument.TextLayout.cs`): `LineGlyph` and +`PptxGlyphPlacement` each gain the same trailing optional `OutlineWidthEmu`/`OutlineColor`/ +`OutlineDashArray` triple (pre-existing direct-construction call sites, including tests, keep +compiling unchanged); `BuildLines` additionally autofit-scales both `OutlineWidthEmu` and every +element of `OutlineDashArray` by the same `fontScale` factor applied to `SizeEmu`, so a +shrunk-to-fit outline - and its own dash pattern - stays proportional to its own shrunk text (a +dash array is itself a set of lengths in EMU, exactly like the outline width it is drawn with, so +it is scaled identically rather than left at its pre-shrink scale). + +**Correction - dash-array threading (a second post-merge code-review finding)**: the original +implementation hard-coded `DashArray: null` on the ad hoc `PptxLineStyle` `PaintTextLayout` +constructed for a glyph's own outline stroke, even though `ResolveLineStyle` - the very resolver +`GetRunOutline` reuses - already resolves a run's `/` into a proper +`DashArray` for a shape's own geometry outline. The result was silent: a dashed run-level text +outline rendered as solid, with no error or warning, simply because the resolved dash array was +discarded three layers downstream of where it was correctly computed. The fix threads the resolved +dash array, unmodified in shape, through every layer this feature already threads +`OutlineWidthEmu`/`OutlineColor` through - +`PptxEffectiveRunProperties.OutlineDashArray` -> `LineGlyph.OutlineDashArray` -> +`PptxGlyphPlacement.OutlineDashArray` -> `PaintTextLayout`'s own `PptxLineStyle(outlineWidthEmu, +new PptxSolidFill(glyph.OutlineColor), glyph.OutlineDashArray)` - so a dashed run-level outline now +renders dashed exactly like a shape's own dashed geometry outline already does, with the same +autofit `fontScale` scaling applied along the way. + +**Correction - picture-fill run outline short-circuit (a third post-merge code-review finding)**: +`GetRunOutline` delegated straight to `ResolveLineStyle` with no `resolveBlipImage` delegate +supplied. For a run-level `...`, `ResolveLineStyle`'s own +`ResolveFill` call throws `PptxUnsupportedFeatureException("pptx-picture-fill", ...)` rather than +returning a non-solid `PptxPaint`; the exception propagated out of `GetRunOutline` and aborted +rendering the entire slide over a single run's out-of-scope stroke paint. The initial fix added an +explicit guard checking only for a `` child of the selected `` element - +superseded by the broader guard documented immediately below, which also covers ``/ +``. + +**Correction - short-circuit every non-`solidFill` run outline paint (a fourth post-merge +code-review finding)**: the picture-fill-only guard above was itself incomplete. `ResolveFill` +resolves a _supported_ ``/`` variant successfully to a non-solid +`PptxPaint`, which `GetRunOutline`'s own `is { Paint: PptxSolidFill }` pattern match already +discards harmlessly as "no outline" - but an _unsupported_ variant (a path/no-direction gradient, +or a pattern preset outside `PptxPresetPattern`'s covered set) still throws +`PptxUnsupportedFeatureException` out of `ResolveFill`, exactly like the picture-fill case the +third correction above fixed. The guard in `GetRunOutline` now checks for any of ``, +``, or `` as a direct child of the selected `` element and returns +`null` immediately for all three, before `ResolveLineStyle` is ever called - consistent with this +feature's documented solid-color-only scope, and without needing to care whether the specific +gradient/pattern variant happens to be one `ResolveFill` would have resolved successfully or +rejected (every non-`solidFill` run outline paint is out of scope regardless, so the two cases are +treated identically up front rather than relying on the post-resolution pattern match to catch only +the subset that happens not to throw). + +**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), OutlineDashArray)`) 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 `` resolves both fields), +`ResolveEffectiveRunProperties_NoLnDeclared_OutlineWidthEmuIsNull` (absent at every tier, a +regression guard that the chain still defaults to "no outline"), +`ResolveEffectiveRunProperties_RunLnExplicitNoFill_CancelsParagraphOutlineInheritance` and +`ResolveEffectiveRunProperties_RunLnExplicitZeroWidth_CancelsParagraphOutlineInheritance` (a run's +own explicit ``/zero-width `` correctly suppresses a paragraph-level outline +instead of inheriting it - the Finding 1 regression guards), and +`ResolveEffectiveRunProperties_RunLnWithPrstDash_ResolvesMatchingOutlineDashArray` (a run-level +`` resolves an `OutlineDashArray` matching the same values +`ResolveLineStyle` produces for an identically-configured shape outline - the Finding 2 regression +guard). `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) and `PaintTextLayout_GlyphWithDashedOutline_ +PaintsOutlineWithGaps` (the same geometry with a `[10,10]`-unit dash array: sampling along the +edge's own stroke band proves the stroke is no longer painted continuously - some samples are the +outline color, some are untouched gaps). 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 original 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 (``) **Bug**: a `` whose `` declares `{GUID}` diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.TextInheritance.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.TextInheritance.cs index 1984356f..fb33bbd8 100644 --- a/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.TextInheritance.cs +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.TextInheritance.cs @@ -143,7 +143,22 @@ internal static PptxEffectiveRunProperties ResolveEffectiveRunProperties( GetUnderlineColor(masterLevelDefRPr, theme, colorMap) ?? color; - return new PptxEffectiveRunProperties(typeface, sizeEmu, bold, italic, underlineStyle, underlineColor, color); + // Tier selection and tier resolution are deliberately two separate steps (bug fix - see + // GetRunOutline's own remarks): the first tier whose rPrLikeElement has an child + // present AT ALL wins outright, regardless of what that child resolves to, so a run's own + // explicit "no stroke" declaration (for example , or a + // non-positive w) is never mistaken for "undeclared here, keep falling through" and does + // not let a shallower tier's outline incorrectly leak through. + var runLnElement = runRPr?.Element(DrawingNamespace + "ln"); + var paragraphLnElement = paragraphDefRPr?.Element(DrawingNamespace + "ln"); + var placeholderLnElement = placeholderLevelDefRPr?.Element(DrawingNamespace + "ln"); + var masterLnElement = masterLevelDefRPr?.Element(DrawingNamespace + "ln"); + var selectedLnElement = runLnElement ?? paragraphLnElement ?? placeholderLnElement ?? masterLnElement; + var outline = GetRunOutline(selectedLnElement, theme, colorMap); + + return new PptxEffectiveRunProperties( + typeface, sizeEmu, bold, italic, underlineStyle, underlineColor, color, + outline?.WidthEmu, outline?.Color ?? default, outline?.DashArray); } /// @@ -605,6 +620,76 @@ private static string DefaultTypeface(PptxTheme theme, string placeholderType) = return colorElement is null ? null : ResolveColor(colorElement, theme, colorMap: colorMap); } + /// + /// Resolves an already-tier-selected <a:ln> text-outline/stroke element (Phase + /// 2 Follow-Up: Run Text Outline; corrected by the explicit-cancellation fix below), reusing + /// the exact same a shape's own <p:spPr>/<a:ln> + /// 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. + /// + /// + /// must already be the single, tier-selected <a:ln> + /// element - the first one present (regardless of what it resolves to) when walking + /// run/paragraph-defRPr/placeholder-level/master-level in that order (see + /// 's own selection logic). This two-step split - + /// "which tier's element wins" (a plain ?? chain over the raw XElement? itself) + /// followed by "how that single chosen element resolves" (this method, called exactly once) - + /// is required so an explicit, closer-tier cancellation (for example a run's own + /// <a:ln><a:noFill/></a:ln>, or a non-positive w) is honored + /// as "no outline for this run" instead of being indistinguishable from "this tier declares + /// nothing at all, keep searching a shallower tier" - the bug this method's previous + /// "resolve-then-chain-with-??" shape used to have, where a tuple collapsing to + /// could mean either "absent here" or "explicitly cancelled here", + /// and the caller's own ?? chain could not tell them apart. + /// + /// + /// The resolved width/color/dash-array triple, or when + /// is itself (no tier declared an + /// <a:ln> at all), it resolves to "no stroke" (see 's + /// own remarks - for example an explicit <a:noFill/> line or a non-positive + /// w), it declares a non-solidFill paint (<a:gradFill>, + /// <a:pattFill>, or <a:blipFill> - every one of these is + /// short-circuited before is ever called - see this method's + /// body), or its resolved paint is otherwise not a plain - a + /// gradient/pattern/picture text-outline paint is a documented, out-of-scope simplification + /// (this unit's run fill color is already solid-only; its outline color is kept at + /// that same fidelity). Unlike every other run attribute's own tier chain, none of these + /// "resolves to null" cases fall through to a shallower tier here - tier selection already + /// happened in the caller, before this method ever runs. + /// + private static (float WidthEmu, Rgba32 Color, IReadOnlyList? DashArray)? GetRunOutline( + XElement? lnElement, PptxTheme theme, PptxColorMap? colorMap = null) + { + if (lnElement is null) + { + return null; + } + + // A run-level outline's own fill is solid-only in scope (see this method's own remarks) + // - any gradFill/pattFill/blipFill line paint is short-circuited here, before ever calling + // ResolveLineStyle, because ResolveLineStyle's own ResolveFill call can throw + // PptxUnsupportedFeatureException for an unsupported pattern preset, a path/no-direction + // gradient, or a blipFill element with no resolveBlipImage delegate supplied (none is + // supplied here). A *supported* gradient/pattern would otherwise resolve successfully to a + // non-solid PptxPaint this method already discards via the pattern match below, but an + // *unsupported* one would propagate its exception uncaught instead - treating every + // non-solidFill run outline paint as "no outline" up front, regardless of whether the + // specific variant happens to be supported, keeps a single out-of-scope run-level stroke + // paint from aborting rendering of the whole slide + if (lnElement.Element(DrawingNamespace + "gradFill") is not null || + lnElement.Element(DrawingNamespace + "pattFill") is not null || + lnElement.Element(DrawingNamespace + "blipFill") is not null) + { + return null; + } + + var lineStyle = ResolveLineStyle(lnElement, theme, colorMap); + return lineStyle is { Paint: PptxSolidFill solidFill } + ? (lineStyle.WidthEmu, solidFill.Color, lineStyle.DashArray) + : null; + } + /// /// Normalizes a resolved algn value: "just"/"justLow" (full /// justification) is deliberately resolved to "l", a documented simplification (see diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.TextLayout.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.TextLayout.cs index 76b08aaa..73135e1f 100644 --- a/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.TextLayout.cs +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.TextLayout.cs @@ -317,7 +317,15 @@ private readonly record struct ResolvedToken( bool IsTab = false); /// A single laid-out glyph, positioned relative to its own line's start (alignment/margin not yet applied). - 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, + IReadOnlyList? OutlineDashArray = null); /// /// A single contiguous underlined span on one line (Phase 2 Follow-Up: Underline @@ -493,7 +501,16 @@ private static List 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, + // A dash array is itself a set of lengths in EMU (dash/gap segment lengths), + // so each element is scaled by the same fontScale factor as OutlineWidthEmu - + // otherwise a shrunk-to-fit outline's dash pattern would stay at its + // pre-shrink scale, visually disproportionate to the now-thinner stroke. + OutlineDashArray = runProps.OutlineDashArray?.Select(dash => dash * fontScale).ToArray(), + }; foreach (var (text, isWhitespace, isTab) in Tokenize(run.Text)) { @@ -572,7 +589,10 @@ private static List 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, + token.RunProperties.OutlineDashArray)); } cursorX += advance; @@ -1017,7 +1037,9 @@ private static (List Glyphs, List 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, glyph.OutlineDashArray)); } foreach (var span in line.UnderlineSpans) diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.TextRender.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.TextRender.cs index 9ca9bed8..4fc9967e 100644 --- a/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.TextRender.cs +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.TextRender.cs @@ -51,14 +51,27 @@ public sealed partial class PptxDocument /// A glyph whose outline has no subpaths (a space or control character - though /// already omits whitespace glyphs from /// , a defensive, resolver-agnostic check) is skipped - /// entirely rather than painting an empty fill. After every glyph is painted, every - /// segment (Phase 2 Follow-Up: Underline - /// Rendering) is painted as one or two thin filled rectangles (see - /// /'s own remarks - /// for the documented thickness/offset approximation) - - /// paints two thinner, gapped rectangles; every other non- - /// style (/) - /// paints exactly one - never throwing for an unrecognized style. + /// entirely rather than painting an empty fill. A glyph with a non-null + /// (Phase 2 Follow-Up: Run Text Outline) + /// additionally has its outline stroked and filled with + /// , on top of its own fill - mirroring + /// PptxDocument.Render.cs's shape-outline pattern: the glyph's own local-space + /// outline path is stroked via (so + /// 's coordinate-scale-aware tessellation applies + /// correctly) and only then transformed into surface space, exactly like the fill path. + /// The stroke's own (Phase 2 Follow-Up: + /// Run Text Outline Dash Threading) is threaded through to the same ad hoc + /// instead of being hard-coded to , so a + /// dashed run-level <a:ln> renders dashed exactly like a shape's own dashed + /// geometry outline already does, rather than silently degrading to solid. + /// After every glyph is painted, every segment + /// (Phase 2 Follow-Up: Underline Rendering) is painted as one or two thin filled + /// rectangles (see /'s + /// own remarks for the documented thickness/offset approximation) - + /// paints two thinner, gapped rectangles; every + /// other non- style + /// (/) paints + /// exactly one - never throwing for an unrecognized style. /// /// The surface to paint onto. /// The resolved text layout to paint. @@ -81,16 +94,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), glyph.OutlineDashArray); + var strokedOutline = ResolveStrokeOutline(localPath, outlineLineStyle, shapeToSurfaceTransform); + PathFiller.Fill(surface, strokedOutline.Transform(shapeToSurfaceTransform), glyph.OutlineColor, FillRule.NonZero); + } } foreach (var segment in layout.Underlines) diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxEffectiveTextProperties.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxEffectiveTextProperties.cs index 60f79719..8f8ddf47 100644 --- a/src/DemaConsulting.CanvasNet.Pptx/PptxEffectiveTextProperties.cs +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxEffectiveTextProperties.cs @@ -18,6 +18,32 @@ namespace DemaConsulting.CanvasNet.Pptx; /// meaningful when is not . /// /// The run's resolved color. +/// +/// The run's resolved text-outline (stroke) width, in EMU, from a run-level <a:ln> +/// child of <a:rPr>/<a:defRPr> (Phase 2 Follow-Up: Run Text Outline) - +/// both when no tier in the attribute-level inheritance chain declares +/// an <a:ln> at all, and when the first tier that does declare one explicitly +/// cancels the outline (an <a:noFill/> line fill, a non-positive w, or a +/// resolved paint that is not a plain solid color) - either way meaning "no outline is painted +/// for this run"; see 's own tier- +/// selection-then-resolution remarks for why these two distinct cases both collapse to +/// here without the explicit-cancellation case falling through to a +/// shallower tier. Distinct from a shape's own <p:spPr>/<a:ln>, which +/// strokes the shape's geometry, not its text. +/// +/// +/// The run's resolved text-outline ink color, only meaningful when +/// is non-. +/// +/// +/// The run's resolved text-outline dash pattern, in EMU (Phase 2 Follow-Up: Run Text Outline +/// Dash Threading), resolved from the same <a:ln>/<a:prstDash val="..."/> +/// that already resolves for a shape's own +/// geometry outline - means a solid outline (either no +/// <a:prstDash> at all, an explicit val="solid", or an unrecognized preset +/// name - see 's own remarks), only meaningful when +/// is non-. +/// internal sealed record PptxEffectiveRunProperties( string FontFamily, float SizeEmu, @@ -25,7 +51,10 @@ internal sealed record PptxEffectiveRunProperties( bool Italic, PptxUnderlineStyle UnderlineStyle, Rgba32 UnderlineColor, - Rgba32 Color); + Rgba32 Color, + float? OutlineWidthEmu = null, + Rgba32 OutlineColor = default, + IReadOnlyList? OutlineDashArray = null); /// /// The discriminator for a run's resolved <a:rPr u="..."/> underline style diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxTextLayout.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxTextLayout.cs index 0d5df361..a3e90f1e 100644 --- a/src/DemaConsulting.CanvasNet.Pptx/PptxTextLayout.cs +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxTextLayout.cs @@ -63,10 +63,29 @@ internal readonly record struct PptxUnderlineSegment( /// The glyph's baseline origin Y, in EMU, in the owning shape's own local coordinate space. /// The glyph's resolved (already autofit-scaled) font size, in EMU. /// The glyph's resolved ink color. +/// +/// 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 - see +/// . 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. +/// +/// The glyph's resolved outline ink color, only meaningful when is non-. +/// +/// The glyph's resolved text-outline dash pattern, in EMU, already scaled by the same autofit +/// factor as / (Phase 2 Follow-Up: +/// Run Text Outline Dash Threading) - see +/// . means a +/// solid outline, only meaningful when is +/// non-. +/// internal sealed record PptxGlyphPlacement( TrueTypeFont Font, int GlyphIndex, float OriginXEmu, float OriginYEmu, float SizeEmu, - Rgba32 Color); + Rgba32 Color, + float? OutlineWidthEmu = null, + Rgba32 OutlineColor = default, + IReadOnlyList? OutlineDashArray = null); diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxTextLayoutTests.cs b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxTextLayoutTests.cs index 2203aaac..a1ba1239 100644 --- a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxTextLayoutTests.cs +++ b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxTextLayoutTests.cs @@ -873,6 +873,60 @@ public void ResolveTextLayout_NormAutofitAttributeLess_ShrinkLoopConvergesOnFirs Assert.Equal(0.7f, layout.AppliedFontScale, 2); } + /// + /// Proves a run-level <a:ln> text outline's OutlineWidthEmu and + /// OutlineDashArray survive 's own + /// autofit shrink loop (BuildLines) scaled by the same non-unit fontScale + /// applied to SizeEmu - a regression guard distinct from the direct-construction + /// (which never exercise BuildLines at all) and + /// the pre-shrink inheritance tests (which stop before layout + /// ever runs), so neither suite would catch a regression that stopped copying + /// OutlineWidthEmu/OutlineDashArray into a glyph placement, or stopped + /// scaling either by fontScale, inside BuildLines itself. Reuses the same + /// five-single-line-paragraph/50000 EMU available-height shape as the shrink-loop test + /// above, which converges on fontScale == 0.7, with every run additionally + /// declaring <a:ln w="9525"> (a solid accent-colored stroke with a + /// <a:prstDash val="dash"/> pattern). + /// + [Fact] + public void ResolveTextLayout_NormAutofitShrinkWithRunOutline_ScalesOutlineWidthAndDashArrayByFontScale() + { + var bodyPr = new XElement( + DrawingNs + "bodyPr", + new XAttribute("lIns", "0"), new XAttribute("tIns", "0"), new XAttribute("rIns", "0"), new XAttribute("bIns", "0"), + new XElement(DrawingNs + "normAutofit")); + + var ln = new XElement( + DrawingNs + "ln", + new XAttribute("w", "9525"), + new XElement(DrawingNs + "solidFill", new XElement(DrawingNs + "srgbClr", new XAttribute("val", "FF00FF"))), + new XElement(DrawingNs + "prstDash", new XAttribute("val", "dash"))); + + var paragraphs = Enumerable.Range(0, 5) + .Select(_ => new XElement( + DrawingNs + "p", + new XElement( + DrawingNs + "r", + new XElement(DrawingNs + "rPr", new XAttribute("sz", "100"), new XElement(ln)), + new XElement(DrawingNs + "t", "A")))) + .ToArray(); + + var txBody = new XElement(PresentationNs + "txBody", bodyPr, paragraphs); + var textBody = PptxDocument.ParseTextBody(txBody); + + var layout = Layout(textBody, 50000f, 50000f); + + Assert.Equal(0.7f, layout.AppliedFontScale, 2); + var glyph = layout.Glyphs[0]; + Assert.NotNull(glyph.OutlineWidthEmu); + Assert.Equal(9525f * 0.7f, glyph.OutlineWidthEmu.Value, 2); + Assert.NotNull(glyph.OutlineDashArray); + Assert.Equal(new Rgba32(0xFF, 0x00, 0xFF, 0xFF), glyph.OutlineColor); + Assert.Equal(2, glyph.OutlineDashArray.Count); + Assert.Equal(9525f * 4f * 0.7f, glyph.OutlineDashArray[0], 2); + Assert.Equal(9525f * 3f * 0.7f, glyph.OutlineDashArray[1], 2); + } + /// /// Proves a <a:normAutofit fontScale="..."/> with a non-numeric /// fontScale attribute is rejected with rather diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxTextRenderTests.cs b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxTextRenderTests.cs index 83c235ca..d0660c72 100644 --- a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxTextRenderTests.cs +++ b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxTextRenderTests.cs @@ -189,6 +189,77 @@ public void PaintTextLayout_AsymmetricGlyph_PaintsInkAboveBaselineNotBelow() Assert.Equal(default, surface[25, 112]); } + /// + /// Proves a glyph with a non-null (Phase + /// 2 Follow-Up: Run Text Outline) is stroked with + /// straddling its own path edge, in addition to its own interior fill - reproducing the + /// real-world "hollow outlined numeral" effect (a near-invisible fill paired with a + /// strongly-colored <a:ln> stroke) that originally surfaced this gap. + /// + [Fact] + public void PaintTextLayout_GlyphWithOutline_PaintsOutlineStrokeAroundInterior() + { + // Same geometry as PaintTextLayout_IdentityTransform_PaintsGlyphAtExpectedLocationWithResolvedColor: + // font-unit square [100,900]x[100,900], SizeEmu 100 -> scale 0.1, Origin (5,95) -> surface + // square [15,95]x[5,85] (identity transform, so shape-local EMU == surface pixels here). + var font = NewFilledSquareFont(); + var glyphIndex = font.GetGlyphIndex('A'); + var fillColor = new Rgba32(254, 254, 254, 255); + var outlineColor = new Rgba32(120, 30, 170, 255); + var glyph = new PptxGlyphPlacement( + font, glyphIndex, OriginXEmu: 5f, OriginYEmu: 95f, SizeEmu: 100f, fillColor, + OutlineWidthEmu: 4f, outlineColor); + var layout = new PptxTextLayout([glyph], AppliedFontScale: 1f); + + using var surface = new Surface(100, 100); + + PptxDocument.PaintTextLayout(surface, layout, Matrix3x2.Identity); + + // Comfortably inside the square (away from every edge): the fill color, not the outline. + Assert.Equal(fillColor, surface[50, 50]); + // Straddling the square's left edge (x=15, +/- the 4-EMU stroke's half-width): the outline color. + Assert.Equal(outlineColor, surface[15, 50]); + // Comfortably outside the square and its stroke: untouched. + Assert.Equal(default, surface[2, 2]); + } + + /// + /// Proves a glyph with a non-null (Phase + /// 2 Follow-Up: Run Text Outline Dash Threading) is stroked with visible gaps along its own + /// edge, instead of silently degrading to a solid stroke - regression test for the bug + /// where PaintTextLayout hard-coded DashArray: null on the ad hoc + /// it built for a glyph's outline, discarding whatever dash + /// pattern a run-level <a:ln><a:prstDash> had already resolved. + /// + [Fact] + public void PaintTextLayout_GlyphWithDashedOutline_PaintsOutlineWithGaps() + { + // Same geometry as PaintTextLayout_GlyphWithOutline_PaintsOutlineStrokeAroundInterior. + var font = NewFilledSquareFont(); + var glyphIndex = font.GetGlyphIndex('A'); + var fillColor = new Rgba32(254, 254, 254, 255); + var outlineColor = new Rgba32(120, 30, 170, 255); + IReadOnlyList dashArray = [10f, 10f]; + var glyph = new PptxGlyphPlacement( + font, glyphIndex, OriginXEmu: 5f, OriginYEmu: 95f, SizeEmu: 100f, fillColor, + OutlineWidthEmu: 4f, outlineColor, dashArray); + var layout = new PptxTextLayout([glyph], AppliedFontScale: 1f); + + using var surface = new Surface(100, 100); + + PptxDocument.PaintTextLayout(surface, layout, Matrix3x2.Identity); + + // Sample across the top edge's own stroke band (y=3, inside the 4-EMU stroke's + // half-width, just above the square's filled interior) for the square's full x extent + // (x in [16,94]): with a [10,10]-unit dash cycle over an 80-unit edge, a dashed stroke + // must leave some of these samples untouched (a gap - still painted outlineColor by a + // solid stroke, since DashArray: null paints every sample along the edge) while still + // painting others with the outline color. + var samples = Enumerable.Range(16, 79).Select(x => surface[x, 3]).ToList(); + Assert.Contains(outlineColor, samples); + Assert.Contains((Rgba32)default, samples); + } + /// Proves falls back to the bundled font when the family hint does not match any resolvable system font criterion and no installed font happens to be returned as a generic-family fallback (bundled fallback is always reachable regardless). [Fact] public void ResolveTextFont_AnyFamilyHint_ResolvesNonNullFont() diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxTextTests.cs b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxTextTests.cs index cb51bb6c..f27c3421 100644 --- a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxTextTests.cs +++ b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxTextTests.cs @@ -619,6 +619,233 @@ public void ResolveEffectiveRunProperties_RunSolidFillSrgbClr_ResolvesExplicitCo Assert.Equal(new Rgba32(255, 0, 0, 255), result.Color); } + /// + /// Resolve Effective Run Properties - a run-level <a:ln> (Phase 2 Follow-Up: + /// Run Text Outline) resolves / + /// , reproducing the real-world + /// "hollow outlined numeral" markup (a near-invisible fill paired with a solid-colored + /// stroke) that originally surfaced this gap. + /// + [Fact] + public void ResolveEffectiveRunProperties_RunLn_ResolvesOutlineWidthAndColor() + { + var theme = BuildTestTheme(); + var run = Run(new XElement( + DrawingNs + "rPr", + new XElement(DrawingNs + "solidFill", new XElement(DrawingNs + "srgbClr", new XAttribute("val", "FFFFFF"))), + new XElement( + DrawingNs + "ln", + new XAttribute("w", "19050"), + new XElement(DrawingNs + "solidFill", new XElement(DrawingNs + "srgbClr", new XAttribute("val", "7030A0")))))); + var paragraph = Paragraph(null, run); + var placeholderProperties = EmptyPlaceholderProperties(theme); + + var result = PptxDocument.ResolveEffectiveRunProperties(run, paragraph, placeholderProperties, theme, "body"); + + Assert.Equal(19050f, result.OutlineWidthEmu); + Assert.Equal(new Rgba32(0x70, 0x30, 0xA0, 255), result.OutlineColor); + } + + /// Resolve Effective Run Properties - No <a:ln> declared at any inheritance tier - OutlineWidthEmu stays (no outline painted). + [Fact] + public void ResolveEffectiveRunProperties_NoLnDeclared_OutlineWidthEmuIsNull() + { + var theme = BuildTestTheme(); + var run = Run(new XElement( + DrawingNs + "rPr", + new XElement(DrawingNs + "solidFill", new XElement(DrawingNs + "srgbClr", new XAttribute("val", "FF0000"))))); + var paragraph = Paragraph(null, run); + var placeholderProperties = EmptyPlaceholderProperties(theme); + + var result = PptxDocument.ResolveEffectiveRunProperties(run, paragraph, placeholderProperties, theme, "body"); + + Assert.Null(result.OutlineWidthEmu); + } + + /// + /// Resolve Effective Run Properties - a run's own explicit <a:ln><a:noFill/> + /// </a:ln> cancellation must win outright over a shallower tier's own declared + /// outline (regression test for the tier-selection bug: "resolves to null" at the run's own + /// tier used to be indistinguishable from "undeclared here", incorrectly letting the + /// paragraph-level outline leak through). + /// + [Fact] + public void ResolveEffectiveRunProperties_RunLnExplicitNoFill_CancelsParagraphOutlineInheritance() + { + var theme = BuildTestTheme(); + var run = Run(new XElement( + DrawingNs + "rPr", + new XElement(DrawingNs + "ln", new XElement(DrawingNs + "noFill")))); + var paragraphDefRPr = new XElement( + DrawingNs + "defRPr", + new XElement( + DrawingNs + "ln", + new XAttribute("w", "19050"), + new XElement(DrawingNs + "solidFill", new XElement(DrawingNs + "srgbClr", new XAttribute("val", "7030A0"))))); + var paragraph = Paragraph(new XElement(DrawingNs + "pPr", paragraphDefRPr), run); + var placeholderProperties = EmptyPlaceholderProperties(theme); + + var result = PptxDocument.ResolveEffectiveRunProperties(run, paragraph, placeholderProperties, theme, "body"); + + Assert.Null(result.OutlineWidthEmu); + } + + /// + /// Resolve Effective Run Properties - a run's own explicit non-positive w (an + /// explicit zero-width <a:ln>) must likewise cancel a shallower tier's own + /// declared outline, exactly like an explicit <a:noFill/> line does. + /// + [Fact] + public void ResolveEffectiveRunProperties_RunLnExplicitZeroWidth_CancelsParagraphOutlineInheritance() + { + var theme = BuildTestTheme(); + var run = Run(new XElement( + DrawingNs + "rPr", + new XElement( + DrawingNs + "ln", + new XAttribute("w", "0"), + new XElement(DrawingNs + "solidFill", new XElement(DrawingNs + "srgbClr", new XAttribute("val", "7030A0")))))); + var paragraphDefRPr = new XElement( + DrawingNs + "defRPr", + new XElement( + DrawingNs + "ln", + new XAttribute("w", "19050"), + new XElement(DrawingNs + "solidFill", new XElement(DrawingNs + "srgbClr", new XAttribute("val", "7030A0"))))); + var paragraph = Paragraph(new XElement(DrawingNs + "pPr", paragraphDefRPr), run); + var placeholderProperties = EmptyPlaceholderProperties(theme); + + var result = PptxDocument.ResolveEffectiveRunProperties(run, paragraph, placeholderProperties, theme, "body"); + + Assert.Null(result.OutlineWidthEmu); + } + + /// + /// Resolve Effective Run Properties - a run-level <a:ln> declaring + /// <a:prstDash val="dash"/> (Phase 2 Follow-Up: Run Text Outline Dash + /// Threading) resolves a non-null + /// matching the exact same dash-array values + /// already produces for a shape outline declaring the same preset at the same width - + /// proving the dash array is no longer silently discarded. + /// + [Fact] + public void ResolveEffectiveRunProperties_RunLnWithPrstDash_ResolvesMatchingOutlineDashArray() + { + var theme = BuildTestTheme(); + var lnElement = new XElement( + DrawingNs + "ln", + new XAttribute("w", "19050"), + new XElement(DrawingNs + "solidFill", new XElement(DrawingNs + "srgbClr", new XAttribute("val", "7030A0"))), + new XElement(DrawingNs + "prstDash", new XAttribute("val", "dash"))); + var run = Run(new XElement(DrawingNs + "rPr", new XElement(lnElement))); + var paragraph = Paragraph(null, run); + var placeholderProperties = EmptyPlaceholderProperties(theme); + + var result = PptxDocument.ResolveEffectiveRunProperties(run, paragraph, placeholderProperties, theme, "body"); + var expectedLineStyle = PptxDocument.ResolveLineStyle(lnElement, theme); + + Assert.Equal(19050f, result.OutlineWidthEmu); + Assert.NotNull(result.OutlineDashArray); + Assert.Equal(expectedLineStyle!.DashArray, result.OutlineDashArray); + } + + /// + /// Resolve Effective Run Properties - a run-level <a:ln> declaring a + /// <a:blipFill> picture-fill line paint (out of scope - this unit's run + /// outline color is solid-only, see this feature's design-doc scope note) resolves to "no + /// outline" instead of propagating - + /// regression test for a post-merge code-review finding: GetRunOutline previously + /// called with no resolveBlipImage + /// delegate, so a run-level picture-fill outline's own + /// call threw instead of degrading gracefully the way a gradient/pattern line paint + /// already does, aborting rendering of the entire slide over a single unsupported run's + /// stroke paint. + /// + [Fact] + public void ResolveEffectiveRunProperties_RunLnWithBlipFill_ResolvesNoOutlineInsteadOfThrowing() + { + var theme = BuildTestTheme(); + var run = Run(new XElement( + DrawingNs + "rPr", + new XElement( + DrawingNs + "ln", + new XAttribute("w", "19050"), + new XElement( + DrawingNs + "blipFill", + new XElement( + DrawingNs + "blip", + new XAttribute( + XNamespace.Get("http://schemas.openxmlformats.org/officeDocument/2006/relationships") + "embed", + "rId1")))))); + var paragraph = Paragraph(null, run); + var placeholderProperties = EmptyPlaceholderProperties(theme); + + var result = PptxDocument.ResolveEffectiveRunProperties(run, paragraph, placeholderProperties, theme, "body"); + + Assert.Null(result.OutlineWidthEmu); + } + + /// + /// Resolve Effective Run Properties - a run-level <a:ln> declaring an + /// <a:gradFill> path gradient (one of the specific gradient/pattern variants + /// itself rejects with + /// ) resolves to "no outline" instead of + /// propagating that exception - regression test for a second post-merge code-review + /// finding: the first blip-fill-only short-circuit guard still let + /// call through to ResolveFill for every + /// other non-solidFill paint, so an out-of-scope gradient/pattern run outline could + /// likewise abort rendering of the whole slide depending on which specific variant it used. + /// + [Fact] + public void ResolveEffectiveRunProperties_RunLnWithUnsupportedPathGradFill_ResolvesNoOutlineInsteadOfThrowing() + { + var theme = BuildTestTheme(); + var run = Run(new XElement( + DrawingNs + "rPr", + new XElement( + DrawingNs + "ln", + new XAttribute("w", "19050"), + new XElement( + DrawingNs + "gradFill", + new XElement( + DrawingNs + "gsLst", + new XElement( + DrawingNs + "gs", + new XAttribute("pos", "0"), + new XElement(DrawingNs + "srgbClr", new XAttribute("val", "FF0000")))), + new XElement(DrawingNs + "path"))))); + var paragraph = Paragraph(null, run); + var placeholderProperties = EmptyPlaceholderProperties(theme); + + var result = PptxDocument.ResolveEffectiveRunProperties(run, paragraph, placeholderProperties, theme, "body"); + + Assert.Null(result.OutlineWidthEmu); + } + + /// + /// Resolve Effective Run Properties - a run-level <a:ln> declaring an + /// <a:pattFill prst="..."/> naming a preset outside 's + /// covered set resolves to "no outline" instead of propagating + /// - the pattern-fill counterpart to the + /// path-gradient regression test above. + /// + [Fact] + public void ResolveEffectiveRunProperties_RunLnWithUnsupportedPattFill_ResolvesNoOutlineInsteadOfThrowing() + { + var theme = BuildTestTheme(); + var run = Run(new XElement( + DrawingNs + "rPr", + new XElement( + DrawingNs + "ln", + new XAttribute("w", "19050"), + new XElement(DrawingNs + "pattFill", new XAttribute("prst", "notARealPreset"))))); + var paragraph = Paragraph(null, run); + var placeholderProperties = EmptyPlaceholderProperties(theme); + + var result = PptxDocument.ResolveEffectiveRunProperties(run, paragraph, placeholderProperties, theme, "body"); + + Assert.Null(result.OutlineWidthEmu); + } + /// /// Resolve Effective Run Properties - Theme Font Tokens - Resolve Through The Theme's /// FontScheme (rather than being passed through as literal, unresolvable family name