diff --git a/.cspell.yaml b/.cspell.yaml index a27d356c..648ba2ba 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -65,6 +65,7 @@ words: - combinators - CSUM - Decodability + - DOTLESS - dequantize - dequantizes - Deflate @@ -97,6 +98,19 @@ words: - gAMA - Grayscale - grayscale + - gridline + - gridlines + - horz + - Horz + - Dmnd + - patt + - pythonpptx + - slidenum + - xfrm + - drawingml + - Calibri + - unoverridden + - subtable - IDAT - IDCT - IEND @@ -114,8 +128,10 @@ words: - mcus - microarchitecture - misdecoded + - misrender - nameof - nonzero + - paintable - NOTAFONT - NOTAGIF - Outliner @@ -309,6 +325,41 @@ words: - thinspace - Thinspace - subsetted + - rels + - Rels + - Linq + - OOXML + - openxmlformats + - officedocument + - presentationml + - pptx + - Pptx + - autofit + - Autofit + - unrotated + - unflipped + - unshifted + - autoshape + - Autoshape + - autoshapes + - Autonumber + - srgb + - MCMXCIV + - hlink + - Hlink + - clrmap + - clrmapovr + - colormap + - openxml + - textless + - aiden + - misrouting + - parameterless + - Lbls + - texel + - precomputes + - uncontained + - prst # Exclude common build artifacts, dependencies, and vendored third-party code ignorePaths: diff --git a/.reviewmark.yaml b/.reviewmark.yaml index 011131bf..83266295 100644 --- a/.reviewmark.yaml +++ b/.reviewmark.yaml @@ -769,6 +769,238 @@ reviews: - "test/DemaConsulting.CanvasNet.Pdf.Tests/PdfSystemIntegrationTests.cs" # integration tests - "test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtureTests.cs" # fixture conformance tests + # CanvasNetPptx-Architecture Review (one per system) + - id: CanvasNetPptx-Architecture + title: Review that CanvasNetPptx Architecture Satisfies Requirements + context: + - "README.md" + - "docs/user_guide/**/*.md" + paths: + - "docs/reqstream/canvas-net-pptx.yaml" # system requirements + - "docs/design/introduction.md" # design introduction + - "docs/design/canvas-net-pptx.md" # system design + - "docs/verification/introduction.md" # verification introduction + - "docs/verification/canvas-net-pptx.md" # system verification + - "test/DemaConsulting.CanvasNet.Pptx.Tests/PptxSystemIntegrationTests.cs" # integration tests + + # CanvasNetPptx-Design Review (one per system) + - id: CanvasNetPptx-Design + title: Review that CanvasNetPptx Design is Consistent and Complete + context: + - "docs/reqstream/canvas-net-pptx.yaml" + paths: + - "docs/reqstream/canvas-net-pptx/platform-requirements.yaml" # platform requirements + - "docs/design/introduction.md" # design introduction + - "docs/design/canvas-net-pptx.md" # system design + - "docs/design/canvas-net-pptx/**/*.md" # all system design documents + + # CanvasNetPptx-Verification Review (one per system) + - id: CanvasNetPptx-Verification + title: Review that CanvasNetPptx Verification is Consistent and Complete + context: + - "docs/reqstream/canvas-net-pptx.yaml" + paths: + - "docs/verification/introduction.md" # verification introduction + - "docs/verification/canvas-net-pptx.md" # system verification + - "docs/verification/canvas-net-pptx/**/*.md" # all verification documents + + # CanvasNetPptx-AllRequirements Review (one per system) + - id: CanvasNetPptx-AllRequirements + title: Review that All CanvasNetPptx Requirements are Complete + context: + - "docs/design/canvas-net-pptx.md" + - "docs/reqstream/canvas-net-pptx.yaml" + paths: + - "docs/reqstream/canvas-net-pptx/**/*.yaml" # all requirements files + + # CanvasNetPptx-PptxDocument Review (one per direct unit) + - id: CanvasNetPptx-PptxDocument + title: Review that CanvasNetPptx PptxDocument Implementation is Correct + context: + - "docs/design/canvas-net-pptx.md" + - "docs/reqstream/canvas-net-pptx.yaml" + paths: + - "docs/reqstream/canvas-net-pptx/pptx-document.yaml" # unit requirements + - "docs/design/canvas-net-pptx/pptx-document.md" # unit design + - "docs/verification/canvas-net-pptx/pptx-document.md" # unit verification + - "docs/sysml2/model/canvas-net-pptx/pptx-document.sysml" # unit SysML2 model + - "src/DemaConsulting.CanvasNet.Pptx/PptxDocument.cs" # phase 1a unit source (public API) + - "src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Package.cs" # phase 1a unit source (package layer) + - "src/DemaConsulting.CanvasNet.Pptx/NamespaceDoc.cs" # Pptx namespace documentation + - "src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Presentation.cs" # phase 1b unit source (presentation) + - "src/DemaConsulting.CanvasNet.Pptx/PptxSlideSize.cs" # phase 1b unit source (slide size model) + - "src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Theme.cs" # phase 1b unit source (theme parsing) + - "src/DemaConsulting.CanvasNet.Pptx/PptxTheme.cs" # phase 1b unit source (theme model) + - "src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Masters.cs" # phase 1b unit source (master parsing) + - "src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Layouts.cs" # phase 1b unit source (layout parsing) + - "src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Slides.cs" # phase 1b unit source (slide parsing) + - "src/DemaConsulting.CanvasNet.Pptx/PptxPlaceholder.cs" # phase 1b unit source (placeholder model) + - "src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Inheritance.cs" # phase 1b unit source (inheritance) + - "src/DemaConsulting.CanvasNet.Pptx/PptxPlaceholderProperties.cs" # phase 1b unit source (resolved properties) + - "src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Geometry.cs" # phase 1c unit source (geometry resolvers) + - "src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Paint.cs" # phase 1c unit source (fill/color/stroke resolvers) + - "src/DemaConsulting.CanvasNet.Pptx/PptxPresetGeometry.cs" # phase 1c unit source (preset shape builder) + - "src/DemaConsulting.CanvasNet.Pptx/PptxShapeFrame.cs" # phase 1c unit source (resolved shape frame model) + - "src/DemaConsulting.CanvasNet.Pptx/PptxPaint.cs" # phase 1c unit source (resolved paint model) + - "src/DemaConsulting.CanvasNet.Pptx/PptxLineStyle.cs" # phase 1c unit source (resolved line style model) + - "src/DemaConsulting.CanvasNet.Pptx/PptxUnsupportedFeatureException.cs" # phase 1c unit source (exceptions) + - "src/DemaConsulting.CanvasNet.Pptx/PptxTextBody.cs" # phase 1d unit source (text body model) + - "src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Text.cs" # phase 1d unit source (text body parsing) + - "src/DemaConsulting.CanvasNet.Pptx/PptxEffectiveTextProperties.cs" # phase 1d unit source (text props) + - "src/DemaConsulting.CanvasNet.Pptx/PptxDocument.TextInheritance.cs" # phase 1d unit source (inheritance) + - "src/DemaConsulting.CanvasNet.Pptx/PptxTextLayout.cs" # phase 1d unit source (resolved text layout model) + - "src/DemaConsulting.CanvasNet.Pptx/PptxDocument.TextLayout.cs" # phase 1d unit source (text layout) + - "src/DemaConsulting.CanvasNet.Pptx/PptxDocument.TextRender.cs" # phase 1d unit source (glyph rendering) + - "src/DemaConsulting.CanvasNet.Pptx/PptxPicture.cs" # phase 1e unit source (crop-rectangle model) + - "src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Images.cs" # phase 1e unit source (picture decode/crop/paint) + - "src/DemaConsulting.CanvasNet.Pptx/PptxTable.cs" # phase 1e unit source (table data model) + - "src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Tables.cs" # phase 1e unit source (table parsing/painting) + - "src/DemaConsulting.CanvasNet.Pptx/PptxShapeTree.cs" # phase 1e unit source (shape-tree node model) + - "src/DemaConsulting.CanvasNet.Pptx/PptxDocument.ShapeTree.cs" # phase 1e unit source (shape-tree parsing) + - "src/DemaConsulting.CanvasNet.Pptx/PptxRenderOptions.cs" # phase 1f unit source (render options) + - "src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Render.cs" # phase 1f unit source (public Render API) + - "src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Background.cs" # phase 2 follow-up (background fill) + - "src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Bullets.cs" # phase 2 follow-up (bullet/numbering formatting) + - "src/DemaConsulting.CanvasNet.Pptx/PptxArrowheadStyle.cs" # phase 2 follow-up (connector arrowhead model) + - "src/DemaConsulting.CanvasNet.Pptx/PptxArrowheadGeometry.cs" # phase 2 follow-up (arrowhead geometry) + - "src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Connectors.cs" # phase 2 follow-up (connector resolution) + - "src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Charts.cs" # phase 4 unit source (chart frame integration) + - "src/DemaConsulting.CanvasNet.Pptx/DemaConsulting.CanvasNet.Pptx.csproj" # project file (phase 4: Charts ref) + - "test/DemaConsulting.CanvasNet.Pptx.Tests/PptxDocumentTests.cs" # unit tests + - "test/DemaConsulting.CanvasNet.Pptx.Tests/PptxGeometryTests.cs" # phase 1c unit tests (geometry) + - "test/DemaConsulting.CanvasNet.Pptx.Tests/PptxPaintTests.cs" # phase 1c unit tests (paint) + - "test/DemaConsulting.CanvasNet.Pptx.Tests/PptxTextTests.cs" # phase 1d unit tests (text parsing) + - "test/DemaConsulting.CanvasNet.Pptx.Tests/PptxTextLayoutTests.cs" # phase 1d unit tests (text layout) + - "test/DemaConsulting.CanvasNet.Pptx.Tests/PptxTextRenderTests.cs" # phase 1d unit tests (glyph rendering) + - "test/DemaConsulting.CanvasNet.Pptx.Tests/PptxImagesTests.cs" # phase 1e unit tests (images) + - "test/DemaConsulting.CanvasNet.Pptx.Tests/PptxTablesTests.cs" # phase 1e unit tests (tables) + - "test/DemaConsulting.CanvasNet.Pptx.Tests/PptxGroupsTests.cs" # phase 1e unit tests (shape tree/groups) + - "test/DemaConsulting.CanvasNet.Pptx.Tests/PptxSystemIntegrationTests.cs" # integration tests + - "test/DemaConsulting.CanvasNet.Pptx.Tests/PptxRenderTests.cs" # phase 1f unit tests (render API) + - "test/DemaConsulting.CanvasNet.Pptx.Tests/PptxBackgroundTests.cs" # phase 2 follow-up (background resolver) + - "test/DemaConsulting.CanvasNet.Pptx.Tests/PptxBulletTests.cs" # phase 2 follow-up (bullet/numbering tests) + - "test/DemaConsulting.CanvasNet.Pptx.Tests/PptxColorMapTests.cs" # phase 2 follow-up (color map tests) + - "test/DemaConsulting.CanvasNet.Pptx.Tests/PptxConnectorTests.cs" # phase 2 follow-up (connector unit tests) + - "test/DemaConsulting.CanvasNet.Pptx.Tests/PptxChartsTests.cs" # phase 4 unit tests (chart frame integration) + - "test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixturesCorpusTests.cs" # fixture conformance tests + + # CanvasNetCharts-Architecture Review (one per system) + - id: CanvasNetCharts-Architecture + title: Review that CanvasNetCharts Architecture Satisfies Requirements + context: + - "README.md" + - "docs/user_guide/**/*.md" + paths: + - "docs/reqstream/canvas-net-charts.yaml" # system requirements + - "docs/design/introduction.md" # design introduction + - "docs/design/canvas-net-charts.md" # system design + - "docs/verification/introduction.md" # verification introduction + - "docs/verification/canvas-net-charts.md" # system verification + + # CanvasNetCharts-Design Review (one per system) + - id: CanvasNetCharts-Design + title: Review that CanvasNetCharts Design is Consistent and Complete + context: + - "docs/reqstream/canvas-net-charts.yaml" + paths: + - "docs/design/introduction.md" # design introduction + - "docs/design/canvas-net-charts.md" # system design + - "docs/design/canvas-net-charts/**/*.md" # all system design documents + + # CanvasNetCharts-Verification Review (one per system) + - id: CanvasNetCharts-Verification + title: Review that CanvasNetCharts Verification is Consistent and Complete + context: + - "docs/reqstream/canvas-net-charts.yaml" + paths: + - "docs/verification/introduction.md" # verification introduction + - "docs/verification/canvas-net-charts.md" # system verification + - "docs/verification/canvas-net-charts/**/*.md" # all verification documents + + # CanvasNetCharts-AllRequirements Review (one per system) + - id: CanvasNetCharts-AllRequirements + title: Review that All CanvasNetCharts Requirements are Complete + context: + - "docs/design/canvas-net-charts.md" + - "docs/reqstream/canvas-net-charts.yaml" + paths: + - "docs/reqstream/canvas-net-charts/**/*.yaml" # all requirements files + + # CanvasNetCharts-ChartDocument Review (one per unit) + - id: CanvasNetCharts-ChartDocument + title: Review that CanvasNetCharts ChartDocument Implementation is Correct + context: + - "docs/design/canvas-net-charts.md" + - "docs/reqstream/canvas-net-charts.yaml" + - "docs/design/canvas-net-charts/chart-model.md" + - "docs/reqstream/canvas-net-charts/chart-model.yaml" + paths: + - "docs/reqstream/canvas-net-charts/chart-model/chart-document.yaml" # unit requirements + - "docs/design/canvas-net-charts/chart-model/chart-document.md" # unit design + - "docs/verification/canvas-net-charts/chart-model/chart-document.md" # unit verification + - "docs/sysml2/model/canvas-net-charts/chart-model/chart-document.sysml" # unit SysML2 model + - "src/DemaConsulting.CanvasNet.Charts/DemaConsulting.CanvasNet.Charts.csproj" # unit project file + - "src/DemaConsulting.CanvasNet.Charts/NamespaceDoc.cs" # Charts namespace documentation + - "src/DemaConsulting.CanvasNet.Charts/ChartType.cs" # unit source (chart kind enum) + - "src/DemaConsulting.CanvasNet.Charts/ChartAxis.cs" # unit source (axis model) + - "src/DemaConsulting.CanvasNet.Charts/ChartLegend.cs" # unit source (legend model) + - "src/DemaConsulting.CanvasNet.Charts/ChartTitle.cs" # unit source (title model) + - "src/DemaConsulting.CanvasNet.Charts/ChartSeries.cs" # unit source (series model) + - "src/DemaConsulting.CanvasNet.Charts/Chart.cs" # unit source (root aggregate) + - "src/DemaConsulting.CanvasNet.Charts/ChartBuilder.cs" # unit source (fluent builder) + - "test/DemaConsulting.CanvasNet.Charts.Tests/DemaConsulting.CanvasNet.Charts.Tests.csproj" # unit test project + - "test/DemaConsulting.CanvasNet.Charts.Tests/ChartTypeTests.cs" # unit tests + - "test/DemaConsulting.CanvasNet.Charts.Tests/ChartAxisTests.cs" # unit tests + - "test/DemaConsulting.CanvasNet.Charts.Tests/ChartLegendTests.cs" # unit tests + - "test/DemaConsulting.CanvasNet.Charts.Tests/ChartTitleTests.cs" # unit tests + - "test/DemaConsulting.CanvasNet.Charts.Tests/ChartSeriesTests.cs" # unit tests + - "test/DemaConsulting.CanvasNet.Charts.Tests/ChartTests.cs" # unit tests + - "test/DemaConsulting.CanvasNet.Charts.Tests/ChartBuilderTests.cs" # unit tests + + # CanvasNetCharts-ChartRenderer Review (one per unit) + - id: CanvasNetCharts-ChartRenderer + title: Review that CanvasNetCharts ChartRenderer Implementation is Correct + context: + - "docs/design/canvas-net-charts.md" + - "docs/reqstream/canvas-net-charts.yaml" + - "docs/design/canvas-net-charts/chart-model.md" + - "docs/reqstream/canvas-net-charts/chart-model.yaml" + paths: + - "docs/reqstream/canvas-net-charts/chart-model/chart-renderer.yaml" # unit requirements + - "docs/design/canvas-net-charts/chart-model/chart-renderer.md" # unit design + - "docs/verification/canvas-net-charts/chart-model/chart-renderer.md" # unit verification + - "docs/sysml2/model/canvas-net-charts/chart-model/chart-renderer.sysml" # unit SysML2 model + - "src/DemaConsulting.CanvasNet.Charts/ChartColorPalette.cs" # unit source (default palette) + - "src/DemaConsulting.CanvasNet.Charts/ChartRenderOptions.cs" # unit source (render options) + - "src/DemaConsulting.CanvasNet.Charts/ChartRenderer.cs" # unit source (entry point) + - "src/DemaConsulting.CanvasNet.Charts/ChartRenderer.Layout.cs" # unit source (layout computation) + - "src/DemaConsulting.CanvasNet.Charts/ChartRenderer.CategoryChart.cs" # unit source (Bar/Col/Line/Area painters) + - "src/DemaConsulting.CanvasNet.Charts/ChartRenderer.PieChart.cs" # unit source (Pie/Doughnut painter) + - "src/DemaConsulting.CanvasNet.Charts/ChartRenderer.Legend.cs" # unit source (legend painter) + - "src/DemaConsulting.CanvasNet.Charts/ChartRenderer.Title.cs" # unit source (title painter) + - "test/DemaConsulting.CanvasNet.Charts.Tests/ChartRendererTests.cs" # unit tests + - "test/DemaConsulting.CanvasNet.Charts.Tests/ChartRendererAxisLegendTitleTests.cs" # unit tests + - "test/DemaConsulting.CanvasNet.Charts.Tests/ChartRendererEdgeCaseTests.cs" # unit tests + + # CanvasNetCharts-OpenXmlChartParser Review (one per unit) + - id: CanvasNetCharts-OpenXmlChartParser + title: Review that CanvasNetCharts OpenXmlChartParser Implementation is Correct + context: + - "docs/design/canvas-net-charts.md" + - "docs/reqstream/canvas-net-charts.yaml" + - "docs/design/canvas-net-charts/open-xml-chart.md" + - "docs/reqstream/canvas-net-charts/open-xml-chart.yaml" + paths: + - "docs/reqstream/canvas-net-charts/open-xml-chart/open-xml-chart-parser.yaml" # unit requirements + - "docs/design/canvas-net-charts/open-xml-chart/open-xml-chart-parser.md" # unit design + - "docs/verification/canvas-net-charts/open-xml-chart/open-xml-chart-parser.md" # unit verification + - "docs/sysml2/model/canvas-net-charts/open-xml-chart/open-xml-chart-parser.sysml" # unit SysML2 model + - "src/DemaConsulting.CanvasNet.Charts/OpenXml/NamespaceDoc.cs" # OpenXml namespace documentation + - "src/DemaConsulting.CanvasNet.Charts/OpenXml/OpenXmlChartParser.cs" # unit source (parser entry point) + - "src/DemaConsulting.CanvasNet.Charts/OpenXml/ChartUnsupportedFeatureException.cs" # unit source (exception) + - "test/DemaConsulting.CanvasNet.Charts.Tests/OpenXmlChartParserTests.cs" # unit tests + - "test/DemaConsulting.CanvasNet.Charts.Tests/OpenXmlChartFixtures/README.md" # fixture provenance + # OTS-BuildMark Review (one per OTS item) - id: OTS-BuildMark title: Review that BuildMark Provides Required Functionality diff --git a/CanvasNet.slnx b/CanvasNet.slnx index 229269f3..f2e3e588 100644 --- a/CanvasNet.slnx +++ b/CanvasNet.slnx @@ -3,10 +3,14 @@ + + + + diff --git a/README.md b/README.md index f86e6742..6d7e7c44 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,17 @@ image operations using `Span`, and supports independent-copy cropping for loa TrueType text to a surface, automatically substituting a matching system font (or a bundled Liberation Sans/Serif/Mono font) for text using a non-embedded font (ships as the separate `DemaConsulting.CanvasNet.Pdf` package) +- 📊 **Chart Rendering** - Build a validated Bar/Column/Line/Area/Pie/Doughnut chart (with axes, + legend, title, and data labels) via a fluent builder and paint it onto a surface with documented + default styling and a categorical color palette (ships as the separate + `DemaConsulting.CanvasNet.Charts` package) +- 📈 **OOXML Chart Parsing** - Parse a raw ECMA-376 DrawingML-Charts `c:chartSpace`/`c:chart` XML + element (for example, a `chart1.xml` OPC part) into a validated chart, reading only cached + values, with no OPC/ZIP packaging or host document-format knowledge (ships as part of the same + `DemaConsulting.CanvasNet.Charts` package). When rendering a `.pptx` presentation via the + separate `DemaConsulting.CanvasNet.Pptx` package's `PptxDocument.Render`, an embedded + `` chart is automatically parsed and painted using this same package, with no + extra caller code required. - 🔍 **Header-Only Probing** - `GetInfo` reads headers without decoding pixels (GIF excepted) - 🖌️ **Path Filling** - Antialiased nonzero/even-odd fill of vector paths - 🖊️ **Stroke-to-Fill** - Convert stroked paths into fillable outlines @@ -92,6 +103,31 @@ Or via Package Manager Console: Install-Package DemaConsulting.CanvasNet.Pdf ``` +Chart rendering requires the separate `DemaConsulting.CanvasNet.Charts` package: + +```bash +dotnet add package DemaConsulting.CanvasNet.Charts +``` + +Or via Package Manager Console: + +```powershell +Install-Package DemaConsulting.CanvasNet.Charts +``` + +PowerPoint (`.pptx`) document parsing and rendering requires the separate +`DemaConsulting.CanvasNet.Pptx` package: + +```bash +dotnet add package DemaConsulting.CanvasNet.Pptx +``` + +Or via Package Manager Console: + +```powershell +Install-Package DemaConsulting.CanvasNet.Pptx +``` + ## Usage ```csharp @@ -163,6 +199,44 @@ if (!info.CanDecode) using var safeSurface = PngCodec.Load("untrusted.png"); ``` +Building and rendering a chart: + +```csharp +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Charts; + +// Build a validated Chart fluently +var chart = new ChartBuilder() + .OfType(ChartType.Column) + .WithCategoryAxis(["Q1", "Q2"]) + .WithValueAxis(minimum: 0f, maximum: 10f) + .AddSeries("Revenue", [4.0, 9.0], color: new Rgba32(31, 119, 180, 255)) + .WithTitle("Quarterly Revenue") + .WithLegend(ChartLegendPosition.Bottom) + .Build(); + +// Render it onto a new surface using every rendering default +using var chartSurface = ChartRenderer.Render(chart, 400, 300); +``` + +Parsing a raw OOXML chart part and rendering it: + +```csharp +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Charts; +using DemaConsulting.CanvasNet.Charts.OpenXml; +using System.Xml.Linq; + +// Parse a c:chartSpace/c:chart XML document (for example, a chart1.xml OPC part a host +// document-format library - such as CanvasNetPptx - has already located and opened) +var chartDocument = XDocument.Load("chart1.xml"); +var parsedChart = OpenXmlChartParser.Parse(chartDocument); + +// The parsed Chart is directly consumable by ChartRenderer, exactly like a Chart built via +// ChartBuilder +using var parsedChartSurface = ChartRenderer.Render(parsedChart, 400, 300); +``` + Filling a vector path onto a surface: ```csharp diff --git a/docs/design/canvas-net-charts.md b/docs/design/canvas-net-charts.md new file mode 100644 index 00000000..9019aafe --- /dev/null +++ b/docs/design/canvas-net-charts.md @@ -0,0 +1,256 @@ +# System Design + +This document provides the system-level design for CanvasNetCharts. + +![CanvasNetCharts Structure](CanvasNetChartsView.svg) + +## Architecture + +CanvasNetCharts is a .NET library providing chart support, distributed as its own NuGet package +(`DemaConsulting.CanvasNet.Charts`, namespace `DemaConsulting.CanvasNet.Charts`), independent of, +but depending on, the core `CanvasNet` system (its own separate package, +`DemaConsulting.CanvasNet`) — see the Dependencies section below. + +`CanvasNetCharts` consists of two subsystems: + +- **ChartModel** (folder `src/DemaConsulting.CanvasNet.Charts/`, flat — no further nesting): a + public, immutable, validating chart data model (`Chart`/`ChartSeries`/`ChartAxis`/ + `ChartLegend`/`ChartTitle`/`ChartType`), the `ChartBuilder` fluent construction API, and a + pixel-rendering engine (`ChartRenderer`/`ChartRenderOptions`/`ChartColorPalette`) that paints a + `Chart` onto a core `CanvasNet.Canvas.Surface`, contained in two implemented units, + `ChartDocument` and `ChartRenderer`. See _ChartModel Subsystem Design_ + (`canvas-net-charts/chart-model.md`). +- **OpenXmlChart** (folder `src/DemaConsulting.CanvasNet.Charts/OpenXml/`): a format-agnostic + `OpenXmlChartParser` that produces a `Chart` directly from a raw ECMA-376 DrawingML-Charts + `c:chartSpace`/`c:chart` `System.Xml.Linq` element, reading only cached values, plus the + `ChartUnsupportedFeatureException` it throws for a recognized-but-unimplemented chart kind or + data shape, contained in one unit, `OpenXmlChartParser`. See _OpenXmlChart Subsystem Design_ + (`canvas-net-charts/open-xml-chart.md`). + +No Subsystem tier would normally be needed for a system containing only one or two units +(compare `CanvasNetSvg`'s `SvgCodec`, which has no interposed subsystem — see _CanvasNetSvg +System Design_, `canvas-net-svg.md`). A `ChartModel` subsystem was nonetheless introduced from +Phase 1 onward, ahead of `CanvasNetCharts` containing more than one unit, to avoid a disruptive +re-model of the system's tier structure partway through its incremental delivery. Phase 2's +`ChartRenderer` was added as a second unit within the existing `ChartModel` subsystem — rather +than under a new subsystem, as originally anticipated in Phase 1 — because it shares the same +single flat source folder and the same `ChartModel` Purpose statement ("what chart to draw" and +"how to draw it" are two facets of the same data-model-plus-rendering whole). Phase 3's +`OpenXmlChartParser`, by contrast, genuinely is a distinct concern (untrusted-input XML parsing, +in its own `OpenXml/` sub-folder, with no dependency in either direction on `ChartRenderer`'s own +implementation), so it was given its own `OpenXmlChart` subsystem as originally anticipated. + +`CanvasNetPptx` integrates both subsystems into its own slide rendering: it locates and opens a +`chart1.xml` OPC part and hands its content to `OpenXmlChartParser`, then renders the resulting +`Chart` via `ChartRenderer`, so that a `` containing a chart reference renders its +chart content instead of throwing — see _CanvasNetPptx System Design_ (`canvas-net-pptx.md`) for +that integration's own detail. + +## External Interfaces + +The system exposes the following public API, all in the `DemaConsulting.CanvasNet.Charts` +namespace: + +- **`Chart`**: An immutable, validated description of a chart — its `ChartType`, its `Series`, + and optional `CategoryAxis`/`ValueAxis`/`Legend`/`Title`/`ColorPalette`. Constructed directly, + or via `ChartBuilder`. +- **`ChartSeries`**: An immutable, validated named series of numeric values, with optional + per-point colors, per-point labels, and a series-level color. +- **`ChartAxis`**: An immutable, validated axis description — an optional ordered category label + collection, an optional value range (minimum/maximum/tick interval), and an optional title. + Used for both a chart's category axis and its value axis. +- **`ChartLegend`**: An immutable, validated legend description — a placement position + (`ChartLegendPosition`) and a visibility flag. +- **`ChartTitle`**: An immutable, validated title description — text and an optional font-size + hint. +- **`ChartType`**: An enumeration of the chart kinds Phase 1 models: `Bar`, `Column`, `Line`, + `Pie`, `Doughnut`, `Area`. +- **`ChartLegendPosition`**: An enumeration of legend placements: `Top`, `Bottom`, `Left`, + `Right`, `None`. +- **`ChartBuilder`**: A fluent, mutable-until-`Build` API for constructing a `Chart` ergonomically, + delegating all data-shape validation to the model types' own constructors. +- **`ChartRenderer`**: A public static entry point that paints a `Chart` onto a new + `CanvasNet.Canvas.Surface`, via `Render(Chart, int, int, ChartRenderOptions?)` (direct pixel + dimensions) or `Render(Chart, float, float, float, ChartRenderOptions?)` (physical + width/height/DPI). +- **`ChartRenderOptions`**: An immutable options bag for `ChartRenderer.Render` — background + color, an optional font override, per-element default font sizes, and an optional color + palette override — with a `Default` singleton, mirroring `PptxRenderOptions`'s own shape. +- **`ChartColorPalette`**: A fixed, documented 10-entry categorical color palette, used by + `ChartRenderer` when neither `Chart.ColorPalette` nor `ChartRenderOptions.ColorPalette` supplies + one, resolved by index with wraparound. +- **`OpenXmlChartParser`** (namespace `DemaConsulting.CanvasNet.Charts.OpenXml`): A public static + entry point that parses a raw `c:chartSpace`/`c:chart` `System.Xml.Linq.XElement` or + `XDocument` into a validated `Chart`, via `Parse(XDocument)`/`Parse(XElement)`. +- **`ChartUnsupportedFeatureException`** (namespace `DemaConsulting.CanvasNet.Charts.OpenXml`): An + `IOException`-derived exception, carrying a short, stable `Feature` token, thrown by + `OpenXmlChartParser` for a recognized-but-unimplemented chart kind or data shape. + + +| Interface | Direction | Format | Constraints | +| ------------------------ | ---------------- | -------------------------- | --------------------------------------------------------------- | +| `Chart` constructor | Inbound | Method call / `Chart` instance | At least one non-null series; see _ChartModel Subsystem Design_ | +| `ChartBuilder.Build()` | Inbound/Outbound | Method call / `Chart` return | A chart type and at least one series must be configured first | +| `ChartRenderer.Render(...)` | Inbound/Outbound | Method call / `Surface` return | `chart` must not be null; pixel/physical dimensions must be positive and within `Surface`'s supported range | +| `OpenXmlChartParser.Parse(...)` | Inbound/Outbound | Method call / `Chart` return | `chartDocument`/`chartSpaceOrChartElement` must not be null; must resolve to a `c:chart` with a `c:plotArea` | + + +See _ChartModel Subsystem Design_ (`canvas-net-charts/chart-model.md`), _ChartDocument Unit +Design_ (`canvas-net-charts/chart-model/chart-document.md`), _ChartRenderer Unit Design_ +(`canvas-net-charts/chart-model/chart-renderer.md`), _OpenXmlChart Subsystem Design_ +(`canvas-net-charts/open-xml-chart.md`), and _OpenXmlChartParser Unit Design_ +(`canvas-net-charts/open-xml-chart/open-xml-chart-parser.md`) for every type's complete +constructor parameter and exception detail. + +## Dependencies + +`CanvasNetCharts` depends on the separate `CanvasNet` system (its own package, +`DemaConsulting.CanvasNet`, referenced via a project reference from +`src/DemaConsulting.CanvasNet.Charts/DemaConsulting.CanvasNet.Charts.csproj`), specifically: + +- The `Canvas` subsystem's `Rgba32` and `Surface` units — representing a series/point/palette + color and the pixel buffer `ChartRenderer` paints onto and returns +- The `Drawing` subsystem — path filling/stroking and tile-paint primitives `ChartRenderer` uses + to paint bars/columns/lines/areas/wedges/axes/legend/title +- The `Geometry` subsystem — transforms and rectangles used throughout layout and painting +- The `Fonts` subsystem — TrueType text layout/metrics, used to measure and draw every text label +- The `Rendering` subsystem — the bundled Liberation Sans fallback font used when + `ChartRenderOptions.Font` is not supplied + +**`CanvasNetCharts` must never reference `DemaConsulting.CanvasNet.Pptx`, +`DemaConsulting.CanvasNet.Pdf`, `DemaConsulting.CanvasNet.Svg`, or a future +`DemaConsulting.CanvasNet.Vsdx`.** This is a hard architectural constraint, not a Phase 1-specific +limitation: `CanvasNetCharts` is designed to be a reusable chart library any consumer of the core +`CanvasNet` system can use directly, independent of any specific document format. `CanvasNetPptx` +depends on `CanvasNetCharts` as of its own Phase 4 (the opposite direction), the same way it +already depends on the core `CanvasNet` system — never the other way around; see +`docs/design/canvas-net-pptx.md`'s own Dependencies section for that integration's details. + +This is an ordinary, same-repository, system-to-system dependency: both `CanvasNet` and +`CanvasNetCharts` are produced by this repository, so it is neither an OTS Software Item (not a +third-party/external-program dependency) nor a Shared Package (that category is scoped to a +package produced by a _different_ repository within the same program). `CanvasNetCharts` +introduces no new OTS Software Item beyond those already used to build and verify the `CanvasNet` +system (BuildMark, FileAssert, Pandoc, ReqStream, ReviewMark, SarifMark, SonarMark, VersionMark, +WeasyPrint, xUnit) — see _OTS Integration Design_ (`docs/design/ots.md`). + +## Risk Control Measures + +Construction-time validation remains the primary risk control measure: every `ChartModel` +subsystem type validates its complete set of constructor arguments eagerly and fails closed (via +`ArgumentException`/`ArgumentNullException`/`ArgumentOutOfRangeException`) rather than silently +coercing, clamping, or accepting invalid data — see _ChartModel Subsystem Design_ +(`canvas-net-charts/chart-model.md`) for the complete set of validation rules; `OpenXmlChartParser` +ultimately calls these same constructors, so it inherits this same risk control rather than +duplicating it. + +Phase 3 introduces this system's first untrusted-input parsing surface: `OpenXmlChartParser` +reads a `c:chartSpace`/`c:chart` XML element that, in a real deployment, would originate from an +externally-authored document (for example, a PowerPoint file a `CanvasNetPptx` caller opened). +Its risk control measures are: reading only cached values (`c:numCache`/`c:strCache`), never +recomputing from a `c:f` formula, so no spreadsheet-formula-evaluation attack surface exists at +all; classifying the plot area's chart-type element against a fixed, closed lookup table before +any series parsing is attempted, rejecting an unrecognized or combo shape immediately via +`ChartUnsupportedFeatureException`; and delegating every data-shape invariant (non-empty series, +finite values, matching category/value counts) to the `ChartModel` subsystem's own constructors, +so a malformed cached value surfaces the identical, already-reviewed exception a directly +constructed `Chart` would throw, rather than a second, parser-specific validation path that could +drift out of sync. `OpenXmlChartParser` performs no XML entity expansion of its own (no `DTD` +processing is attempted; `System.Xml.Linq`'s own default `XDocument.Load`/`Parse` behavior does +not resolve external entities), so it carries no XXE risk either, consistent with +`CanvasNetSvg`'s own precedent for untrusted-XML parsing — see _CanvasNetSvg System Design_, +`canvas-net-svg.md`, Risk Control Measures. + +## Data Flow + +**`ChartBuilder` → validated `Chart` construction path (the only data flow Phase 1 implements):** + +1. **Input**: A sequence of fluent `ChartBuilder` method calls (`OfType`, `AddSeries`, + `WithCategoryAxis`, `WithValueAxis`, `WithLegend`, `WithTitle`, `WithColorPalette`), each either + storing already-valid state or delegating construction of a model type to that type's own + validating constructor +2. **Validation**: Each `ChartBuilder` method that constructs a model type propagates that type's + own constructor exceptions unchanged; `Build()` additionally throws + `InvalidOperationException` when no chart type was set via `OfType`, or when no series was + added via `AddSeries` +3. **Processing**: `Build()` calls `Chart`'s own constructor with the builder's accumulated state, + which performs the complete set of cross-member validation (for example, series/category-axis + length matching for category-based chart types) and defensively copies every collection +4. **Output**: A new, fully validated, immutable `Chart` instance + +A caller may equivalently construct a `Chart` (and its constituent `ChartSeries`/`ChartAxis`/ +`ChartLegend`/`ChartTitle` instances) directly, without `ChartBuilder`, with identical validation +behavior, since `ChartBuilder` enforces no rule the model constructors do not already enforce +themselves. + +**`Chart` → rendered `Surface` path (added in Phase 2):** + +1. **Input**: A validated `Chart` instance, caller-chosen pixel (or physical width/height/DPI) + dimensions, and an optional `ChartRenderOptions` +2. **Validation**: `ChartRenderer.Render` throws `ArgumentNullException` when `chart` is null, and + `ArgumentOutOfRangeException` when the requested (or DPI-computed) pixel dimensions are not + positive/finite or exceed `Surface.MaxDimension` +3. **Processing**: `ChartRenderer` computes a title/legend/plot-area layout (skipping a band that + would not fit the render target), resolves every series'/point's color (`Chart.ColorPalette`, + then `ChartRenderOptions.ColorPalette`, then `ChartColorPalette.Default`, wrapping by index), + and dispatches per `Chart.Type` to paint bars/columns/lines/areas or pie/doughnut wedges, plus + axes (for category-based types), a legend, a title, and any opt-in per-point data labels +4. **Output**: A new `Surface` containing the rendered chart, which the caller owns and disposes + +**OOXML `c:chartSpace`/`c:chart` XML → validated `Chart` path (added in Phase 3):** + +1. **Input**: A `System.Xml.Linq.XDocument` or `XElement` carrying a `c:chartSpace` (or bare + `c:chart`) element, typically loaded directly from a `chart#.xml` part's content by a future + caller such as `CanvasNetPptx` +2. **Validation**: `OpenXmlChartParser.Parse` throws `ArgumentNullException` for a null document/ + element, and `ArgumentException` for a document with no root, an element with no `c:chart` + child (and that is not itself one), or a `c:chart` with no `c:plotArea`; the plot area's + chart-type element is then classified against a fixed supported/unsupported lookup table, + throwing `ChartUnsupportedFeatureException` for zero, more than one, or a single + recognized-but-unimplemented match +3. **Processing**: Each `c:ser` is parsed into a `ChartSeries` (cached name from `c:tx`, cached + values from `c:val/c:numCache`, cached categories from `c:cat` routed to the shared category + axis or to per-series point labels depending on the classified `ChartType`); the chart title, + legend, and category/value axis titles/range are extracted from their own cached text/scaling + elements; every extracted value is finally handed to `Chart`'s own constructor, which performs + the identical cross-member validation a directly constructed `Chart` would +4. **Output**: A new, fully validated, immutable `Chart` instance, directly consumable by + `ChartRenderer.Render` + +## Design Constraints + +- **Simplicity**: Minimal functionality kept easy to understand and extend +- **Compliance**: All functionality must be traceable to requirements +- **Quality**: Zero warnings, full test coverage, complete documentation +- **Portability**: Compatible across supported .NET platforms +- **Dependency direction**: `CanvasNetCharts` must never reference `CanvasNetPptx`, + `CanvasNetPdf`, `CanvasNetSvg`, or a future `CanvasNetVsdx` (see Dependencies above) + +### Platform Support + +The library targets the following frameworks, identical to the `CanvasNet` system it depends on, +enabling compatibility across modern, currently supported .NET runtimes: + +| Target Framework | Runtime / Environment | +| ---------------- | --------------------- | +| `net8.0` | .NET 8 LTS | +| `net9.0` | .NET 9 | +| `net10.0` | .NET 10 | + +The library is supported on the following operating systems: + +- **Windows** — primary developer and CI platform +- **Linux** — CI/CD and containerized environments +- **macOS** — developer workstations using Apple platforms + +Portability is achieved by restricting the implementation exclusively to Base Class Library (BCL) +APIs and the `CanvasNet` system's own public API, available across all target frameworks. No +platform-specific native interop, OS-specific APIs, or framework-version-specific features are +used. + +### Integration Patterns + +- **NuGet Packaging**: Standard .NET library packaging and distribution, referencing the + `CanvasNet` package as an ordinary NuGet dependency +- **CI/CD Integration**: Automated build, test, and quality validation +- **Requirements Traceability**: All features linked to passing tests diff --git a/docs/design/canvas-net-charts/chart-model.md b/docs/design/canvas-net-charts/chart-model.md new file mode 100644 index 00000000..cfab766b --- /dev/null +++ b/docs/design/canvas-net-charts/chart-model.md @@ -0,0 +1,53 @@ +## ChartModel + +![ChartModel Structure](ChartModelView.svg) + +The `ChartModel` subsystem is the first (and, as of this release, only) software subsystem in +`CanvasNetCharts`. It groups two flat, hand-rolled units: `ChartDocument`, containing the public, +immutable, validating chart data model and the `ChartBuilder` fluent construction API; and +`ChartRenderer`, a pixel-rendering engine that paints a `Chart` onto a core +`CanvasNet.Canvas.Surface`. + +### Purpose + +The `ChartModel` subsystem provides both the in-memory representation of "what chart to draw" +(`ChartDocument`) and "how to draw it" (`ChartRenderer`) — the complete set of chart functionality +the `OpenXmlChart` subsystem's `chart1.xml` parser and the `CanvasNetPptx` integration both +consume. `ChartDocument` owns the complete set of data-shape validation rules — non-empty series, +finite numeric values, count-matched point colors/labels, consistent series/category-axis +lengths, and well-ordered axis ranges — so that both `ChartRenderer` and the OOXML parser can +trust every `Chart` instance they receive without re-validating it themselves. `ChartRenderer` +owns every pixel-rendering decision — layout, default styling, color-palette resolution, and +graceful degradation for extreme render-target sizes or legend overflow — so that the OOXML +parser and `CanvasNetPptx` integration need not duplicate any of that logic. + +### Units + +- **ChartDocument** — the `Chart`/`ChartSeries`/`ChartAxis`/`ChartLegend`/`ChartTitle`/ + `ChartType` model types and the `ChartBuilder` fluent construction API; see + _ChartDocument Unit Design_ (`chart-model/chart-document.md`) +- **ChartRenderer** — the `ChartRenderer`/`ChartRenderOptions`/`ChartColorPalette` pixel-rendering + engine; see _ChartRenderer Unit Design_ (`chart-model/chart-renderer.md`) + +### Dependencies + +The `ChartModel` subsystem depends on the core `CanvasNet` system's `Canvas` subsystem (its +`Rgba32` unit, for series/point/palette color, and, for `ChartRenderer` only, its `Surface` unit, +the pixel buffer it paints onto and returns), and, for `ChartRenderer` only, the `Drawing`, +`Geometry`, and `Fonts` subsystems (path filling/stroking, transforms/rectangles, and TrueType +text layout/metrics, respectively) and the `Rendering` subsystem (the bundled Liberation Sans +fallback font) — see _CanvasNetCharts System Design_ (`../canvas-net-charts.md`)'s Dependencies +section. Beyond this, `ChartDocument` uses only the .NET base class library +(`System.Collections.ObjectModel`'s `ReadOnlyCollection`, for defensive-copy immutable +snapshots), available on every one of `CanvasNetCharts`'s target frameworks with no new runtime +NuGet dependency. + +### Callers + +`ChartDocument` and `ChartRenderer` are each a public API entry point, invoked directly by +consumers of the `DemaConsulting.CanvasNet.Charts` package. `ChartRenderer` additionally calls +`ChartDocument`'s public types as its own input (a caller-supplied `Chart` instance and its +constituent `ChartSeries`/`ChartAxis`/`ChartLegend`/`ChartTitle`/`ChartType` members) — this is +the one intra-subsystem unit-to-unit call `ChartModel` contains as of this release. No unit +within the `ChartModel` subsystem is called by any other subsystem as of this release (there is +no other subsystem yet). diff --git a/docs/design/canvas-net-charts/chart-model/chart-document.md b/docs/design/canvas-net-charts/chart-model/chart-document.md new file mode 100644 index 00000000..1dcf7ac3 --- /dev/null +++ b/docs/design/canvas-net-charts/chart-model/chart-document.md @@ -0,0 +1,92 @@ +### ChartDocument + +The `ChartDocument` unit is the first software unit in the `ChartModel` subsystem. It comprises +six public types, all in the `DemaConsulting.CanvasNet.Charts` namespace, each a `sealed class` +with a single validating constructor and read-only auto-properties — not a C# `record` — plus one +public enum and one fluent builder: + +- `ChartType` (enum): `Bar`, `Column`, `Line`, `Pie`, `Doughnut`, `Area` +- `ChartAxis` (sealed class): an optional ordered category label collection, an optional value + range, and an optional title +- `ChartLegendPosition` (enum): `Top`, `Bottom`, `Left`, `Right`, `None` +- `ChartLegend` (sealed class): a placement position and a visibility flag +- `ChartTitle` (sealed class): title text and an optional font-size hint +- `ChartSeries` (sealed class): a name, at least one finite value, and optional per-point colors/ + labels and a series-level color +- `Chart` (sealed class): the root aggregate — a `ChartType`, a non-empty series collection, and + optional category axis, value axis, legend, title, and color palette +- `ChartBuilder` (sealed class): a fluent, mutable-until-`Build` API for constructing a `Chart` + +#### Purpose + +`ChartDocument` lets callers construct a fully validated, immutable description of a chart, +either directly via each type's own constructor or ergonomically via `ChartBuilder`'s fluent +API, with identical validation behavior either way. It is implemented entirely against the .NET +base class library (`System.Collections.ObjectModel.ReadOnlyCollection` for defensive copies) +and the core `CanvasNet` system's `Rgba32` color type, with no third-party dependency. + +Every type's constructor validates its complete argument set eagerly, rejecting invalid data with +a specific, documented exception rather than silently coercing or clamping it: + +- A `null` required reference-type argument → `ArgumentNullException` +- An empty or whitespace-only name/title → `ArgumentException` +- A non-finite (`NaN`/`Infinity`) numeric value, where a finite value is required → + `ArgumentOutOfRangeException` (for a single scalar argument, e.g. `ChartAxis.Minimum`) or + `ArgumentException` (for an entry within a collection argument, e.g. a non-finite + `ChartSeries.Values` entry) +- A collection argument that is empty where at least one entry is required (`ChartSeries.Values`, + `Chart.Series`), or that is empty where supplied-but-empty is itself invalid + (`Chart.ColorPalette`) → `ArgumentException` +- A collection argument containing a `null` entry where none is permitted (`Chart.Series`, + `ChartAxis.Labels`, `ChartSeries.PointLabels`) → `ArgumentException` +- A collection argument whose count does not exactly match a related collection's count + (`ChartSeries.PointColors`/`PointLabels` vs. `Values`; a category-based chart type's series + `Values` vs. `ChartAxis.Labels`) → `ArgumentException` +- An enum argument that is not a defined member of its enumeration (`ChartType`, + `ChartLegendPosition`) → `ArgumentOutOfRangeException` +- A value range where the minimum is not strictly less than the maximum, when both are supplied + (`ChartAxis.Minimum`/`Maximum`) → `ArgumentOutOfRangeException` + +Every caller-supplied collection argument (`Chart.Series`, `Chart.ColorPalette`, +`ChartSeries.Values`/`PointColors`/`PointLabels`, `ChartAxis.Labels`) is copied into an immutable +`ReadOnlyCollection` snapshot during construction, so mutating the caller's original +collection after construction has no effect on the constructed instance's exposed property — +see `Constructor_MutatingOriginal*List_DoesNotAffectStoredSnapshot` in +`test/DemaConsulting.CanvasNet.Charts.Tests/` for the test coverage proving this for every such +argument. + +`Chart`'s constructor additionally validates one cross-member invariant: for a category-based +`ChartType` (`Bar`, `Column`, `Line`, `Area`) whose supplied `CategoryAxis` has non-null `Labels`, +every series' `Values.Count` must exactly equal that label count. This check is skipped for a +proportional-share `ChartType` (`Pie`, `Doughnut`, whose single series' values represent wedge +shares rather than per-category values), and skipped when no category axis — or a category axis +with null `Labels` (a pure value axis) — is supplied. + +`ChartBuilder` performs no independent data-shape validation of its own: every chainable method +either stores already-valid caller state (`OfType`, `AddSeries(ChartSeries)`, +`WithCategoryAxis(ChartAxis)`, `WithValueAxis(ChartAxis)`, `WithLegend(ChartLegend)`, +`WithColorPalette`) or constructs a model type and lets that type's own constructor validate the +arguments (the convenience overloads of `AddSeries`, `WithCategoryAxis`, `WithValueAxis`, +`WithLegend`, and `WithTitle`). Its only independent behavior is `Build()` throwing +`InvalidOperationException` when `OfType` was never called, or when no series was added via +`AddSeries`, before delegating to `Chart`'s own constructor for every other invariant. + +#### Design + +- `ChartType.cs` — the `ChartType` enum +- `ChartAxis.cs` — the `ChartAxis` sealed class +- `ChartLegend.cs` — the `ChartLegendPosition` enum and the `ChartLegend` sealed class +- `ChartTitle.cs` — the `ChartTitle` sealed class +- `ChartSeries.cs` — the `ChartSeries` sealed class +- `Chart.cs` — the `Chart` sealed class (the root aggregate) +- `ChartBuilder.cs` — the `ChartBuilder` sealed class + +Each model type's constructor performs its validation inline (or via a small private static +helper, for a collection argument needing both validation and a defensive copy in the same pass, +e.g. `ChartAxis.CopyLabels`/`ChartSeries.CopyValues`/`ChartSeries.CopyPointLabels`/ +`Chart.CopySeries`), then assigns every property from already-validated local state. No type in +this unit exposes a public mutator; every property is a read-only auto-property (`{ get; }`) set +only from the constructor. + +The `ChartModel` subsystem depends on the core `CanvasNet` system's `Canvas` subsystem's `Rgba32` +unit — see _ChartModel Subsystem Design_ (`../chart-model.md`)'s Dependencies section. diff --git a/docs/design/canvas-net-charts/chart-model/chart-renderer.md b/docs/design/canvas-net-charts/chart-model/chart-renderer.md new file mode 100644 index 00000000..3d18474a --- /dev/null +++ b/docs/design/canvas-net-charts/chart-model/chart-renderer.md @@ -0,0 +1,103 @@ +### ChartRenderer + +The `ChartRenderer` unit is the second software unit in the `ChartModel` subsystem. It comprises +three public types, all in the `DemaConsulting.CanvasNet.Charts` namespace: + +- `ChartRenderer` (static class): the public rendering entry point, exposing two `Render` + overloads and dispatching per `ChartType` to the appropriate painter +- `ChartRenderOptions` (sealed class): an immutable options bag for `ChartRenderer.Render` — a + background color, an optional font override, per-element default font sizes, and an optional + color-palette override — with a `Default` singleton +- `ChartColorPalette` (static class): a fixed, documented 10-entry categorical color palette, + exposed as `ChartColorPalette.Default` + +plus a private, file-partitioned implementation spread across `ChartRenderer.Layout.cs`, +`ChartRenderer.CategoryChart.cs`, `ChartRenderer.PieChart.cs`, `ChartRenderer.Legend.cs`, and +`ChartRenderer.Title.cs` (all `partial` continuations of the one public `ChartRenderer` class). + +#### Purpose + +`ChartRenderer` paints a validated `ChartDocument` unit's `Chart` onto a new core `CanvasNet` +`Canvas.Surface`, producing a deterministic, legible rendering with sensible defaults for every +optional `Chart`/`ChartRenderOptions` setting a caller omits. It is implemented entirely against +the core `CanvasNet` system's own public rendering primitives (`Surface`, `PathBuilder`, +`TrueTypeFont`/text layout, `Rgba32`, `Matrix3x2`/`RectangleF`), with no third-party dependency +and no reference to any other `DemaConsulting.CanvasNet.*` document-format package. + +`ChartRenderer.Render(Chart, int, int, ChartRenderOptions?)` is the primary entry point: it +constructs a `Surface` of the requested pixel dimensions, fills it with +`ChartRenderOptions.BackgroundColor`, computes a title/legend/plot-area layout, then dispatches +per `Chart.Type`: + +- `Bar`/`Column`/`Line`/`Area` → the shared category-chart painter (`ChartRenderer. + CategoryChart.cs`), which also draws category/value axes and opt-in data labels +- `Pie`/`Doughnut` → the wedge painter (`ChartRenderer.PieChart.cs`), via `PathBuilder.ArcTo` + +`ChartRenderer.Render(Chart, float, float, float, ChartRenderOptions?)` is a convenience overload +that computes pixel dimensions as `round(widthInches * dpi)`/`round(heightInches * dpi)` and +delegates to the primary overload. Unlike `PdfDocument.Render(int, float, PdfRenderOptions?)`, +which scales a page's own intrinsic physical size, a `Chart` carries no intrinsic physical +size — this overload exists purely so a caller who prefers to reason in physical units/DPI (for +example, to match a target print or slide size) need not compute the pixel dimensions by hand; +its behavior is otherwise identical to the primary overload once the pixel dimensions are known. + +Both overloads validate eagerly, before constructing anything: + +- `chart` is `null` → `ArgumentNullException` +- The requested (or DPI-computed) pixel width/height is not positive, or exceeds + `Surface.MaxDimension` → `ArgumentOutOfRangeException` +- The DPI overload's `widthInches`/`heightInches`/`dpi` is not finite or not positive → + `ArgumentOutOfRangeException` + +Every other rendering decision degrades gracefully rather than throwing: + +- A render target too small to reserve a title/legend/axis band simply skips that band (see + `ChartRenderer.Layout.cs`), rather than throwing or painting a corrupted layout +- A legend with more entries than fit its reserved band omits the entries that do not fit, + rather than overlapping the plot area or throwing +- A category/legend label too long to fit its available width is truncated, rather than + overflowing its region or throwing +- A single-value `Pie`/`Doughnut` series, or a series whose total is zero, paints a well-defined + degenerate full-circle (or omits wedges) rather than dividing by zero + +#### Design + +- `ChartRenderer.cs` — the public `Render` overloads, argument validation, background fill, and + per-`ChartType` dispatch +- `ChartRenderer.Layout.cs` — internal layout computation: reserves a title band (when + `Chart.Title` is set and the render target is tall enough), a legend band (when + `Chart.Legend`/`ChartRenderOptions` requests a visible legend and the render target has room, + sized per `ChartLegendPosition`), and the remaining plot-area rectangle +- `ChartRenderer.CategoryChart.cs` — the shared `Bar`/`Column`/`Line`/`Area` painter: value-range + resolution (from `ChartAxis.Minimum`/`Maximum`, or the data's own min/max with zero included), + axis gridline/tick-label painting, per-series/per-point bar/segment painting, and opt-in + data-label painting +- `ChartRenderer.PieChart.cs` — the `Pie`/`Doughnut` wedge painter, converting each value's + proportional share into a start/sweep angle pair and drawing each wedge via + `PathBuilder.ArcTo`, with a documented degenerate-case rule (a single non-zero value, or a + zero-total series, paints the single remaining non-zero wedge as a full circle instead of + computing an empty 0° sweep) +- `ChartRenderer.Legend.cs` — the legend painter: a color swatch and name label per series/point + (series-level for category charts, point-level for `Pie`/`Doughnut`), laid out per + `ChartLegendPosition`, truncating or omitting entries that do not fit +- `ChartRenderer.Title.cs` — the title painter: centers `Chart.Title.Text` in the reserved title + band at `Chart.Title.FontSize` or `ChartRenderOptions.TitleFontSize` + +Every file's painting logic resolves series/point color via a single shared precedence: an +explicit `ChartSeries.Color`/`PointColors` entry, else `Chart.ColorPalette`, else +`ChartRenderOptions.ColorPalette`, else `ChartColorPalette.Default` — each resolved by index with +modulo wraparound, so a chart with more series/points than palette entries still renders every +one with a distinct (repeating) color rather than throwing or leaving a gap. Text is measured and +drawn via `ChartRenderOptions.Font`, falling back to the bundled Liberation Sans Regular font +(the same fallback font the core `CanvasNet` `Rendering` subsystem already bundles) when omitted. + +`ChartColorPalette.Default` is a fixed 10-entry array of `Rgba32` values (a standard qualitative +categorical palette), documented inline with its exact RGB values and the fallback-by-index +(modulo) resolution rule described above. `ChartRenderOptions` is a `sealed class` with +`init`-only properties and a `public static readonly ChartRenderOptions Default` singleton, +mirroring `PptxRenderOptions`'s established shape so a caller already familiar with +`CanvasNetPptx`'s rendering options recognizes the same pattern immediately. + +The `ChartModel` subsystem's dependencies gained by this unit (`Canvas.Surface`, `Drawing`, +`Geometry`, `Fonts`, and `Rendering`) are documented in _ChartModel Subsystem Design_ +(`../chart-model.md`)'s Dependencies section. diff --git a/docs/design/canvas-net-charts/open-xml-chart.md b/docs/design/canvas-net-charts/open-xml-chart.md new file mode 100644 index 00000000..d0d737b0 --- /dev/null +++ b/docs/design/canvas-net-charts/open-xml-chart.md @@ -0,0 +1,50 @@ +## OpenXmlChart + +![OpenXmlChart Structure](OpenXmlChartView.svg) + +The `OpenXmlChart` subsystem is the second software subsystem in `CanvasNetCharts`, introduced in +Phase 3. It groups one unit, `OpenXmlChartParser`, located in its own `src/ +DemaConsulting.CanvasNet.Charts/OpenXml/` sub-folder (distinct from `ChartModel`'s flat +`src/DemaConsulting.CanvasNet.Charts/` folder). + +### Purpose + +The `OpenXmlChart` subsystem provides a single, format-agnostic capability: turning a raw ECMA-376 +DrawingML-Charts `c:chartSpace`/`c:chart` `System.Xml.Linq` element into the validated `Chart` +the `ChartModel` subsystem's `ChartRenderer` already knows how to paint. It is deliberately +format-agnostic — it has no knowledge of OPC/ZIP packaging, part relationships, or any specific +host document format (PresentationML/`.pptx`, SpreadsheetML/`.xlsx`, or a future `.vsdx`) — the +`CanvasNetPptx` integration is responsible for locating and opening the relevant `chart#.xml` +part and handing this subsystem only the resulting XML content. This +boundary is what keeps `OpenXmlChartParser` reusable by any future document-format library that +embeds an OOXML chart part, not coupled to one specific host format. `OpenXmlChartParser` reads +only cached values (`c:numCache`/`c:strCache`) and never recomputes from a `c:f` formula, mirroring +`CanvasNetPptx`'s own documented `` "paint the cached value, never recompute" convention. + +### Units + +- **OpenXmlChartParser** — the `OpenXmlChartParser` static class and its + `ChartUnsupportedFeatureException`; see + _OpenXmlChartParser Unit Design_ (`open-xml-chart/open-xml-chart-parser.md`) + +### Dependencies + +The `OpenXmlChart` subsystem depends on the `ChartModel` subsystem's `ChartDocument` unit (its +`Chart`/`ChartSeries`/`ChartAxis`/`ChartLegend`/`ChartTitle`/`ChartType` model types, which +`OpenXmlChartParser` constructs directly), and, beyond that, only the .NET base class library's +`System.Xml.Linq` (`XDocument`/`XElement`/`XNamespace`, for XML parsing) and `System.Globalization` +(`CultureInfo.InvariantCulture`, for formatting a numeric category label deterministically) — see +_CanvasNetCharts System Design_ (`../canvas-net-charts.md`)'s Dependencies section. It introduces +no new runtime NuGet dependency, and - per that same System Design document's hard architectural +constraint - must never reference `CanvasNetPptx`, `CanvasNetPdf`, `CanvasNetSvg`, or a future +`CanvasNetVsdx`. + +### Callers + +`OpenXmlChartParser` is a public API entry point, invoked directly by consumers of the +`DemaConsulting.CanvasNet.Charts` package, and is also invoked by `CanvasNetPptx`'s own +chart-rendering integration (`PptxDocument.Charts.cs`), which locates and opens the relevant +`chart#.xml` OPC part before handing its content to `OpenXmlChartParser`. No unit within the +`OpenXmlChart` subsystem is called by any other subsystem of `CanvasNetCharts` itself as of this +release; `OpenXmlChartParser` itself calls `ChartModel`'s `ChartDocument` unit, the one +intra-system subsystem-to-subsystem call `OpenXmlChart` contains. diff --git a/docs/design/canvas-net-charts/open-xml-chart/open-xml-chart-parser.md b/docs/design/canvas-net-charts/open-xml-chart/open-xml-chart-parser.md new file mode 100644 index 00000000..fa9ba285 --- /dev/null +++ b/docs/design/canvas-net-charts/open-xml-chart/open-xml-chart-parser.md @@ -0,0 +1,98 @@ +### OpenXmlChartParser + +The `OpenXmlChartParser` unit is the first (and, as of this release, only) software unit in the +`OpenXmlChart` subsystem. It comprises two public types, both in the +`DemaConsulting.CanvasNet.Charts.OpenXml` namespace: + +- `OpenXmlChartParser` (static class): `Parse(XDocument)` and `Parse(XElement)` - the unit's one + entry point +- `ChartUnsupportedFeatureException` (sealed class, derives `IOException`): carries a short, + stable `Feature` token, mirroring `CanvasNetPptx`'s own `PptxUnsupportedFeatureException` shape + (five parallel constructors, an `IOException` base) without referencing that type + +#### Purpose + +`OpenXmlChartParser` lets a caller turn a raw `c:chartSpace` (or bare `c:chart`) XML element into +a validated `Chart`, reading only cached values (`c:numCache`/`c:strCache`) and never +recomputing from the sibling `c:f` formula a spreadsheet application would use. It supports the +five chart kinds `ChartType` models: + +| OOXML element | Classified `ChartType` | +| ------------- | ----------------------- | +| `c:barChart` with `c:barDir val="bar"` | `Bar` | +| `c:barChart` with `c:barDir val="col"` or no `c:barDir` | `Column` | +| `c:lineChart` | `Line` | +| `c:pieChart` | `Pie` | +| `c:doughnutChart` | `Doughnut` | +| `c:areaChart` | `Area` | + +and throws `ChartUnsupportedFeatureException` - with a dedicated, documented `Feature` token - for +every other schema-legal plot-area shape: + +| Plot-area shape | `Feature` token | +| ---------------- | --------------- | +| No recognized chart-type element | `charts-openxml-no-chart-type` | +| More than one recognized chart-type element (a combo chart) | `charts-openxml-combo-chart` | +| `c:radarChart` | `charts-openxml-radar-chart` | +| `c:scatterChart` | `charts-openxml-scatter-chart` | +| `c:bubbleChart` | `charts-openxml-bubble-chart` | +| `c:stockChart` | `charts-openxml-stock-chart` | +| `c:surfaceChart` / `c:surface3DChart` | `charts-openxml-surface-chart` | +| `c:bar3DChart` / `c:line3DChart` / `c:pie3DChart` / `c:area3DChart` | `charts-openxml-3d-chart` | +| `c:ofPieChart` | `charts-openxml-of-pie-chart` | +| A series' `c:val` with no cached `c:numCache` | `charts-openxml-uncached-values` | + +Beyond chart-kind classification and the cached-values-only check above, `OpenXmlChartParser` +performs no independent data-shape validation: every other invariant (non-empty series, finite +values, matching category/value counts, a well-ordered value-axis range, and so on) is enforced +exactly once, by `ChartDocument`'s own validating constructors, which this parser ultimately +calls. A malformed cached value (for example, a series/category count mismatch) therefore +surfaces the identical `ArgumentException` a directly constructed `Chart`/`ChartSeries`/ +`ChartAxis` would throw, not a parser-specific exception. + +#### Extraction Rules + +- **Series name** (`c:tx`): a cached `c:strRef`/`c:strCache` entry, or a literal `c:tx/c:v`, + falling back to a 1-based `"Series N"` default when `c:tx` is absent or carries no usable text + (never an error - unlike a missing value cache, a missing name has a safe, well-defined + fallback). +- **Values** (`c:val`): the cached `c:val/c:numRef/c:numCache`, sized by `c:ptCount`, with a + sparse `c:pt` gap defaulting to `0.0`. Required - its absence throws + `ChartUnsupportedFeatureException` with `Feature` `"charts-openxml-uncached-values"`. +- **Categories** (`c:cat`): a cached `c:strRef`/`c:strCache`, or a cached `c:numRef`/`c:numCache` + (converted to invariant-culture-formatted strings). For a category-based `ChartType` (`Bar`, + `Column`, `Line`, `Area`), only the first series' categories become the shared + `Chart.CategoryAxis.Labels` (every series of one chart-type element shares identical categories + in valid OOXML); for `Pie`/`Doughnut` (which have no per-category concept in the `ChartDocument` + model), each series' own categories instead become that series' own `ChartSeries.PointLabels`. +- **Title** (`c:title`, and identically for `c:catAx`/`c:valAx`'s own `c:title`): a cached + `c:tx/c:strRef/c:strCache`, a rich-text `c:tx/c:rich` body (every descendant `a:t` run + concatenated), or a literal `c:tx/c:v`. An absent `c:title`, a `c:title` with no `c:tx` child + (a styled-but-textless PowerPoint auto-title placeholder - a real shape, confirmed directly + against a genuine `chart1.xml` part), or empty/whitespace-only resolved text, all map to `null`. +- **Legend** (`c:legend`/`c:legendPos`): an absent `c:legend` maps to `null`; a present one maps + `t`/`b`/`l`/`r` to the identically named `ChartLegendPosition`, and approximates `tr` + (top-right - not representable by `ChartLegendPosition`'s five-value enumeration) and any + missing/unrecognized value to `Right` (ECMA-376's own documented default). +- **Value axis** (`c:valAx`): `c:scaling/c:min`/`c:max` map to `Minimum`/`Maximum`, `c:majorUnit` + maps to `TickInterval`, and `c:valAx`'s own `c:title` maps to `Title`; an absent `c:valAx`, or + one carrying none of those four values, maps to a `null` `Chart.ValueAxis`. + +#### Dependencies + +`OpenXmlChartParser` depends on the `ChartModel` subsystem's `ChartDocument` unit (the model +types it constructs), and, beyond that, only `System.Xml.Linq` (`XDocument`/`XElement`/ +`XNamespace`, for XML traversal) and `System.Globalization.CultureInfo.InvariantCulture` (for +deterministic numeric-category formatting) - both part of the .NET base class library, available +on every one of `CanvasNetCharts`'s target frameworks with no new runtime NuGet dependency. It +has no knowledge of, and no dependency on, OPC/ZIP packaging or any host document-format package +(`CanvasNetPptx`, `CanvasNetPdf`, `CanvasNetSvg`, or a future `CanvasNetVsdx`). + +#### Callers + +`OpenXmlChartParser` is a public API entry point, invoked directly by consumers of the +`DemaConsulting.CanvasNet.Charts` package, and is invoked by `CanvasNetPptx`'s own +chart-rendering integration (`PptxDocument.Charts.cs`), which locates and opens the relevant +`chart#.xml` OPC part and hands this unit only the resulting XML content. It calls +`ChartModel`'s `ChartDocument` unit (`Chart`, `ChartSeries`, `ChartAxis`, `ChartLegend`, +`ChartTitle` constructors) as its own final step. diff --git a/docs/design/canvas-net-pdf/pdf-document.md b/docs/design/canvas-net-pdf/pdf-document.md index d671e1dc..016aad13 100644 --- a/docs/design/canvas-net-pdf/pdf-document.md +++ b/docs/design/canvas-net-pdf/pdf-document.md @@ -535,6 +535,25 @@ its own distinguishable `Feature` token. rule. Every path-painting operator, including `n`, always clears the current path afterward (`_pathBuilder.Clear()`); the surrounding graphics state is entirely unaffected by that clear, so a subsequent path in the same content stream still sees the same CTM/stroke style/color. + - **Shared `StrokeOutliner` false-collapse investigation (regression fix note).** A separate + investigation into a PPTX rendering regression (a `` shape's thin stroke outline + rendering as a solid-filled interior instead of a thin ring) traced the root cause to the + shared core `Drawing.StrokeOutliner`'s inner-ring collapse detector, which used a fixed + absolute tolerance that false-positives when stroking a closed, curved contour tessellated + finely relative to its own large coordinate magnitude (see + `docs/design/canvas-net/drawing/path-stroker.md`'s "Inner-ring collapse" section). `PaintStroke` + above shares this exact `Drawing.PathStroker.Stroke`/`StrokeOutliner` code path, so the + investigation confirmed the same underlying defect is reproducible here too, with plain + large-magnitude device-space coordinates independent of PPTX/EMU. In practice, `PaintStroke` + strokes `path` _after_ it is already device-space-baked (unlike the PPTX caller that was + stroking in native, pre-transform EMU space) and typical PDF device-space coordinates stay at + a modest, page/pixel scale, so this defect was judged unlikely to manifest for ordinary PDF + documents - no `PdfDocument` source change was required. The shared `StrokeOutliner` tolerance + fix itself was still made (hardening every caller, including this one, against unusually large + pages/zoom levels), and a dedicated regression test, + `PdfDocumentTests.PdfDocument_PathOps_StrokeOnlyClosedBezierCircle_RendersThinRingNotSolidDisc`, + confirms a stroke-only, unfilled closed Bezier-curve circle continues to render as a ring + (not a solid disc) through this exact code path. - **Device color operators (`PdfDocument.Color.cs`, added in Phase 3; color-space model replaced in Phase 14)** — a private, immutable `PdfColorSpace` class (nested `Family` enum: `DeviceGray`/`DeviceRGB`/`DeviceCMYK`/`Indexed`, with shared `DeviceGray`/`DeviceRGB`/ @@ -650,7 +669,16 @@ its own distinguishable `Feature` token. image.Height)`, since image sample row `0` is the _top_ of the unit square per the PDF specification's image-space convention, the opposite of user-space's y-up convention) — no bilinear interpolation, a documented Phase 3 simplification consistent with Phase 2's own - stroke-width simplification precedent. + stroke-width simplification precedent. Each sampled source pixel is alpha-blended "over" the + existing destination pixel via the shared internal `Canvas.Rgba32.CompositeOver(Rgba32, Rgba32)` + helper (standard Porter-Duff "over" alpha compositing — straight/unassociated alpha in and out, + `outA = fgA + bgA * (1 - fgA)`, each color channel `outC = (fgC * fgA + bgC * bgA * (1 - fgA)) / + outA` when `outA != 0` else `0`, round-half-away-from-zero, clamped to `[0, 255]`) rather than + overwritten outright — a documented fix (every decoded PDF image XObject is currently always + fully opaque per the `/SMask`/`/Mask` limitation noted above, so this matters only for a future + phase that decodes a non-opaque alpha channel, or for this same helper's shared reuse by + `DemaConsulting.CanvasNet.Pptx`'s own `PaintPicture`, which does sample genuinely non-opaque + source pixels today — see that package's own design documentation). - **CCITT Group 4 fax decoding (`PdfDocument.CcittFax.cs`, added in Phase 15)** — a from-scratch ITU-T T.6 decoder, implementing two-dimensional MMR coding only (`/K` must be negative; non-negative `/K`, i.e. Group 3, and `/EndOfLine true` are rejected up front by the top-level @@ -1047,13 +1075,24 @@ its own distinguishable `Feature` token. bundled Liberation Sans/Serif/Mono font (which is itself already process-lifetime-cached by `SystemFontCatalog`). This process-lifetime cache is deliberately broader-scoped than `_fontCache`'s per-`Render` call scope, since a system or bundled font file's bytes never - change between calls or between documents. + change between calls or between documents. **This ordinary (non-Symbol/non-ZapfDingbats) branch + now returns a 2-element candidate list** - `[primaryMatch, Fonts.SystemFontCatalog. + LoadBundledFallback(flavor)]`, where `primaryMatch` is whichever of the matched-system-font or + already-bundled-fallback result the paragraph above resolved - reusing the same composite + glyph-lookup mechanism the Symbol/ZapfDingbats path already established (see + `ResolvedSimpleFont.Resolve` immediately below) rather than introducing a second, parallel + mechanism: a codepoint the primary match itself does not cover now falls through to the bundled + Liberation fallback's own glyph for that codepoint (closing the "tofu box"/missing-glyph gap for + an otherwise-fully-resolved ordinary font whose own coverage is merely incomplete), instead of + painting `.notdef` outright. Primary-font-first ordering means a font with full coverage is + completely unaffected - the bundled fallback is only ever consulted on an actual per-codepoint + glyph-0 miss. `BuildResolvedSimpleFont`'s `ResolvedSimpleFont.Fonts` property (plural, renamed from the earlier single `Font` property) is an ordered, non-empty `IReadOnlyList` - - a single-element list for every pre-existing resolution path (embedded `/FontFile2`, a matched - system font, or the bundled Liberation fallback), and only ever multi-element for the Symbol - Noto-substitute union above. `ResolvedSimpleFont.Resolve(code)` (the sole + a single-element list only for the embedded `/FontFile2` resolution path now, and multi-element + for every fallback-substitution path (the ordinary 2-element primary/bundled-fallback list + above, and the Symbol Noto-substitute union below). `ResolvedSimpleFont.Resolve(code)` (the sole `IResolvedFont.Resolve` implementation this concerns) tries each font in `Fonts` in priority order, returning the first one whose `GetGlyphIndex` for the code's mapped codepoint is non-zero (its advance width is derived from that winning font), falling back to `Fonts[0]` and diff --git a/docs/design/canvas-net-pptx.md b/docs/design/canvas-net-pptx.md new file mode 100644 index 00000000..e9508038 --- /dev/null +++ b/docs/design/canvas-net-pptx.md @@ -0,0 +1,272 @@ +# System Design + +This document provides the system-level design for CanvasNetPptx. + +![CanvasNetPptx Structure](CanvasNetPptxView.svg) + + + +## Architecture + +CanvasNetPptx is a .NET library providing PowerPoint (`.pptx`) presentation-rendering support, +distributed as its own NuGet package (`DemaConsulting.CanvasNet.Pptx`, namespace +`DemaConsulting.CanvasNet.Pptx`), independent of, but depending on, the core `CanvasNet` system +(its own separate package, `DemaConsulting.CanvasNet`) — see the Dependencies section below. The +system consists of a single implemented unit: + +- **PptxDocument** (namespace `DemaConsulting.CanvasNet.Pptx`, folder + `src/DemaConsulting.CanvasNet.Pptx/`, flat — no further nesting): a hand-rolled OOXML (Office + Open XML) package parser and, in later phases, presentation-rendering codec. See _PptxDocument + Unit Design_ (`canvas-net-pptx/pptx-document.md`). + +`CanvasNetPptx` is modeled as its own top-level software system (rather than as a further unit of +the `CanvasNet` system's `Codecs` subsystem) because it is distributed as its own +independently-versioned NuGet package — per `software-items.md`'s rule that a software package +contains exactly one software system, a system whose entire content ships in a distinct package +must itself be modeled as a distinct system, not folded into the package it depends on. +`CanvasNetPptx` contains exactly one unit, so no Subsystem tier is interposed between the system +and `PptxDocument`: inserting one here would not separate anything (there is nothing else in the +system to separate it from), mirroring `CanvasNetPdf`'s own single-unit precedent (see +_CanvasNetPdf System Design_, `docs/design/canvas-net-pdf.md`). + +**Phased delivery — this is an in-progress, multi-phase feature.** A `.pptx` file is an OOXML +package: a ZIP archive whose parts (`[Content_Types].xml`, `_rels/.rels`, `ppt/presentation.xml`, +each slide/layout/master XML part, and so on) are discovered by following a chain of +relationships from the package root. **Phase 1a** implemented only this underlying package layer: +`PptxDocument.Open` opens a `.pptx` file as a read-only `ZipArchive`, resolves +`[Content_Types].xml`'s default extension-to-content-type mappings and part-specific overrides, +and resolves package-level (`_rels/.rels`) and per-part (`{dir}/_rels/{partName}.rels`) +relationships, including OPC's relative-target resolution rules (a `"../"` parent-directory +traversal segment, resolved relative to the referencing part's own directory, not the package +root). `Open` succeeds on any well-formed OOXML/ZIP package, even one that is not actually a +presentation, since nothing beyond the package layer was validated that phase. + +**Phase 1b** adds the presentation/theme/master/layout/slide model and the +placeholder property-inheritance resolver, building on the Phase 1a package layer: `Open` now +additionally locates `ppt/presentation.xml` (via the package's `/officeDocument` relationship), +parses its declared slide size and ordered slide list (exposed as the new public `SlideSize`/ +`SlideCount` properties and `GetSlideSizeInPixels` conversion method), and rejects a package that +is not a navigable presentation with at least one declared slide. Each slide master/layout/slide's +placeholder shapes, and a slide master's theme (color/font scheme, resolved into +`DemaConsulting.CanvasNet.Canvas.Rgba32` values), are resolved lazily on first access through new +`internal` members. A new `internal` resolver (`ResolvePlaceholderProperties`) implements the +verified ECMA-376 (ISO/IEC 29500) §19.3.1.36 placeholder matching algorithm, resolving a slide +placeholder's effective ``/`/` property fragments through its +layout and master. No shape geometry/paint rendering, non-placeholder (freeform) shape parsing, +font loading, or rendering surface was implemented that phase - deferred to later phases. + +**Phase 1c** adds DrawingML shape geometry and paint resolution, building on the +Phase 1b model: a shape's `` position/rotation/flip resolves to a transform; a +`` group's `chOff`/`chExt` child coordinate space composes correctly with its children's +own transforms; a shape's `` (24 supported preset names) or `` (parsed +`moveTo`/`lnTo`/`cubicBezTo`/`quadBezTo`/`close` path commands) resolves to a concrete path; and a +shape's ``/``/`` fill and `` stroke resolve to concrete +paint, including scheme-color lookup through the resolved theme, the `lumMod`/`lumOff`/`shade`/ +`tint`/`alpha` color-transform pipeline, and a stroked outline realized via the core +`PathStroker`. Pattern/picture fill, radial/path gradients, `` adjustment-value parsing, +and full group-shape rendering semantics beyond transform composition remain explicitly deferred +(see _PptxDocument Unit Design_'s "Geometry and Paint (Phase 1c)" section for the complete +supported/deferred boundary). **No non-placeholder (freeform) shape _enumeration_ from a slide's +full ``, font loading, or rendering surface is implemented yet** - all deferred to later +phases, which will build on this model to actually render a presentation's slides. + +**Phase 1d (this release)** adds DrawingML text body parsing, attribute-level run/paragraph +property inheritance (including a slide master's own `` title/body/other style +buckets), word-wrap/alignment/vertical-anchor/autofit text layout, and glyph-ink rendering onto a +core `Canvas.Surface`, building on the Phase 1b/1c models: a shape's `` resolves to an +ordered paragraph/run structure; each run's/paragraph's effective properties resolve through the +placeholder/layout/master/theme inheritance chain, attribute-by-attribute; the resolved text is +word-wrapped, aligned, vertically anchored, and (where a `normAutofit` element is present) +autofit within the owning shape's own bounding box; and the resulting glyphs are painted onto a +`Canvas.Surface` via the core `Fonts.TrueTypeFont`/`SystemFontCatalog` and `Drawing.PathFiller`, +mirroring `CanvasNetPdf`'s own glyph-painting pattern. Full text justification, +`` shape-resize autofit, kerning, text clipping on overflow, and a full per-slide +rendering entry point remain explicitly deferred (see _PptxDocument Unit Design_'s "Text Layout +and Rendering (Phase 1d)" section for the complete supported/deferred boundary; bullets/numbering +were deferred at that time but were later implemented by a Phase 2 Follow-Up). **No non- +placeholder (freeform) shape _enumeration_ from a slide's full ``, nor a full per-slide +public rendering entry point, is implemented yet** - deferred to a later phase. + +## External Interfaces + +The system exposes the following public API to external consumers, all on the sealed, +`IDisposable` `PptxDocument` class: + +- **PptxDocument.Open(Stream stream)** / **PptxDocument.Open(string path)**: Opens and parses a + `.pptx` package exactly once, from a stream or a file path, buffering the input fully in + memory. `Open(Stream)` never takes ownership of (or disposes) the caller's stream. `Open(string)` + opens, reads, and closes its own internal `FileStream` before returning. Throws + `ArgumentNullException` for a null `stream`/`path`, `ArgumentException` for an + empty/whitespace-only `path`, and `InvalidDataException` for an unreadable/corrupt ZIP, a + package missing `[Content_Types].xml`/`_rels/.rels`, or (as of Phase 1b) a package that is not + a navigable presentation with a valid slide size and at least one declared slide. +- **PptxDocument.SlideCount** (`int`, Phase 1b): The presentation's declared slide count. +- **PptxDocument.SlideSize** (`PptxSlideSize`, Phase 1b): The presentation's declared slide size, + in EMU. +- **PptxDocument.GetSlideSizeInPixels(float dpi)** (Phase 1b): Converts `SlideSize` to pixels at + the given resolution. Throws `ArgumentOutOfRangeException` for a non-positive/non-finite `dpi`. +- **PptxDocument.Dispose()**: Idempotent; releases the underlying `ZipArchive` and its backing + buffer. No other public member may be called afterward without throwing + `ObjectDisposedException` (enforced, as of Phase 1b, by `SlideCount`/`SlideSize`/ + `GetSlideSizeInPixels`). + + +| Interface | Direction | Format | Constraints | +| -------------------------------------- | ---------------- | ------------------------------------ | ------------------------------- | +| `PptxDocument.Open(...)` | Inbound/Outbound | Method call / `PptxDocument` return | Valid `.pptx`/OPC presentation stream or path | +| `PptxDocument.SlideCount` / `SlideSize` | Outbound | Property access | Document must not be disposed | +| `PptxDocument.GetSlideSizeInPixels(...)` | Inbound/Outbound | Method call / `(int, int)` return | Positive, finite `dpi`; document must not be disposed | +| `PptxDocument.Dispose()` | Inbound | Method call | Safe to call more than once | + + +The package layer's `ResolvePart`/`ResolveRelationship` methods, and every Phase 1b +presentation/theme/master/layout/slide/inheritance member, are `internal` (not part of the +public surface), used by later phases of `PptxDocument` itself to navigate the package and +resolve placeholder properties - see _PptxDocument Unit Design_ +(`canvas-net-pptx/pptx-document.md`) for their complete contract. + +## Dependencies + +`CanvasNetPptx` depends on the separate `CanvasNet` system (its own package, +`DemaConsulting.CanvasNet`, referenced via a project reference from +`src/DemaConsulting.CanvasNet.Pptx/DemaConsulting.CanvasNet.Pptx.csproj`), matching every other +package-bounded system in this repository's architecture convention of depending on the core +system for its shared types (`Canvas.Surface`, `Codecs.UnsupportedImageFeatureException`, and so +on, as later phases add presentation rendering). + +**As of Phase 1b, `CanvasNetPptx` uses the `CanvasNet` core system's `Canvas` subsystem** - +specifically `Canvas.Rgba32`, to represent a theme's resolved 12-slot color scheme (each +``/`` slot resolved to a concrete `Rgba32` value). **As of Phase 1c, +`CanvasNetPptx` additionally uses the `CanvasNet` core system's `Geometry` and `Drawing` +subsystems** - `Geometry.Path`/`PathBuilder` to build and compose resolved shape geometry, and +`Drawing.PathStroker`/`StrokeStyle` to realize a resolved stroke's outline, plus `Canvas.Gradient`/ +`LinearGradient` to represent a resolved gradient fill. **As of Phase 1d (this release), +`CanvasNetPptx` additionally uses the `CanvasNet` core system's `Fonts` subsystem** - +`Fonts.TrueTypeFont`/`SystemFontCatalog` to resolve a DrawingML typeface name hint to a concrete +font and extract its glyph outlines - and the core `Drawing.PathFiller`/`Canvas.Surface` types to +paint resolved glyph ink onto a rendering surface, mirroring `CanvasNetPdf`'s own +font-resolution/glyph-painting pattern. **As of Phase 4, `CanvasNetPptx` additionally depends on +the sibling `CanvasNetCharts` system** (its own separate package, +`DemaConsulting.CanvasNet.Charts`) - specifically `DemaConsulting.CanvasNet.Charts.OpenXml`'s +`OpenXmlChartParser`, to parse a ``'s referenced `chart#.xml` OPC part into a +`Chart`, and `DemaConsulting.CanvasNet.Charts`'s `ChartRenderer`, to render that `Chart` onto a +dedicated `Surface` before compositing it onto the slide via the existing `PaintPicture` +primitive - see `docs/design/canvas-net-pptx/pptx-document.md`'s "Chart Graphic Frames (Phase 4)" +design section for the full integration. This dependency is strictly one-directional: +`CanvasNetCharts` must never reference `CanvasNetPptx` (see `docs/design/canvas-net-charts.md`'s +own Dependencies section), so `CanvasNetPptx` depends on `CanvasNetCharts` exactly as it already +depends on the core `CanvasNet` system, never the reverse. The package layer itself still relies +solely on the .NET base class library's `System.IO.Compression.ZipArchive` (reading +the `.pptx` ZIP container) and `System.Xml.Linq.XDocument`/`XElement` (parsing +`[Content_Types].xml`, each `.rels` part, and, as of Phase 1b, every presentation/theme/master/ +layout/slide part). The `ProjectReference` to `CanvasNet` now has corresponding `dependency` +edges in this system's own SysML2 model (`docs/sysml2/model/canvas-net-pptx.sysml`): +`dependency usesCanvas from CanvasNetPptxSystem to Canvas;` (added at Phase 1b, the phase that +actually first uses a `Canvas` subsystem type) and, added at Phase 1c, +`dependency usesGeometry from CanvasNetPptxSystem to Geometry;`/ +`dependency usesDrawing from CanvasNetPptxSystem to Drawing;`, mirroring `CanvasNetPdf`'s and +`CanvasNetSvg`'s own precedent of adding a `dependency` edge only at the phase that actually +first uses the referenced subsystem (for example `CanvasNetPdf`'s `Fonts` dependency was added +only at its own Phase 4, not at Phase 1). Added this phase (Phase 1d), mirroring that same +precedent: `dependency usesFonts from CanvasNetPptxSystem to Fonts;`. No other `CanvasNet` +subsystem (`Codecs`) is used yet; a future phase that implements picture fill may add a further +`dependency` edge at that time. Added at Phase 4 (this release), mirroring that same per-phase +pattern but targeting a sibling _system_ rather than a `CanvasNet` subsystem: +`dependency usesCharts from CanvasNetPptxSystem to CanvasNetCharts;` - the SysML2 edge for the +new, one-directional `ProjectReference` from `DemaConsulting.CanvasNet.Pptx.csproj` to +`DemaConsulting.CanvasNet.Charts.csproj` described above. + +This is an ordinary, same-repository, system-to-system dependency: both `CanvasNet` and +`CanvasNetPptx` are produced by this repository, so it is neither an OTS Software Item (not a +third-party/external-program dependency) nor a Shared Package (that category is scoped to a +package produced by a _different_ repository within the same program). `CanvasNetPptx` +introduces no new OTS Software Item beyond those already used to build and verify the +`CanvasNet` system (BuildMark, FileAssert, Pandoc, ReqStream, ReviewMark, SarifMark, SonarMark, +VersionMark, WeasyPrint, xUnit) — see _OTS Integration Design_ (`docs/design/ots.md`). + +## Risk Control Measures + +`PptxDocument.Open` parses an externally-supplied, potentially untrusted `.pptx` package (a ZIP +archive whose structure, `[Content_Types].xml`, relationship parts, and - as of Phase 1b - +presentation/theme/master/layout/slide XML content may be malformed, incomplete, or +adversarially constructed). Risk control remains deliberately simple and entirely fail-closed, +with no tolerated-recovery path (unlike `CanvasNetPdf`'s linear-scan fallback): an +unreadable/corrupt ZIP, a missing `[Content_Types].xml` or `_rels/.rels`, an unresolvable +relationship ID, an external-target relationship, a relationship target that would traverse past +the package root, a package with no navigable presentation part, or a presentation with no valid +slide size/slide list are all immediately rejected with a thrown `InvalidDataException` rather +than silently producing an incomplete or incorrect package view. Phase 1b's per-slide/layout/ +master/theme validation is deliberately lazy (only performed when that specific part is first +accessed via `GetSlide`/`GetLayout`/`GetMaster`/`GetTheme`), matching Phase 1a's own eager +(package-level)/lazy (per-part) validation split - a documented scope boundary, not an oversight. +Phase 1c's geometry/paint resolvers extend this same fail-closed philosophy to shape content: a +malformed ``/``/color-definition element throws `InvalidDataException` +exactly like a malformed Phase 1a/1b part, and a recognized-but-unsupported DrawingML construct +(an unsupported preset geometry name, color-definition kind, gradient kind, or pattern/picture +fill) throws the new `PptxUnsupportedFeatureException` rather than being silently substituted +with incorrect geometry or paint - the same fail-closed-over-silent-approximation choice Phase +1a/1b already made for the package layer. + +## Data Flow + +**`.pptx` open/parse path:** + +1. **Input**: A `.pptx` package (stream or file path) +2. **Validation**: `Open` rejects a null `stream`/`path` with `ArgumentNullException` and an + empty/whitespace `path` with `ArgumentException` +3. **Processing**: Fully buffers the input into memory, opens it as a read-only `ZipArchive` + (rejecting an unreadable/corrupt archive with `InvalidDataException`), parses + `[Content_Types].xml` into its default extension and part-specific override content-type + mappings, eagerly parses the package-level `_rels/.rels` relationships part (rejecting a + missing or malformed part with `InvalidDataException`), then locates and parses + `ppt/presentation.xml` (via the package's `/officeDocument` relationship), reading its + declared slide size and resolving its ordered slide list (rejecting a non-navigable or + structurally invalid presentation with `InvalidDataException`) +4. **Output**: A new `PptxDocument` instance exposing `SlideCount`/`SlideSize`/ + `GetSlideSizeInPixels` directly, and whose internal `ResolvePart`/`ResolveRelationship`/ + `GetTheme`/`GetMaster`/`GetLayout`/`GetSlide`/`ResolvePlaceholderProperties` methods can now + navigate the package's content-type, relationship, and placeholder-inheritance graph — the + presentation-level state is parsed once at `Open()` time; each slide/layout/master/theme is + parsed lazily, on first access, and cached, reused across every subsequent call on that + instance + +## Design Constraints + +- **Simplicity**: Minimal functionality kept easy to understand and extend +- **Compliance**: All functionality must be traceable to requirements +- **Quality**: Zero warnings, full test coverage, complete documentation +- **Portability**: Compatible across supported .NET platforms +- **One-time parse, reused across calls**: `Open` performs the full package-layer parse exactly + once; later phases' presentation-specific members will all read from that already-resolved + state rather than re-parsing on every call +- **Decode-only**: `PptxDocument` provides no encode/`Save` direction, matching every other + decode-only codec in this repository + +### Platform Support + +The library targets the following frameworks, identical to the `CanvasNet` system it depends on, +enabling compatibility across modern, currently supported .NET runtimes: + +| Target Framework | Runtime / Environment | +| ---------------- | --------------------- | +| `net8.0` | .NET 8 LTS | +| `net9.0` | .NET 9 | +| `net10.0` | .NET 10 | + +The library is supported on the following operating systems: + +- **Windows** — primary developer and CI platform +- **Linux** — CI/CD and containerized environments +- **macOS** — developer workstations using Apple platforms + +Portability is achieved by restricting the implementation exclusively to Base Class Library (BCL) +APIs and the `CanvasNet` system's own public API, available across all target frameworks. No +platform-specific native interop, OS-specific APIs, or framework-version-specific features are +used. + +### Integration Patterns + +- **NuGet Packaging**: Standard .NET library packaging and distribution, referencing the + `CanvasNet` package as an ordinary NuGet dependency +- **CI/CD Integration**: Automated build, test, and quality validation +- **Requirements Traceability**: All features linked to passing tests diff --git a/docs/design/canvas-net-pptx/pptx-document.md b/docs/design/canvas-net-pptx/pptx-document.md new file mode 100644 index 00000000..3d849a45 --- /dev/null +++ b/docs/design/canvas-net-pptx/pptx-document.md @@ -0,0 +1,3126 @@ +## PptxDocument + +![CanvasNetPptx Structure](CanvasNetPptxView.svg) + + + + + + + + + + + + +`PptxDocument` is distributed as the separate `DemaConsulting.CanvasNet.Pptx` NuGet package +(namespace `DemaConsulting.CanvasNet.Pptx`), which references the core `DemaConsulting.CanvasNet` +package. + +The `PptxDocument` class is the sole software unit of the `CanvasNetPptx` system. As of Phase 1a, +its dependencies were limited to the .NET base class library's `System.IO.Compression.ZipArchive` +(reading the `.pptx` ZIP container) and `System.Xml.Linq` (parsing `[Content_Types].xml` and each +`.rels` relationships part) — no `CanvasNet` core-system type was used yet. As of Phase 1c, the +unit additionally depends on several core `DemaConsulting.CanvasNet` types - `Geometry.Path`/ +`PathBuilder`, `Drawing.PathStroker`/`StrokeStyle`, and `Canvas.Rgba32`/`Gradient`/ +`LinearGradient` - reusing the already-tested core geometry/drawing machinery rather than +reimplementing it in this package (see _CanvasNetPptx System Design_'s Dependencies section, +`../canvas-net-pptx.md`, and the _Geometry and Paint (Phase 1c)_ section below). As of Phase 1d +(this release), the unit additionally depends on core `Fonts.TrueTypeFont`/`SystemFontCatalog` +(font resolution and glyph-outline extraction) and `Drawing.PathFiller` (glyph-ink fill), reusing +the same font-resolution/glyph-painting pattern `DemaConsulting.CanvasNet.Pdf` already established +(see the _Text Layout and Rendering (Phase 1d)_ section below). As of Phase 1e (this release), +the unit additionally reuses the raster codec dispatch pattern established by +`DemaConsulting.CanvasNet.Svg`'s `SvgCodec.ResolveRasterDecoder` to decode a `` shape's +embedded image (see the _Images, Tables, and Shape Tree (Phase 1e)_ section below); no new core +`CanvasNet` type is introduced by Phase 1e beyond what Phase 1c/1d already established. + +### Package Layer (Phase 1a) + +A `.pptx` file is an OOXML (Office Open XML) package: a ZIP archive whose parts are discovered by +following a chain of relationships, starting from the package root. `PptxDocument.Package.cs` +implements this generic package layer, independent of any presentation-specific semantics: + +- **Opening the ZIP archive**: the private constructor buffers the entire input stream into a + byte array, wraps it in a non-writable `MemoryStream`, and opens that stream as a read-only + `ZipArchive`. Any failure to do so - an unreadable or corrupt (non-ZIP) stream - is wrapped in + `InvalidDataException` (`ZipArchive`'s own constructor already throws this type for a corrupt + archive; other failure shapes, such as `IOException`/`ArgumentException`/`NotSupportedException`/ + `EndOfStreamException`, are caught and re-thrown wrapped in the same type, so every + package-layer failure is uniformly `InvalidDataException`). +- **`[Content_Types].xml` resolution**: parsed once, at construction time, into two lookups - a + case-insensitive `Default` extension-to-content-type map (for example `"xml"` -> + `"application/xml"`) and an exact-match `Override` part-path-to-content-type map (for example + `"ppt/presentation.xml"` -> `"application/vnd.openxmlformats-officedocument.presentationml. + presentation.main+xml"`). `ResolvePart(string partPath)` consults the override map first, + falling back to the default map keyed by the part's file extension, and throws + `InvalidDataException` when the package does not contain the requested part, or when neither + map resolves a content type for it. +- **Relationship resolution**: the package-level relationships (`_rels/.rels`) are parsed eagerly + at construction time (so a malformed package-level relationships part fails fast at `Open()` + time), while every other part's own relationships (`{dir}/_rels/{partName}.rels`) are parsed + lazily, on first access, and cached. `ResolveRelationship(string sourcePartPath, string + relationshipId)` (the empty string for `sourcePartPath` denotes the package root) looks up the + given relationship ID within the source part's own relationships, then resolves its `Target` + attribute per OPC's relative-reference rules: a target beginning with `'/'` is resolved relative + to the package root; any other target is resolved relative to the source part's own directory, + with `"."`/`".."` segments in the combined path normalized away exactly like a familiar + filesystem path (so, for example, `ppt/slides/slide1.xml`'s relationship to + `"../slideLayouts/slideLayout1.xml"` resolves to `"ppt/slideLayouts/slideLayout1.xml"`, not + `"ppt/slides/slideLayouts/slideLayout1.xml"`). An unknown relationship ID, a relationship whose + `TargetMode` is `"External"` (not supported this phase), or a target that would traverse past + the package root (more `".."` segments than preceding directory segments) all throw + `InvalidDataException`. + +**Phase 1a scope boundary**: no presentation-specific part (`ppt/presentation.xml`, a slide, a +slide layout/master) is itself interpreted - `ResolvePart`/`ResolveRelationship` only resolve a +part's content type and a relationship's target path, both of which are generic OPC package +concepts. The `_entriesByPath`/`_defaultContentTypes`/`_overrideContentTypes`/ +`_relationshipCache` fields hold the entirety of this phase's parsed state. + +**Phase 1b additions**: `PackageRelationship` gained a `Type` field (the relationship's own +`Type` attribute, captured by `ParseRelationships` alongside the existing `Target`/`TargetMode` +fields). Four OOXML navigations used by Phase 1b (package root -> `ppt/presentation.xml`, slide +-> layout, layout -> master, master -> theme) have no explicit `r:id` anywhere in the referencing +part's own body XML - each is, by OOXML convention, simply "the referencing part's one +relationship of a given `Type`". `ResolveRelationshipByType(string sourcePartPath, string +relationshipTypeSuffix)` resolves such a relationship by an ordinal `EndsWith` match against each +non-external relationship's `Type` URI (for example `"/officeDocument"`, `"/slideLayout"`, +`"/slideMaster"`, `"/theme"`), throwing `InvalidDataException` when none match. +`LoadPartXmlRoot(string partPath)` loads and parses a part's XML root element (checking the part +exists first), reused by every Phase 1b parser (`InitializePresentation`/`GetTheme`/`GetMaster`/ +`GetLayout`/`GetSlide`) instead of each duplicating the same existence-check-then-parse logic. + +### Public API (Phase 1a) + +`PptxDocument.cs` implements the sealed, `IDisposable` public surface: + +- **`Open(Stream stream)`**: validates `stream` is non-null (`ArgumentNullException`), buffers it + fully into a byte array via `CopyTo` (so the caller's stream need not remain open or seekable + afterward, exactly like `PdfDocument.Open`'s own buffering pattern), and constructs a new + `PptxDocument` from the buffered bytes. +- **`Open(string path)`**: validates `path` is non-null (`ArgumentNullException`) and not + empty/whitespace-only (`ArgumentException`), then opens a `FileStream` and delegates to + `Open(Stream)`. +- **`Dispose()`**: idempotent (a `_disposed` flag guards a second call from re-disposing already- + released resources); disposes the underlying `ZipArchive` and its backing `MemoryStream`. + +### Presentation Parsing (Phase 1b) + +`PptxDocument.Presentation.cs` locates and parses `ppt/presentation.xml`, called once from the +constructor (`InitializePresentation`), immediately after the Phase 1a package layer has parsed +the package-level relationships: + +- **Locating the presentation part**: `ppt/presentation.xml` is not referenced by an explicit + `r:id` anywhere - it is the package's single `/officeDocument` relationship target, resolved + via `ResolveRelationshipByType(string.Empty, "/officeDocument")` (see the Package Layer + section's "Phase 1b additions" above). Its root element is validated to be a PresentationML + `` element, throwing `InvalidDataException` otherwise. +- **Slide size**: `` is parsed into a `PptxSlideSize` (an EMU-unit + width/height record struct), exposed as the public `SlideSize` property. A missing ``, + or a missing/non-numeric/non-positive `cx`/`cy` attribute, throws `InvalidDataException`. +- **Slide list**: ``'s `` children are resolved, in document + order, into a list of slide part paths (via the presentation part's own relationships), + exposed as the public `SlideCount` property (the resolved list's count). A missing + ``, an empty slide list, or any child with a missing or unresolvable `r:id` throws + `InvalidDataException`. +- **Pixel conversion**: `GetSlideSizeInPixels(float dpi)` converts `SlideSize`'s EMU dimensions to + pixels at the caller's chosen resolution (914400 EMU per inch), rounding each dimension to the + nearest pixel (`MidpointRounding.AwayFromZero`) - the EMU analogue of `PdfDocument.Render(int, + float)`'s own points-to-pixels conversion, provided ahead of any Phase 1b rendering surface so a + later phase reuses this exact, already-tested arithmetic. Rejects a non-positive or non-finite + `dpi` with `ArgumentOutOfRangeException`. + +**Eager/lazy validation split**: only the presentation part itself, its slide size, and its slide +list's relationship resolvability are validated eagerly, at `Open()` time. Each resolved slide +part's own existence, well-formedness, and layout/master/theme chain are validated lazily, on +first access via `GetSlide(int)` - mirroring Phase 1a's own eager (package-level relationships) / +lazy (per-part relationships) split. + +### Theme Parsing (Phase 1b) + +`PptxDocument.Theme.cs` resolves a theme part (`ppt/theme/themeN.xml`, reached via a slide +master's `/theme` relationship - see _Master/Layout/Slide Placeholder Models_ below) into a +`PptxTheme`, lazily and cached by part path (`GetTheme(string themePartPath)`): + +- **Color scheme**: ``'s 12 named slots (`dk1`/`lt1`/`dk2`/`lt2`/`accent1`-`accent6`/ + `hlink`/`folHlink`) are each resolved to a concrete `DemaConsulting.CanvasNet.Canvas.Rgba32` + value - an `` resolves directly via `Rgba32.Parse`; an + `` resolves to its cached `lastClr` RGB equivalent + (no actual OS system-color resolution is performed or possible offline). `Rgba32.Parse`'s own + `FormatException` on invalid hex is caught and re-thrown wrapped in `InvalidDataException`, so + every theme-parsing failure is uniformly `InvalidDataException` like every other structural + failure in this unit. +- **Font scheme**: ``'s major (heading) and minor (body) typeface collections are + each resolved into a `PptxFontCollection` naming the Latin (``), East + Asian (``), and complex-script (``) typeface - a + missing `typeface` attribute defaults to an empty string (tolerated), but a missing ``/ + ``/`` element itself throws `InvalidDataException`. + +This is `internal` state (`PptxTheme`/`PptxColorScheme`/`PptxFontScheme`/`PptxFontCollection` are +all `internal` records), carried alongside the placeholder property-inheritance chain as context +for a later rendering phase to resolve a scheme-color/font token - not itself part of the +placeholder matching chain (see _Placeholder Inheritance Resolution_ below). + +### Master/Layout/Slide Placeholder Models (Phase 1b) + +`PptxDocument.Masters.cs`/`PptxDocument.Layouts.cs`/`PptxDocument.Slides.cs` structurally parse a +slide master/layout/slide part, respectively, each lazily resolved and cached (`GetMaster(string +masterPartPath)`, `GetLayout(string layoutPartPath)`, `GetSlide(int slideIndex)`): + +- **`GetMaster`**: validates the part's root is `` with a `/` + descendant, resolves the master's own `/theme` relationship (via `ResolveRelationshipByType`, + see the Package Layer section's "Phase 1b additions" above), and parses its immediate + placeholder shapes into a `PptxMaster` (part path, theme part path, placeholder list). +- **`GetLayout`**: validates the part's root is ``, resolves the layout's own + `/slideMaster` relationship, and parses its immediate placeholder shapes into a `PptxLayout` + (part path, master part path, placeholder list). +- **`GetSlide(int slideIndex)`**: range-checks `slideIndex` against `SlideCount` + (`ArgumentOutOfRangeException`), validates the slide part's root is ``, resolves the + slide's own `/slideLayout` relationship, and parses its immediate placeholder shapes into a + `PptxSlide` (part path, layout part path, placeholder list). + +All three share `PptxPlaceholder.cs`'s `PptxPlaceholderParser.ParsePlaceholderShapes`: it walks a +``'s immediate `` children only (no group/recursive descent - Phase 1b does not +parse freeform or grouped shapes), filters to those with a `//` +descendant, and builds one `PptxPlaceholder` (type, idx, raw `` element) per match, applying +the OOXML schema defaults (`type="obj"`, `idx="0"`) when the corresponding attribute is omitted. +A non-placeholder `` (no `` descendant) is silently excluded, not an error. + +### Placeholder Inheritance Resolution (Phase 1b) + +`PptxDocument.Inheritance.cs`'s `ResolvePlaceholderProperties` implements the verified ECMA-376 +(ISO/IEC 29500) §19.3.1.36 placeholder matching algorithm - **two different, level-specific, +single-criterion matches**, not a uniform "type+idx exact match, then type-only fallback": + +1. **Slide -> layout**: matched by `idx` ALONE. `type` is not consulted at this hop. A miss (no + layout placeholder shares the slide placeholder's `idx`) is a genuine miss - it is not retried + by type. +2. **Layout -> master**: matched by `type` ALONE, through a fixed remapping table (`body`/`chart`/ + `bitmap`/`orgChart`/`mediaClip`/`obj`/`pic`/`subTitle`/`tbl`/`clipArt`/`dgm`/`media` all remap + to `"body"`; `ctrTitle` remaps to `"title"`; every other type maps to itself), using the + _matched layout placeholder's own type_ (not the slide placeholder's type). `idx` is not + consulted at this hop. This hop only runs when hop 1 found a layout match - a hop 1 miss + short-circuits the chain entirely (no fallback to matching the master directly against the + slide's own type). + +Once the matched layout/master placeholders (if any) are determined, each of exactly two named +property categories - `` and `/` - is independently resolved via a +first-non-null-in-chain walk: slide -> matched layout -> matched master, taking the first element +present at all; no deeper per-attribute merging is performed in Phase 1b. For `` itself +(fill/line resolution), an empty element still counts as present, stopping the fallback for that +category. `/` is the one named exception (see "Genuine bug 4" below): a +tier's own empty, self-closing `` (no ``..`` level-override +child) is walked past rather than "winning", mirroring the same empty-element-skipping precedent +``/geometry resolution already establishes (see "Genuine bug 2" below). The resolved +theme is carried through unchanged as context (`PptxPlaceholderProperties.Theme`) - it is not +itself part of the matching chain, since a theme supplies scheme-level tokens a property fragment +may reference, rather than containing placeholder-shaped XML to match against. + +### Public API (Phase 1b) + +`PptxDocument.cs` gains three new public members, each guarded by +`ObjectDisposedException.ThrowIf(_disposed, this)`: + +- **`SlideCount`** (`int`): the presentation's declared slide count (`_slidePartPaths.Count`). +- **`SlideSize`** (`PptxSlideSize`): the presentation's declared slide size, in EMU. +- **`GetSlideSizeInPixels(float dpi)`** (`(int Width, int Height)`): converts `SlideSize` to + pixels at the given resolution - see _Presentation Parsing (Phase 1b)_ above. + +All other Phase 1b members (`GetTheme`/`GetMaster`/`GetLayout`/`GetSlide`/ +`ResolvePlaceholderProperties`, and every new record type) are `internal`, exercised by the test +project via `InternalsVisibleTo`, matching Phase 1a's own `ResolvePart`/`ResolveRelationship` +precedent - no public shape/rendering surface is introduced this phase. + +### Geometry and Paint (Phase 1c) + +Phase 1c adds DrawingML shape geometry and paint resolution: resolving a shape's transform, +geometric path, fill, and stroke into the core `DemaConsulting.CanvasNet.Geometry`/ +`DemaConsulting.CanvasNet.Drawing`/`DemaConsulting.CanvasNet.Canvas` types a future rendering +phase paints directly, rather than reinventing path/stroke/gradient machinery in this package. +Every new member introduced this phase is `internal` - no public shape/rendering surface is +introduced yet (matching Phase 1b's own precedent); this is confirmed by inspection, not merely +asserted. + +#### Shape Frame Transform + +`PptxDocument.Geometry.cs`'s `ResolveShapeFrame(XElement xfrm)` resolves a shape's `` +element into a `PptxShapeFrame` (an `internal sealed record` pairing a `System.Numerics.Matrix3x2` +transform with the shape's own declared width/height in EMU): + +- **Position/size**: `` and `` are both + required; either missing throws `InvalidDataException`. +- **Rotation**: `rot` (an optional attribute, 60,000ths of a degree, defaulting to `0` when + omitted) rotates the shape about its own center - `(w/2, h/2)` in local coordinates - matching + DrawingML's own rotation semantics (never about the parent origin). A non-numeric `rot` throws + `InvalidDataException`. + OOXML's `rot` is a clockwise angle in screen (y-down) coordinates; `Matrix3x2.CreateRotation`'s + standard mathematical rotation matrix, applied in this package's y-down coordinate space, + already turns a positive angle clockwise on screen (the y-axis flip inherent in screen + coordinates reverses the usual y-up counter-clockwise sense) - so `rot`'s 60,000ths-of-a-degree + value converts directly to radians with no extra sign negation needed. +- **Flip**: `flipH`/`flipV` (optional boolean attributes, `"1"`/`"true"` truthy, defaulting to + `false`) mirror the shape about its own center's vertical/horizontal axis respectively, applied + _before_ rotation (matching DrawingML's own documented order of operations: flip, then + rotate, then translate). +- **Composition order**: the final transform is `flip * rotate * translate(off)`, composed so + that `Vector2.Transform(localPoint, frame.Transform)` (row-vector convention, matching + `Path.Transform(Matrix3x2)`'s own convention) maps a point in the shape's own `(0,0)`-`(w,h)` + local coordinate space directly into its parent's coordinate space in one call. + +#### Group Shape Child Transform Composition + +A group shape (``) has its own `` carrying _two_ coordinate boxes: `off`/`ext` +(the group's own placement, in its parent's coordinate space - resolved exactly like any other +shape's frame via `ResolveShapeFrame`) and `chOff`/`chExt` (the child coordinate space every +shape nested directly inside the group is authored in, which generally differs in both offset +and scale from the group's own `off`/`ext` box). + +`ResolveGroupChildTransform(XElement groupXfrm)` resolves only the `chOff`/`chExt`-to-`off`/`ext` +half of this composition - a `Matrix3x2` mapping a point in the group's _child_ coordinate space +into the group's _parent_ coordinate space (not merely its own local box - the group's own +resolved `ResolveShapeFrame` transform, including its own flip/rotation about its own center, is +folded in as the final step). `chOff` defaults to `(0,0)` and `chExt` defaults to the group's own +`ext` (`WidthEmu`/`HeightEmu`) when either is omitted - a common case for an unscaled, +un-translated child coordinate space. A zero-width/height `chExt` (which cannot be meaningfully +scaled from) falls back to an identity scale factor of `1` along that axis rather than dividing by +zero. + +A child shape nested in the group resolves its _own_ `ResolveShapeFrame(childXfrm).Transform` +exactly as it would as a top-level shape (its `` is still expressed in the group's child +coordinate space, not the group's own parent space), then composes +`childFrame.Transform * groupChildTransform` to obtain the child's full transform into the +group's parent space (row-vector composition order, child-local-first - matching every other +transform composition in this phase). For a shape nested in multiple levels of groups, this +composition chains: each level's `ResolveGroupChildTransform` result is itself post-multiplied by +its own parent group's child transform before being combined with a descendant's own +`ResolveShapeFrame` result. + +**Group-semantics deferral boundary**: this phase implements transform composition only. Full +group-shape semantics beyond that - inherited/overridden fill or line style cascading from a +group to its children, and group-level effects - remain deferred to a later phase (see +_Deferred to a Later Phase_ below). The ``'s own enumeration/recursion through a slide's +full shape tree (Phase 1b's placeholder parser still only walks a ``'s _immediate_ +`` children, not recursing into ``/parsing freeform shapes at all) was deferred to +Phase 1e at the time this phase was written, and is now implemented - see +_Images, Tables, and Shape Tree (Phase 1e)_'s _Shape Tree_ subsection below. + +#### Preset Geometry + +`PptxPresetGeometry.cs`'s `Build(string prst, float w, float h)` builds the core `Path` for a +named preset geometry (``), sized to exactly `(0,0)`-`(w,h)` local +coordinates (the caller then applies the shape's resolved `PptxShapeFrame.Transform` via +`Path.Transform(Matrix3x2)` to place it into parent coordinates). + +**Supported presets (28)**: `rect`, `roundRect`, `ellipse`, `triangle`, `rtTriangle`, `diamond`, +`parallelogram`, `trapezoid`, `hexagon`, `octagon`, `pentagon`, `chevron`, `homePlate`, `pie`, +`donut`, `plus`, `rightArrow`, `leftArrow`, `upArrow`, `downArrow`, `leftRightArrow`, +`upDownArrow`, `star4`, `star5`, `curvedRightArrow`, `curvedLeftArrow`, `curvedUpArrow`, +`curvedDownArrow`. This set was chosen to cover the small handful of presets +(`rect`/`roundRect`/`ellipse`/`triangle`) PowerPoint itself defaults new shapes to, plus a +practical cross-section of the arrow, star, and other basic-shape categories real-world +presentations most commonly use, per the approved plan's "aim for ~24 total" guidance. The four +curved-arrow presets were added in a later real-world-corpus hardening pass (see _Phase 2: Real- +World Corpus Hardening_'s "Genuine bug 3" entry below) once `curvedUpArrow` was found to crash +rendering of a real-world deck; the remaining three (`curvedDownArrow`/`curvedLeftArrow`/ +`curvedRightArrow`) were added alongside it because the existing `Mirror`/`RotateQuarter` +derivation pattern already used for `upArrow`/`downArrow`/`leftArrow` (each derived from +`rightArrow`/`leftRightArrow`) made deriving all four curved arrows from one canonical +`CurvedRightArrow` builder equally cheap, not because they were independently reported. + +**Adjustment-value scope**: OOXML preset shapes are parameterized by named "adjustment values" +(`/`) a document may override to reshape a preset (for +example a `roundRect`'s corner radius, or a `homePlate`'s notch depth). This phase does not parse +`` at all - every preset is built using a single, fixed, documented proportion +approximating that preset's own OOXML schema default adjustment value(s) (for example +`roundRect`'s corner radius is fixed at `min(w,h)/6`, matching the schema default of `16667` +(16.667%) rather than reading any `` override). This is a deliberate +simplification: a shape whose author customized its adjustment handles renders using the default +proportion instead, not pixel-exact to every possible customization, but still a recognizable +instance of the named preset shape. Parsing `` is deferred to a later phase. + +**Unsupported presets**: an unrecognized `prst` name throws `PptxUnsupportedFeatureException` +(feature token `"pptx-preset-geometry"`) rather than silently falling back to an incorrect shape +(for example rendering an unknown preset as a plain rectangle) - this fail-closed approach lets a +caller distinguish "this shape's geometry could not be resolved" from "this shape resolved to the +wrong geometry", which a silent fallback would make indistinguishable. + +`ResolveShapeGeometry(XElement spPr, float widthEmu, float heightEmu)` dispatches between +`` (delegating to `PptxPresetGeometry.Build`) and `` (delegating to +`ResolveCustomGeometry` below), throwing `InvalidDataException` when `` has neither +element, or when a present `` is missing its required `prst` attribute. + +#### Custom Geometry + +`ResolveCustomGeometry(XElement custGeom, float widthEmu, float heightEmu)` parses a shape's +`` element - every `` under its `` (not merely the first; multiple +`` elements each contribute their own independent subpath/subpaths to the result) - into +the core `Path` type, interpreting each path command: + +- **``/``**: `PathBuilder.MoveTo`/`.LineTo`. +- **``** (two control points + an endpoint): `PathBuilder.CubicBezierTo`. +- **``** (one control point + an endpoint): `PathBuilder.QuadraticBezierTo`. +- **``**: `PathBuilder.Close`. + +Each ``'s own `w`/`h` attributes declare that path's local coordinate space (defaulting to +the shape's own `widthEmu`/`heightEmu` - an identity scale - when either is omitted); every point +within that `` is scaled by `widthEmu / w` and `heightEmu / h` respectively before being +added to the builder, so a path authored in an arbitrary coordinate space still fills the shape's +declared extent exactly. A `` with no `` resolves to `Path.Empty` (not an +error - a degenerate but well-formed case). + +**Best-effort command handling**: an unrecognized path-command element (for example OOXML's own +distinct, center-parameterized `` command, which this phase does not convert to the core +`Path`'s SVG-endpoint-parameterized arc representation) is silently skipped rather than thrown - +a deliberate, documented difference from preset geometry's fail-closed philosophy, chosen because +a custom geometry's overall silhouette is still usably close even with one skipped arc segment, +whereas an entire unsupported preset shape has no usable partial rendering at all. + +#### Color Resolution and Transforms + +`PptxDocument.Paint.cs`'s `ResolveColor(XElement colorElement, PptxTheme theme)` resolves any +DrawingML color-definition element to a concrete `Rgba32`: + +- **``**: resolved directly via `Rgba32.Parse`. +- **``**: resolved via its `lastClr` attribute, identical to Phase 1b's own theme + `sysClr` handling. +- **``**: resolved by looking the named slot up in the _supplied_ + `PptxTheme`'s `PptxColorScheme` - reusing Phase 1b's already-verified theme resolver rather than + re-implementing its 12-slot lookup table a second time. The 12 ordinary slot names + (`dk1`/`lt1`/`dk2`/`lt2`/`accent1`-`accent6`/`hlink`/`folHlink`) map directly to their + like-named `PptxColorScheme` property; the four background/text aliases `bg1`/`tx1`/`bg2`/`tx2` + map to `lt1`/`dk1`/`lt2`/`dk2` respectively (OOXML's own documented aliasing of the "background" + and "text" roles onto the light/dark scheme slots); `phClr` (a placeholder-fill color, only + meaningful inside a shape-style-reference context this phase does not thread through) falls + back to `dk1` as a documented simplification. An unrecognized slot name throws + `InvalidDataException`. +- **Any other color-definition element** (for example ``, ``, + ``) throws `PptxUnsupportedFeatureException` (feature token + `"pptx-color-kind"`) rather than guessing at an approximate RGB conversion. + +**Color-transform pipeline**: a color-definition element's child transforms +(``/``/``/``/``, each a percentage expressed in +OOXML's thousandths-of-a-percent unit, where `100000` = 100%) are applied to the resolved base +color in a **fixed pipeline order**, deliberately independent of each transform element's actual +position in the source XML (real-world documents do not reliably order these consistently, so a +fixed, documented order is simpler and more predictable than attempting to honor document order): + +1. **`lumMod`/`lumOff`** (luminance modulation/offset), applied together first, in HSL space: + the base color is converted to HSL, `L' = clamp(L * lumMod + lumOff, 0, 1)` is computed (each + of `lumMod`/`lumOff` defaulting to `1.0`/`0.0` respectively when absent), and the result is + converted back to RGB. +2. **`shade`** (darken toward black), in RGB space: `c' = c * (val / 100000)` per channel. +3. **`tint`** (lighten toward white), in RGB space: `c' = c * (val / 100000) + 255 * (1 - val / + 100000)` per channel. +4. **`alpha`**, applied last: replaces the color's alpha channel outright with + `val / 100000 * 255` (not multiplied against any existing alpha - every base color this phase + resolves is fully opaque before this step). + +Each RGB channel conversion casts its computed `float` result directly to `byte` (truncating, not +rounding, toward zero) after `Math.Clamp`-ing to `[0, 255]` - for example a `50%` `alpha` applied +to a fully-opaque color resolves to alpha `127`, not `128` (`0.5 * 255 = 127.5`, truncated). + +#### Fill Resolution + +`ResolveFill(XElement? spPr, PptxTheme theme, float widthEmu, float heightEmu)` resolves a +shape's fill from its `` (or lack thereof) into one of three closed `PptxPaint` subtypes: + +- **`PptxNoFill`** (a singleton, `PptxNoFill.Instance`): resolved when `spPr` is `null`, when + `` has an explicit `` child, or when it has no recognized fill child element + at all (a documented simplification: no placeholder/theme format-scheme fill inheritance is + implemented this phase - an inherited fill a real presentation would show is resolved as + unfilled instead). +- **`PptxSolidFill(Rgba32 Color)`**: resolved from ``'s single color-definition + child, via `ResolveColor` above. +- **`PptxGradientFill(Gradient Gradient)`**: resolved from ``, via + `ResolveGradientFill` below. +- **`PptxImageFill(Surface Image, Matrix3x2 ImageToLocalTransform)`**: resolved from + `` when a blip-image resolver is supplied by the caller - see _Picture Fill + (``) as a Shape/Paragraph Fill_ below (a Phase 2 follow-up to this phase's own, + originally fail-closed, posture). +- **`PptxPatternFill(PptxPresetPattern Preset, Rgba32 Foreground, Rgba32 Background)`**: resolved + from `` for a covered preset-pattern name - see _Pattern Fill + (``) (Phase 2 Follow-Up)_ below (a later Phase 2 follow-up to this phase's own, + originally fail-closed, posture). An uncovered `prst` still throws + `PptxUnsupportedFeatureException` (feature token `"pptx-pattern-fill"`). + +#### Gradient Fill + +`ResolveGradientFill(XElement gradFill, PptxTheme theme, float widthEmu, float heightEmu)` +resolves ``'s `` gradient stops (each ``'s `pos` - OOXML's +own thousandths-of-a-percent unit - mapped to the core `GradientStop`'s `[0,1]` position, and its +single color-definition child resolved via `ResolveColor`) combined with its `` +linear-direction angle (60,000ths of a degree) into a core `LinearGradient`: + +- `angleRadians = (ang / 60000) * (pi / 180)`; `direction = (cos(angleRadians), sin(angleRadians))`. +- `extent = (|direction.X| * widthEmu + |direction.Y| * heightEmu) / 2` (the gradient's half-length + along its own direction, sized so the gradient exactly spans the shape's bounding box corner to + corner along that direction). +- `center = (widthEmu / 2, heightEmu / 2)`; `start = center - direction * extent`; + `end = center + direction * extent`. + +**Scope**: only linear (``) gradients are supported this phase - the most common gradient +fill in practice. A `` with no `` child (for example a path/radial gradient, +`` or similar) throws `PptxUnsupportedFeatureException` (feature token +`"pptx-gradient-path"`) rather than silently approximating it as a linear gradient, which would +visibly misrender the shape's fill. A `` missing its required `` throws +`InvalidDataException`. Radial/path gradients are deferred to a later phase, pending a core +`RadialGradient`/path-gradient paint type this package can reuse. + +#### Stroke/Line Style Resolution + +`ResolveLineStyle(XElement? lnElement, PptxTheme theme)` resolves a shape's `` stroke +element into a `PptxLineStyle` (an `internal sealed record` capturing width in EMU, a resolved +`PptxPaint`, and an optional dash array), or `null` (meaning "no stroke is drawn") when: + +- `lnElement` is `null` (no `` element at all - a documented simplification: this does + _not_ resolve a theme-inherited default line width/style, matching this phase's broader + decision not to implement placeholder/theme format-scheme property inheritance for shape + styling); +- ``'s `w` attribute (width, in EMU) is _explicitly present_ with a non-positive + (`<= 0`) value (covering both `w="0"` and any negative `w` - OOXML's own "explicit zero/negative + width means no stroke" idiom); or +- the resolved fill (typically ``, via `ResolveFill`) is `PptxNoFill` (an explicit + `` line, or a line with no recognized fill child). + +A `w` attribute that is genuinely _absent_ (as opposed to explicitly zero/negative) instead +resolves to a documented default stroke width of `9525` EMU (`0.75`pt), provided the `` +still resolves a recognized (non-`PptxNoFill`) fill. This matches a real-world, commonly seen +document pattern - an `` that declares only a color/fill (for example +``), relying on PowerPoint's own +default stroke weight for an un-set `w` - which previously rendered with no visible outline at +all, collapsing to the exact same "no stroke" outcome as an explicit `w="0"`. The `9525` EMU +(`0.75`pt) value is PowerPoint's own observed default new-shape outline weight; this is an +**application-level convention**, not a formally declared ECMA-376/ISO-29500 XSD schema default - +the `CT_LineProperties` schema type declares no `default="..."` for `w` - so the value is +cross-checked against community/library documentation (for example python-pptx's own documented +default) and this codebase's own existing `1pt = 12700` EMU conversion, rather than cited as a +schema default. + +This default-width fallback applies **only** to `ResolveLineStyle` itself (and therefore to every +call site that defers its width/fill decision to it directly - an ordinary shape's own `` +with a recognized fill, table-cell borders, and a `/` theme style-list entry). It +does **not** change `ResolveShapeLineStyle`'s case-3 "fill-less, width-less ``, no +style-color" behavior, nor `ResolveConnectorLineStyle`'s own per-attribute merge logic - both +deliberately, separately retain their existing "a present, width-less `` never gains a width +from style or default" policy, each independently documented and tested below. + +**Dash resolution**: `` resolves a dash array proportional to the line's +own `widthEmu`, for `dash`/`dashDot`/`dot`/`lgDash`/`lgDashDot`/`sysDash`/`sysDot`; any other +preset name (including the common `solid`, and any name this phase does not recognize) resolves +to `null` (a solid line) - a documented, deliberate cosmetic degradation (an unrecognized dash +preset renders as a solid line rather than throwing) distinct from preset-geometry's fail-closed +philosophy, chosen because a solid line where a dashed one was intended is still a usable, visible +stroke, unlike an entirely unresolved shape. + +**Realizing a stroke outline**: `ResolveStrokeOutline(Path shapePath, PptxLineStyle lineStyle, +Matrix3x2 localToSurface)` mirrors the PDF renderer's own stroke-operator call pattern exactly: it +constructs a core `StrokeStyle(lineStyle.WidthEmu, dashArray: lineStyle.DashArray)` and calls the +core `PathStroker.Stroke(shapePath, style, flattenTolerance)` static entry point, producing the +stroked outline path a later rendering phase fills (via `PathFiller.Fill(..., +FillRule.NonZero)`) with `lineStyle.Paint`'s resolved color - reusing the exact same, already-tested +stroking machinery the PDF renderer uses, rather than a second, divergent implementation for this +package. This phase has no rendering surface yet, so only the outline geometry is produced here. + +- **Scale-aware flatten tolerance (regression fix).** Unlike `PdfDocument.PaintStroke`, which + strokes an already device-space-baked path, `ResolveStrokeOutline` strokes `shapePath` in the + shape's own native, pre-transform local coordinate space (PPTX's own EMU units - typically + hundreds of thousands to millions per shape), with the caller applying `localToSurface` only + afterward. Calling `PathStroker.Stroke` with its library default `flattenTolerance: 0.25f` + against that native EMU-space geometry produced a confirmed regression: a curved shape (for + example an `ellipse` or `roundRect` preset geometry) flattened to roughly 200x more tessellation + points than its eventual rendered pixel scale ever needed (around 8,000 points for a typical + slide-scale ellipse, versus the ~40 actually needed), which in turn triggered a false-positive + inner-ring collapse in the shared `StrokeOutliner` (see + `docs/design/canvas-net/drawing/path-stroker.md`'s "Inner-ring collapse" section) - rendering a + `` shape's thin `` outline as a solid-filled interior instead of a thin ring. + `ResolveStrokeOutline` now derives its own `flattenTolerance` from `localToSurface`'s own scale + (the larger of its two row-vector lengths, a conservative choice ensuring the more-magnified + axis still stays within the intended deviation), via a private `ResolveFlattenTolerance` helper + that returns `0.25f / scale` - keeping the _effective_, post-transform flattening deviation at + the library's intended ~0.25 device-pixel target regardless of the shape's own native EMU + magnitude, falling back to the unscaled `0.25f` default when the transform's scale is + non-finite or non-positive (for example, a degenerate zero-size shape). This both resolves the + false-collapse regression and incidentally fixes a ~200x stroke-tessellation performance cost + that affected every stroked PPTX shape, not only the reported bug. The shared `StrokeOutliner` + tolerance fix above was still made and is the primary defense (it does not depend on this + caller choosing a sane `flattenTolerance`); this change is a secondary, scale-appropriate + hardening of the specific caller that originally triggered it. See + `PptxRenderTests.Render_FullSlideScaleEllipseWithNoFillAndLnMissingWidth_RendersThinRingNotSolidDisc` + and `PptxRenderTests.Render_FullSlideScaleRoundRectWithNoFillAndLnMissingWidth_RendersThinOutlineNotSolidFill`. + +#### Deferred to a Later Phase + +The following are explicitly out of scope for Phase 1c, each because it depends on machinery not +yet implemented anywhere in `CanvasNet` (not merely unimplemented in this package): + +- **Radial/path gradients** - require a core `RadialGradient`/path-gradient paint type this + package can reuse; deferred to a later phase. +- **`` adjustment-value parsing** for preset geometries - deferred to a later phase; see + _Preset Geometry_ above. +- **Full group-shape semantics** beyond transform composition and shape-tree recursion (style + cascading, group-level effects) - remains deferred to a later phase. The ``'s own + enumeration/recursion through a slide's full shape tree, listed here as deferred at the time + this phase was written, is now implemented - see _Shape Tree_ in + _Images, Tables, and Shape Tree (Phase 1e)_ below. + +### Text Layout and Rendering (Phase 1d) + +Phase 1d adds DrawingML text body parsing, attribute-level property inheritance, word-wrap/ +alignment/vertical-anchor/autofit text layout, and glyph-ink rendering onto a core +`DemaConsulting.CanvasNet.Canvas.Surface`. It additionally depends on the core +`DemaConsulting.CanvasNet.Fonts.TrueTypeFont`/`SystemFontCatalog` types (font resolution and +glyph-outline extraction) and `DemaConsulting.CanvasNet.Drawing.PathFiller` (glyph-ink fill), +reusing exactly the same font-resolution/glyph-painting pattern `DemaConsulting.CanvasNet.Pdf`'s +`PdfDocument.Text.cs` already established, rather than a second, divergent implementation. Every +new member introduced this phase is `internal` - no public shape/rendering surface is introduced +yet (matching Phase 1b/1c's own precedent). + +#### Text Body Parsing + +`PptxDocument.Text.cs` parses a shape's `` element (`ParseTextBody`) into a +`PptxTextBody` (an `internal sealed record`, defined alongside its sibling small record types in +`PptxTextBody.cs`, mirroring `PptxShapeFrame.cs`/`PptxPaint.cs`'s one-file-per-small-type-family +convention): resolved body properties plus an ordered list of paragraphs. + +- **`ParseBodyProperties(XElement? bodyPrElement)`** resolves ``'s vertical anchor + (`anchor="t"/"ctr"/"b"`, defaulting to `t`/`PptxTextAnchor.Top`), word-wrap mode + (`wrap="square"/"none"`, defaulting to `square`/`PptxTextWrap.Square`), and the four insets + (`lIns`/`tIns`/`rIns`/`bIns`, each in EMU), defaulting to the OOXML schema's own documented + defaults - `91440`/`45720`/`91440`/`45720` EMU respectively - when `` itself, or an + individual inset attribute, is absent. The raw autofit child element (``/ + ``/``, if present) is retained **unparsed**, as an `XElement?`, for + the layout engine (`PptxDocument.TextLayout.cs`) to interpret - body-property parsing does not + itself implement autofit policy. +- **`ParseParagraph(XElement pElement)`** resolves `` into a `PptxParagraph`: raw paragraph + properties (via `ParseParagraphProperties`) plus an ordered list of runs (via `ParseRun`). A + paragraph with no recognized child element resolves to an empty run list - a valid, empty + paragraph (a blank line), not an error; `InvalidDataException` is reserved for genuinely + malformed structure, not merely sparse/empty content. +- **`ParseParagraphProperties(XElement? pPrElement)`** resolves ``'s `algn`, `marL`, + `indent`, `lnSpc`, `spcBef`, `spcAft`, and `defRPr` child/attributes, each retained **raw and + unresolved** (a `PptxRawParagraphProperties` record) - resolution against the inheritance chain + happens later, in `PptxDocument.TextInheritance.cs`, not here. The paragraph's own `lvl` + attribute (its placeholder/master style level) is clamped to the OOXML schema's documented + ten-level `[0,8]` range. +- **`ParseRun(XElement rElement)`** resolves `` into a `PptxTextRun`: its own raw, + unresolved `` element (or `null`) plus its `` text, defaulting to `string.Empty` + when `` is absent, per the OOXML schema. + +#### Master `` Parsing + +`PptxDocument.Masters.cs`'s `GetMaster` additionally parses the slide master root element's own +`` child - a direct sibling of `` per ECMA-376 (ISO/IEC 29500) §19.3.1.53's +`CT_SlideMaster` content model, not nested inside it, exactly paralleling how `GetMaster` already +reads `` as a direct child of the same root - into a new `PptxMasterTextStyles(XElement? +TitleStyle, XElement? BodyStyle, XElement? OtherStyle)` record (`PptxPlaceholder.cs`), each an +``-shaped element or `null` when the master omits that schema-optional style bucket. +`PptxMaster` gains a new, non-nullable `TxStyles` field (always populated, even when all three +buckets inside are `null`), and `PptxPlaceholderProperties` gains a new optional +`MasterTextStyles` field threaded through unconditionally from `ResolvePlaceholderProperties`'s +new optional `masterTextStyles` parameter - independent of whether a layout/master placeholder +match was found, since a master's own default text styling exists even for a shape with no +placeholder match at all (see _Property Inheritance_ below). + +#### Property Inheritance + +**Design decision: attribute-level, not element-level, fallback.** Phase 1b's placeholder +property inheritance (`` and `/`) resolves at the level of the +_whole element_: the first category-level element present anywhere in the chain wins outright, +with no deeper merging. Run/paragraph text-property inheritance deliberately does **not** reuse +that same element-level pattern. Real-world DrawingML very commonly overrides a single attribute +at the run level (for example, bolding one word within an otherwise plain sentence) without +overriding every other attribute at the same level; an element-level fallback would incorrectly +discard every other, more specific attribute resolution available at a shallower level for that +run. `PptxDocument.TextInheritance.cs` therefore resolves **each attribute independently**, +walking the same conceptual chain - run/paragraph own value -> paragraph's own `defRPr` -> +placeholder's level-indexed `` entry -> master's level-indexed `` bucket +entry -> theme/hard-coded default - via six small private per-attribute helpers for run +properties (`ResolveTypeface`, `ResolveFontSizeEmu`, `ResolveBold`, `ResolveItalic`, +`ResolveUnderline`, `ResolveRunColor`) and an analogous set for paragraph properties, each +independently taking its own first non-`null` hit. + +- **`ResolveEffectiveRunProperties(PptxTextRun run, PptxParagraph paragraph, + PptxPlaceholderProperties placeholderProperties, PptxTheme theme, string placeholderType)`** + resolves a `PptxEffectiveRunProperties` (font family, size in EMU, bold, italic, underline, + color). Font size (`` means 18pt, i.e. `sz / 100` points, further scaled by + `* 12700` to EMU) and every boolean flag (`"1"`/`"true"`) are parsed at whichever tier first + supplies a non-`null` value. +- **`ResolveEffectiveParagraphProperties(...)`** resolves a `PptxEffectiveParagraphProperties` + (alignment, left margin, indent, line spacing). Alignment normalizes `algn="just"`/`"justLow"` + (full/low text justification) to `"l"` (left) - full justification requires redistributing + inter-word spacing per line, a separable refinement explicitly deferred (see _Deferred to a + Later Phase_ below). Line spacing (``) is either `` (percentage, + `val / 100000`) or `` (fixed points, `val / 100 * 12700` EMU) - mutually + exclusive per schema - modeled as a `PptxLineSpacing(float? Percent, float? FixedEmu)` + discriminated record (exactly one field non-`null`), with a static `Default` of 100%. +- **`` bucket selection**: for a given shape's placeholder type, `title`/`ctrTitle` + selects the master's `TitleStyle`, the empty-string sentinel (used for a non-placeholder shape + - see `PptxDocument.Masters.cs`'s `RemapPlaceholderType`/placeholder-matching precedent in + Phase 1b) selects `OtherStyle`, and every other type (including `body` and every type the + Phase 1b remapping table folds into `body`) selects `BodyStyle`. + +#### Text Layout + +`PptxDocument.TextLayout.cs`'s `ResolveTextLayout(PptxTextBody textBody, PptxPlaceholderProperties +placeholderProperties, PptxTheme theme, string placeholderType, float widthEmu, float heightEmu, +Func fontResolver)` produces a `PptxTextLayout` +(`PptxTextLayout.cs`): an ordered list of `PptxGlyphPlacement` (resolved font, glyph index, +baseline-relative origin in the shape's own local, unrotated coordinate space, size, and color) +plus the resolved total block height. + +1. Every run's/paragraph's effective properties are resolved via `PptxDocument.TextInheritance.cs` + (above). +2. **Autofit** (the body's raw autofit element) is applied in three tiers, resolved once for the + whole text body before layout: + - **No scaling** - ``, an absent autofit element, or `` (shape-resize + autofit; deliberately not implemented - see _Deferred to a Later Phase (Phase 1d)_ below, + since it would require resizing the owning shape itself, a cross-cutting concern beyond this + unit's text-layout scope). + - **Explicit ``** - the stored factors are + applied verbatim (PowerPoint persists these once it has itself computed a fit). + - **Attribute-less ``** - a bounded, deterministic shrink loop: starting at + 100% and stepping down in 10% increments (at most 9 iterations, floored at 10%), the loop + stops at the first scale whose naturally laid-out height fits the available height. This + reproduces PowerPoint's own "shrink text on overflow" user-visible effect without depending + on PowerPoint's own unpublished exact algorithm (which lazily computes and persists its own + `fontScale`/`lnSpcReduction` once a user has interacted with the file - an attribute-less + `` is PowerPoint's own "not yet computed" state). +3. **Tokenization and word-wrap**: each run's text is tokenized into whitespace/non-whitespace + tokens via a small manual scanner (not `string.Split(' ')`, so consecutive spaces/tabs are + preserved as their own token's own advance width, rather than collapsed). Each distinct + `(FontFamily, Bold, Italic)` combination is resolved to a `TrueTypeFont` once via + `fontResolver`, memoized per layout call (a document-level, cross-call cache is not needed + this phase, mirroring Phase 1b's own per-document, not static, theme/master/layout caches). + Tokens are packed greedily onto lines bounded by `widthEmu` less the body's horizontal insets; + a single token that alone exceeds the available width is placed alone on its own (overflowing) + line rather than looping indefinitely - a documented overflow policy, not a crash. +4. **Line height** is computed from the _tallest_ run on that line: + `(Ascender - Descender + LineGap) / UnitsPerEm * SizeEmu * LineSpacingFactor`. **Horizontal + start X** is computed from the effective alignment (`l`/`ctr`/`r` - `just`/`justLow` already + normalized to `l` by property inheritance, above) against the available width, honoring + `MarginLeftEmu`/`IndentEmu` (first line of a paragraph only, per the OOXML indent model). +5. **Vertical start Y** is computed from `PptxBodyProperties.Anchor` (`t`/`ctr`/`b`) and the + total resolved block height within `heightEmu` less the body's vertical insets. No clipping is + applied when the block overflows the available height - text is positioned exactly as + computed, even past the shape's own box (see _Deferred to a Later Phase (Phase 1d)_ below). +6. **Per-character glyph-coverage fallback**: for each non-whitespace character, the run's own + primary font (resolved once per run - see step 3 above) is tried first + (`TrueTypeFont.GetGlyphIndex(ch)`); only when that returns glyph `0` (`.notdef`) is a single + bundled fallback font (one per `(bold, italic)` pair, memoized the same way the primary-font + cache is) consulted, and that character alone paints from whichever font actually resolved a + non-`.notdef` glyph for it. A run is never rebound to the fallback font as a whole - each + character's resolution is independent, so a character the primary font _does_ cover is + unaffected by a neighboring character's fallback substitution. +7. One `PptxGlyphPlacement` is emitted per non-whitespace glyph, carrying whichever font (primary + or fallback) actually resolved that specific character per step 6. + +#### Rendering + +`PptxDocument.TextRender.cs`'s `PaintTextLayout(Surface surface, PptxTextLayout layout, Matrix3x2 +shapeToSurfaceTransform)` paints a resolved layout's glyphs onto a core `Surface`, mirroring +`DemaConsulting.CanvasNet.Pdf`'s `PdfDocument.Text.cs`'s own glyph-painting pattern exactly: for +each `PptxGlyphPlacement`, a `glyphMatrix = Matrix3x2.CreateScale(glyph.SizeEmu / +glyph.Font.UnitsPerEm) * Matrix3x2.CreateTranslation(glyph.OriginXEmu, glyph.OriginYEmu) * +shapeToSurfaceTransform` is composed (scale to EMU-per-unit, then translate to the shape-local +baseline origin, then compose with the shape's own frame transform), the glyph's outline +(`TrueTypeFont.GetGlyphOutline`) is re-issued through a fresh `Geometry.PathBuilder` with every +point transformed by `glyphMatrix`, and the result filled via `Drawing.PathFiller.Fill(surface, +path, glyph.Color, Drawing.FillRule.NonZero)`. A glyph whose outline has zero subpaths (a +whitespace or control character) is skipped entirely, rather than issuing an empty fill. + +`ResolveTextFont(string familyNameHint, bool bold, bool italic)` resolves a DrawingML typeface +name hint to a concrete `TrueTypeFont` via the core `Fonts.SystemFontCatalog.FindBestMatch` +(always passing `serif: false, fixedPitch: false`, since DrawingML never signals either +classification - unlike the PDF renderer's Standard-14 font concept, DrawingML typeface names are +a free-form string with no implied serif/fixed-pitch/symbolic classification), falling back to +`Fonts.SystemFontCatalog.LoadBundledFallback(serif: false, fixedPitch: false, bold, italic)` when +no installed font matches. This resolves only a run's **primary** font (one call per distinct +`(FontFamily, Bold, Italic)` combination, before any character is inspected); it is the default +`fontResolver` delegate `ResolveTextLayout` is driven with in production use. Per-character +coverage fallback (when the primary font itself lacks a given codepoint) is a distinct, later +step living entirely in `PptxDocument.TextLayout.cs` - see _Text Layout_ step 6 above - and is +driven by a second, independent delegate (`fallbackFontResolver`, defaulting to +`Fonts.SystemFontCatalog.LoadBundledFallback` with the same `serif: false, fixedPitch: false` +convention, keyed/cached by `(bold, italic)` only, not by family name, since a fallback font is +family-agnostic by design). Tests inject their own synthetic resolvers (for either delegate) for +deterministic, installed-font-independent assertions. + +#### Deferred to a Later Phase (Phase 1d) + +The following are explicitly out of scope for Phase 1d, each because it depends on machinery not +yet implemented anywhere in `CanvasNet`, or is a separable refinement with its own, independent +design cost: + +- ~~**Bullets and numbering** (``/``/``) - require a separate + glyph/counter-rendering pass distinct from run-text layout; deferred to a later phase.~~ (closed + by the _Phase 2 Follow-Up: Bullets and Numbering Rendering_ section below.) +- **Full text justification** (`algn="just"`/`"justLow"`) - requires redistributing inter-word + spacing per line to reach the line's own right margin exactly; normalized to left-alignment + this phase (see _Property Inheritance_ above). +- **`` shape-resize autofit** - requires resizing the owning shape itself in response + to its own text content, a cross-cutting concern beyond this unit's text-layout scope; treated + as a pass-through (no scaling) this phase (see _Text Layout_ above). +- **Kerning** - `` and font-pair kerning tables are not consulted; glyph advances + use each glyph's own unscaled advance width only. +- **Text clipping on overflow** - an overflowing text block is positioned exactly as computed, + without being clipped to the shape's own bounding box (see _Text Layout_ above). +- **Per-character glyph-coverage fallback is a single-tier, single-font mechanism, not a full + font-linking/script-itemization pipeline** - a character missing from the primary font falls + back to exactly one bundled font (step 6 above); DrawingML's own `` (East Asian) and + `` (complex script) typeface overrides, and a genuine multi-font fallback chain keyed by + Unicode script/block (as a real font-linking implementation would provide), are not consulted or + implemented - a character missing from both the primary font and the single bundled fallback + still paints as `.notdef` (unchanged from pre-fix behavior for that residual case). + +A full per-slide public `Render` API was deferred to a later phase alongside full group-shape +semantics and picture/table support, and is now implemented (see _Images, Tables, and Shape Tree +(Phase 1e)_ and _Full Slide Rendering (Phase 1f)_ below). + +### Images, Tables, and Shape Tree (Phase 1e) + +Phase 1e adds three additive capabilities: raster picture decoding/cropping/compositing for +dedicated `` picture shapes (`PptxDocument.Images.cs`), `` table structural +parsing/cell-rect computation/painting (`PptxDocument.Tables.cs`), and a full recursive +shape-tree walker recognizing every shape kind this phase supports, including nested +`` groups (`PptxDocument.ShapeTree.cs`). Every new member introduced this phase is +`internal` - no public shape/rendering surface is introduced yet (matching Phase 1b/1c/1d's own +precedent). This phase introduces no new core `CanvasNet` dependency beyond what Phase 1c/1d +already established (`Geometry.Path`, `Drawing.PathFiller`, `Canvas.Surface`/`Rgba32`) - raster +image decoding reuses the core codec infrastructure the `DemaConsulting.CanvasNet.Svg` package's +`SvgCodec.ResolveRasterDecoder` already established for embedding a raster image inside another +document format. + +**Scope decision**: only a dedicated `` picture shape is in scope this phase for image +rendering. An ordinary shape's own _background_ fill as a picture or pattern +(``/`` under ``/`ResolveFill`, as distinct from a `` shape) +remains out of scope this phase and continues to be rejected by `ResolveFill` exactly as Phase 1c +left it (see _Fill Resolution_ above) - this phase does not touch `ResolveFill` at all. (Both are +implemented in later Phase 2 follow-ups - see _Picture Fill (``) as a Shape/Paragraph +Fill_ and _Pattern Fill (``) (Phase 2 Follow-Up)_ below - which reuse this phase's own +picture decoding/color resolution directly, unchanged.) + +#### Picture Decoding, Cropping, and Painting + +`PptxDocument.Images.cs`'s `ResolvePictureSurface(XElement picElement, PptxDocument document)` +resolves a `` shape's `/` into a decoded core `Surface`: + +- **Relationship resolution**: ``'s `r:embed` attribute (an embedded-image relationship + ID) is resolved via the existing `ResolveRelationship`/`GetPartBytes` package-layer primitives + into the raw media part bytes. `GetPartBytes` bounds the part's decompressed size to + `MaxPartBytes` (256 MiB), rejecting an oversized/zip-bomb-shaped media part with + `InvalidDataException` rather than trusting its declared or compressed size. One with only + `r:link` (an external, non-embedded image + reference) throws `PptxUnsupportedFeatureException` (feature token `"pptx-image-link"`) - a + linked image has no embedded bytes this package can read without performing file-system I/O + outside the supplied package stream, a well-formed-but-deliberately-unsupported construct, not + malformed data. One with neither `r:embed` nor `r:link`, but carrying only a Microsoft SVG + extension fallback (`/` wrapping an + `` element - the "Insert Icon" SVG-with-no-raster-fallback pattern real PowerPoint + produces) throws `PptxUnsupportedFeatureException` (feature token `"pptx-image-svg-only"`) - + also a well-formed, valid OOXML construct this package does not yet decode, not malformed data. + Only when neither an embed/link attribute nor this recognized extension fallback is present does + `` throw `InvalidDataException` (genuinely malformed - ECMA-376 requires at least one of + these). +- **Content-type dispatch**: the resolved media part's content type (via the existing + `ResolvePart` content-type lookup) dispatches to a private `ResolveRasterDecoder` helper + mirroring `DemaConsulting.CanvasNet.Svg.SvgCodec.ResolveRasterDecoder`'s own + content-type-to-decoder dispatch pattern (PNG/JPEG/BMP/GIF, via the core raster codecs already + used elsewhere in `CanvasNet`) rather than inventing a second, divergent raster-decode entry + point. An unrecognized content type throws `PptxUnsupportedFeatureException` (feature token + `"pptx-image-format"`). +- **Crop rectangle**: `ResolveSrcRect(XElement blipFillElement)` resolves ``'s + optional `` into a `PptxSrcRect(float Left, float Top, float Right, float Bottom)` + record (`PptxPicture.cs`) - each edge a `[0,1]`-normalized fraction of the image's own + full extent, parsed from OOXML's thousandths-of-a-percent `l`/`t`/`r`/`b` attributes (each + defaulting to `0` - no crop on that edge - when its own attribute is absent), or `null` when + `` itself is absent entirely (meaning "no cropping at all" - the full image). +- **Painting**: `PaintPicture(Surface destination, Surface image, PptxSrcRect? srcRect, Matrix3x2 + shapeTransform, float widthEmu, float heightEmu, Path? clipPath = null)` composes the crop + rectangle's own crop-to-unit-square mapping with `shapeTransform` into a single + image-to-surface matrix, inverts it once, and for every destination pixel whose inverse-mapped + coordinate falls within `[0,1]x[0,1]` samples the source image with nearest-neighbor filtering + (no bilinear/anti-aliased resampling this phase - a documented simplification, consistent with + this package's existing "no anti-aliasing yet" posture established by `PaintTextLayout`'s own + glyph-ink fill). Each sampled source pixel is alpha-blended "over" the existing destination + pixel via the shared internal `Canvas.Rgba32.CompositeOver(Rgba32, Rgba32)` helper (standard + Porter-Duff "over" alpha compositing - straight/unassociated alpha in and out, `outA = fgA + + bgA * (1 - fgA)`, each color channel `outC = (fgC * fgA + bgC * bgA * (1 - fgA)) / outA` when + `outA != 0` else `0`, round-half-away-from-zero, clamped to `[0, 255]`) rather than overwritten + outright - a fully or partially transparent source pixel therefore lets the existing destination + content show through correctly instead of being replaced by whatever RGB value happens to be + stored alongside that non-opaque alpha (a real-world-confirmed bug fixed after this phase first + shipped: a logo PNG with an unassociated-alpha white matte around its letters previously painted + a solid white rectangle instead of a transparent background). This same helper is reused, not + duplicated, by `DemaConsulting.CanvasNet.Pdf`'s own `CompositeImageOntoSurface` (see that + package's own design documentation) - exposed cross-assembly via this package's own + `InternalsVisibleTo` grant from `DemaConsulting.CanvasNet`. A singular (non-invertible) + `shapeTransform` (for example a zero-area shape frame) paints nothing, rather than throwing or + dividing by zero. The optional `clipPath` parameter (default `null`, preserving the original + unclipped behavior) clips the painted image to the picture's own resolved non-rectangular + preset/custom geometry - see "Phase 2 Follow-Up: Picture Preset-Geometry Clipping" below for the + full rationale. + +**Deliberate divergence from the PDF renderer**: `PaintPicture` does **not** apply the `(1 - v)` +row flip `DemaConsulting.CanvasNet.Pdf`'s own image-painting code applies. PDF's content stream +coordinate space is y-up, so painting a top-down-row-ordered decoded image there requires +flipping its sampled V coordinate; this package's shape-local/surface space is already y-down +(matching every other transform in this unit - see _Shape Frame Transform_ above), so the image's +own top-down row order already matches the surface's own row order with no flip needed. Applying +the PDF renderer's flip here, by copy-paste habit, would paint every picture upside down - this +is a deliberate, documented decision, not an oversight. Both renderers' per-pixel alpha-blending +behavior is otherwise identical (see above). + +#### Picture Fill (``) as a Shape/Paragraph Fill (Phase 2 Follow-Up) + +A later Phase 2 follow-up lifted the "Scope decision" above's own original restriction and +implemented `` used as a fill-definition child (an ordinary shape's own ``, +table-cell ``, line/stroke ``, slide/layout/master background ``, and +connector style fills - everywhere `ResolveFill`/`ResolveLineStyle` are consulted), as distinct +from the `` picture _shape_ this phase (Phase 1e) already implements above. Pattern fill +(``) remained separately out of scope at the time of this follow-up - see +_Pattern Fill (``) (Phase 2 Follow-Up)_ below for the later follow-up that covers it, +for a documented preset subset. + +- **`PptxImageFill(Surface Image, Matrix3x2 ImageToLocalTransform)`** (`PptxPaint.cs`) is the new + closed `PptxPaint` subtype `ResolveFill` returns for a resolvable `` - `Image` is + the fully decoded raster image (reusing `ResolvePictureSurface` above, completely unchanged - + structurally, a fill-context `` and a ``'s own `` have identical + `` children, so no new decoding logic was needed), and `ImageToLocalTransform` maps the + image's own pixel-space coordinates into the owning shape's local EMU coordinate space (composed + with a caller's own shape-to-surface transform at fill time, mirroring `PptxGradientFill`'s own + `Gradient.WithTransform` composition pattern). +- **Threading a resolver through `ResolveFill`, backward-compatibly**: since `ResolveFill` and + the resolver methods that defer to it (`ResolveLineStyle`, `ResolveShapeStyleFill`, + `ResolveShapeStyleLineStyle`, `ResolveShapeLineStyle`, `ResolveConnectorLineStyle`, + `ResolveTableCellFill`, `ResolveBackgroundElement`, `ResolveBackgroundStyleReference`, + `ResolveSlideBackgroundFill`) are `internal static` methods directly unit-tested on bare + `XElement` fragments with no owning-part context, each gained one new, optional trailing + `Func? resolveBlipImage = null` parameter rather than a required one - an + ``'s `r:embed` relationship is part-scoped (it must resolve against the specific + slide/layout/master/table-owning part's own `.rels`), so only a caller with that context can + supply a concrete resolver; every pre-existing call site that does not supply one preserves its + exact prior behavior unchanged (`ResolveFill` still throws `PptxUnsupportedFeatureException`, + feature token `"pptx-picture-fill"`, when `resolveBlipImage` is `null` - now documented as "no + resolver supplied" rather than "picture fill is unsupported"). `PptxDocument.Render.cs`'s + `RenderShape`/`RenderConnector` (and each of `GetSlide`/`GetLayout`/`GetMaster`'s own + `ParseShapeTree` calls) supply the concrete resolver, a closure binding `ResolvePictureSurface` + to the owning part's own path (`blip => ResolvePictureSurface(ownerPartPath, blip)`) - + `ResolvePictureSurface` is an instance method, which is why those two `Render.cs` methods were + converted from `static` to instance methods to build that closure via `this`, while every + other, directly-tested resolver kept its own `static` modifier unchanged. +- **`` and `` transform derivation**: `ResolveImageFillTransform(XElement + blipFillElement, Surface image, float widthEmu, float heightEmu)` (`PptxDocument.Images.cs`) + derives `ImageToLocalTransform` from whichever sub-element is present, consulting `` + first when both could theoretically coexist: + - **``** (the mandatory, common case - see + _Pythonpptx-DML-Fill Fixture_ below) - `tx`/`ty` are EMU offsets (each defaulting to `0`) and + `sx`/`sy` are 1/100000-of-a-percent scale factors (each defaulting to `100000`, 100%) applied + to the image's own native 96-DPI pixel size (`914400` EMU/inch over `96` pixels/inch = + `9525` EMU/pixel, a private `EmuPerPixelAt96Dpi` constant): `scaleX = sx * 9525`, + `scaleY = sy * 9525`, and the resulting `Matrix3x2` translates by `(tx, ty)` - every tile + repeats from the shape's own local origin, unflipped. This phase deliberately does not + resolve ``'s own `flip`/`algn` refinements (a documented scope reduction - the common + case, `flip="none" algn="tl"`, is the only one this package's own real fixture exercises, and + general flip/alignment support would require `TilePaint`/pattern-space changes beyond this + follow-up's own scope). + - **``** (consulted when no `` is present, including when neither element is + present at all - the OOXML schema default for an otherwise-unqualified ``, treated + identically to an empty ``) - its optional nested `` crops the image first (each edge a 1/100000-of-a-percent fraction of the + image's own width/height, each absent edge defaulting to `0`, reusing `ParseSrcRectEdge`'s + identical parsing convention), and the remaining cropped span is then linearly stretched to + exactly fill `widthEmu` x `heightEmu`. A degenerate crop (the left+right, or top+bottom, + edges summing to `100%` or more on an axis) falls back to an uncropped full stretch on that + axis, rather than producing a non-finite or divide-by-zero transform. +- **Painting**: every `FillPaint`-style switch over a resolved `PptxPaint` (currently only + `PptxDocument.Tables.cs`'s own `FillPaint`, the sole such switch in this package - see + _Table Parsing_ below) gained a `case PptxImageFill imageFill` branch constructing + `new TilePaint(imageFill.Image, imageFill.ImageToLocalTransform, imageFill.Image.Width, + imageFill.Image.Height).WithTransform(shapeToSurfaceTransform)` and filling via the existing + `PathFiller.Fill(Surface, Path, TilePaint, FillRule, float)` overload - composing the + shape-to-surface transform exactly as `PptxGradientFill`'s own + `Gradient.WithTransform(shapeToSurfaceTransform)` branch already does, immediately above it in + the same switch, rather than introducing a divergent composition convention for this new paint + kind. +- **Linked-only and SVG-only-fallback blips behave identically to ``**: since + `resolveBlipImage` is typically a closure directly over `ResolvePictureSurface`, a + ``'s `` that carries only `r:link` (no `r:embed`), or only the Microsoft + SVG-extension fallback pattern, throws exactly the same `PptxUnsupportedFeatureException` + (feature tokens `"pptx-image-link"`/`"pptx-image-svg-only"` respectively) `ResolvePictureSurface` + already throws for a `` in either case - no new, divergent exception behavior was + introduced for the fill-context case. +- **Pythonpptx-DML-Fill Fixture**: this package's own `pythonpptx-dml-fill.pptx` fixture's slide 0 + places six shapes in document order - an inherited fill, an explicit ``, a solid RGB + fill, a real embedded ``/`` picture + fill, a linear gradient fill, and finally an `` pattern fill, on this + _same_ slide. Before the picture-fill follow-up, rendering that slide threw `"pptx-picture-fill"` + as soon as the walk reached the fourth shape; the picture-fill follow-up made it proceed past + that shape and instead throw `"pptx-pattern-fill"` from the sixth shape. **Update (pattern-fill + follow-up)**: `divot` is now a covered preset (see _Pattern Fill (``) (Phase 2 + Follow-Up)_ below), so this fixture's slide 0 now renders completely, end-to-end, without + throwing at all - see that section's own "Pythonpptx-DML-Fill Fixture" note for the full detail, + including slide 1's own three additional pattern-fill shapes. A genuine, full end-to-end + "renders without throwing, with a pixel-level sanity check" proof of picture fill specifically + instead uses a minimal, purpose-built single-shape package (mirroring this phase's own + `PptxSystemIntegrationTests`-style package-building helpers), since no single real fixture + shape-tree in this corpus isolates an ``/`` shape without another, + independently-interesting construct alongside it. + +#### Pattern Fill (``) (Phase 2 Follow-Up) + +A later Phase 2 follow-up (after the picture-fill follow-up above) lifted this package's own +original fail-closed posture for `` used as a fill-definition child, for a documented +subset of the 54 named ECMA-376 `ST_PresetPatternVal` preset-pattern values - the remaining names +still throw `PptxUnsupportedFeatureException` (feature token `"pptx-pattern-fill"`), exactly as +before this follow-up, rather than attempting the full preset table. Unlike picture fill, no new +resolver parameter is threaded through `ResolveFill`'s own call sites - every covered preset is +synthesized procedurally from its own resolved foreground/background colors, with no +image/relationship resolution of any kind. + +- **`PptxPatternFill(PptxPresetPattern Preset, Rgba32 Foreground, Rgba32 Background)`** + (`PptxPaint.cs`) is the new closed `PptxPaint` subtype `ResolveFill` returns for a covered + ``. `PptxPresetPattern` (also `PptxPaint.cs`) is a plain enum naming + exactly the 30 of 54 covered preset values - see that enum's own `` for the full + covered/deferred lists, reproduced here: **covered** - the horizontal/vertical stripe family + (`horz`, `vert`, `ltHorz`, `ltVert`, `dkHorz`, `dkVert`), the diagonal-stripe family (`dnDiag`, + `upDiag`, `ltDnDiag`, `ltUpDiag`, `dkDnDiag`, `dkUpDiag`, `wdDnDiag`, `wdUpDiag`), the + cross-hatch family (`cross`, `diagCross`), the percentage/dot-density family (`pct5`, `pct10`, + `pct20`, `pct25`, `pct30`, `pct40`, `pct50`, `pct60`, `pct70`, `pct75`, `pct80`, `pct90`), and + the two fixture-mandatory names (`divot`, `wave`); **explicitly deferred** (still throwing) - + `narHorz`, `narVert`, `dashHorz`, `dashVert`, `dashDnDiag`, `dashUpDiag`, `diagBrick`, + `horzBrick`, `plaid`, `sphere`, `weave`, `shingle`, `trellis`, `zigZag`, `dotGrid`, `dotDmnd`, + `openDmnd`, `solidDmnd`, `smCheck`, `lgCheck`, `smGrid`, `lgGrid`, `smConfetti`, `lgConfetti`. +- **A non-conformant, attribute-less `` (no `prst` at all)** - observed in this + package's own real `pythonpptx-dml-fill.pptx` fixture (slide 1's first shape, whose own text run + reads "autoshape with inherited fill") - is not a member of `PptxPresetPattern` at all: + `ResolveFill` resolves it directly to `PptxNoFill.Instance`, the same "no override" value a + shape with no fill-definition child at all would resolve to, rather than either falling back to + the shape's own `/` (its text run's own name notwithstanding - a deliberate + design decision, not an attempt to honor that name) or throwing. Resolving it to `PptxNoFill` + keeps this edge case consistent with every other "absent/unrecognized fill" case `ResolveFill` + already resolves to `PptxNoFill` (see _Fill Resolution_ above), rather than inventing new, + divergent handling for one non-conformant attribute-less element. +- **Foreground/background color resolution**: ``/``'s own single + color-definition child is resolved via the existing `ResolveColor` helper - the same one + `` already uses - defaulting to black/white respectively when either child is + altogether absent. This package's own fixture's `bgClr` values use ``, + which required a narrowly-scoped companion fix (below) to `ResolveColor`'s own color-kind + dispatch, which previously threw for every `` unconditionally. +- **`` companion fix (narrowly scoped)**: `ResolveColor`'s private + `ResolveBaseColor` helper gained a case for ``, resolving only `"white"` + (`Rgba32(255,255,255,255)`) and `"black"` (`Rgba32(0,0,0,255)`) - the two names this package's + own real fixture corpus requires - to their standard RGB values. Every other preset-color name + (ECMA-376's own `ST_PresetColorVal` names the full OOXML preset-color table beyond these two) + still throws `PptxUnsupportedFeatureException` (feature token `"pptx-color-kind"`), narrowed + rather than removed - this is a deliberately minimal fix, not an attempt at the full ~140-name + preset-color table. +- **Tile synthesis**: `PptxPatternTileRenderer.RenderTile(PptxPresetPattern preset, Rgba32 + foreground, Rgba32 background)` (new file `PptxPatternTileRenderer.cs`) procedurally synthesizes + a small (8x8-pixel) repeating-tile `Surface` per covered preset, painting each pixel foreground + or background per that preset's own geometric rule (for example, `horz`/`vert` alternate + whole rows/columns; `dnDiag`/`upDiag` follow a `(x +/- y) mod period` diagonal rule; the `pctNN` + family thresholds a per-pixel density test scaled to approximate the named percentage, not + pixel-perfect to PowerPoint's own exact dot placement; `divot`/`wave` approximate their own named + shapes closely enough to visually read as that pattern at tile scale). See that class's own + `` for the complete per-preset rule list. +- **Painting, and a shape/connector/stroke/arrowhead transform-composition fix**: `FillPaint`'s + existing switch (`PptxDocument.Tables.cs`) gained a `case PptxPatternFill` branch synthesizing a + tile via the above, building a `TilePaint` from it (mirroring `PptxImageFill`'s own branch's + transform-composition math exactly), and filling via the existing + `PathFiller.Fill(Surface, Path, TilePaint, FillRule, float)` overload. Doing so exposed a real, + pre-existing gap in `PptxDocument.Render.cs`'s own `RenderShape`/`RenderPicture`/ + `RenderConnector`/`PaintArrowhead`: each called the 3-parameter `FillPaint(Surface, Path, + PptxPaint)` overload for its own shape/connector fill and stroke-outline/arrowhead paint (unlike + `PaintTable`/`PaintCellBorder`'s own table-specific calls, which already compose the real + `shapeToSurfaceTransform`), which composes `Matrix3x2.Identity` into any `TilePaint`/`Gradient`'s + own transform instead of the shape's own local-to-surface transform - already documented (see + the 4-parameter `FillPaint` overload's own ``) as a pre-existing, codebase-wide gap + affecting every paint kind identically at those call sites, deliberately left as "a + separately-scoped follow-up" rather than fixed there. For a pattern fill's own small, fixed-size + repeat tile, this gap is not cosmetic: since a surface pixel coordinate divided by the fill's + own ~1/9525 EMU-per-pixel scale is always tiny relative to an 8-pixel tile step, every sampled + pixel across the _entire_ render surface (not just the one shape) collapses onto the same + single tile cell unless the shape's own position and scale are composed in first - flattening + the pattern to a single solid color (gradient/picture fill at these same call sites share the + identical gap, but it remains cosmetically invisible for a gradient spanning a shape's own + bounds, and picture fill's own existing test happens to position its shape at the origin). + Rather than generalizing a fix for every paint kind at these call sites (out of this follow-up's + own scope, and risking a behavior change to already-established gradient/picture-fill rendering + output), a narrowly-scoped `FillPaintComposingLocalTransform(Surface, Path, PptxPaint, Matrix3x2 + localToSurface)` helper composes the real local-to-surface transform (via the 4-parameter + `FillPaint` overload) only when `paint is PptxPatternFill`, delegating to the unchanged + 3-parameter overload for every other paint kind. + + `` (line/stroke) fills share the exact same shared `ResolveFill` resolver used for shape + fills (`ResolveLineStyle`, `PptxDocument.Paint.cs`, calls it identically for an `` + element's own fill-definition child) - so a covered-preset ` + ...` line/stroke fill is equally resolvable, and was therefore equally + subject to this same transform-composition gap. This is now fixed too: `RenderShape`'s, + `RenderPicture`'s, and `RenderConnector`'s own stroke-outline `FillPaint` calls, and + `PaintArrowhead`'s two arrowhead-paint calls, all now use `FillPaintComposingLocalTransform` + instead of the 3-parameter overload directly - the shape/picture/connector calls pass their own + `localToSurface`; `PaintArrowhead`'s two calls pass its own already-computed `orientToSurface` + (the arrowhead's own rotated/translated local-to-surface composition), not the connector's plain + `localToSurface`, since an arrowhead's own local origin is itself offset/rotated relative to the + connector (see `PaintArrowhead`'s own remarks). Every one of these call sites is now covered for + `PptxPatternFill`, exactly like the pre-existing shape/connector _fill_ call sites. + + The slide/layout/master background fill (`PptxDocument.Render.cs`'s own `Render` method) is now + also covered: `ResolveFill` is shared there too, so a background `` pattern fill is + equally resolvable, and the background rectangle's own local origin coincides with the slide's + own EMU origin, so the slide's own EMU-to-pixel `baseTransform` (already computed and in scope + at that call site, and already applied to the background path itself) plays exactly the same + role as a shape's own local-to-surface transform elsewhere. The background-fill call now uses + `FillPaintComposingLocalTransform(surface, backgroundPath, backgroundFill, baseTransform)` + instead of the 3-parameter overload directly - no remaining call site in production code calls + the 3-parameter overload directly any more. +- **Pythonpptx-DML-Fill Fixture**: slide 0's sixth shape (see the _Picture Fill_ section's own + "Pythonpptx-DML-Fill Fixture" note above) is a `prst="divot"` pattern fill, now covered - the + slide renders completely, end-to-end, without throwing. Slide 1 adds three more shapes + exercising pattern fill specifically: a non-conformant, attribute-less `` (resolves + to `PptxNoFill`, see above), a `prst="divot"` shape, and a `prst="wave"` shape (both with + ``/``) - both + genuinely paint a visible mix of their own foreground and background colors once rendered, + proven by `PptxFixturesCorpusTests.cs`'s own pixel-sampling assertions. + +#### Table Parsing + +`PptxDocument.Tables.cs`'s `ParseTable(XElement graphicFrameElement, PptxTheme theme)` parses a +``'s `//` into a `PptxTable` (`PptxTable.cs`): + +- **Graphic-frame kind dispatch**: a `` whose `uri` attribute does not end in + `"/table"` throws `PptxUnsupportedFeatureException` (feature token + `"pptx-graphic-frame-kind"`) - a `` can declare other graphic data kinds (for + example an embedded OLE object), each a well-formed-but-deliberately-unsupported construct + `ParseTable` itself does not implement. A missing ``/`` throws + `InvalidDataException` (structurally malformed). **`ParseTable` itself is unchanged as of + Phase 4**: a chart-kind `` (`uri` ending in `"/chart"`) is dispatched to the new + `ParseChart` by `ParseShapeTree` _before_ `ParseTable` is ever called for that graphic frame - + see _Chart Graphic Frames (Phase 4 — delegation to `CanvasNetCharts`)_ below - so `ParseTable` + only ever sees a chart-kind `` if a caller invokes it directly, bypassing + `ParseShapeTree`'s own dispatch. +- **Column grid**: `/` elements resolve `PptxTable.ColumnWidthsEmu` (each + `w` attribute, in EMU); a missing `` or a non-numeric `w` throws + `InvalidDataException`. +- **Rows**: each `` resolves a `PptxTableRow(HeightEmu, Cells)`; a missing/non-numeric `h` + attribute throws `InvalidDataException`. +- **Cells**: `ParseTableCell(XElement tcElement, PptxTheme theme, float widthEmu, float + heightEmu)` resolves each `` into a `PptxTableCell`, reading `gridSpan`/`rowSpan` + (defaulting to `1`) and `hMerge`/`vMerge` (defaulting to `false`) attributes - each + merge-continuation placeholder cell (`hMerge`/`vMerge` set) is still structurally parsed (it is + a required, well-formed `` entry per ECMA-376), just excluded from + `ResolveCellRects`'s own output (see below). A non-numeric `gridSpan`/`rowSpan` throws + `InvalidDataException`. +- **Cell fill/border/text reuse**: a cell's `` fill (via the already-verified Phase 1c + `ResolveFill`), left/right/top/bottom line styles (``/``/``/``, via + the already-verified Phase 1c `ResolveLineStyle`), and `` text body (via the + already-verified Phase 1d `ParseTextBody`) are each resolved **verbatim, unmodified** - + deliberately reusing the exact same resolvers an ordinary shape's own fill/border/text use, + rather than maintaining a second, divergent resolution path solely for table cells. + +**Signature deviation from the original plan**: the plan's one-line signature summary for +`ParseTable` omitted a `theme` parameter; this implementation adds it (`ParseTable(XElement +graphicFrameElement, PptxTheme theme)`), since `ParseTableCell`'s own fill resolution +(`ResolveFill`) requires a `PptxTheme` to resolve theme-referenced colors - the plan's own +detailed prose already implied this requirement even though its one-line signature summary did +not spell it out explicitly. + +#### Cell-Rect Resolution + +`ResolveCellRects(PptxTable table)` resolves each of a table's non-merge-continuation cells (a +cell with neither `hMerge` nor `vMerge` set) into a `PptxResolvedTableCell` carrying its own +EMU-space rectangle (`X`, `Y`, `Width`, `Height`), by walking each row's `` entries in +document order while accumulating a running column offset. + +**Merge-continuation column-advancement rule**: each `` XML entry - whether a real cell or +an `hMerge`/`vMerge` continuation placeholder - represents (in the common, well-formed case) one +physical grid column; a `gridSpan="N"` cell is typically followed by `(N-1)` separate continuation +`` entries, each itself a single-column-wide placeholder. The per-iteration running column +offset therefore normally advances by that **single column's own width** +(`table.ColumnWidthsEmu[columnIndex]`), **never** by the governing cell's own full merged-span +width - only the surviving (non-continuation) cell's own stored rectangle width uses the full +merged-span sum (`SumColumnWidths(..., cell.GridSpan)`). Advancing by the merged width instead +would double-count the columns already covered by that cell's own trailing continuation entries, +corrupting every subsequent cell's computed left edge in the same row - a defect caught and fixed +during this phase's own test-driven implementation (see `ResolveCellRects_HorizontalMerge_ +ComputesSpannedWidth`). + +**Corrected invariant - a schema-permitted gap, not a guarantee (Review Follow-Up)**: the +preceding paragraph's original wording overstated ECMA-376's own actual guarantee. `CT_TableRow` +declares its `tc` children as `minOccurs="0" maxOccurs="unbounded"` - the schema does **not** +require a `gridSpan="N"` cell's `(N-1)` continuation placeholders to actually be present in the +XML at all; a producer could omit them (or emit fewer than expected), and a conformant consumer +must still render something sensible rather than corrupting every later cell's position in the +row. `ResolveCellRects` now walks each row by index and, for every governing (non-continuation) +cell, counts how many of the actually-present following entries are its own `hMerge` +continuations (bounded by the cell's own declared `GridSpan - 1`); any shortfall between that +actual count and the expected count is compensated defensively - both the running `xEmu` offset +and `columnIndex` are advanced past the missing placeholders' own column widths before resuming +the per-iteration loop - so a malformed row can no longer cause a later, unrelated cell to overlap +the governing cell's own merged region. This mirrors the file's existing clamp-don't-throw +convention elsewhere in this unit: a structurally-sparse row degrades to a best-effort, visually +sensible layout rather than throwing (see +`ResolveCellRects_GridSpanMissingHMergePlaceholder_CompensatesColumnAdvance`). + +#### Table Painting + +`PaintTable(Surface destination, PptxTable table, PptxTheme theme, Matrix3x2 shapeTransform, +Func fontResolver)` paints each resolved cell rectangle in +turn: + +- **Fill**: the cell's own resolved `PptxPaint` (solid or gradient) is painted via the core + `PathFiller`, after transforming the cell's own `Path.Rectangle` through `shapeTransform` (a + `PptxNoFill` cell paints no fill pixels at all). +- **Border**: each of the cell's four resolved `PptxLineStyle`s (left/right/top/bottom, each + independently nullable) is stroked via the already-verified Phase 1c `ResolveStrokeOutline` + pattern, one side at a time. +- **Text**: the cell's own text body (when present) is painted via the already-verified Phase 1d + `ResolveTextLayout`/`PaintTextLayout` pipeline, constrained to the cell's own rectangle + dimensions exactly as an ordinary shape's own text body would be. + +An empty cell (no fill, no border, no text) paints no pixels at all - a common, valid table shape. + +#### Shape Tree + +`PptxDocument.ShapeTree.cs`'s `ParseShapeTree(XElement spTreeOrGroupElement, Func +themeResolver)` recursively parses a `` (or, for a nested group, a ``'s own +direct children - **not** wrapped in a nested ``, per ECMA-376's `CT_GroupShape` +content model, an assumption the plan flagged as "likely, not fully verified" and this phase's +own synthetic test fixtures (`PptxGroupsTests.cs`) now directly encode and exercise) into a +closed `PptxShapeTreeNode` hierarchy (`PptxShapeTree.cs`): + +- **`` → `PptxSpShapeNode`**: resolves its placeholder, if any, via + `PptxPlaceholderParser.TryParsePlaceholder` - a pure extract-method refactor of Phase 1b's own + per-`` placeholder-detection logic (previously inline in `ParsePlaceholderShapes`, now a + standalone, independently callable helper), with no behavior change to the existing + `PptxSlide.Placeholders` flat list (confirmed by re-running the full pre-existing test suite + immediately after the extraction, before writing any Phase 1e code that depends on it). +- **`` → `PptxPictureShapeNode`**: the raw element only - `ResolvePictureSurface`/ + `PaintPicture` are invoked later, by a future rendering pass, not eagerly here (unlike a + table's own eager `ParseTable` - decoding image bytes eagerly for every picture in a shape tree + that might never be rendered would be wasteful, whereas a table's own structural parse is cheap + pure-XML work). +- **`` → `PptxGraphicFrameShapeNode`**: eagerly parses its table via `ParseTable`, + or, as of Phase 4, its chart via `ParseChart` when its `` `uri` ends in + `"/chart"` - see _Chart Graphic Frames (Phase 4 — delegation to `CanvasNetCharts`)_ below. +- **`` → `PptxGroupShapeNode`**: resolves its own child-coordinate-space transform via + the already-verified Phase 1c `ResolveGroupChildTransform` (reading its `/` + element, throwing `InvalidDataException` when absent) and recurses `ParseShapeTree` on the same + group element for its own children. +- **Anything else** (``, ``, ``, or any other unrecognized + element kind) is **silently skipped** - a tree-walk tolerance, not a fail-closed feature + rejection, matching the existing Phase 1b precedent of silently excluding non-placeholder + shapes from the flat placeholder list. (`` is recognized and dispatched to its own + `PptxConnectorShapeNode`, not silently skipped - see the _Phase 2 Follow-Up: Connector Shape + Rendering_ section below.) + +**Deferred, lazy theme resolution**: a ``'s table needs a `PptxTheme` to resolve +its cells' fills (see _Table Parsing_ above), but the overwhelming majority of slides declare no +tables at all. Rather than `GetSlide` eagerly walking its slide's layout → master → theme +relationship chain for every slide, `ParseShapeTree` accepts a `Func themeResolver` +invoked lazily, only when a `` is actually encountered - confirmed by a dedicated +test (`ParseShapeTree_NoGraphicFrame_NeverInvokesThemeResolver`) asserting the resolver delegate +is never called for a shape tree with no tables. `GetSlide` populates the new +`PptxSlide.ShapeTree` field (`IReadOnlyList`) alongside the existing, unchanged +`PptxSlide.Placeholders` flat list - the inheritance resolver continues to consult the flat list, +not the shape tree. + +`PptxMaster`/`PptxLayout` are **not** changed - full freeform shape-tree enumeration for masters/ +layouts remains explicitly out of scope (masters/layouts commonly only declare placeholders plus +occasional decorative pictures; extending this is a reasonable, documented future increment). + +#### Deferred to a Later Phase (Phase 1e) + +The following are explicitly out of scope for Phase 1e, each because it depends on machinery not +yet implemented anywhere in `CanvasNet`, or is a separable refinement with its own, independent +design cost: + +- **Picture effects/shadows** - ``'s own `/` is not consulted; only + the picture's own pixels are painted. +- **Nested tables** - a table cell's own `` is painted as plain text only; a cell + containing another `` is not specially recognized. +- **Table auto-sizing to fit overflowing cell content** - `ResolveCellRects` computes each cell's + rectangle purely from the table's own declared column widths/row heights; a cell whose text + content overflows its own declared row height is not given additional vertical space (beyond + the row height the table itself declares) the way PowerPoint's own auto-grow-row behavior does. +- ~~**Table style/banding** (``'s `` and first-row/banded-row + styling)~~ (closed by the _Phase 2 Follow-Up: Table Style/Banding Resolution_ section below - + `firstCol`/`lastCol`/`lastRow`/corner-cell style parts, column banding (`band1V`/`band2V`), and + table-style-driven font color remain deferred, as documented in that section). +- ~~`` connector shapes~~ (closed by the _Phase 2 Follow-Up: Connector Shape Rendering_ + section below). +- **Master/layout full shape-tree enumeration** - `PptxMaster`/`PptxLayout` still only expose + their own flat placeholder lists, not a full `ParseShapeTree` result (see _Shape Tree_ above). +- **Group-level style cascading** - a group's own `` (for example an inherited line/ + fill style flowing down to a child with no `` of its own) is not implemented; each + child shape's own properties are resolved independently of its enclosing group's properties. + +A full per-slide public `Render` API was deferred to a later phase and is now implemented (see +_Full Slide Rendering (Phase 1f)_ below). + +### Full Slide Rendering (Phase 1f) + +Phase 1f adds the public, slide-level rendering API (`PptxDocument.Render.cs`): +`Render(int slideIndex, int width, int height, PptxRenderOptions? options = null)` and a DPI +convenience overload `Render(int slideIndex, float dpi, PptxRenderOptions? options = null)`, +mirroring `DemaConsulting.CanvasNet.Pdf.PdfDocument.Render`'s own public API shape/semantics as +closely as PPTX's different, EMU-based, rotation-free slide geometry model allows. This phase is +purely integration - no new geometry/paint/text/image/table resolution logic is introduced; every +pixel painted is produced by an already-verified Phase 1c/1d/1e resolver or painter. + +#### Rendering Options + +`PptxRenderOptions` mirrors `PdfRenderOptions`'s own shape: a `sealed` class with a single +`init`-only `BackgroundColor` property (defaulting to opaque white) and a `Default` static +instance, so future rendering options can be added as new properties without a breaking change +to `Render`'s own signature. + +#### Base Transform + +`Render` builds the EMU-to-pixel base transform directly from the slide's own `SlideSize` (EMU) +and the requested pixel width/height: + +```text +baseTransform = Scale(width / SlideSize.WidthEmu, height / SlideSize.HeightEmu) +``` + +Unlike `PdfDocument`'s own `BuildBaseCtm`, this transform needs no page-rotation handling (PPTX +slides have no rotation concept analogous to a PDF page's own `/Rotate` entry) and no y-axis flip +(PPTX's coordinate space is already y-down, matching this library's own surface convention - see +_Picture Decoding, Cropping, and Painting_'s own "Deliberate divergence from the PDF renderer" +note in the Phase 1e section above for the same point applied to picture compositing). +`SlideSize.WidthEmu`/`HeightEmu` are `long`; both are explicitly +cast to a floating-point type before dividing, so a requested pixel size far smaller than the +slide's own EMU magnitude does not truncate the transform to a zero scale factor via integer +division. + +#### Shape-Tree Walk and Per-Node-Kind Dispatch + +`Render` resolves the slide's layout/master/theme chain once, eagerly, at the top of the method - +unlike `GetSlide`'s own lazy, invoke-on-demand theme resolution (see _Shape Tree_ in the Phase 1e +section above) - because virtually any shape's own fill or text can reference a theme scheme +color at paint time, not only a table's own cell fills. It then walks the slide's own +`PptxSlide.ShapeTree` (Phase 1e) recursively, in document order, threading an accumulating +`Matrix3x2` transform (starting from the base transform above) through each node: + +- **`PptxGroupShapeNode`**: composes its own `ChildTransform` (already resolved by + `ParseShapeTree` via `ResolveGroupChildTransform` - not re-resolved here) with the accumulated + parent transform, then recurses into its own `Children` with the composed transform. +- **`PptxSpShapeNode`** (an ordinary or placeholder shape): resolves its effective `` - + for a placeholder, via `ResolvePlaceholderProperties` walking the slide → layout → master + chain; for a freeform shape, directly from its own `` - resolves its geometry ( + `ResolveShapeGeometry`) and fill/stroke (`ResolveFill`/`ResolveLineStyle`/ + `ResolveStrokeOutline`), paints them, and then, if the shape declares its own ``, + resolves and paints its text via `ResolveTextLayout`/`PaintTextLayout`. A placeholder's own + text content is always read from the slide-level shape element's own `` - never from + a layout/master placeholder's own text, which supplies styling only, never content (confirmed + by a dedicated test, `Render_PlaceholderShape_ReadsTextFromSlideLevelShapeNotLayout`). A + non-placeholder shape's text resolves its placeholder properties as + `new PptxPlaceholderProperties(null, null, theme, master.TxStyles)` - the fuller form carrying + the master's own text styles, distinct from `PaintTable`'s own narrower, cell-scoped + `new PptxPlaceholderProperties(null, null, theme)`. +- **`PptxPictureShapeNode`**: resolves and composites its embedded image via + `ResolvePictureSurface`/`ResolveSrcRect`/`PaintPicture` (Phase 1e). +- **`PptxGraphicFrameShapeNode`**: paints its already-parsed `PptxTable` via `PaintTable` (Phase + 1e), using the graphic frame's own direct `` child - **not** nested in a `` + like an ordinary shape or picture, per ECMA-376's `CT_GraphicalObjectFrame` content model. + +A `` connector shape is now represented in the parsed shape tree as its own +`PptxConnectorShapeNode`, with a dedicated `RenderConnector` case in `Render`'s own dispatch (see +the _Phase 2 Follow-Up: Connector Shape Rendering_ section below). + +#### Tolerant Skip Policy + +A shape, picture, or graphic-frame whose fully-resolved geometry element declares no ``/ +`` anywhere in its own ancestry is **skipped silently**, not treated as an error - a +position-less shape is a genuinely unrenderable (not malformed) construct this phase tolerates, +mirroring `ParseShapeTree`'s own established "tolerant tree walk" precedent (see _Shape Tree_ +above). The same policy applies to a `` missing its own ``. + +#### Deferred to a Later Phase (Phase 1f) + +With this phase, the planned PPTX 1.0 feature set is complete. The following remain +unimplemented, carried forward unchanged from Phase 1d/1e's own deferred-items lists (see above): +~~a slide's own `` background fill (no parsing support exists anywhere in this codebase),~~ +(closed by the _Phase 2 Follow-Up: Slide/Layout/Master Background Fill_ section below - the single +highest-visual-impact gap left by this phase), +~~`` connector shapes,~~ (closed by the _Phase 2 Follow-Up: Connector Shape Rendering_ +section below), nested tables, table auto-sizing/banding, group-level style +cascading beyond transform composition, picture effects/shadows, +~~master/layout full shape-tree rendering,~~ +(closed by the _Phase 2 Follow-Up: Master/Layout Decorative Shape Rendering_ section below - a +still-later pass that independently walks and paints each master's/layout's own non-placeholder +shapes), ~~bullets/numbering,~~ (closed by the _Phase 2 Follow-Up: Bullets and Numbering +Rendering_ section below) full text justification, `` shape-resize autofit, +kerning, and text clipping on overflow. None of these is a currently planned phase; any of them +remaining important is a candidate for a future, corpus-driven hardening pass (`pptx-phase-2`), +not a scheduled increment of this unit's own design. + +### Phase 2: Real-World Corpus Hardening + +Phase 1a-1f were verified exclusively against small, hand-authored, synthetic `.pptx` packages +(see each phase's own test file). Phase 2 adds no new feature; it instead renders a corpus of ten +real-world `.pptx` files - every slide of every file - and investigates any ungraceful failure +(an unexpected exception, or a silent paint of zero pixels where content is genuinely expected) +discovered against an already-implemented, in-scope Phase 1b/1c/1d/1e/1f feature. + +**Corpus sourced**: ten files from two independent, non-synthetic sources - `python-pptx`'s own +MIT-licensed `features/steps/test_files/` integration-test corpus (eight files, each exercising a +specific, named construct: blank slide, autoshape adjustment handles, group shapes, pictures, +tables, font properties, text-frame properties, and a richer multi-construct "shapes" deck) and +two files downloaded from samplelib.com (a blank slide and an eight-slide general-purpose sample +presentation). Licensing: `python-pptx`'s MIT license is reproduced verbatim in +`PptxFixtures/PythonPptx.LICENSE`; samplelib.com's own terms.html states only that downloading its +files locally is permitted, which is a permissive download statement, not a formal copyright or +license grant - see `PptxFixtures/README.md` for the full, file-by-file provenance table and the +exact wording of both statements. Two additional staged candidates +(`pythonpptx-minimal.pptx`/`pythonpptx-mst-placeholders.pptx`) were excluded: both declare zero +slides, so neither can exercise `Render` at all. + +**Methodology (at the time of this pass)**: `PptxFixturesCorpusTests.cs` opens each of the ten +fixtures, asserts a sane (positive) slide count and slide size, and then, for every slide, either +renders it against a transparent background and asserts at least one non-transparent pixel was +painted somewhere (proving real content, not a vacuously-true all-background render), or - for the +two slides known in advance to declare chart/SmartArt `` content - asserts the +render instead throws `PptxUnsupportedFeatureException`, since charts/diagrams remain an +explicitly deferred feature (see the Phase 1e deferred-items list above). **This chart-specific +half of the methodology no longer reflects current behavior**: as of Phase 4, a chart +`` renders successfully (see _Chart Graphic Frames (Phase 4 — delegation to +`CanvasNetCharts`)_ below) - only a SmartArt/diagram or OLE-object graphic frame still throws +`PptxUnsupportedFeatureException`; `PptxFixturesCorpusTests.cs`'s own assertions for these two +fixtures were updated accordingly in that later phase. + +**What was found**: running the full corpus surfaced three ungraceful results, one of which was +an already-correct, merely-undocumented behavior and two of which were genuine, in-scope bugs. + +- **Chart-exception-graceful-behavior hypothesis: confirmed.** Both of the two chart/SmartArt- + bearing slides (`pythonpptx-shp-shapes.pptx` slide index 0 and + `samplelib-sample-presentation.pptx` slide index 4) throw `PptxUnsupportedFeatureException` + (feature token `pptx-graphic-frame-kind`) exactly as the pre-existing `ParseTable` uri-check + logic (Phase 1e) already implements - no source change was needed for this. +- **`pythonpptx-shp-groupshape.pptx` paints zero pixels: confirmed as correct, deferred behavior, + not a bug.** All four shapes in this fixture declare their fill/line/font exclusively via a + `` shape-style-matrix reference (``/``/``/ + ``, each only an `idx` plus a ``), never an explicit ``/ + `` inside their own ``, and every shape's own `` is empty. Resolving a + shape-style-matrix reference into a concrete color is explicitly out of scope (see the Phase 1c + deferred-items list above); `ResolveFill` already gracefully resolves an unrecognized/absent + fill to `PptxNoFill` rather than throwing. The fixture, as authored, genuinely has zero + paintable ink under the currently-implemented feature set - the corpus test's own assertion was + adjusted to match (`PptxDocument_Render_ShpGroupShapeFixture_RendersWithoutError`: renders + without throwing, no painted-pixel assertion), not the source. +- **Genuine bug 1 - centered/right-aligned text invisible in a `wrap="none"` shape.** + `pythonpptx-txt-font-props.pptx` slide index 3 (a centered, `wrap="none"` underline demo) + painted zero pixels. Root cause: `ResolveTextLayout` (`PptxDocument.TextLayout.cs`) used the + same effectively-infinite width (`float.MaxValue / 4f`, used to suppress word-wrapping for a + `wrap="none"` shape) for both line-breaking **and** `PositionLines`'s horizontal alignment math. + For `algn="ctr"`/`algn="r"`, this placed every glyph at an astronomical X offset, far outside + the shape and the rendered surface - a silent, incorrect-output failure on text alignment, an + already-implemented, in-scope feature (Phase 1d), not a deferred one. **Fix**: a new + `alignmentWidth` (`MathF.Max(0f, widthEmu - insetLeft - insetRight)` - the shape's real, + finite declared width, independent of the wrap setting) is now passed to `PositionLines` + instead of the infinite wrap-suppression width, which continues to be used only for + `BuildLines`'s own line-breaking decision. Before: all glyphs of a centered/right-aligned + `wrap="none"` paragraph positioned off-canvas, painting nothing. After: alignment is computed + against the same finite width a `wrap="square"` shape would use, identically for both wrap + modes. Regression test: `ResolveTextLayout_AlignCenter_WrapNone_StillCentersAgainstShapesDeclaredWidth` + (`PptxTextLayoutTests.cs`). +- **Genuine bug 2 - placeholder geometry/position inheritance broken by an empty, but present, + ``.** `samplelib-sample-presentation.pptx` slide indices 0, 1, and 2 each painted zero + pixels. Root cause: `ResolvePlaceholderProperties` (`PptxDocument.Inheritance.cs`, Phase 1b) + resolves a placeholder's effective `` via a first-non-null-**element**-wins chain + (slide's own element, then the matched layout's, then the matched master's) - since `??` only + falls through on a `null` reference, a slide's own empty, self-closing `` (declaring + neither `` nor ``/``, and deliberately relying on the + layout/master for both - extremely common in `.pptx` files not authored by actual PowerPoint, + exactly this corpus's generator) still "wins" that chain outright, so the layout/master's real, + populated `` is never consulted. For ``, this caused `RenderShape`'s existing + "no resolvable `` anywhere - skip silently" tolerant-skip policy (see above) to discard + the whole shape. Fixing only the `` half then exposed a second, cascading failure: + `ResolveShapeGeometry` (Phase 1c) has no equivalent graceful fallback for a missing geometry + child - it throws `InvalidDataException` - so once the shape stopped being silently skipped, it + instead crashed. Both `` and geometry resolution/position inheritance are Phase 1b/1c + features, already implemented and already expected to work via inheritance; this is squarely + in scope, not a deferred-feature gap. **Fix**: rather than changing `EffectiveSpPr`'s own + element-level semantics (which fill/line resolution continues to rely on unchanged, matching + the Phase 1c "fill/line inheritance from placeholder/layout/master is a deliberate + simplification, not resolved at all" design), two new fields were added to + `PptxPlaceholderProperties` - `EffectiveXfrmElement` and `EffectiveGeometrySpPr` - each + resolved independently via its own per-tier (slide -> matched layout -> matched master) walk + that looks specifically for an `` child, or a `` that itself declares an + ``/`` child, respectively, continuing past an empty-but-present + `` that lacks one. This mirrors the precedent `PptxDocument.TextInheritance.cs` already + established for per-attribute (not per-whole-element) run/paragraph property resolution. + `RenderShape` now resolves its `` and geometry `` from these new fields for the + placeholder branch (a non-placeholder shape's own `` is unaffected), and skips the + shape silently - consistent with the existing tolerant-skip policy - if either is still + unresolved anywhere in the chain, rather than letting `ResolveShapeGeometry` throw. Before: + slides 0-2 either rendered nothing (shape skipped) or threw `InvalidDataException`. After: all + three slides' placeholders inherit their master's standard `` geometry + and their layout's `` position/size, and paint visible content. Regression test: + `Render_PlaceholderShapeWithEmptySpPr_InheritsXfrmAndGeometryFromLayout` (`PptxRenderTests.cs`). +- **Genuine bug 3 - `curvedUpArrow` preset geometry crash, plus a placeholder text-style-bucket + mis-selection causing a "Section Header" title placeholder to render at roughly 1/3 PowerPoint's + own font size.** Discovered via a separate real-world-corpus visual comparison against real + PowerPoint COM-automation ground truth (not the ten-file automated corpus above) on two decks + not held in this repository. + - **Crash**: one slide used ``, which `PptxPresetGeometry.Build` + did not recognize, throwing `PptxUnsupportedFeatureException` and failing that slide's render + entirely. **Fix**: added `curvedRightArrow` as a new canonical quarter-annulus-band builder + (an outer arc sweeping 90°→0° and an inner arc sweeping 20°→90°, + connected by straight lines, producing the arrowhead naturally where the inner arc stops + short of the outer arc's full sweep), with `curvedLeftArrow` derived via the existing `Mirror` + helper and `curvedUpArrow`/`curvedDownArrow` derived via a new `CurvedUpOrDownArrow` helper + composing `RotateQuarter` and `Mirror` - mirroring the existing `upArrow`/`downArrow`/ + `leftArrow` derivation pattern from `rightArrow`/`leftRightArrow` exactly, since the + architecture already made deriving all four cheaply and naturally. Regression tests: + `PptxPresetGeometry_Build_CurvedUpArrow_PointsUpwardWithoutThrowing`, + `PptxPresetGeometry_Build_CurvedArrowSiblings_EachPointsTowardItsOwnDeclaredEdge` + (`PptxGeometryTests.cs`), `Render_CurvedUpArrowShape_RendersWithoutThrowingAndPaintsInk` + (`PptxRenderTests.cs`). + - **Title font-size bug - confirmed root cause and fixed.** A "Section Header"-layout-style + title placeholder whose slide-level `` omits `type` (a common, valid OOXML + pattern - the slide placeholder inherits its effective type from the idx-matched layout + placeholder, per ECMA-376/`python-pptx`'s own matching algorithm) rendered at the master's + `` font size (28pt in the reported case) instead of `` (90pt). + Root cause, empirically confirmed by a synthetic repro built against the unmodified code + (master `titleStyle` sz="9000"/`bodyStyle` sz="2800", layout placeholder `type="title" + idx="0"`, slide placeholder `` with `type` omitted): `RenderShape` + (`PptxDocument.Render.cs`) passed the slide placeholder's own raw, schema-defaulted `Type` + ("obj" - OOXML's `type`-omitted default) directly into + `ResolveEffectiveRunProperties`/`SelectMasterTextStyle`'s master-text-style-bucket selection, + even though `ResolvePlaceholderProperties` (`PptxDocument.Inheritance.cs`) already correctly + resolves an idx-matched **effective** type for ``/geometry inheritance via its + existing hop-1 (idx-only) match - that correctly-resolved type was simply never reused for + text-style selection. **Fix**: `PptxPlaceholder` gained a new `DeclaredType` field (the raw + `` value, or `null` when omitted, distinct from `Type`'s schema-defaulted + value) and `PptxPlaceholderProperties` gained a new `EffectivePlaceholderType` field, computed + by `ResolvePlaceholderProperties` as the slide placeholder's own `DeclaredType` when present, + otherwise the idx-matched layout placeholder's `Type`, otherwise the slide placeholder's own + schema-defaulted `Type` as a last resort. `RenderShape` now passes + `placeholderProperties.EffectivePlaceholderType ?? placeholder.Type` into text-style + resolution instead of `placeholder.Type` directly. Before: an omitted-type title placeholder + resolved to `bodyStyle`. After: it correctly resolves to `titleStyle`, matching an + explicitly-`type="title"`-declared placeholder's rendering exactly. Verified + fail-before/pass-after by temporarily reverting the fix and confirming the new test failed + with the pre-fix (28pt/`bodyStyle`) result before re-applying it. Regression tests: + `ResolveEffectiveRunProperties_SectionHeaderTitlePlaceholderWithOmittedType_ResolvesMasterTitleStyleNotBodyStyle`, + `Render_SectionHeaderTitleWithOmittedType_PaintsIdenticallyToExplicitTitleType` + (`PptxRenderTests.cs`). + - **Subtitle "invisible sliver" bug - investigated, not reproduced as a bug in isolation.** A + subtitle placeholder on a different slide rendered as a tiny, essentially unreadable sliver of + text compared to PowerPoint's ground truth. Two independent hypotheses were investigated with + synthetic repro cases built directly against the unmodified code: (1) the same placeholder-type + mis-threading bug above - ruled out, because `SelectMasterTextStyle` already buckets every + non-`title`/`ctrTitle` type (including both the schema-defaulted `"obj"` and an explicitly + declared `"subTitle"`) identically into `bodyStyle` - an omitted-vs-explicit `type` therefore + makes no difference to a subtitle's resolved font size either way, so the title bug's fix + cannot be the (sole) cause of a subtitle-specific symptom; (2) the attribute-less + `` bounded shrink loop (`ResolveAutofitScale`, `PptxDocument.TextLayout.cs`) + over-shrinking given correct inputs - tested directly with a master `bodyStyle` sz="3200", + a realistically-sized subtitle box, and content that genuinely fits: the resolved + `AppliedFontScale` stayed at `1.0` (no over-shrink), refuting this hypothesis for this + scenario. Neither hypothesis reproduces the reported symptom from a minimal, correctly-formed + synthetic fixture; the real-world deck's specific box dimensions/content that produced the + "sliver" are not available in this repository, so the precise trigger could not be isolated + further in this pass. This is reported as an open, unconfirmed root cause rather than a forced + fix - the two negative-result repro cases are retained as permanent regression tests + (`ResolveTextLayout_SubtitlePlaceholderWithAttributeLessNormAutofit_DoesNotOverShrinkContentThatFits`, + `PptxRenderTests.cs`) both documenting the two mechanisms' confirmed-correct behavior in + isolation and guarding against a future regression of either. +- **Genuine bug 4 - `` level-override inheritance broken by an empty, but present, + ``.** Reported from a real-world deck ("ERF IWF Breadboard Peer Review.pptx", not + in this repository): a `ctrTitle` placeholder rendered its title at roughly 28pt instead of its + layout's declared 60pt. Root cause: the exact same structural mechanism as Genuine bug 2, but + for `/` instead of ``/``/geometry - `ResolvePlaceholderProperties` + resolved `effectiveTxBodyListStyle` via a first-non-null-**element**-wins chain (slide, then + matched layout, then matched master), so a slide's own empty, self-closing `` (no + ``..`` level-override child at all, deliberately relying on the + layout/master for level-based run/paragraph overrides) still "won" that chain outright. Because + `PptxDocument.TextInheritance.cs`'s `GetLevelDefRPr`/`GetLevelElement` index this element by + level, an empty `` with no level children caused every level lookup against it to + silently fail exactly as if the element were entirely absent, so the run's font-size resolution + fell through past the layout's real 60pt `` override to the master's `` + bucket (28pt) instead. **Fix**: a new `GetTxBodyListStyleWithLevelOverride` helper + (`PptxDocument.Inheritance.cs`) mirrors `GetSpPrWithGeometry`'s own precedent exactly - it + returns a tier's `` element only when that element itself declares at least one + ``..`` child, walking past a tier's own empty-but-present `` + otherwise. The three `effectiveTxBodyListStyle` resolution call sites in + `ResolvePlaceholderProperties` now call this new helper instead of the unconditional + `GetTxBodyListStyle`. Unlike Genuine bug 2, no new `PptxPlaceholderProperties` field was needed - + `EffectiveTxBodyListStyle` itself is now resolved this way directly, since (unlike ``, + which fill/line resolution still relies on whole-element semantics for) nothing in this + codebase depends on ``'s own unconditional, whole-element-presence resolution. + Before: an empty slide-level `` silently blocked the layout's real level override. + After: the layout/master's own level-bearing `` is correctly consulted, while a + slide-level `` that genuinely declares its own level override still wins (no + regression to that pre-existing, correct case). Regression tests: + `PptxDocumentInheritance_ResolvePlaceholderProperties_SlideLstStyleEmptyWithNoLevelOverride_FallsThroughToLayoutLevelOverride`, + `PptxDocumentInheritance_ResolvePlaceholderProperties_SlideLstStyleHasLevelOverride_SlideWinsOverLayout` + (`PptxDocumentTests.cs`), + `ResolveEffectiveRunProperties_CtrTitlePlaceholderWithEmptySlideLstStyle_ResolvesLayoutDefRPrSizeNotMasterTitleStyleFallback`, + `Render_CtrTitlePlaceholderWithEmptySlideLstStyle_PaintsIdenticallyToAbsentLstStyle` + (`PptxRenderTests.cs`). + - **Candidate follow-up (investigated, not fixed in this pass): `effectiveSpPr`'s fill/line + resolution has the identical structural mechanism.** `effectiveSpPr`'s own three-tier `??` + chain (`PlaceholderInheritancePerCategory`) has the same "empty element still counts as + present" behavior for ``-based fill/line resolution. However, this is not the same bug + class in effect: `ResolveFill`'s own graceful `fillParentElement is null`-means-`PptxNoFill` + no-op behavior, and `EffectiveGeometrySpPr`'s own documented "fill/line inheritance from + placeholder/layout/master is a deliberate, documented Phase 1c simplification, not resolved at + all" remarks, both confirm per-tier fill/line inheritance for a placeholder was never + implemented or promised - unlike ``/geometry and `` level overrides, which + the design does promise to resolve via inheritance. An empty `` "winning" fill/line + resolution today produces `PptxNoFill`/no stroke, indistinguishable in effect from the + already-accepted "fill/line inheritance is not resolved" simplification - there is no + currently-promised behavior being silently defeated. Recorded here as a candidate follow-up: + if `effectiveSpPr`-based fill/line inheritance is ever promoted to a promised, per-tier- + inherited feature (mirroring geometry/``), the identical empty-element fix pattern + (`GetTxBodyListStyleWithLevelOverride`/`GetSpPrWithGeometry`'s own precedent) would need to be + applied there too. No code change made for `effectiveSpPr` in this pass. + +**What remains intentionally deferred**: every item already listed in the Phase 1c, 1d, 1e, and +1f _Deferred to a Later Phase_ sections above remains deferred unchanged - this phase fixed two +genuine inheritance/alignment bugs on already-implemented features, confirmed one already-graceful +deferred-feature boundary (chart/diagram graphic frames) against real files, and confirmed one +already-correct deferred-feature boundary (shape-style-matrix fill/line resolution) likewise; it +did not implement, and does not propose implementing, any item from those lists (charts, OLE, +movies, connectors, nested tables, table auto-sizing/banding, group-level style cascading, +bullets/numbering, full text justification, shape auto-fit, kerning, text-overflow clipping, +picture effects/shadows, +pattern/picture shape fill, radial/path gradients, or adjustment-value (`avLst`) geometry +parsing). Slide background fill (``) is no longer on this list - see _Phase 2 Follow-Up: +Slide/Layout/Master Background Fill (``)_ below, which closed this gap in a later pass. +Master/layout full shape-tree rendering is likewise no longer on this list - see _Phase 2 +Follow-Up: Master/Layout Decorative Shape Rendering_ below, which closed that gap in a +still-later pass. Bullets and numbering rendering is likewise no longer on this list - see +_Phase 2 Follow-Up: Bullets and Numbering Rendering_ below, which closed that gap in a still +later pass. Chart graphic frames are likewise no longer on this list - see _Chart Graphic Frames +(Phase 4 — delegation to `CanvasNetCharts`)_ below, added in a still-later phase that delegates +chart rendering to the sibling `CanvasNetCharts` system; SmartArt/diagram and OLE-object graphic +frames remain deferred. The shape-style-matrix (``'s `fillRef`/`lnRef`) fill/line +resolution noted above as an "already-correct deferred-feature boundary" has since been +superseded: it was in fact +a genuine, unimplemented gap (the matrix reference was parsed by nothing at all, rather than +gracefully deferred) - see _Phase 2 Follow-Up: Shape Style References (``)_ below, which +closed it in a still-later pass. + +#### Phase 2 Follow-Up: Corpus Growth to Three Sources + +A later follow-up pass grew the fixture corpus from ten files across two sources to **twenty** +files across **three** independent sources, adding seven further `python-pptx` files (a slide +background fill, placeholder geometry-inheritance regression coverage, nine unpopulated- +placeholder types, `wrap="none"` + `` text, a lone connector plus a picture, and +explicit solid-fill/line-stroke property variety) and three files newly sourced from +`aiden0z/pptx-renderer` (Apache-2.0 licensed, not MIT - see `PptxFixtures/README.md` and +`PptxFixtures/Aiden0zPptxRenderer.LICENSE` for the corrected license finding and full provenance): +a dense org-chart-style slide with a sibling chart slide, an image-crop/background-fill slide, and +a "stale table frame" slide. Every candidate was empirically verified against a built harness +calling `PptxDocument.Open`/`Render` directly before inclusion, the same methodology the original +Phase 2 pass established. + +**New confirmed-graceful deferred-feature boundaries found this pass**: + +- `pythonpptx-dml-fill.pptx` is the first fixture in this corpus to place a `` or + `` directly inside an ordinary shape's own `` (a picture or pattern used as a + shape's own background fill, rather than a picture/pattern applied to a `` or table cell). + At the time of this pass, `ResolveFill` threw `PptxUnsupportedFeatureException` for both cases + (feature tokens `pptx-picture-fill` and `pptx-pattern-fill` respectively) - this pass was the + first to exercise either exception token against a real file; no source change was needed then. + **Update (Phase 2 follow-up)**: picture fill (``) is now implemented - see _Picture + Fill (``) as a Shape/Paragraph Fill_ above - so this fixture's own slide 0 no longer + throws `pptx-picture-fill`; at that point it instead reached, and still threw, + `pptx-pattern-fill` from a later pattern-filled shape on that same slide. **Update (pattern-fill + follow-up)**: that shape's own preset (`divot`) is now covered too - see _Pattern Fill + (``) (Phase 2 Follow-Up)_ above - so this fixture's slide 0 now renders completely, + end-to-end, without throwing at all. +- `aiden0z-table-stale-frame.pptx`'s single table graphic frame declares a `` extent that + does not match the sum of its own `` widths - a real-world "stale frame size" + authoring artifact. Confirmed, by direct code reading of `PptxDocument.Tables.cs`, that column/ + row sizing is derived entirely from ``/``, independent of the graphic frame's + own declared extent, and empirically confirmed this renders and paints without exception - a + genuine, confirmed-graceful edge case, not a bug. + +**No new bugs were found in this pass** - unlike the original Phase 2 pass (which fixed two +genuine inheritance/alignment bugs), every ungraceful-looking result investigated in this +follow-up pass (two chart/SmartArt exceptions, two shape-background-fill exceptions, several +zero-painted-pixel slides) matched an already-implemented, already-documented deferred-feature or +exception-path precedent; none required a source change. + +**Seven additional candidates were evaluated and excluded** (see `PptxFixtures/README.md`'s own +"Excluded Staged Candidates" section for full per-candidate rationale): `shp-freeform.pptx` (no +freeform content actually baked into the published file), `shp-autoshape-adjustments.pptx` +(strictly redundant with the already-included `pythonpptx-shp-autoshape-props.pptx`), +`dml-effect.pptx` (its only effect-related element is an empty, no-op `` override), +`mst-shapes.pptx`/`mst-slide-layouts.pptx`/`lyt-shapes.pptx` (each declares zero slides, the same +disqualifying precedent as the original pass's two excluded candidates), and +`aiden0z/pptx-renderer`'s own `docs/example/embedded-font/source.pptx` (near-byte-identical in XML +structure to the included `aiden0z-image-crop-css-reset.pptx`; the font-embedding differentiator +is not observable to CanvasNet, which has no custom font-embedding support). + +Every item in this unit's own _Deferred to a Later Phase_ lists (Phase 1c/1d/1e/1f, reproduced +above) remains deferred unchanged after this follow-up pass; this pass grew real-file test +coverage and confirmed additional already-graceful boundaries, it did not implement any +previously-deferred feature. + +#### Phase 2 Follow-Up: Slide/Layout/Master Background Fill (``) + +A later follow-up pass closed the single highest-visual-impact gap left unimplemented by Phase 1f +(and carried forward, unchanged, through the two corpus-hardening passes above): a slide's own +``/`` background fill. Before this pass, `Render` painted every slide against a +plain, uniform `PptxRenderOptions.BackgroundColor` (opaque white by default) regardless of what +the real `.pptx` file itself declared - the real-world fixture corpus already contained two +independent files (`pythonpptx-sld-background.pptx`, added specifically for this feature, and +`aiden0z-image-crop-css-reset.pptx`) whose slides declare a branded, non-white background that +rendered as plain white before this pass. + +**Resolution algorithm** (`PptxDocument.Background.cs`'s `ResolveSlideBackgroundFill`): mirrors +the Phase 1b placeholder-inheritance "first element present at all wins" precedent exactly (see +_Placeholder Inheritance_ above) - a slide's own `` wins outright when present (even an +empty ``); otherwise its layout's own `` wins; otherwise its master's own `` +wins; when none of the three declare a `` at all, `Render` falls back unchanged to +`PptxRenderOptions.BackgroundColor`, matching this unit's pre-existing behavior exactly for every +file that declares no background anywhere. The resolved paint, when non-null, is painted as a +single full-slide rectangle (`Path.Rectangle(0, 0, SlideSize.WidthEmu, SlideSize.HeightEmu)`, +transformed by the same base EMU-to-pixel transform every shape uses) immediately after the +surface's own `BackgroundColor` clear and immediately before the shape-tree walk begins - so slide +content continues to draw on top of it unchanged. + +A `` element declares either `` (an explicit fill - resolved by calling the existing +`ResolveFill` directly, since `` has the same "fill-definition child element" shape as +``, no new fill-resolution logic was written) or `` (a theme +format-scheme style-matrix reference). A ``'s `idx` follows ECMA-376's +`CT_StyleMatrixReference` convention: `idx` 0 or 1000 means "no background fill" (resolved to +`PptxNoFill`); `idx` 1-999 indexes the theme's `` (the rare, not-used-for- +backgrounds-in-practice half of the style matrix - throws `PptxUnsupportedFeatureException`, +feature token `pptx-bg-fill-style-ref`); `idx` 1001 and above indexes the theme's own +`/` (a new `PptxTheme.BgFillStyleList`, parsed by +`PptxDocument.Theme.cs`'s new `ParseBgFillStyleList`, defaulting to an empty list for the +overwhelming majority of themes that declare no `` at all), 0-based from that offset. +A matched `` entry is itself parameterized with an `` +placeholder-color token; ``'s own single color-definition child supplies the concrete +substitution value, resolved via the existing `ResolveColor`. Reusing `ResolveFill` for a style- +list entry (which is itself a bare fill-definition element, not a parent containing one) required +wrapping it in a synthetic, unnamespaced parent `XElement` first - adding an `XElement` that +already has a parent as content to a new element clones it rather than reparenting it, so the +theme's own cached, shared tree is never mutated by this (confirmed by a dedicated regression +test, `ResolveSlideBackgroundFill_BgRefResolution_DoesNotMutateThemeBgFillStyleList`). + +**`phClr` substitution is a general mechanism, not special-cased to backgrounds**: `ResolveFill`/ +`ResolveGradientFill`/`ResolveColor`/`ResolveBaseColor` each gained a new, optional, trailing +`Rgba32? phClrOverride = null` parameter - when supplied and non-null, a `` resolves to that value instead of the pre-existing `ResolveSchemeColor(val, +theme.ColorScheme)` lookup (which still resolves `"phClr"` to `Dark1`, unchanged, for every +pre-existing call site that does not supply an override). This is a purely additive, source- +compatible change - every pre-existing call site continues to compile and behave unchanged. + +**Fidelity achieved**: + +- **Solid-color fills and theme-indexed `` fills**: fully supported, including `phClr` + substitution - the highest-priority, most-common-in-real-decks case this feature targeted. +- **Linear gradient background fills**: best-effort - inherits Phase 1c's own existing linear- + only boundary (`ResolveGradientFill` already throws `PptxUnsupportedFeatureException` for a + radial/path gradient; a ``'s own `` is resolved via the same `ResolveFill`/ + `ResolveGradientFill` pipeline every shape fill already uses, so this boundary is inherited + unchanged, not newly introduced). +- **Picture background fills**: now supported (Phase 2 follow-up) - a ``'s own + `` resolves through the same `ResolveFill`/`PptxImageFill` pipeline every shape fill + now uses, with `ResolveSlideBackgroundFill` supplying a tier-appropriate resolver (slide, layout, + or master, selected per the winning background tier, since an embedded blip's own relationship + is part-scoped to its owning part) - see _Picture Fill (``) as a Shape/Paragraph + Fill_ above. +- **Pattern background fills**: a covered preset now resolves and paints exactly like any other + shape's own `` (`ResolveFill` is shared, un-specialized, across every fill context - + see _Pattern Fill (``) (Phase 2 Follow-Up)_ above); an uncovered preset still throws + exactly as before. This background call site (`PptxDocument.Render.cs`'s own slide-background + paint) is now also covered by the shape/connector-specific transform-composition fix documented + in that same section: it uses `FillPaintComposingLocalTransform`, passing the slide's own + EMU-to-pixel `baseTransform` as the composed local-to-surface transform - the background + rectangle's own local origin coincides with the slide's EMU origin, so `baseTransform` plays + exactly the same role here that a shape's own resolved local-to-surface transform plays + elsewhere. Before this fix, the background fill's own `FillPaint` call composed the identity + transform instead, collapsing a pattern-filled background onto a single flat color across the + entire slide (the same collapse-to-one-texel defect originally fixed for shape/connector + strokes). `Render_SlideLevelPatternFillBackground_PaintsCorrectlyTransformedStripesNotFlatColor` + (`PptxRenderTests.cs`) now exercises this combination end-to-end, proving the background + genuinely tiles (both the resolved foreground and background colors appear) rather than + collapsing to a single color. + +**Real-file regression coverage**: both `pythonpptx-sld-background.pptx` and +`aiden0z-image-crop-css-reset.pptx`'s own fixture-corpus tests, previously honestly titled/worded +to describe the background as "not painted" (because no `` parsing existed at all), were +extended with pixel-level assertions confirming their own real, solid-color backgrounds (`FF0000` +and `FAFAF9` respectively) now paint correctly - see +`PptxDocument_Render_SldBackgroundFixture_RendersAndPaintsSlideBackgroundFill` and +`PptxDocument_Render_Aiden0zImageCropCssResetFixture_PaintsVisibleContentAndBackgroundFill` in +`PptxFixturesCorpusTests.cs`. + +#### Phase 2 Follow-Up: Master/Layout Decorative Shape Rendering + +A still-later follow-up pass closed the remaining visual-fidelity gap left by every prior +pass: a slide master's and slide layout's own non-placeholder shapes (pictures, autoshapes, +groups, freeform/custGeom shapes - anything in a `/` that is not itself a +placeholder) were parsed only far enough to find placeholder shapes (for the Phase 1b +inheritance chain) and, after the previous pass, to resolve a `` background - their own +decorative shapes were never independently walked or painted. A real PowerPoint deck frequently +places logos, rules, and other decoration directly on a master or layout's own shape tree +(outside any placeholder), so every such file rendered with that decoration silently missing +before this pass. + +**Parsing**: `PptxMaster` and `PptxLayout` (`PptxPlaceholder.cs`) each gained a new +`IReadOnlyList ShapeTree` property (defaulting to `Array.Empty< +PptxShapeTreeNode>()` via the same "redeclare the positional-record property with an `init` +default" pattern already used elsewhere in this unit), populated by `GetMaster`/`GetLayout` +(`PptxDocument.Masters.cs`/`PptxDocument.Layouts.cs`) calling the pre-existing +`ParseShapeTree` - the same shape-tree parser `GetSlide` already used - against each part's own +`/`. `GetLayout` resolves its theme lazily (`() => GetTheme(GetMaster( +masterPartPath).ThemePartPath)`), mirroring `GetSlide`'s own existing lazy-theme pattern exactly; +`GetMaster` resolves its theme eagerly, since a master's own theme part path is already resolved +eagerly by the time its shape tree is parsed. + +**Rendering order** (`PptxDocument.Render.cs`'s `Render`): between painting the resolved +background (previous pass, unchanged) and walking the slide's own shape tree (pre-existing, +unchanged), two new loops walk the slide's own master's shape tree, then its own layout's shape +tree, each back-to-front in document order - the documented, intended back-to-front order for a +real slide is background, then master decoration, then layout decoration, then the slide's own +content on top. A slide only ever walks its **own** layout's and that layout's **own** master's +shape tree (the same slide -> layout -> master relationship already resolved for placeholder +inheritance and background), never an unrelated layout or master's shapes. + +**Placeholder shapes on a master/layout are still never rendered directly**: a master/layout +placeholder shape is real PowerPoint's own "Click to edit…" prompt/edit-mode-only text, invisible +on an actual slide - rendering it directly would paint phantom prompt content no real exported +slide ever shows. `RenderNode` (`PptxDocument.Render.cs`) gained a new `bool +skipPlaceholderShapes = false` parameter, propagated unchanged into every recursive call +(including group children); the two new master/layout loops pass `skipPlaceholderShapes: true`, +while the pre-existing slide-shape-tree loop continues to pass the parameter's own default +(`false`, unchanged slide behavior) - a `PptxSpShapeNode` whose own `Placeholder` is non-null is +skipped outright when the flag is set, every other node kind (picture, autoshape, group, +freeform, graphic frame) is unaffected and dispatches through the exact same per-shape-type paint +functions a slide-level shape already used, so a picture/autoshape/group on a master or layout +renders pixel-for-pixel identically to the same shape declared directly on a slide. + +**A latent relationship-resolution bug was fixed as a prerequisite**: `RenderNode`/ +`RenderPicture` previously took a `PptxSlide slide` parameter and used `slide.PartPath` to resolve +a ``'s own image relationship - correct only for a slide's own pictures, +since OPC relationships are always scoped to the owning part's own `.rels` file, not the part +that happens to be rendering. Both methods' `PptxSlide slide` parameter was replaced with a +`string ownerPartPath`, threaded through recursively so a master-owned or layout-owned picture +resolves its own `rId` against its own owning part's own `.rels` file, never the slide's. A +dedicated regression test (`Render_MasterNonPlaceholderPicture_PaintsOnEverySlideUsingThatMasterResolvingOwnRels`) +proves this is load-bearing by giving the slide's own `.rels` a deliberately conflicting, +wrong-colored `rId2` and confirming the master's own picture still resolves its own correct +color. + +**Already-deferred features throw the same `PptxUnsupportedFeatureException` for a master/layout +shape as for a slide-level shape, but a master/layout shape's own exception is now caught and +only that single shape skipped**: a master/layout decorative shape that itself uses a feature +this codebase does not yet support (an unsupported preset geometry, a chart/OLE graphic frame, an +unsupported raster picture format, and so on) hits the exact same `PptxUnsupportedFeatureException` boundary +a slide-level shape already hits, because both now flow through the same `RenderNode` dispatch. +Unlike a slide-level shape, however, this exception is now caught at the leaf dispatch point +inside `RenderNode` (gated on the existing `skipPlaceholderShapes` flag, which this fix repurposed +to also mean "this is a master/layout decorative-shape walk") and only that one shape is skipped - +rendering continues with the shape's own siblings (including, for a shape nested inside a +`` group, its siblings within that same group), the rest of that master's or layout's +own shapes, the subsequent layout/slide walks, and `Render` as a whole completes successfully. +This is a deliberate, newly-introduced containment boundary that applies **only** to +master/layout-owned shapes (`skipPlaceholderShapes == true`) - a slide's own shape throwing the +same exception still hard-fails `Render` exactly as before this fix, unchanged. This containment +was added as a regression fix after real-file testing found that, without it, a single +already-deferred feature used by a decorative master/layout shape (for example an EMF picture) +would abort rendering for every slide using that master/layout - a severe regression versus the +pre-existing behavior where master/layout shape trees were never walked at all. See +`Render_MasterNonPlaceholderPictureWithUnsupportedFormat_SkipsThatShapeAndStillRendersSlideContent` +and `Render_SlideOwnPictureWithUnsupportedFormat_StillThrowsPptxUnsupportedFeatureException` in +`PptxRenderTests.cs` for the regression coverage proving both halves of this boundary. + +**Fidelity achieved**: + +- A master's own non-placeholder pictures, autoshapes, groups, and freeform shapes now paint on + every slide that uses that master (regardless of which layout the slide itself uses). +- A layout's own non-placeholder pictures, autoshapes, groups, and freeform shapes now paint only + on a slide that uses that specific layout. +- Correct back-to-front z-order is preserved: a slide's own content always paints on top of its + own layout's decoration, which always paints on top of its own master's decoration. +- A master/layout's own placeholder shapes remain invisible, exactly as before this pass - + this pass only changed non-placeholder sibling shapes. + +**Real-file regression coverage**: `aiden0z-1-chart-and-complex.pptx`'s own +`ppt/slideLayouts/slideLayout1.xml` contains a real, non-placeholder, `userDrawn="1"` +`` freeform shape named "Freeform 5", exercised by +`PptxDocument_Render_Aiden0zChartAndComplexFixture_Slide0PaintsSlide1ThrowsUnsupportedFeature` +(`PptxFixturesCorpusTests.cs`). This fixture's own `bg1` theme color and its own slide master +background both already resolve to pure white, the same color this freeform shape itself is +filled, so a pixel sample alone cannot show a dramatic visual contrast against the background (the +synthetic tests below carry that contrast proof with deliberately contrasting colors) and would +pass whether or not this feature actually walks the layout's own shape tree. This test's primary, +discriminating proof is therefore structural instead: it resolves the layout directly via +`PptxDocument.GetLayout` and asserts its parsed `ShapeTree` genuinely contains "Freeform 5" as a +`PptxSpShapeNode` with a `null` `Placeholder` - proving this real file's own layout shape tree is +actually parsed and that this shape is recognized as non-placeholder decoration, independent of +its own paint color. The near-white pixel sample within the freeform's own bounding box is kept +only as a secondary, no-throw regression check (a tolerance, not an exact match, because the real +custGeom path is a thin decorative flourish rather than a solid-filled rectangle). + +Six new synthetic, pixel-level tests in `PptxRenderTests.cs` carry the primary proof, each with +deliberately contrasting colors so a wrong z-order or a missed shape fails visibly rather than +silently: `Render_MasterNonPlaceholderPicture_PaintsOnEverySlideUsingThatMasterResolvingOwnRels`, +`Render_LayoutNonPlaceholderAutoshape_PaintsOnlyOnSlidesUsingThatLayout`, +`Render_MasterAndSlideShapesOverlap_SlideShapePaintsOnTopOfMasterShape`, +`Render_MasterAndLayoutShapesOverlap_LayoutShapePaintsOnTopOfMasterShape`, +`Render_MasterPlaceholderShape_DoesNotRenderItsOwnPromptContent`, and +`Render_LayoutPlaceholderShape_DoesNotRenderItsOwnPromptContent`. + +Two further regression tests cover the graceful-skip containment fix described above: +`Render_MasterNonPlaceholderPictureWithUnsupportedFormat_SkipsThatShapeAndStillRendersSlideContent` +(a master's own EMF picture shape is skipped and the slide's own content still paints) and +`Render_SlideOwnPictureWithUnsupportedFormat_StillThrowsPptxUnsupportedFeatureException` (a +slide's own EMF picture shape still hard-fails `Render`, proving the containment is scoped to +master/layout-owned shapes only). + +#### Phase 2 Follow-Up: Bullets and Numbering Rendering + +A still-later follow-up pass closed Phase 1d's own longest-standing deferred item (see _Deferred +to a Later Phase (Phase 1d)_ above): a paragraph's own bullet or number glyph +(``/``/``, plus the ``/``/``/ +``/`` modifiers that style it) was never parsed or painted - a bulleted or +numbered list rendered as plain, un-marked paragraphs, a visually obvious gap against a real +PowerPoint export of the same deck. + +**Parsing** (`PptxDocument.Text.cs`'s `ParseBulletProperties`): a paragraph's own `` (or a +placeholder/master level-override element - see _Resolution_ below) is inspected for four +_independent_ choice-groups, each captured as its own raw `XElement?` on a new +`PptxRawBulletProperties` record (`PptxTextBody.cs`): **type** (`` / `` / +`` - mutually exclusive per ECMA-376's own `EG_TextBulletColor`/`EG_TextBulletTypeface`/ +`EG_TextBulletSizeGroup`/`EG_TextBullet` choice groups), **color** (`` / ``), +**font** (`` / ``), and **size** (`` / `` / +``). Capturing each choice-group's winning raw element separately (rather than parsing +straight to a single resolved value) is what lets resolution treat each group as its own +independent inheritance chain - see below. + +**Resolution** (`PptxDocument.TextInheritance.cs`'s `ResolveEffectiveBulletProperties`): reuses +this unit's own established attribute-level inheritance pattern (own paragraph -> placeholder +level element -> master level element -> hard-coded default) _four times_, once per choice-group, +exactly mirroring how `algn`/`marL`/`indent` are each already resolved independently in +`ResolveEffectiveParagraphProperties`. This is deliberate and load-bearing: a real-world paragraph +commonly declares only its own bullet _character_ while relying on its placeholder/master level +style for the bullet's color/font/size (or vice versa) - resolving bullets as one monolithic +"whichever tier's whole bullet block wins" object, copied wholesale from a single tier, would +silently defeat that common partial-override pattern. A `Kind == None` result (explicit +``, or no bullet markup declared anywhere in the chain - the conservative default) +short-circuits color/font/size resolution entirely, returning `PptxEffectiveBulletProperties. +CreateNone`. + +``'s `type` attribute defaults to `"arabicPeriod"` and `startAt` to `1` per ECMA-376's +own schema defaults, in `ResolveBulletType`. The three "follow text" sentinels - ``, +``, `` (and, identically, no color/font/size markup at all) - resolve to the +paragraph's own _first run's_ already-resolved effective color/typeface/size (falling back to the +theme's `Dark1`/the placeholder-type default typeface/the hard-coded default font size for a +run-less paragraph), which required `ResolveEffectiveParagraphProperties` to grow a new, optional +`firstRunProperties` parameter so its caller (`ResolveTextLayout`) can resolve a paragraph's first +run's properties before its own paragraph properties, reversing their previous resolution order +without changing any pre-existing call site's behavior (every pre-existing caller omits the new +parameter and gets `null`, the previous, bullet-free behavior). `` resolves to +that `val/100000` fraction of the same "follow text" base size; `` resolves to +an absolute size (hundredths of a point, the same convention as ``), independent of +the run's own size entirely. + +**Auto-number formatting** (new file `PptxDocument.Bullets.cs`'s `FormatAutoNumber`): formats a +1-based counter value into the bullet string for eleven of ECMA-376's `ST_TextAutonumberScheme` +values - `arabicPeriod`/`arabicParenR`/`arabicPlain`, `alphaLcPeriod`/`alphaUcPeriod`/ +`alphaLcParenR`/`alphaUcParenR` (base-26, spreadsheet-column-style, so value 27 formats as +`"aa."`/`"AA."`), and `romanLcPeriod`/`romanUcPeriod`/`romanLcParenR`/`romanUcParenR` (standard +subtractive-notation Roman numerals). An unrecognized/unimplemented scheme (ECMA-376 defines over +twenty, including several circled/parenthesized-digit Unicode-glyph schemes with no straightforward +ASCII-keyboard-font rendering) returns `null` - a deliberate, graceful per-bullet degradation, +matching this codebase's own established convention for an optional, nullable-returning resolver +(`GetFontSizeEmu`, `ParseLineSpacing`, and so on) rather than throwing +`PptxUnsupportedFeatureException` (reserved for a whole-render-aborting unsupported construct): +only that one paragraph's bullet glyph is silently omitted, its own run text, and every other +paragraph's bullet, render normally. + +**Auto-number counter sequencing** (`PptxDocument.TextLayout.cs`'s `BuildLines`, new private +`AdvanceBulletCounters`): a small per-call (that is, per-shape/text-body) state machine tracks one +running counter and one "last scheme used" value per indent level (`0`-`8`, mirroring this unit's +own existing `MaxParagraphLevel` bound). For each paragraph, in level order: every level _strictly +deeper_ than the paragraph's own level is reset to zero (closing out any nested list once the +list returns to a shallower level); a `None` or `Char` bullet resets and clears its own level's +counter (breaking any numbered run a literal bullet character interrupts); an `AutoNum` bullet +resets to its own `startAt` the first time that level is used with that particular scheme (or +after an intervening paragraph at that level used a _different_ scheme, or after any deeper level +was closed and reused), otherwise increments by one. This reproduces real PowerPoint's own +behavior for a list that returns to a shallower level after a nested sub-list: the shallower +level's own sequence resumes where it left off, while the (now-closed) deeper level restarts from +its own `startAt` the next time a paragraph uses it. + +**Hanging-indent fix** (`PptxDocument.TextLayout.cs`'s `PositionLines`): a previously-undocumented, +closely-related defect, found and fixed alongside the bullet glyph itself because both are needed +together to be visually indistinguishable from PowerPoint - a bulleted paragraph's first line +incorrectly placed its own run text at the full `marL + indent` first-line position (the same +rule a non-bulleted paragraph's first line correctly uses), rather than at `marL`. ECMA-376's own +convention (and every real PowerPoint export) treats `indent` as the _hanging-indent gutter +width_ reserved for the bullet glyph alone, not as an additional first-line indent applied to the +text itself once a bullet occupies that gutter - the bullet glyph paints at `marL + indent` (the +gutter) while the paragraph's own text, even on its first line, starts flush at `marL`, exactly as +every other line of that same paragraph already does. This fix is strictly gated on "this line +has a resolved, non-empty bullet glyph list" (`IsFirstLineOfParagraph && BulletGlyphs.Count > 0`): +a non-bulleted paragraph's first-line indent continues to apply to its own text exactly as +before this phase, unchanged. + +**Fidelity achieved**: + +- `` literal-character bullets, `` auto-numbered bullets (eleven common + schemes), and explicit `` suppression all parse, resolve through the full + placeholder/master inheritance chain, and paint. +- Each of the four bullet choice-groups (type/color/font/size) resolves independently, so a + paragraph may override only one of the four while inheriting the other three from its + placeholder/master level style - the same partial-override fidelity this unit's run/paragraph + property resolution already provides. +- The hanging-indent gutter fix above ensures a bulleted paragraph's own text aligns correctly + relative to its bullet glyph, matching PowerPoint's own rendering. +- An unrecognized `` scheme gracefully omits only that one paragraph's bullet glyph, + never aborting the render. + +**Scoped limitations, left as explicit, documented simplifications**: + +- Bullet glyphs are never bold or italic (`BuildBulletGlyphs` always resolves its font with + `bold: false, italic: false`), regardless of the paragraph's own run formatting - a simplifying, + visually minor deviation real PowerPoint itself rarely exercises for bullet glyphs in practice. +- A bullet glyph is always anchored at the `marL + indent` gutter, independent of the paragraph's + own horizontal alignment (`ctr`/`r`) - bullets are not re-justified for centered or + right-aligned paragraphs, a real but rare combination in practice (bulleted lists are + overwhelmingly left-aligned in real decks). +- Nine of ECMA-376's twenty-plus `ST_TextAutonumberScheme` values remain unimplemented (see + `FormatAutoNumber`'s own remarks for the full list) - each gracefully omits its own bullet glyph + per-paragraph rather than aborting the render, per the graceful-degradation policy described + above. +- **Closed risk (found during visual QA, fixed over two cycles)**: `"sldNum"`/`"dt"`/`"ftr"` field + placeholder types (slide number, date, and footer) were, for a brief window, susceptible to a + stray, isolated bullet glyph with no accompanying text whenever a slide master's `bodyStyle` + declared a bullet for the inherited level - confirmed by independently rendering the + `aiden0z-1-chart-and-complex.pptx` corpus fixture, which showed exactly this defect at its own + slide-number placeholder's geometry. Root cause: `SelectMasterTextStyle` routes every + non-title placeholder type, including these three field types, to the master's `bodyStyle` + bucket (which commonly declares a list bullet) rather than the bullet-free `otherStyle` bucket + these types should consult in genuine PowerPoint output - a separate, pre-existing, + out-of-scope routing defect that also drives font/size/bold/italic/color resolution for these + placeholder types and was therefore left unchanged. The first-cycle fix was an unconditional + early-return guard in `ResolveEffectiveBulletProperties` that forced `PptxBulletKind.None` + whenever `placeholderType` was `"sldNum"`, `"dt"`, or `"ftr"` - this closed the common-case + defect but, because it short-circuited before the paragraph's own `` was ever consulted, + also incorrectly suppressed a paragraph's own explicit ``/`` override on + these same placeholder types, a regression caught by a follow-up quality review. The corrected, + narrower fix instead applies a **master-tier type exclusion** solely within the TYPE + choice-group's own `raw ?? placeholder ?? master ?? default` chain: the master text-style + bucket's bullet-type element is skipped for these three placeholder types only, while the + paragraph's own `raw.TypeElement` and the placeholder's own level-indexed element are consulted + exactly as normal and still win whenever present, matching the fact that an own-paragraph + explicit bullet choice always takes precedence over any style-bucket default, regardless of + placeholder type, per real OOXML/PowerPoint semantics - while PowerPoint's own UI never exposes + bullet/list formatting for these field placeholder types, which is why the master's inherited + bucket is excluded rather than the paragraph's own markup. Status: closed, proven by + `ResolveEffectiveParagraphProperties_SldNumDtFtrPlaceholderType_SuppressesMasterBodyStyleBullet` + (the master-tier exclusion still suppresses an inherited-only bullet) and + `ResolveEffectiveParagraphProperties_SldNumDtFtrPlaceholderTypeWithOwnBuChar_StillResolvesOwnBullet` + (an own-paragraph explicit override on the same three placeholder types still resolves and + wins), plus the pre-existing extended `aiden0z-1-chart-and-complex.pptx` fixture assertion (see + "Test coverage" below), which continues to confirm no regression on the original fix. + +**Test coverage**: a new `PptxBulletTests.cs` covers raw parsing (all three type choices plus all +three color/font/size "follow text" vs. explicit modifier choices), inheritance (own-paragraph vs. +placeholder-level vs. master-level wins for each of the four independent choice-groups, explicit +`` suppressing an inherited bullet, the four choice-groups resolving independently of +each other, ``'s schema defaults, ``/`` size-modifier +arithmetic, and `"sldNum"`/`"dt"`/`"ftr"` placeholder types applying the master-tier type +exclusion described above - both suppressing an inherited-only master `bodyStyle` bullet and +still resolving an own-paragraph explicit override - the closed-risk regression guards above), +`FormatAutoNumber`'s own direct unit tests (all eleven supported schemes at representative values, +including alphabetic rollover and several Roman-numeral edge cases, plus an unsupported scheme +returning `null`), and layout-level tests via `ResolveTextLayout` (the hanging-indent fix +contrasted against its own pre-existing non-bulleted-paragraph regression case, auto-number +sequencing across consecutive paragraphs, the reset/resume behavior across a nested +then-returned-to shallower level, `` suppression, and an unsupported auto-number scheme +gracefully skipping only its own bullet). `PptxFixturesCorpusTests.cs`'s own +`aiden0z-1-chart-and-complex.pptx` fixture test gained a further pixel-level assertion confirming +this real file's own "Rectangle 5" shape (two consecutive ``-bulleted +paragraphs) actually paints visible ink in its own bullet gutter column, distinct from the +surrounding, un-inked inset, plus a second new assertion confirming no stray bullet ink appears at +this same fixture's own slide-number placeholder geometry (the exact real-world location the +closed risk above was found). + +**Closed risk (empty/spacer-paragraph bullet suppression)**: `BuildLines` painted a bullet glyph +for any paragraph whose resolved bullet properties were non-`None`, regardless of whether that +paragraph had any `` run children at all - a run-less (blank/spacer) paragraph that merely +inherited a list's bullet properties (for example a blank line left between two bulleted items, +or a trailing blank paragraph closing out a list) rendered a stray, isolated bullet glyph with no +accompanying text, a visually obvious defect against real PowerPoint output, which never shows a +bullet next to an empty line. The fix computes `hasRuns = paragraph.Items.Any(item => item is +PptxRunItem)` once per paragraph (not per line) and gates only the glyph itself - +`bulletGlyphs = isFirstLine && hasRuns ? BuildBulletGlyphs(...) : []` - deliberately leaving +`AdvanceBulletCounters`'s own counter-state advancement untouched, so a run-less auto-numbered +paragraph still consumes its own position in the sequence (matching real PowerPoint's own counter +semantics) even though nothing is painted for it. **Known, accepted limitation**: a run whose +text is whitespace-only (for example a single run containing only `" "`) still counts as "has a +run" under this simpler zero-runs rule, so such a paragraph's bullet still paints - PowerPoint's +own true rule (zero _non-whitespace-only_ runs) is not resolved by this fix and remains a +documented, minor gap. + +**Closed risk (bullet/text gutter clearance)**: a further, visually similar defect was reported +against real-world content - a `buAutoNum` paragraph appeared to render its auto-number marker +stacked/overlapping its own first word, as if two markers had painted on top of each other. +Investigation (direct code inspection plus numeric probes against the actual pipeline, since +reverted) refuted both of the plausible duplicate-paint/type-precedence hypotheses: there is +exactly one bullet-glyph-painting call site, and the TYPE choice-group's own `raw ?? placeholder +?? master` chain never merges across bullet kinds - a paragraph's own `` fully +supersedes an inherited master `` default, never painting both. The actual root cause +was a **bullet-gutter/text-start-X collision** in `PositionLines`: when a bulleted paragraph's +resolved `IndentEmu` was zero (or not negative enough to clear the bullet glyph's own rendered +width), the bullet glyph and the paragraph's own first-line text were painted at the same (or an +overlapping) X coordinate - a positioning defect, not a bullet-type defect, equally capable of +affecting `buChar` and `buAutoNum` bullets alike. Confirmed numerically on a real-world slide's +exact attribute set (`marL="320040"`, `lvl="1"`, no `indent` attribute, a plain non-placeholder +`` TextBox contributing no indent from any placeholder/master tier, ``): the bullet glyph and the paragraph's own first run glyph resolved to the +identical X coordinate. The fix: `BuildBulletGlyphs` now also returns the bullet string's own +total measured advance width (already computed internally as its running cursor position, simply +surfaced), threaded through a new `LineBox.BulletWidthEmu` field; `PositionLines` then clamps the +bulleted first line's own text-start-X to `MathF.Max(marL, bulletGutterX + BulletWidthEmu)`, +guaranteeing the bullet and the paragraph's own text never share an X range. This clamp is a +no-op whenever the existing gutter already clears the bullet's own width (the already-correct, +sufficiently-negative-indent case), so it introduces no regression there; it does not attempt to +reproduce any additional visual padding PowerPoint's own renderer may add beyond the bullet's own +measured width, a minor, acceptable, documented simplification. Separately, and **not fixed +here**: a visually similar "number squished against text" symptom observed on another real-world +slide was found, on inspection, to not involve `buAutoNum` at all - its "numbers" are literal +typed digits separated from the following word by a literal tab character, and this unit's +`Tokenize`/`MeasureTokenWidthEmu` treat a tab as ordinary collapsible whitespace with no OOXML +tab-stop (`defTabSz`/`tabLst`) expansion; this is a distinct, pre-existing, out-of-scope defect +(tab-stop support), flagged for a separate unit of work rather than folded into this fix (closed by +the _Phase 2 Follow-Up: Default Tab-Stop Expansion_ section below). Status: +closed, proven by `ResolveTextLayout_BulletedParagraphWithZeroIndent_TextClearsBulletWidth` (exact +numeric clamp proof), `PaintTextLayout_BulletedParagraphWithZeroIndent_BulletAndTextInkDoNotOverlap` +(pixel-level disjoint-ink proof), and +`ResolveTextLayout_MasterBuCharDefault_OwnBuAutoNumOverride_PaintsOnlyAutoNumberMarker` (permanent +regression guard confirming the type-precedence hypothesis remains refuted), alongside the +pre-existing `ResolveTextLayout_BulletedParagraph_TextStartsAtMarLGutterHoldsBullet` and +`ResolveTextLayout_BuNone_PaintsNoGlyphBeyondRunText`, both confirmed unaffected. + +**Closed risk (`buAutoNum` gutter-clearance minimum gap)**: a follow-up real-world report against +the same gutter-clearance clamp above found it, while correct for `buChar`, still insufficient for +`buAutoNum`: CanvasNet rendered an auto-numbered marker immediately touching the paragraph's own +first word (e.g. `"1.3 custom tip geometries"`, `"2.QO Reagent durable probe (J18604)"`), whereas +real PowerPoint shows a clear, word-space-sized gap after the marker (e.g. `"1."` then a visible +gap then `"3 custom tip geometries"` - the `"3"` is the paragraph's own typed first word, not part +of the marker). Investigation confirmed `BuildBulletGlyphs` already measures a multi-character +auto-number marker's full string width correctly (refuting a suspected "single-glyph width" +regression) - the actual gap was the prior fix's own explicitly documented simplification: its +clamp reserves a "just touching, zero extra padding" minimum uniformly for every bullet kind, +which the design document above already flagged as not reproducing PowerPoint's own additional +visual padding. The fix: `BuildBulletGlyphs` now also returns a `BulletGlyphsResult.TrailingGapEmu` + +- one space character's own advance width, measured in the bullet's already-resolved font/size - +but **only** for `PptxBulletKind.AutoNum` (`0` for `Char`/`None`, leaving the already-correct, +already-verified `buChar` "touching, zero extra gap" behavior unaffected); threaded through a new +`LineBox.BulletTrailingGapEmu` field, `PositionLines`'s existing clamp now folds it in additively: +`MathF.Max(marL, bulletGutterX + BulletWidthEmu + BulletTrailingGapEmu)`. This is a best-effort, +font-metric-derived approximation of PowerPoint's own tab-stop-like spacing, not full OOXML +`defTabSz`/`tabLst` tab-stop support (a distinct, pre-existing, out-of-scope limitation already +noted above) - a reasonable, documented simplification, since a single space's advance width +closely approximates ordinary inter-word spacing without requiring new tab-stop geometry. Status: +closed, proven by the updated +`ResolveTextLayout_BulletedParagraphWithZeroIndent_TextClearsBulletWidth` (exact numeric clamp +proof, now including the trailing gap term), a new +`ResolveTextLayout_BulletedParagraphWithZeroIndent_AutoNumTwoDigitMarker_TextClearsBulletWidthPlusGap` +(proves the gap is additive to a genuinely multi-character marker's own full width - e.g. `"12."` +- rather than a fixed constant, using a digit-first run text reproducing the exact real-world +ambiguous pattern), and a new +`PaintTextLayout_BulletedParagraphWithZeroIndent_AutoNumMarker_BulletAndTextHaveVisibleGap` +(pixel-level proof of a genuinely blank column range between the marker's own measured-width +boundary and the clamped text start, stronger than mere non-overlap). + +#### Phase 2 Follow-Up: Color Map (``/``) Resolution + +A further visual-fidelity defect was found and fixed: `ResolveSchemeColor` (`PptxDocument. +Paint.cs`) hard-coded `bg1` -> `Light1`, `tx1` -> `Dark1`, `bg2` -> `Light2`, `tx2` -> `Dark2`, +entirely ignoring the slide master's own required `` and any layout/slide `` +override - per ECMA-376, these four aliases are an **indirection**, not a fixed mapping: a slide +master's own `` (the identity mapping, +true of the overwhelming majority of real-world themes) happens to agree with the previous +hard-coded behavior, but a master, layout, or slide that declares a non-identity map - most +commonly a "dark" layout/master variant that inverts `bg1`/`tx1` to `dk1`/`lt1` - was rendered +with every `bg1`/`tx1`/`bg2`/`tx2` scheme color silently wrong. + +**Data model** (`PptxTheme.cs`): a new `PptxColorMap(string Bg1, string Tx1, string Bg2, string +Tx2)` record captures a resolved ``/effective ``'s own four indirection +targets (each one of the theme's twelve canonical slot names, in practice always one of +`dk1`/`lt1`/`dk2`/`lt2`), with a `Default` static instance holding the identity mapping - +deliberately omitting `accentN`/`hlink`/`folHlink`, which a real-world `` always maps to +themselves. + +**Parsing**: `` is a **required** child of `` per ECMA-376's own schema; +`GetMaster` (`PptxDocument.Masters.cs`) now parses it via a new `ParseColorMap` helper, throwing +`InvalidDataException` if the element or any of its four required attributes is missing, and +exposes the result as a new `PptxMaster.ColorMap` property (an optional trailing constructor +parameter defaulting to `PptxColorMap.Default`, so every pre-existing positional +`new PptxMaster(...)` test call site keeps compiling unchanged). `` is optional on +both `` and ``; `GetLayout`/`GetSlide` (`PptxDocument.Layouts.cs`/ +`PptxDocument.Slides.cs`) capture its raw, unparsed `XElement?` as `PptxLayout.ClrMapOvr`/ +`PptxSlide.ClrMapOvr` respectively - deferred, unparsed, because a `` can wrap either +an `` (an actual override) or an `` (an explicit +"no override at this tier" marker), and only the resolver (below) needs to distinguish them. + +**Resolution** (`PptxDocument.Theme.cs`'s new `ResolveEffectiveColorMap`): implements the +documented slide -> layout -> master fallback chain - a slide's own `` wins if it +wraps ``; otherwise the layout's own `` wins under the same +condition; otherwise the master's own parsed `` applies. A `` wrapping +`` at either tier is treated as "no override at this tier" and falls through +to the next tier, exactly matching real PowerPoint's own semantics for that marker. + +**Threading**: an optional `PptxColorMap? colorMap = null` parameter (defaulting via +`colorMap ??= PptxColorMap.Default` at the top of each method body) was threaded through every +color-resolving entry point this unit already has - `ResolveFill`/`ResolveGradientFill`/ +`ResolveColor`/`ResolveBaseColor`/`ResolveLineStyle` (`PptxDocument.Paint.cs`), +`ResolveSlideBackgroundFill`/`ResolveBackgroundElement`/`ResolveBackgroundStyleReference` +(`PptxDocument.Background.cs`), `ResolveEffectiveRunProperties`/`GetRunColor`/ +`ResolveEffectiveParagraphProperties`/`ResolveBulletColor` (`PptxDocument.TextInheritance.cs`), +and `ResolveTextLayout` (`PptxDocument.TextLayout.cs`). `ResolveSchemeColor` itself was rewritten +so `bg1`/`tx1`/`bg2`/`tx2` first indirect through `colorMap.Bg1`/`.Tx1`/`.Bg2`/`.Tx2` to their own +target slot name, then resolve that target through a new, non-recursive `ResolveNamedSlot` helper +(the same switch as before, minus the `bg`/`tx` aliasing arms, so a non-identity map can never +recurse back through another alias) - every other slot (`accent1`-`accent6`/`hlink`/`folHlink`/ +`dk1`/`lt1`/`dk2`/`lt2`/`phClr`) is unaffected. + +**Render-time wiring** (`PptxDocument.Render.cs`): the public `Render` method computes +`ResolveEffectiveColorMap(slide.ClrMapOvr, layout.ClrMapOvr, master.ColorMap)` exactly once per +render call, then threads the result as a new **required** parameter through the private +`RenderNode`/`RenderShape`/`RenderGraphicFrame` helpers and into every nested +`ResolveSlideBackgroundFill`/`ResolveFill`/`ResolveLineStyle`/`ResolveTextLayout`/`PaintTable` +call - safe to make required (rather than optional) since these helpers are `private`/`internal` +and have no external or test call sites that bypass `Render` itself. + +**Scoped limitation, left as an explicit, documented simplification**: a table cell's own fill +and border colors (`ParseTableCell`, `PptxDocument.Tables.cs`) are resolved once, eagerly, at +parse/load time - before any per-slide effective color map is known in the general case - so a +cell's ``-referenced colors could always effectively use `PptxColorMap.Default`, +regardless of any real `` in effect for that slide. A table cell's own **text**, by +contrast, is not baked at parse time: `PaintTable` calls `ResolveTextLayout` for each cell's text +at paint time, so cell text color correctly resolves through the real, per-render effective color +map like any other text run. + +**Narrowed by Review Follow-Up, for slide-owned tables only**: `GetSlide`'s own slide cache +(`_slideCache`, `PptxDocument.Slides.cs`) is keyed 1:1 by slide index - a cached `PptxSlide`'s own +`ShapeTree` is never consumed by any other slide - so it is sound for `GetSlide` to supply +`ParseShapeTree` a lazy `Func colorMapResolver` computing +`ResolveEffectiveColorMap(clrMapOvr, layout.ClrMapOvr, master.ColorMap)` from that slide's own, +already-in-scope inheritance chain. `ParseShapeTree` (`PptxDocument.ShapeTree.cs`) threads the new, +optional `colorMapResolver` parameter through its recursive `` self-call and invokes it +lazily - only when a `` table is actually found - passing the resolved +`PptxColorMap` into `ParseTable`'s own `colorMap` parameter, so a slide-owned table's cell fills +now correctly resolve `` references against that slide's real effective color map, +not always `PptxColorMap.Default`. + +`GetMaster`/`GetLayout`'s own part caches, by contrast, are keyed by part path and shared across +every slide that references that master/layout - each of which may have its own, different +`` - so a single cached master/layout `ShapeTree` cannot soundly bake in any one +particular slide's effective color map. `PptxDocument.Masters.cs`/`PptxDocument.Layouts.cs` +therefore intentionally omit `colorMapResolver` at their own `ParseShapeTree` call sites, each with +an inline comment explaining why; a **master- or layout-owned table's** own ``-filled +cells remain the one residual, narrower case of this limitation - still effectively +`PptxColorMap.Default`-resolved - left as an explicitly documented, architecturally-larger +follow-up (it would require per-consuming-slide cache keys or fully deferring color resolution to +paint time, mirroring how cell text already works, rather than this fix's narrower, safe +per-slide-cache threading). + +**Test coverage**: `PptxPaintTests.cs` gained direct `ResolveSchemeColor`/`ResolveColor` unit +tests proving the default (identity) map's `tx1` -> `Dark1` behavior is preserved, a non-identity +map correctly redirects `tx1` to an overridden slot, and the indirection is applied before any +``/``-style color transform. A new `PptxColorMapTests.cs` covers +`GetMaster`'s required `` parsing (including the `InvalidDataException` thrown when +absent), `GetLayout`/`GetSlide`'s optional `` parsing, and `ResolveEffectiveColorMap`'s +full slide -> layout -> master fallback chain, including both `` +fall-through cases and the `InvalidDataException` thrown for a malformed +`` missing a required attribute. `PptxRenderTests.cs` gained an end-to-end +render-level pixel test confirming a slide-level `/` makes an ``-filled shape actually paint the theme's `Dark1` pixel +color rather than the previously hard-coded `Light1`, alongside a companion test confirming the +pre-existing, no-override baseline behavior is unchanged. + +#### Phase 2 Follow-Up: Shape Style References (``) + +A further visual-fidelity gap was closed: a shape built from PowerPoint's own "Shape Styles" +gallery (the ribbon gallery that applies a theme-coordinated fill/line/effect/font combination to +a shape without the user ever touching an explicit ``/``) declares that choice +purely via ``'s sibling `` element - ``/`` +indexing the theme's own `/`/``, each entry's own +`` tokens substituted with the ref's own declared color child. Before +this fix, `` was parsed by nothing in this unit at all, so a gallery-styled shape with no +explicit `` fill/line silently rendered with no fill and no stroke whatsoever. + +**Data model** (`PptxTheme.cs`): two new `IReadOnlyList FillStyleList`/ +`IReadOnlyList LnStyleList` properties, each an optional trailing constructor parameter +defaulting to `[]`, mirroring the existing `BgFillStyleList` property exactly (so every +pre-existing positional `new PptxTheme(...)` call site keeps compiling unchanged). + +**Parsing** (`PptxDocument.Theme.cs`): new `ParseFillStyleList`/`ParseLnStyleList` private helpers, +parallel to the existing `ParseBgFillStyleList`, read `/` and +`/` respectively into their raw, unparsed child elements in document +order, each tolerant of an absent `` or absent list (returns `[]`). Wired into +`GetTheme`'s `new PptxTheme(...)` construction alongside the existing `bgFillStyleList`. + +**Resolution** (`PptxDocument.Paint.cs`): two new internal resolvers, `ResolveShapeStyleFill` +and `ResolveShapeStyleLineStyle`, reuse the exact phClr-substitution pattern +`ResolveBackgroundStyleReference` (`PptxDocument.Background.cs`) already establishes for +`` - resolve the ref's own single color-definition child (when present) via +`ResolveColor`, then pass it as a `phClrOverride` into the matched style-list entry's own +resolution. Unlike ``, which indexes `BgFillStyleList` with a `+1000`/`+1001` offset +matrix (a background-specific "other half"), ordinary ``/`` index +`FillStyleList`/`LnStyleList` **directly, 1-based, with no offset**: `idx="0"` means "none" +(`PptxNoFill.Instance`/`null` respectively); `idx` in `[1,3]` maps to `list[idx-1]`; any other +value - including an `idx` that would index past a theme's own (always-3-entry, or in a +synthetic/test theme, possibly empty) list - throws `InvalidDataException` rather than being +silently clamped, since a real theme's ``/`` always declares exactly +3 entries. A shared private `ParseStyleRefIdx` helper parses the required `idx` attribute, +throwing `InvalidDataException` for a missing or non-numeric value (a `PptxDocument.Paint.cs`- +local analog to the inline `idx`-parsing logic `ResolveBackgroundStyleReference` already has - +not refactored, per this fix's minimum-necessary-change scope). `FillStyleList`'s entries are +themselves fill-definition elements (e.g. ``), so `ResolveShapeStyleFill` wraps the +matched entry in a synthetic parent element before calling the existing `ResolveFill` (mirroring +`ResolveBackgroundStyleReference`'s own synthetic-wrapper precedent exactly); `LnStyleList`'s +entries are already ``-shaped, so `ResolveShapeStyleLineStyle` instead feeds the matched +entry directly into a new optional `phClrOverride` parameter added to the existing +`ResolveLineStyle` (threaded into its own internal `ResolveFill` call), with every pre-existing +call site remaining source-compatible since the new parameter is optional and appended last. + +**Render-time wiring** (`PptxDocument.Render.cs`'s `RenderShape`): a shape's own `` is +read directly from the slide shape's own element (`node.ShapeElement`) - **never** inherited from +a layout/master placeholder, unlike this same method's ``/geometry/fill-position +resolution, which is placeholder-aware. This is a deliberate, narrower-scope limitation (see +"Known limitations" below). An explicit fill-definition child on `` (``/ +``/``/``/``, detected by a new +`HasExplicitFillChild` helper) always wins over a style `` - needed because +`ResolveFill` already collapses "no recognized fill child at all" and "an explicit ``" +to the identical `PptxNoFill.Instance` return value, so that collapsed return value alone cannot +distinguish "shape explicitly declared no fill, which wins over any style ref" from "shape +declared nothing at all, so falls back to the style ref". + +The line side is resolved by a new `ResolveShapeLineStyle` (`PptxDocument.Paint.cs`), covering +four cases: (1) an explicit fill-definition child on the shape's own `` (including +``) wins outright over the style ``, reusing the same `HasExplicitFillChild` +helper and delegating to the existing `ResolveLineStyle` unchanged; (2) is the same check's +`` sub-case, which `ResolveLineStyle` already correctly resolves to "no stroke", +distinguishing it from case 3 below; (3) a present `` that declares no recognized +fill-definition child of its own (for example `` - width/dash/cap attributes +only) keeps its own width and dash from that `` verbatim, but defers only its _color_ to the +style `` via `ResolveShapeStyleLineStyle`, falling back to "no stroke" when the style +itself supplies no usable color (no `` at all, no ``, an ``, +or a style entry that itself resolves to `PptxNoFill`); (4) a fully absent `` defers +entirely to the style `` for both width and color, via `ResolveShapeStyleLineStyle` +unchanged. + +Before this fix, the line side used a much simpler, incorrect "is `` present at all" check: +a present `` - even one with no recognized fill child of its own - always won over +`` in full, silently discarding the style's color and resolving to "no stroke" for case +3's shapes. This was a genuine visual-fidelity bug: a real-world shape commonly declares an +`` that carries only width/dash/cap attributes, relying entirely on its `/ +` for its own visible color - exactly PowerPoint's own "Shape Styles" gallery usage +pattern for an outline-only style. `ResolveShapeLineStyle`'s case-3 "no style color available -> +no stroke" default is not a newly invented behavior: it is the exact, pre-existing default every +other fill-less-line call site in this unit already produces (table-cell borders in +`PptxDocument.Tables.cs`; `ResolveShapeStyleLineStyle` itself consulting an absent/`idx="0"` +``), and it mirrors `ResolveConnectorLineStyle`'s own already-shipped paint-fallback +default (`styleLineStyle?.Paint ?? PptxNoFill.Instance`) for its connector-specific, broader +per-attribute merge (see "Phase 2 Follow-Up: Connector Shape Rendering" below). `ResolveShapeLineStyle` +is deliberately **narrower** than `ResolveConnectorLineStyle`: only color ever falls back to +style - width and dash always come from the shape's own `` whenever it is present at all, +never merged from style, even when that `` itself declares no `w`/`` - connectors +and ordinary shapes are intentionally documented as having different, scoped merge policies. + +**Known limitations, left as explicit, documented simplifications**: + +- **`` is out of scope.** Unlike ``/``, its own `idx` is one of + `major`/`minor`/`none` (selecting the theme's font scheme, not a style-list position) - a + different resolution mechanism entirely, not merely an extension of this fix's pattern. +- **`` is out of scope.** This unit does not resolve shape effects (shadows, glows, + etc.) at all yet, independent of this fix. +- **Placeholder-inherited `` is out of scope.** A placeholder shape that declares no + `` of its own does not inherit one from its matched layout/master placeholder - only + the slide shape's own, directly-declared `` is consulted. Fixing this would require + extending `PptxPlaceholderProperties`'s existing per-tier inheritance resolution to a fifth + property, mirroring `EffectiveSpPr`/`EffectiveXfrmElement`/`EffectiveGeometrySpPr` - deferred as + a candidate follow-up, not required by the reported symptom (a non-placeholder shape using the + Shape Styles gallery). + +**Test coverage**: `PptxPaintTests.cs` gained direct `ResolveShapeStyleFill`/ +`ResolveShapeStyleLineStyle` unit tests covering a null/style-ref-less styleElement, `idx="0"` +("none"), the no-offset pin (`idx="1"` resolves list entry `0`, not `1` and not a `+1000`-offset +entry), `phClr` substitution from the ref's own color child, and `InvalidDataException` for an +out-of-range or missing/non-numeric `idx`. `PptxBackgroundTests.cs` gained `GetTheme` parsing +tests for `FillStyleList`/`LnStyleList` (empty-by-default absent any ``/list, and +parsed in document order when present). `PptxRenderTests.cs` gained end-to-end render-level pixel +tests: a shape with only a `/` (no explicit `` fill) renders the +resolved, phClr-substituted style-list color; an explicit `` fill wins over a +simultaneously-present `` on the same shape; a shape with only a `/` +(no explicit ``) renders the resolved stroke color; and an explicit `` +wins over a simultaneously-present `` on the same shape. + +`PptxPaintTests.cs` additionally gained direct `ResolveShapeLineStyle` unit tests for all four +cases above - a null `` deferring entirely to `ResolveShapeStyleLineStyle`; an explicit +``/`` on the shape's own `` winning outright over a visible style +``; a fill-less `` keeping its own width and dash while adopting the +style's own color; and that same fill-less `` resolving to "no stroke" when no +`` element is present at all, and separately when its `` resolves to +"no line" - plus an edge case proving a fill-less, width-less `` never pulls a width from +style either. `PptxRenderTests.cs` gained one further end-to-end render-level pixel regression +test, `Render_ShapeWithLnWidthOnlyNoFillChildAndStyleLnRef_UsesOwnWidthAndStyleColor`, reproducing +the exact reported construct (``, no fill child, alongside a `/` +resolving to a distinct color) and asserting, at the pixel level, both that the style's own color +paints the stroke and that the painted band matches the shape's own (narrower) width rather than +the style's own (much wider) line-style-list entry width. + +#### Phase 2 Follow-Up: Connector Shape Rendering (``) + +A `` connector shape - the straight/elbow/curved lines PowerPoint draws between other +shapes in flowcharts and diagrams - was, until this fix, never represented in the parsed shape +tree at all (see _Shape Tree_ in the Phase 1e section above): `ParseShapeTree` silently dropped +every `` it encountered, so a slide containing one rendered with that connector simply +absent, no error, no visible line. This closes that gap. + +**Shape tree** (`PptxShapeTree.cs`/`PptxDocument.ShapeTree.cs`): a new +`PptxConnectorShapeNode(XElement CxnSpElement) : PptxShapeTreeNode` record, dispatched from +`ParseShapeTree`'s own `` case alongside the existing ``/``/ +``/`` cases - so a connector's position in document order (and therefore +its z-order among siblings), and its ancestry under a `` (and therefore its own group +transform composition), are handled identically to every other shape kind, with no special-casing +anywhere in the tree walk itself. + +**Preset geometry** (`PptxPresetGeometry.cs`): ten connector-specific preset names - `line`/ +`straightConnector1` (a plain open 2-point diagonal), `bentConnector2`-`bentConnector5` (a +fixed-proportion "staircase" elbow, alternating horizontal/vertical segments starting +horizontal, evenly dividing the available width/height across however many segments run in each +axis), and `curvedConnector2`-`curvedConnector5` (the same elbow vertices rounded via the +existing `CornerRoundEffect.Apply`, radius `Min(|w|,|h|) * 0.15`, with the true start/end +vertices never rounded) - are recognized by a new `IsConnectorPreset` check and built by a new +`BuildConnectorPreset` dispatch. Critically, this check also **bypasses** `Build`'s own +pre-existing "non-positive bounding box renders as `Path.Empty`" guard for these ten preset names +only (every other preset keeps that guard unchanged): a real-world connector frequently declares +a zero-height or zero-width `` (a perfectly horizontal or vertical line), confirmed in the +`pythonpptx-shp-connector-props.pptx` fixture's own second slide, and such a connector must still +paint its line rather than silently vanishing. `Ellipse` (previously `private`, used internally +by `oval`) was changed to `internal` so the new `oval` arrowhead kind (below) could reuse it +without duplicating ellipse-path-construction logic. + +**Arrowheads** (`PptxArrowheadStyle.cs`, new; `PptxArrowheadGeometry.cs`, new): a new +`PptxArrowheadKind` enum (`None`/`Triangle`/`Stealth`/`Diamond`/`Oval`/`Arrow`) and +`PptxArrowheadStyle(Kind, WidthKey, LengthKey)` record capture a resolved ``/ +`` declaration. `PptxArrowheadGeometry.Build(style, lineWidthEmu)` builds the +arrowhead's own **local-space** geometry with its tip fixed at the local origin, pointing along +local +X, body extending toward local -X (the caller orients/translates it later): `Triangle` is +a 3-point closed polygon; `Stealth` a 4-point closed polygon with a concave notch cut into its +back edge; `Diamond` a 4-point closed rhombus straddling the origin; `Oval` reuses +`PptxPresetGeometry.Ellipse`; `Arrow` is an open, stroked (not filled) 2-segment chevron; `None` +is `Path.Empty`. Sizing is relative to the connector's own stroke width +(`lineWidthEmu`): half-width = `lineWidthEmu * 1.5 * SizeScale(widthKey)`, length = +`lineWidthEmu * 3.6 * SizeScale(lengthKey)`, where `SizeScale` maps PowerPoint's own `sm`/`med`/ +`lg` size keywords to `0.75`/`1`/`1.5` respectively (a reasonable, documented approximation of +PowerPoint's own visual scaling - no normative EMU/pixel table for these keywords is published). + +**Line style and arrowhead resolution** (`PptxDocument.Connectors.cs`, new partial class file): + +- `ResolveConnectorLineStyle` merges a connector's own `` with its `/` + fallback **per attribute** (width, paint, dash), not as an all-or-nothing choice the way + `RenderShape`'s own "any present `` wins outright over ``" policy works for + ordinary shapes. This divergence is deliberate and was proven necessary by the real + `pythonpptx-shp-connector-props.pptx` fixture: its own connector declares a bare + `` - no width, no fill - relying entirely on its sibling + `/` for its actual visible color and width. Under the ordinary + shape's own policy this would resolve to an invisible line; the per-attribute merge instead + reuses the width/paint/dash from the style reference whenever the connector's own `` + doesn't itself declare that specific attribute. An explicit `` on the connector's + own `` always wins outright over the style reference, regardless of any other merging. +- `ResolveArrowhead` reads a connector's own ``/`` child (by element name), + recognizing `triangle`/`stealth`/`diamond`/`oval`/`arrow` type values (`none` and any + unrecognized value both resolve to no arrowhead at all - not rendered as an unknown/placeholder + marker), defaulting an absent `w`/`len` attribute to `"med"`. +- `ComputeEndpointsAndTangents` walks a resolved connector geometry path's first subpath to + extract its start/end points and start/end tangent directions (via the existing + `PathCommand.ComputeTangents`), used to orient the arrowhead geometry at each endpoint. + +**Render-time wiring** (`PptxDocument.Render.cs`): a new `RenderConnector` method, dispatched +from `RenderNode`'s switch for `PptxConnectorShapeNode` (with the same `skipPlaceholderShapes`- +gated outer catch every other node kind already has). Unlike every other node kind, connector +geometry resolution is **additionally** wrapped in its own unconditional inner try/catch +(regardless of `skipPlaceholderShapes`): an exotic or future preset name this phase does not +implement degrades to "this one connector paints nothing" rather than aborting the rest of the +slide's own render - satisfying this fix's own graceful-degradation requirement without weakening +the existing, stricter failure behavior for every other shape kind. Painting order is fill (only +if the connector's own `` explicitly declares one via ``/etc. - connectors +have no fill by default, unlike an ordinary autoshape, and `RenderConnector` never synthesizes +one), then stroke (via the merged line style above), then arrowheads. `` paints at the +geometry path's **start** point, oriented along the **negated** outgoing tangent (pointing back +toward the line's own start, matching PowerPoint's own visual convention that an arrowhead points +away from the line it terminates); `` paints at the path's **end** point, oriented +along the incoming tangent (continuing the line's own direction of travel) - this head/tail +semantic split was confirmed against the real fixture's own `` +declaration. `PaintArrowhead` rotates/translates the arrowhead's local-space geometry (tip at +local origin, pointing +X) to the target point/direction entirely within the connector's own +local coordinate space, **before** applying `localToSurface` - consistent with how the +connector's own stroked line outline is built/transformed, so any non-uniform scale or flip +baked into `localToSurface` (from `flipH`/`flipV`/group nesting) applies identically to both the +line and its arrowheads. A connector has no text body, so none of `RenderConnector`'s own +painting steps ever attempt to resolve or paint one. + +**Known limitations, left as explicit, documented simplifications**: + +- **Only `line`/`straightConnector1` and the four `bentConnectorN`/four `curvedConnectorN` + presets are implemented.** Any other connector preset name (for example a custom/exotic one, or + one of the less common named connector presets this phase did not prioritize) degrades to "this + one connector paints nothing", per the inner-try/catch policy above - not a crash, but also not + a visible line. +- **Arrowhead size-keyword-to-EMU mapping is an approximation**, not derived from any published + PowerPoint-internal constant table (none is publicly documented); it was chosen to be visually + reasonable relative to the connector's own line width, not pixel-matched against a specific + PowerPoint export. +- **Connection-site (``/``) auto-routing is out of scope.** A connector's + endpoints are taken purely from its own resolved ``/preset-geometry local space - + PowerPoint's own behavior of re-routing a connector's endpoints to track a moved/resized + connected shape (via ``/`` referencing the connected shape's + own connection-site index) is not implemented; a connector whose source document relies on this + live-routing behavior renders at its own last-saved, static `` position instead. + +**Test coverage**: `PptxConnectorTests.cs` (new) covers preset geometry path resolution for +`line`/`straightConnector1` (both preset-name aliases) across `flipH`/`flipV`/both/neither +combinations (via `ResolveShapeFrame` + `Build` + `.Transform`), the zero-width/zero-height +bug-fix proof (contrasted against an ordinary, non-connector preset still correctly degrading to +`Path.Empty` under the same zero-size input), bent/curved connector endpoint-reaching proof, +`ResolveConnectorLineStyle`'s own merge-precedence rules (own-value-wins, style-fallback, +`noFill`-always-wins), `ResolveArrowhead`'s type/size-key resolution (including the `none`/ +unrecognized/absent-element cases), `PptxArrowheadGeometry.Build`'s per-kind shape/sizing +properties (tip-at-origin, `lg` larger than `sm`), and `ComputeEndpointsAndTangents`'s tangent +directions for horizontal/vertical/diagonal/bent connectors. `PptxRenderTests.cs` gained +end-to-end render-level pixel tests: a straight connector paints its diagonal line at the +expected pixel positions; a zero-height connector still paints its horizontal line (the bug-fix +proof, at the render level); and a connector with a `tailEnd type="triangle"` arrowhead paints +ink well outside the plain line's own stroke width, flared out just above its tip at the +connector's own endpoint. `PptxFixturesCorpusTests.cs`'s own +`PptxDocument_Render_ShpConnectorPropsFixture_ConnectorsPaintVisibleLines` test (renamed from +its prior "connectors skipped silently" name) now asserts both of the fixture's slides paint at +least one non-background pixel, where previously only slide 0's dimensions were asserted and +slide 1 was untested. + +#### Phase 2 Follow-Up: Underline Rendering (``) + +A real-world corpus fixture ("ERF IWF Breadboard Motion System Overview.pptx", slide 23 +"Configurations") contains text runs declaring `` that PowerPoint's own +ground-truth rendering shows with a visible single underline, while CanvasNet rendered no +underline at all. Root cause: `GetUnderline` (in `PptxDocument.TextInheritance.cs`) parsed the +`u` attribute into a boolean `PptxEffectiveRunProperties.Underline`, but nothing downstream (text +layout or paint) ever consumed it - it was silently dropped before reaching `PptxTextLayout`/ +`PaintTextLayout`. This closes that gap. + +**Style model** (`PptxEffectiveTextProperties.cs`): a new `PptxUnderlineStyle` enum - +`None`/`Single`/`Double`/`Other` - replaces the prior boolean. Every ECMA-376 `u` value other than +`none`/`sng`/`dbl` (`heavy`, `dotted`, `dottedHeavy`, `dash`, `dashHeavy`, `dashLong`, +`dashLongHeavy`, `dotDash`, `dotDashHeavy`, `dotDotDash`, `dotDotDashHeavy`, `wavy`, `wavyHeavy`, +`wavyDbl`), plus any unrecognized string value, resolves to `Other` and is rendered as a single +solid line - a documented simplification rather than a distinct visual rendering per variant. +`PptxEffectiveRunProperties.Underline` is replaced by `UnderlineStyle` (`PptxUnderlineStyle`), and +a new `UnderlineColor` (`Rgba32`) is added alongside it. + +**Inheritance resolution** (`PptxDocument.TextInheritance.cs`): `GetUnderline` is replaced by +`GetUnderlineStyle(XElement?)`, returning `PptxUnderlineStyle?` (`null` when the element declares +no `u` attribute at all, so the existing four-tier run/paragraph-defRPr/lstStyle-level/ +master-txStyles inheritance chain keeps falling through exactly as it does for every other text +property); `"none"` resolves to `None`, `"sng"` to `Single`, `"dbl"` to `Double`, and every other +recognized/unrecognized non-empty value to `Other`. A new `GetUnderlineColor(XElement?, PptxTheme, +PptxColorMap?)` resolves a run's own underline color: it returns `null` (meaning "inherit, keep +searching the chain") whenever no `` child element exists at all - this single condition +also covers the explicit `` marker ("use the text's own fill"), since `` +is a sibling element name, not a child of ``; otherwise it resolves `/ +`'s color child via the same `ResolveColor` helper `GetRunColor` already uses. +`ResolveEffectiveRunProperties` gains two four-tier inheritance chains, mirroring the pattern used +for every other property: `GetUnderlineStyle(...) ?? ... ?? PptxUnderlineStyle.None` for the +style, and `GetUnderlineColor(...) ?? ... ?? color` for the color, where `color` is the run's own +already-resolved text-fill color computed earlier in the same method - so a run with no `` +anywhere in its own inheritance chain renders its underline in its own text color, matching +PowerPoint's own default behavior. + +**Per-run span construction** (`PptxDocument.TextLayout.cs`): a new `LineUnderlineSpan(StartXEmu, +EndXEmu, RunProperties)` record struct accumulates one contiguous underline span per underlined +run as `BuildLines` walks a line's own tokens: each token's `tokenStartX`/`tokenEndX` (the cursor +position immediately before/after that token's own characters are advanced - exactly where its +glyphs are emitted) extends a pending span when the token's own `RunProperties` is the same run +instance (via `ReferenceEquals`, reliable because `BuildLines` creates one scaled `RunProperties` +instance per run and reuses it across every token belonging to that run) as the span currently +being accumulated; a token belonging to a different run, or one whose own `UnderlineStyle` is +`None`, flushes any pending span and either starts a new one or clears it. This means a single +run's own interior whitespace remains part of its own contiguous span (the span is never broken +at a word boundary within one run), while an underline never bridges two separate runs even when +both happen to be underlined - matching PowerPoint's own visual behavior. `PositionLines` +converts each flushed `LineUnderlineSpan` into a `PptxUnderlineSegment` (`StartXEmu`, `EndXEmu`, +`BaselineYEmu`, `SizeEmu`, `Style`, `Color`) once the line's own final `startX`/`baselineY` are +known, threading an accumulating list of these segments alongside the existing glyph list all the +way to `ResolveTextLayout`'s own final `new PptxTextLayout(glyphs, fontScale, underlines)` call. + +**Painting** (`PptxDocument.TextRender.cs`): `PaintTextLayout` iterates `layout.Underlines` after +its own existing glyph-painting loop. For each segment, `thicknessEmu = segment.SizeEmu * +UnderlineThicknessRatio` and `offsetEmu = segment.SizeEmu * UnderlineOffsetRatio` (two new named +`const float` fields, `0.05f`/`0.08f`) compute a pragmatic, `SizeEmu`-proportional approximation +of the underline's own thickness and vertical offset below the baseline - this phase does not +parse the target font's own OpenType `post` table `underlinePosition`/`underlineThickness` +fields, a documented limitation; true font-metric-derived values are deferred to a later phase. +A rectangle spanning `[StartXEmu, BaselineYEmu + offsetEmu]` to `[EndXEmu, BaselineYEmu + +offsetEmu + thicknessEmu]`, built directly in shape-local, y-down space (no glyph-space Y-flip, +unlike glyph outlines which are authored in a y-up font coordinate space), is transformed through +`shapeToSurfaceTransform` and filled with the segment's own resolved color. A `Double`-style +segment paints two such rectangles, each half as thick, separated by a gap proportional to +`SizeEmu` (a third new constant, `DoubleUnderlineGapRatio = 0.06f`); every other non-`None` style +(`Single`, `Other`, or any unrecognized value) paints exactly one rectangle - this painting step +never throws regardless of style value. + +**Test coverage**: `PptxTextTests.cs` covers the style resolution theory (`sng`/`none`/`dbl`/ +`wavy`/`heavy`) and the underline-color inheritance rules (explicit `` override, default- +to-text-color with no fill markup, default-to-text-color with explicit ``). +`PptxTextLayoutTests.cs` covers per-run span construction: a single underlined run emits one +segment spanning its own measured width; a non-underlined run emits none; of two runs on one +line, only the first underlined, exactly one segment is emitted bounded to that first run; and a +single underlined run with an interior space emits one segment spanning that space too. +`PptxTextRenderTests.cs` covers painting: a `Single`-style segment paints a visible stroke below +the baseline at the expected position/color with no ink above the baseline; a layout with no +segments paints no extra ink (a regression guard); and `Double`/`Other`-style segments paint +without throwing. Visual verification (a hand-built, in-memory `.pptx` package containing a +single `` run, rendered end-to-end through the public `PptxDocument.Render` API +and saved to PNG) confirms a single straight horizontal line appears beneath the rendered word's +own baseline, spanning its full rendered width, in the same color as the text itself. + +#### Phase 2 Follow-Up: Picture Preset-Geometry Clipping (`` on ``) + +A real-world corpus fixture ("ERF IWF and Reagent Probe Breadboard Motion System Overview.pptx") +contains `` picture shapes whose own `/` declares a +non-`rect` preset (for example `ellipse` or `roundRect`) - PowerPoint's own ground-truth rendering +visibly crops the embedded image to that preset's own shape, while CanvasNet previously painted +every picture as a plain, unclipped full rectangle regardless of its own declared geometry. Root +cause: `RenderPicture` (`PptxDocument.Render.cs`) never read a picture's own ``/ +`` at all, and `PaintPicture` (`PptxDocument.Images.cs`) had no clip/mask mechanism of +any kind - every picture's footprint was always its full `(0,0)-(1,1)` unit square mapped through +`shapeToSurfaceTransform`, with no notion of a non-rectangular outline. + +**Fix - reusing, not reimplementing, existing geometry and fill infrastructure**: + +- **Clip-path resolution** (`PptxDocument.Images.cs`'s new `ResolvePictureClipPath(XElement + spPrElement, float widthEmu, float heightEmu)`): a thin, picture-specific wrapper around the + exact same preset/custom-geometry dispatch `ResolveShapeGeometry` already uses for an ordinary + auto-shape (`PptxPresetGeometry.Build`/`ResolveCustomGeometry`, both in `PptxDocument.Geometry.cs`) + - not a second, divergent geometry resolver. It returns `null` (meaning "no clip - paint the + full bounding-box rectangle", this package's original behavior, with zero overhead) when + `` declares neither `` nor `` at all (a schema-valid, historically + unclipped picture - not a malformed document, unlike `ResolveShapeGeometry`'s own all-or-nothing + contract for an auto-shape), or when `` explicitly names the `rect` preset (clipping + to a `rect` would be a pure no-op, since that preset's own resolved geometry is already the full + bounding-box rectangle a "no clip" paint produces). Otherwise it delegates outright to + `PptxPresetGeometry.Build`/`ResolveCustomGeometry` and returns their resolved `Path`, propagating + their own `InvalidDataException`/`PptxUnsupportedFeatureException` unchanged - an unsupported + preset on a picture fails exactly the same way an unsupported preset on an auto-shape already + does, a deliberate, consistent fail-closed posture rather than a silent unclipped fallback. +- **Clip-to-mask compositing** (`PptxDocument.Images.cs`'s `PaintPicture`, new trailing optional + `Path? clipPath = null` parameter): no generic "clip an arbitrary raster draw to a path" + primitive exists anywhere in this repository's core Canvas/Drawing API - the actually-reusable + primitive is the existing anti-aliased path-fill rasterizer, `Drawing.PathFiller.Fill(Surface, + Path, Rgba32, FillRule, float)` (already used by every shape/table fill in this codebase), + repurposed as a mask-builder rather than a dedicated clip API, mirroring + `DemaConsulting.CanvasNet.Svg`'s own `SvgCodec.ClippingAndMasking.cs`'s `ApplyClipPath` (an + opaque-white path fill onto a fresh, fully-transparent `Surface`), with one deliberate + difference: instead of post-multiplying an already-fully-painted offscreen buffer's own alpha by + that coverage mask (`ApplyClipPath`'s own `ApplyCoverageClip` approach - unsuitable here, since + `PaintPicture` composites directly onto the live destination surface pixel-by-pixel, not into an + isolated offscreen buffer), `PaintPicture` instead scales each _sampled source pixel's own + alpha_ by that same pixel's mask coverage (`0` outside the clip geometry, `255` fully inside it, + an anti-aliased in-between value exactly on its edge) immediately before compositing via the + existing `Rgba32.CompositeOver` - a zero-pixels-touched early-out skips a destination pixel + outright when its own mask coverage is `0`. When `clipPath` is non-null, it is transformed by + the same `shapeToSurfaceTransform` the picture itself is already painted through (not the + unit-square `CreateScale(widthEmu, heightEmu) * shapeToSurfaceTransform` used for image + sampling - `ResolvePictureClipPath`'s own resolved `Path`, exactly like `ResolveShapeGeometry`'s + for an auto-shape, is already sized to the shape's own local `(0,0)-(widthEmu,heightEmu)` box). +- **Wiring** (`PptxDocument.Render.cs`'s `RenderPicture`): resolves `ResolvePictureClipPath` from + the picture's own `` immediately alongside its existing `ResolvePictureSurface`/ + `ResolveSrcRect` calls, and passes the result as `PaintPicture`'s new trailing argument. +- **`` on a picture is supported, not scoped out**: `ResolveCustomGeometry` is already + a fully general, already-tested resolver with no auto-shape-specific assumptions baked in - + supporting it for a picture via the same dispatch costs one additional branch, not new geometry + machinery, so it is deliberately not deferred to a later phase. + +**Known limitations, left as explicit, documented simplifications**: + +- The clip mask's own anti-aliased edge inherits `PathFiller.Fill`'s default `flattenTolerance` + (`0.25f`) and `PptxPresetGeometry`'s own fixed-segment-count curve approximation - the identical + characteristic every other preset-geometry shape fill in this codebase already carries, not a + new limitation this fix introduces. +- A same-size scratch mask `Surface` is allocated per clipped picture paint, rather than a + clip-bounds-restricted smaller surface - consistent with `PaintPicture`'s own existing + full-bounding-box-footprint iteration style (`Surface`'s documented max-dimension bound keeps + worst-case allocation cost small and bounded). + +**Test coverage**: `PptxImagesTests.cs` gained `ResolvePictureClipPath` unit tests (`rect` preset +and absent geometry both resolve to `null`; `ellipse`/`roundRect` presets and `` each +resolve to a non-null `Path`; a `` missing its own `prst` attribute throws +`InvalidDataException`; an unsupported preset name propagates `PptxUnsupportedFeatureException` +unchanged; a null argument throws `ArgumentNullException`) and `PaintPicture` clip-path tests (an +ellipse clip path leaves a bounding-box corner unpainted while the shape's own center paints the +image's color; a `null` clip path - the default - continues painting the full rectangle +unclipped, a direct regression guard for every pre-existing `PaintPicture` call in this same +file). `PptxRenderTests.cs` gained end-to-end render-level pixel tests: a `` with +`` clips the image to the elliptical region; one with an explicit +`` still paints the full bounding-box rectangle including its own corner +(strengthening the existing center-only `Render_Picture_PaintsEmbeddedImageAtExpectedLocation` +into an explicit no-regression proof); one with no ``/`` at all also still +paints unclipped (a defensive, schema-edge-case regression guard); and one with +`` clips its own bounding-box corners while its center remains +painted. A non-permanent visual-verification generator (`GeneratePictureEllipseClipReproPng`, +mirroring `PptxTextLayoutTests.cs`'s own `GeneratePlusMinusDegreeTofuReproPng` precedent) renders +the same ellipse-clipped picture end-to-end and saves it to +`.agent-logs/pptx-picture-ellipse-clip-repro.png` for a human reviewer to open and visually +confirm the circular photo-crop effect. `Render_PictureEllipseGeometry_NearSquareRealWorldAspect_ClipsAllFourBoundingBoxCorners` +(added later, in response to a bug report alleging an `ellipse`-clipped picture at a near-square, +real-world corpus aspect ratio rendered with an unclipped rectangular top) adds regression coverage +sampling all four bounding-box corners rather than one diagonal pair. A direct mask-boundary-trace +comparison against the real-world document's own rendered output confirmed the clip mask itself +was already pixel-correct and fully symmetric on all four sides - this remains correct and +unchanged. **A prior investigation pass incorrectly concluded from that same comparison that the +bug report's own underlying asymmetry complaint was entirely "a visual illusion" with no code +defect at all, and made no further source change.** That conclusion was wrong: the bug report's +own fixture additionally declared a red `` on the same ``, and a from-scratch, +independently re-verified investigation (direct code inspection plus a fresh rendered repro PNG - +see `.agent-logs/planning-picture-ln-stroke-fix-7f2a4d.md`) proved `RenderPicture` never read or +painted ANY ``'s own `` stroke at all - 0% of the ellipse's perimeter rendered any +stroke color, not merely a partial arc. The image-content clip itself (this section, above) was +correctly re-confirmed unaffected and required no further change; only the separate, previously +undiagnosed missing-stroke defect was real. See "Phase 2 Follow-Up: Picture Own-Stroke Outline +Rendering" below for the actual root cause and fix. + +#### Phase 2 Follow-Up: Picture Own-Stroke Outline Rendering (`` on ``) + +**Root cause**: `RenderPicture` (`PptxDocument.Render.cs`) resolved and painted a ``'s own +embedded image content (via `ResolvePictureSurface`/`ResolveSrcRect`/`ResolvePictureClipPath`/ +`PaintPicture`, see the preceding section), but never read its own `/` child at all, +and never called `ResolveShapeLineStyle`/`ResolveStrokeOutline`/`FillPaint` for a picture node - +unlike `RenderShape` (``), `RenderConnector` (``), and `PaintTableBorder` +(`PptxDocument.Tables.cs`, for table cell borders), which all already resolve and paint their own +node kind's stroke via that exact same trio of resolvers. `PptxPictureShapeNode` is a closed, +separate case in `RenderNode`'s switch that dispatches only to `RenderPicture` - there was no +secondary pass anywhere else in the codebase that painted a ``'s own stroke. The result: a +picture declaring an `` (for example a red outline around an `ellipse`-cropped photo) always +rendered with that stroke entirely absent, regardless of its own resolved geometry - confirmed by +an isolated, from-scratch visual repro (the bug report's own exact `` XML, rendered and +visually inspected before this fix: a plain pale ellipse with zero red anywhere on its perimeter). + +**Fix - reusing, not reimplementing, existing line-style and stroke infrastructure**: + +- **Geometry resolution for stroking, deliberately separate from the clip-path resolver above** + (`PptxDocument.Images.cs`'s new `ResolvePictureGeometryPath(XElement spPrElement, float + widthEmu, float heightEmu)`): `ResolvePictureClipPath` collapses "no geometry at all" and an + explicit `` to `null`, a pure optimization valid only for its own + image-content-clipping use case (clipping to a full bounding-box rectangle is a no-op). A stroke + outline has no equivalent no-op shortcut - an implicit or explicit full-rectangle picture with an + `` must still stroke an actual, closed rectangle boundary, exactly as an ordinary `` + auto-shape with an implicit/explicit `rect` preset already does via `ResolveShapeGeometry`. So + `ResolvePictureGeometryPath` always returns a concrete `Path`: the resolved preset geometry + (`PptxPresetGeometry.Build`, with `rect` NOT special-cased to `null`) when `` is + present; the resolved custom geometry (`ResolveCustomGeometry`) when `` is present; + or the implicit full-rectangle boundary (`PptxPresetGeometry.Build("rect", widthEmu, heightEmu)`) + when neither is present - reusing the identical dispatch `ResolvePictureClipPath` itself already + reuses, not reimplementing it. +- **Line-style resolution and stroke painting** (`PptxDocument.Render.cs`'s `RenderPicture`, after + `PaintPicture`): reads the picture's own `/` and sibling `` (mirroring + `RenderShape`'s identical sibling-element lookup pattern), resolves + `ResolveShapeLineStyle(lnElement, styleElement, theme, colorMap)` - the exact same resolver + `RenderShape` already uses, requiring no new line-style-resolution code - and, when non-null, + strokes `ResolvePictureGeometryPath`'s resolved geometry via `ResolveStrokeOutline(...). + Transform(localToSurface)` followed by `FillPaint(surface, strokedOutline, lineStyle.Paint)`, + mirroring `RenderShape`'s exact fill-then-stroke order (image content first, stroke frames it on + top, matching real-world PowerPoint's own visual stacking). +- **Signature extension**: `RenderPicture` gained `PptxTheme theme` and `PptxColorMap colorMap` + parameters, threaded through from its one call site in `RenderNode`'s `case + PptxPictureShapeNode pic:`, which already held both values in scope (used by the sibling + `RenderShape` call in the same switch). +- **The image-content clip (`ResolvePictureClipPath`/`PaintPicture`, from the preceding section) + was not touched** - it was independently re-verified correct and unaffected by this defect (see + this section's own root-cause remarks above); only the missing stroke pass was added. + +**Test coverage**: `PptxImagesTests.cs` gained `ResolvePictureGeometryPath` unit tests mirroring +`ResolvePictureClipPath`'s own test structure (an explicit `rect` preset and absent geometry both +now resolve to a non-null rectangle `Path`, unlike `ResolvePictureClipPath`'s `null`; `ellipse` +preset and `` each resolve to a non-null `Path`; a `` missing its own +`prst` attribute throws `InvalidDataException`; an unsupported preset name propagates +`PptxUnsupportedFeatureException` unchanged; a null argument throws `ArgumentNullException`). +`PptxRenderTests.cs` gained `Render_PictureEllipseGeometry_WithRedLnStroke_RendersCompleteClosedEllipseOutline` +(the bug report's own exact geometry/stroke, sampling all four of the ellipse's own perimeter +midpoints - top/bottom/left/right-center, not just corners - for the stroke's own red color; a +defect covering 0% of the perimeter would fail at all four) and +`Render_PictureNoPrstGeom_WithLnStroke_StrokesRectangleOutline` (a picture with no geometry child +at all but a present `` now strokes a complete rectangle, not nothing). The pre-existing +`Render_PictureEllipseGeometry_NearSquareRealWorldAspect_ClipsAllFourBoundingBoxCorners` test was +left unchanged - it remains valid, correct coverage for the separate image-content-clip concern. +A before/after visual repro (the bug report's own exact geometry, rendered and visually inspected) +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: Table Style/Banding Resolution (``) + +**Bug**: a `` whose `` declares `{GUID}` +(optionally with `firstRow="1"`/`bandRow="1"`) renders in real PowerPoint with a shaded header +row, alternating banded row fills, and style-defined cell borders, driven by the presentation +package's own `ppt/tableStyles.xml` part. `ParseTable`/`ParseTableCell` (Phase 1e, above) never +resolved `` against that part at all - every cell's fill/border came from its own +`` only, so a table declaring a style but no per-cell overrides (the common case for a +plain data table) rendered as a bare, mostly-invisible grid of thin border lines with no shading +at all, a visibly incorrect rendering confirmed against a real-world ground-truth PowerPoint COM +export (`.agent-logs/planning-table-style-apply-7f3a2c.md`). + +**Fix - a new resolver unit, threaded through unchanged parsing/rendering call sites**: + +- **`PptxDocument.TableStyles.cs`** (new file) implements the table-style lookup and the core + precedence cascade: + - `TryResolveTableStyle(string styleId)` resolves `styleId` against the presentation part's own + `/tableStyles` relationship (via a new, non-throwing `TryResolveRelationshipByType` sibling of + the existing, throwing `ResolveRelationshipByType` in `PptxDocument.Package.cs`) and the + resulting `ppt/tableStyles.xml` part's `/` children - + tolerating every cause of "not found" (no `/tableStyles` relationship at all, a missing or + not-well-formed `ppt/tableStyles.xml`, or no matching `styleId`) by returning `null`, mirroring + `PptxDocument.Theme.cs`'s own `ParseBgFillStyleList`/`ParseFillStyleList`/`ParseLnStyleList` + leniency precedent for optional, rarely-consulted style-sheet content - a table-style-sheet + must never fail an otherwise well-formed table parse closed. + - `GetTableStyles(string tableStylesPartPath)` parses and caches (`_tableStylesCache`, keyed by + part path) the part's full `styleId -> ` lookup on first access, the same + lazy-parse-then-cache shape every other auxiliary-part resolver in this unit already uses. + - `ResolveTableCellStyle(...)` implements the precedence cascade itself: a cell's own explicit + `` fill/border always wins outright - the same "explicit always wins over + style/theme fallback" pattern `ResolveShapeLineStyle` already established for shape-stroke + resolution, mirrored here rather than reinvented; otherwise the matched ``'s + `` tier (only for the header row, when ``) wins over its + ``/`` banding tier (only when ``), which in turn wins + over its `` base tier - the only non-null tier applied when neither `firstRow` nor + `bandRow` select a higher tier for a given cell. A tier missing its own `` (for + example a style whose `` declares borders but no fill) falls through to the next + lower tier's fill, not to "no fill" - confirmed against the ground-truth render, where an + unfilled band tier visibly shows the table's base shading, not a transparent gap. + - **Row-parity convention** (confirmed against the real-world ground-truth render): a styled + header row (`firstRow="1"`, row index `0`) is excluded from band-row counting entirely, and + `` is applied to the first row after the header, alternating with `` + thereafter (`bandRowIndex = firstRowEnabled ? rowIndex - 1 : rowIndex`; even `bandRowIndex` + selects `band1H`, odd selects `band2H`) - the same convention ECMA-376 itself documents for a + styled, banded table. + - **Border edge-name mapping**: each tier's own `/` declares up to six named + edges (`left`/`right`/`top`/`bottom`/`insideH`/`insideV`, each itself wrapping a nested + ``). A cell edge on the table's own true outer boundary (determined structurally from + the cell's own column/row index, span, and the table's total column/row counts) consults the + literal edge name; every interior edge instead consults `insideV` (left/right) or `insideH` + (top/bottom) - required so a style that visually differentiates its outer frame from its + interior gridlines (unlike the one available real-file fixture, whose style uses an identical + line for all six edges) renders correctly. + - **Color resolution is entirely delegated, not reimplemented**: a tier's `` is resolved + via the existing `ResolveFill`, and a tier's border `` via the existing + `ResolveLineStyle` - both already resolve `` against the supplied theme/color + map, so a table style's own `schemeClr`-templated colors (the common case for a theme-matched + style) resolve correctly with zero new color-resolution code. +- **`PptxDocument.Tables.cs`**: `ParseTable` gained a new, optional `tableStyleResolver` parameter + (`Func?`, defaulting to `null`) and now parses `/`'s + `tableStyleId`/`firstRow`/`bandRow`, resolving the matched `` once per table (not + once per cell) when a non-empty `` is present. Row materialization changed from + a lazy `.Select(...)` chain to an eagerly-indexed list, needed so each cell's own `rowIndex`/ + `totalRows` context (required by the row-parity and outer-edge logic above) is known before the + per-row loop runs. `ParseTableCell` gained matching new optional parameters (`matchedTblStyle`, + `bandRowEnabled`, `firstRowEnabled`, `rowIndex`, `totalRows`, `columnIndex`, `totalColumns`, each + defaulted) and now delegates its fill/border resolution to `ResolveTableCellStyle` instead of + calling `ResolveFill`/`ResolveLineStyle` directly - every pre-existing call site (including every + pre-existing unit test) keeps compiling and behaving identically, since omitting the new + parameters degrades the cascade to exactly this method's own pre-existing cell-only behavior. +- **Signature threading**: `ParseShapeTree` (`PptxDocument.ShapeTree.cs`) gained the same optional + `tableStyleResolver` parameter, threaded to its own recursive self-call and to `ParseTable`; + `PptxDocument.Masters.cs`/`PptxDocument.Layouts.cs`/`PptxDocument.Slides.cs`'s own + `ParseShapeTree` call sites now each pass `tableStyleResolver: TryResolveTableStyle`. + +**Explicitly deferred** (fall back to the `` tier, or to no special treatment, exactly +as if no table style were matched): `firstCol`/`lastCol`/`lastRow`/corner-cell (`neCell`/`nwCell`/ +`seCell`/`swCell`) style parts; column banding (`band1V`/`band2V`, gated on ``); and table-style-driven font color (``) - a cell's own text-run font +color resolution is entirely unchanged. Each is a separable, independently-scoped refinement with +no bearing on the primary header-row/row-banding defect this fix addresses. + +**Test coverage**: `PptxTablesTests.cs` gained +`ParseTable_FirstRowTblPrWithMatchingTableStyle_HeaderRowCellUsesFirstRowStyleFill` (a matched +style's `` tier fill applies to the header row, falling back to `` for the +data row), +`ParseTable_BandRowTblPrWithMatchingTableStyle_AlternatesBand1HAndBand2HFillStartingAfterHeaderRow` +(a five-row, header-plus-banded table alternates `band1H`/`band2H` starting immediately after the +header row, with an unfilled `band2H` tier falling through to `wholeTbl`), +`ParseTable_TblPrWithNoTableStyleId_FallsBackToPlainCellOnlyBorderAndNoFill` and +`ParseTable_TblPrWithUnresolvableTableStyleId_FallsBackToPlainCellOnlyBorderAndNoFillWithoutThrowing` +(no `` at all never even invokes the resolver delegate; an unresolvable one +resolves via a resolver returning `null`; both degrade to today's pre-existing plain, style-less +cell-only rendering without throwing), +`ParseTableCell_TcPrExplicitFillAndBorder_OverridesTableStyleFillAndBorder` (a cell's own explicit +`` fill and a single explicit border edge take final precedence over a matched style's own +fill/borders, while the cell's remaining, non-overridden edges still fall back to the style), and +`ParseTableCell_InteriorColumnBorder_UsesInsideVNotLeftRightTcBdrEdge` (a three-column table's +interior column boundaries resolve against the style's own `insideV` edge rather than its `left`/ +`right` edges, while the table's two true outer-boundary edges still resolve against `left`/ +`right`). + +#### Phase 2 Follow-Up: Default Tab-Stop Expansion + +**Bug**: a literal U+0009 TAB character inside a run's text (e.g. a typed `"1\tReference fluid +evaporation..."` run, the pattern a real-world "numbered list" paragraph actually uses when it is +not a `buAutoNum` bullet at all) was measured by this unit's `Tokenize`/`MeasureTokenWidthEmu`/ +`BuildLines` exactly like any other whitespace character - resolved via the shared `ResolveGlyph` +helper and measured by its primary font's own `cmap`/`GetAdvanceWidth`. Most fonts have no `cmap` +entry for U+0009, so this resolved to glyph index `0` (`.notdef`), whose advance width is +typically `0` or near-zero - producing a near-zero gap instead of PowerPoint's wide, tab-stop-sized +gap, jamming the numbered-list text together and corrupting word-wrap width calculations for any +line containing a tab. This was previously flagged, but explicitly deferred as out of scope, in +the "Bullets and Numbering Rendering" follow-up above (`"its 'numbers' are literal typed digits +separated from the following word by a literal tab character... a distinct, pre-existing, +out-of-scope defect (tab-stop support)"`) - this follow-up closes that deferred item. + +**Root cause confirmation and chosen default interval**: a real-world corpus file ("ERF IWF +Breadboard Peer Review.pptx", slide 9, the single highest-divergence slide in a full 73-slide +pixel-diff pass) contains 19 literal tab characters, each between a literal digit run and its +sentence text, with no `` anywhere in the slide, its layout, or its master. Both the +slide master's `` and the presentation's own `` declare +`defTabSz="914400"` (exactly 1 inch) on every list level. Independent ground-truth pixel +measurement of the rendered slide (three different bullet-number widths - "1", "2", "10") each +showed the sentence text starting within 1px of the position a single 914400-EMU tab-stop +expansion predicts, confirming both the root cause and the chosen interval from two independent +sources. + +**Fix - a tab becomes a position-dependent, dynamically-expanding token, not a glyph-measured +one**: `PptxDocument.TextLayout.cs` gained a `DefaultTabStopEmu = 914400f` constant and a +`GetNextTabStopEmu(currentXEmu, tabStopEmu)` helper implementing standard tab semantics - the +smallest multiple of `tabStopEmu` strictly greater than `currentXEmu`, guaranteeing a tab always +advances by at least a minimal, non-zero amount even when already exactly on a stop boundary. +`Tokenize` now always isolates a `'\t'` character as its own single-character token - never merged +with adjacent whitespace of another kind, nor with another adjacent tab - since a tab's effective +width is never a fixed, pre-computable glyph advance. `ResolvedToken` gained a matching `IsTab` +flag (mirroring the existing `IsLineBreak` flag's convention); a tab token's `WidthEmu` is left +unused (`0`) rather than glyph-measured, since its true width depends entirely on where it falls in +the line. + +Because a tab's width is position-dependent, it must be computed twice, from two different running +positions, and the two computations must stay formula-identical: `PackTokensIntoLines`'s +wrap/fit-decision loop computes a tab token's effective width as +`GetNextTabStopEmu(currentWidth, DefaultTabStopEmu) - currentWidth`, read from its own running +`currentWidth` position at that point in the token stream - this is what makes the wrap decision +correctly reflect the tab's true, expanded width rather than its near-zero glyph-measured width, +so an early tab in a long line no longer silently under-counts the line's true occupied width and +mis-places the wrap point. `BuildLines`'s own per-line rendering loop special-cases an `IsTab` +token: instead of resolving a glyph/advance via `resolveGlyph`/`GetAdvanceWidth` (already skipped +for every whitespace-kind token's own glyph-emission guard), it computes +`cursorX = GetNextTabStopEmu(cursorX, DefaultTabStopEmu)` directly, expanding the cursor to the +next tab stop with no glyph painted. The line's own natural width (`LineBox.LineWidthEmu`) is now +taken as a single `lineWidth = cursorX` read once after the per-token loop completes, rather than a +separate `lineWidth += token.WidthEmu` running accumulator - `cursorX`'s own per-character +accumulation is already the authoritative total for every other token kind, so this removes a +redundant, now-inconsistent-for-tabs duplicate computation rather than introducing a new one. + +**Explicitly out of scope**: full OOXML `` explicit tab-stop support (per-paragraph, +per-level, multiple named tab stops with alignment/leader options) remains unimplemented - no +slide in the investigated corpus (slide 9, its layout, or its master) declares `` +anywhere, so default-interval-only expansion fully resolves the observed defect, and there is +currently no `tabLst` parsing/inheritance infrastructure anywhere in +`PptxDocument.TextInheritance.cs`/`PptxEffectiveTextProperties.cs` to build on. An explicit +`` paragraph simply continues to fall back to this same default-interval behavior today, +no regression - a reasonable, documented simplification, flagged as a still-open, separate, +future unit of work rather than folded into this fix. + +**Test coverage**: `PptxTextLayoutTests.cs` gained +`ResolveTextLayout_TabCharacter_ExpandsToNextDefaultTabStop` (a run containing `"A\tB"` proves the +glyph after the tab lands at the exact `914400`-EMU tab-stop X coordinate, not the near-zero +position a `.notdef` glyph advance would produce), +`ResolveTextLayout_TabCharacter_WrapDecisionUsesExpandedWidthNotGlyphWidth` (a long run with an +early tab, sized so the glyph-measured near-zero tab width would keep both words on one line but +the true, tab-stop-expanded width correctly wraps the second word onto a new line, proving +`PackTokensIntoLines` uses the dynamically-computed expanded width for its fit decision, not the +static, unused `ResolvedToken.WidthEmu`), and +`ResolveTextLayout_MultipleTabCharacters_EachAdvancesToItsOwnNextTabStop` (a run containing +`"A\t\tB"` proves the second tab's expansion is computed from the cursor position after the first +tab's own expansion - including correctly advancing by a full interval, never zero, when the +second tab starts exactly on an already-aligned tab-stop boundary). + +#### Review Follow-Up: Table Gradient Sizing, Gradient Transform Composition, and ColorMap Threading + +A focused code-review pass surfaced three true-positive defects in `PptxDocument.Tables.cs` and +its immediately collaborating files, all touching the same gridSpan/rowSpan/colorMap code paths +and fixed together. + +**1. Merged-cell gradient sizing (`ParseTable`)**: `ParseTable` previously called +`ParseTableCell(tcElement, theme, columnWidthEmu, rowHeightEmu, ...)` using only the cell's own +single column's width and single row's height - always a span-1 size, regardless of the cell's own +declared `gridSpan`/`rowSpan`. A cell's gradient fill (`ResolveGradientFill`, `PptxDocument. +Paint.cs`) builds its `Start`/`End` stop positions directly from the `widthEmu`/`heightEmu` it is +given, so a merged cell's gradient was always sized against its first column/row alone - visibly +too narrow/short, with the gradient's own end stop landing partway across the merged cell's true +rectangle instead of at its far edge. **Fix**: `ParseTable` now precomputes a `rowHeightsEmu` list +once, up front, before its per-row loop (replacing the previous once-per-row inline parse); for +each ``, it peeks the cell's own `gridSpan`/`rowSpan` attributes (via the existing +`ParseOptionalIntAttribute` helper) _before_ calling `ParseTableCell`, and passes a merged +(summed) `cellWidthEmu`/`cellHeightEmu` computed via a new `SumConsecutive(IReadOnlyList +valuesEmu, int startIndex, int count)` private helper - a generalization of the pre-existing +`SumColumnWidths`, which now delegates to it as a one-line wrapper, reused here for the new +row-height peek as well. This peek is a sizing hint only, not a validation step: `ParseTableCell` +still independently re-parses and authoritatively validates the same `gridSpan`/`rowSpan` +attributes (throwing `InvalidDataException` for a non-positive value), so a malformed value can +never silently bypass that check via the peek. + +**2. Gradient transform composition (`FillPaint`)**: every other transform-sensitive paint +operation in this unit composes the shape's own local-to-surface transform into whatever it paints +before filling; a gradient fill's own `Gradient`, however, was always filled with its `Transform` +left at the default `Matrix3x2.Identity`, regardless of any non-identity `shapeToSurfaceTransform` +in scope - the one precedent for this kind of composition elsewhere in the codebase is +`Rendering/Canvas.cs`'s own `paint.WithTransform(_current)` call. For an ordinary (non-rotated, +non-skewed, 1 EMU-per-pixel) shape this defect is invisible, since the identity transform is a +no-op; it becomes visible for a rotated/scaled/skewed shape (or, for a table, any non-identity +`shapeToSurfaceTransform` the table itself is painted through), where the gradient's own ramp +stays fixed in the shape's local coordinate space instead of following the shape into surface +space. **Fix, scoped to table cells only**: `FillPaint` (a single, shared private method also +called from eight-plus non-table sites in `PptxDocument.Render.cs`) gained a second, four- +parameter overload - `FillPaint(Surface, Path, PptxPaint, Matrix3x2 shapeToSurfaceTransform)` - +that composes `gradientFill.Gradient.WithTransform(shapeToSurfaceTransform)` before filling; the +pre-existing three-parameter overload is **unchanged** and now simply delegates to the new one +with `Matrix3x2.Identity`, preserving every pre-existing (non-table) call site's exact current +behavior bit-for-bit. `PaintTable`'s cell-fill call and `PaintCellBorder`'s stroked-border call +(both of which already have `shapeToSurfaceTransform` in scope) now use the new four-argument +overload. **Explicitly out of scope**: the identical gap exists at every one of `FillPaint`'s +other (non-table) call sites in `PptxDocument.Render.cs` - an ordinary shape's own gradient fill +is, today, filled exactly as before this fix, still without transform composition. This is a real, +visually-observable gap for a rotated/skewed shape with a gradient fill, but it is not +table-specific, is already fully pre-existing (not introduced or worsened by this fix), and +closing it for every non-table call site is a separable, independently-scoped follow-up left +explicitly for a future unit of work, not folded into this table-focused fix. + +**3. ColorMap threading for slide-owned tables (`ParseShapeTree`/`GetSlide`)**: see the "Color Map +(``/``) Resolution" follow-up above - its own "Scoped limitation" text has +been updated in place to describe this fix (an optional `Func? colorMapResolver` +parameter added to `ParseShapeTree`, invoked lazily only for a `` table and +threaded through its own recursive `` call; `GetSlide` supplies a lazy resolver computing +that slide's own real `ResolveEffectiveColorMap(...)`, safe because `_slideCache` is keyed 1:1 per +slide; `GetMaster`/`GetLayout`'s own shared caches are intentionally left unchanged, narrowing - not +eliminating - the pre-existing known limitation to master/layout-owned tables only). + +**Test coverage**: `PptxTablesTests.cs` gained +`ResolveCellRects_GridSpanMissingHMergePlaceholder_CompensatesColumnAdvance` (see "Cell-Rect +Resolution" above), `ParseTable_GridSpanCellWithGradientFill_UsesMergedWidth` and +`ParseTable_RowSpanCellWithGradientFill_UsesMergedHeight` (each comparing a merged cell's own +resolved gradient extent against an equivalent span-1 cell's, proving the merged cell's own +gradient spans its true summed dimension), and +`PaintTable_GradientFilledCellUnderNonIdentityTransform_PositionsGradientInSurfaceSpace` (a +gradient-filled cell painted through a 2x-scale `shapeToSurfaceTransform` samples the correct +stop colors at the surface-space-mapped ends of the ramp). `PptxColorMapTests.cs` gained +`GetSlide_TableCellSchemeColorFillWithSlideClrMapOvr_UsesEffectiveColorMapNotDefault`, an +integration-style test proving `GetSlide` itself (not a direct `ParseTable` call) threads a real +slide's effective color map - computed from a `` overriding `bg1="dk1"` - into a +table cell's `` fill resolution, which resolves to the theme's `dk1` color +rather than the default `lt1`. + +#### Chart Graphic Frames (Phase 4 — delegation to `CanvasNetCharts`) + +Before this phase, every `` declaring a chart (`` whose `uri` +attribute ends in `"/chart"`) fell through `ParseTable`'s own graphic-frame-kind dispatch (see +_Table Parsing_ above) and always threw `PptxUnsupportedFeatureException` (feature token +`"pptx-graphic-frame-kind"`) - no presentation containing a chart could ever render past that +slide. The sibling `DemaConsulting.CanvasNet.Charts` system (its own separate NuGet package, +`CanvasNetCharts`) had, by its own Phases 1-3, already independently implemented and verified a +chart data model, OOXML-chart (DrawingML-Charts) parsing (`OpenXmlChartParser`), and pixel +rendering (`ChartRenderer`). This phase's own job is purely integration: resolve a chart graphic +frame's relationship, invoke `CanvasNetCharts`'s already-verified parser, and composite its +already-verified renderer's output onto the slide surface - no chart-model, chart-parsing, or +chart-rendering logic is re-implemented here, and `DemaConsulting.CanvasNet.Charts` itself is not +modified by this phase at all (see `docs/design/canvas-net-charts.md`'s "Dependencies" section - +`CanvasNetCharts` must never reference `CanvasNetPptx`; this integration is a one-directional +`ProjectReference` from `DemaConsulting.CanvasNet.Pptx.csproj` to +`DemaConsulting.CanvasNet.Charts.csproj` only). + +**Dispatch (`PptxDocument.ShapeTree.cs`)**: `ParseShapeTree` inspects a ``'s own +`` `uri` attribute _before_ either `ParseTable` or the new `ParseChart` is called - +a chart-kind `uri` (ending in `"/chart"`), when a non-null `resolveChartPart` delegate was +supplied, dispatches to `ParseChart` and records the result as +`PptxGraphicFrameShapeNode(GraphicFrameElement, Table: null, Chart: )`; every other +kind (including a chart-kind `uri` when `resolveChartPart` is `null` - an owning part that chose +not to supply chart support, see below) falls through to the pre-existing `ParseTable` call, +recorded as `PptxGraphicFrameShapeNode(GraphicFrameElement, Table: , Chart: null)`. +`PptxGraphicFrameShapeNode`'s own `Table` and `Chart` properties are therefore each independently +nullable (a behavior-visible change from before this phase, when `Table` was never null for any +graphic frame node) and mutually exclusive - exactly one is non-null for any graphic frame node +`ParseShapeTree` actually produces. `ParseTable` itself is **not modified** by this phase (see the +corrected _Table Parsing_ note above) - the chart/table decision is made entirely by +`ParseShapeTree`'s own dispatch, before either parser is invoked. + +**`ParseChart` (new `PptxDocument.Charts.cs`)**: resolves the graphic frame's own +`/` relationship reference (throwing `InvalidDataException` +for a missing `` child or a missing `r:id` attribute - a malformed, not merely +unsupported, chart graphic frame) via a caller-supplied `Func resolveChartPart` +delegate (mirroring `ParseTable`'s own `theme`-via-delegate pattern for lazy, per-owning-part +resolution), loads the referenced `chart#.xml` part, and parses it via +`OpenXmlChartParser.Parse(XElement)`. Any `ChartUnsupportedFeatureException` thrown by that parser +for a recognized-but-unsupported chart construct (for example a radar chart - see +`OpenXmlChartParser`'s own remarks for the complete supported/deferred chart-kind boundary) is +caught and re-thrown as a `PptxUnsupportedFeatureException` whose own `Feature` token is +`"pptx-chart-"` followed by the wrapped exception's own `Feature` value (for example +`"pptx-chart-charts-openxml-radar-chart"`) - this keeps a chart-specific deferred-feature token +distinguishable, in `CanvasNetPptx`'s own exception contract, from every other `"pptx-*"` feature +token this package already raises, while still surfacing `CanvasNetCharts`'s own, more specific +`Feature` value rather than discarding it. + +**`resolveChartPart` threading (`PptxDocument.Slides.cs`/`.Layouts.cs`/`.Masters.cs`)**: each of +`GetSlide`/`GetLayout`/`GetMaster` supplies its own `resolveChartPart` closure to +`ParseShapeTree`, bound to that owning part's own path - typically +`id => LoadPartXmlRoot(ResolveRelationship(ownPartPath, id))` - exactly mirroring the existing +`themeResolver`/`colorMapResolver` closure-binding pattern already established for tables, so a +chart relationship id is always resolved relative to the slide/layout/master part that actually +declared it, never the package root. + +**Rendering (`PptxDocument.Render.cs`)**: `RenderGraphicFrame` paints a node's already-parsed +`Table` via the pre-existing Phase 1e table pipeline, unchanged, or, when `Chart` is non-null +instead, renders that `Chart` onto its own, dedicated `Surface` via +`ChartRenderer.Render(Chart, int, int, ChartRenderOptions?)` - sized via a small new +`ResolveGraphicFramePixelSize` helper that derives a pixel width/height from the graphic frame's +own resolved on-slide footprint (transforming its unit-square corners through +`localToSurface` and measuring the resulting pixel distances, each clamped to +`[1, Surface.MaxDimension]` so a degenerate or excessively large footprint can never request an +invalid `Surface` size) - and composites the resulting chart surface onto the slide surface via +the exact same `PaintPicture` primitive `RenderPicture` already uses for a ``'s own decoded +image. No new compositing code was written: a rendered chart is, from `PaintPicture`'s own +perspective, indistinguishable from any other source `Surface` being composited at a resolved +frame - only the small pixel-size-derivation helper above is new, needed because a chart (unlike a +picture) has no source image of its own whose pixel dimensions could be reused directly. + +**The `pythonpptx-shp-shapes.pptx` slide-0 SmartArt-coexistence nuance**: this corpus fixture's +own slide index 0 declares three sibling `` elements in document order - "Table 1" +(a real table), "Chart 2" (a real, supported line-ish chart), and "Diagram 7" (a SmartArt/diagram +graphic frame, still unsupported - SmartArt and OLE-object graphic frames remain explicitly out of +scope for this phase). Before this phase, that slide's whole-`Render` call threw +`PptxUnsupportedFeatureException` because of "Chart 2" (the first unsupported kind `ParseTable` +encountered); as of this phase, "Chart 2" resolves and would render successfully, but the same +slide's whole-`Render` call **still** throws `PptxUnsupportedFeatureException` - now because of +"Diagram 7" alone, confirmed directly (not assumed) by a dedicated, narrower isolation test +(`PptxDocument_Render_ShpShapesFixture_Chart2NodeHasNonNullChart`) that parses this slide's own +shape tree with `containUnsupportedGraphicFrames: true` to isolate "Chart 2" from its unsupported +sibling and proves it alone resolves to a non-null `Chart`/null `Table` node. This is the same +already-graceful, already-designed "contain one unsupported sibling, still fail the whole +uncontained slide `Render`" behavior this phase inherits unchanged from Phase 1e/1f - narrower in +scope (diagrams/OLE objects only, not charts) rather than new in kind. + +**Test coverage**: `PptxChartsTests.cs` (new) covers `ParseShapeTree`'s own chart-vs-table +dispatch (bar/line/pie/area chart kinds, plus a table-still-dispatches-to-`ParseTable` sanity +check), the radar-chart deferred-feature wrap-to-`PptxUnsupportedFeatureException` path, two +malformed-`` cases (no `` child; no `r:id` attribute) via a direct `ParseChart` +call, and a synthetic, fully in-memory chart-only slide package proving an end-to-end `Render` +call paints visible, non-transparent pixels. `PptxFixturesCorpusTests.cs` gained two new real- +world-chart-fixture facts (`pythonpptx-chart-line.pptx` renders successfully; +`pythonpptx-chart-unsupported-radar.pptx` throws `PptxUnsupportedFeatureException` with `Feature` +`"pptx-chart-charts-openxml-radar-chart"`) and had its three pre-existing chart-fixture assertions +flipped or corrected: `aiden0z-1-chart-and-complex.pptx` and +`samplelib-sample-presentation.pptx` now assert every slide renders successfully (their own chart +slides no longer throw), and `pythonpptx-shp-shapes.pptx` slide 0's existing throw-assertion test +had only its doc comment corrected (the throw is still asserted, now attributed to "Diagram 7", +not "Chart 2") alongside the new, narrower "Chart 2"-isolation fact above. diff --git a/docs/design/canvas-net/canvas/rgba32.md b/docs/design/canvas-net/canvas/rgba32.md index 19c893b8..1639a275 100644 --- a/docs/design/canvas-net/canvas/rgba32.md +++ b/docs/design/canvas-net/canvas/rgba32.md @@ -7,7 +7,9 @@ channel layout, this unit design covers the `Parse` and `TryParse` methods that #### Purpose `Rgba32` is a plain, four-byte carrier of RGBA channel values with no additional behavior beyond -value equality and hex-string parsing. Keeping it a minimal, fixed-layout struct lets `Surface` +value equality, hex-string parsing, and (internal only) a single-pixel Porter-Duff "over" +alpha-blending helper reused by sibling renderer packages. Keeping it a minimal, fixed-layout +struct lets `Surface` row buffers be reinterpreted between raw `byte` spans and `Rgba32` spans via `System.Runtime.InteropServices.MemoryMarshal` with no copying or conversion. Additional pixel formats (if ever needed) would be introduced as new standalone struct types rather than by @@ -62,6 +64,31 @@ missing `#`, wrong length, and non-hex characters are all rejected. Both methods share a single private `TryParseCore` implementation so their accept/reject behavior is guaranteed to agree. +##### CompositeOver(Rgba32 background, Rgba32 foreground) — internal + +A single-pixel counterpart of `Surface.CompositeOver(Rgba32)`'s own per-row blend pipeline, +computing the identical standard Porter-Duff "over" alpha-compositing formula for exactly one +pixel: with `fgA`/`bgA` normalized to `[0, 1]`, `outA = fgA + bgA * (1 - fgA)`, and for each color +channel, `outC = (fgC * fgA + bgC * bgA * (1 - fgA)) / outA` when `outA != 0`, otherwise `outC = 0` +(the same "alpha == 0 implies every color channel is 0" degenerate case `Surface`'s own +`ZeroColorWhereAlphaByteIsZero` documents). Every intermediate result is rounded +half-away-from-zero and clamped to `[0, 255]` before narrowing to a `byte`, matching `Surface`'s +own `NarrowRoundedClamp` rounding rule exactly. + +`internal` (not a public API): exposed cross-assembly only to the two sibling renderer packages +that each composite a decoded source image onto a destination `Surface` one sampled pixel at a +time — `DemaConsulting.CanvasNet.Pptx`'s `PptxDocument.Images.cs`'s `PaintPicture` and +`DemaConsulting.CanvasNet.Pdf`'s `PdfDocument.Images.cs`'s `CompositeImageOntoSurface` — via this +assembly's `InternalsVisibleTo` grants (alongside the existing grant to +`DemaConsulting.CanvasNet.Pdf` for `Fonts.SystemFontCatalog` reuse). Introduced to fix a +confirmed real-world bug: both renderers previously overwrote the destination pixel outright +with the raw sampled source pixel, ignoring its alpha channel entirely — a source pixel with a +non-opaque (including fully transparent) alpha channel replaced existing destination content with +whatever RGB value happened to be stored alongside that non-opaque alpha (for example a logo PNG +with an unassociated-alpha white matte painting a solid white rectangle instead of a transparent +background). Factored into this one shared helper, rather than duplicated independently in both +renderer assemblies, so the identical blending math is written and tested exactly once. + #### Error Handling `Parse(string)` throws `ArgumentNullException` on a null argument, and `FormatException` on any @@ -73,11 +100,15 @@ No other member of this unit validates its input or throws. #### Dependencies `Rgba32` depends only on `System.Globalization.CultureInfo`/`NumberStyles` (used by `Parse`/ -`TryParse`'s hex-digit decoding) and `System.Runtime.InteropServices.StructLayoutAttribute` from -the .NET Base Class Library. It has no dependency on any other CanvasNet unit. +`TryParse`'s hex-digit decoding), `System.Runtime.InteropServices.StructLayoutAttribute`, and +`System.MathF`/`System.Math` (used by `CompositeOver`'s rounding/clamping) from the .NET Base +Class Library. It has no dependency on any other CanvasNet unit. #### Callers `Rgba32` is a public API entry point, used throughout CanvasNet wherever a pixel color value is read or written: `Surface` (as the element type of its pixel spans), every `Codecs` unit, and -every `Drawing` and `Fonts`/`Rendering` unit that accepts or returns a fill color. +every `Drawing` and `Fonts`/`Rendering` unit that accepts or returns a fill color. The internal +`CompositeOver(Rgba32, Rgba32)` helper is additionally called cross-assembly by +`DemaConsulting.CanvasNet.Pptx`'s `PaintPicture` and `DemaConsulting.CanvasNet.Pdf`'s +`CompositeImageOntoSurface` (see those packages' own design documentation). diff --git a/docs/design/canvas-net/drawing/path-stroker.md b/docs/design/canvas-net/drawing/path-stroker.md index 63ad75f7..ca77f41f 100644 --- a/docs/design/canvas-net/drawing/path-stroker.md +++ b/docs/design/canvas-net/drawing/path-stroker.md @@ -163,6 +163,49 @@ instances directly. Every emitted polygon becomes one closed subpath in the resu otherwise become the hole, omits that ring entirely rather than emitting it as a hole - matching what a true geometric erosion of the contour by that half-width would produce (an empty inner boundary) and leaving the whole interior filled as solid stroke. + - **Density-relative false-positive/false-negative tolerance (regression fix).** The + backwards-edge test tolerates a bounded amount of backwards-projected length before concluding + a ring has genuinely collapsed, and that tolerance is derived **per edge**, from that edge's + own flattened *source*-segment length (`Vector2.Distance(points[i], points[next]) * + relativeCollapseTolerance`), floored at the original fixed `NearZeroDistance` for tiny or + degenerate segments. This replaced two successive earlier tolerance bases, each of which + produced a confirmed regression: a first fixed absolute tolerance (`NearZeroDistance = 1e-6f`, + tuned for small/pixel-scale coordinates) was swamped by float32 storage-quantization noise at + large coordinate magnitude and finely tessellated segments - for example a PPTX shape's + native EMU-space geometry (coordinates in the hundreds of thousands to millions), tessellated + finely enough that each segment subtends only a tiny turn angle, forcing each + forced-exact-intersection point to divide by a correspondingly tiny (near-parallel-offset- + lines) cross-product denominator and amplifying that noise into a projected edge length that + is measurably, not just infinitesimally, negative - a false positive that collapsed a + perfectly valid thin ring (for example, a `noFill` ellipse's thin `` outline) into a + solid fill. A second tolerance, derived once from the whole contour's bounding-box extent + (its geometric-mean span, `sqrt(RingAreaScale(points))`), fixed that false positive but + introduced a **false negative**: for a contour of true local radius of curvature `R` + tessellated into `N` vertices, the raw chord length between two adjacent + forced-exact-intersection inner-ring vertices (each at radius `R - h`, independent of `N`) + shrinks proportionally to `1/N` purely because finer tessellation produces more, shorter + inner-ring edges - not because the genuine over-erosion shrinks. A single tolerance computed + from the whole contour's (`N`-independent) bounding-box span could not track this + density-dependent shrinkage of the very signal it was compared against, so at a realistic + tessellation density for a small shape it silently stopped detecting a genuine + half-width-exceeds-inradius collapse for a growing range of real over-erosions, not just a + "near-exact-inradius" edge case. Deriving the tolerance per edge from that edge's own local + source-segment length tracks the same `1/N` density scaling the raw signal undergoes, + restoring a stable, density-invariant comparison without reintroducing the original + large-coordinate false positive. Promoting the line-intersection arithmetic to `double` + precision alone does *not* fix either regression (confirmed by direct experimentation): the + noise already exists in the float32-stored flattened vertices themselves, before the + intersection division even runs, so more precision in the division does not recover + information already lost, and it does nothing to address the density-dependent shrinkage of + the genuine-collapse signal either. All three cases - the original genuine-collapse case (a + thick stroke whose half-width truly exceeds a small shape's inradius at a fine, large-scale + tessellation), the large-coordinate false-positive-at-scale case, and the realistic-density + false-negative case - are covered by dedicated unit tests - see + `StrokeOutlinerTests.StrokeOutliner_Outline_ClosedSquareHalfWidthExceedsInradius_ProducesNoInvalidHole`, + `StrokeOutlinerTests.StrokeOutliner_Outline_LargeCoordinateFinelyTessellatedClosedContour_ProducesValidThinRing`, + `StrokeOutlinerTests.StrokeOutliner_Outline_LargeCoordinateFinelyTessellatedSmallCircleThickStroke_CollapsesToSolid`, + and + `StrokeOutlinerTests.StrokeOutliner_Outline_SmallCircleRealisticTessellation_HalfWidthJustOverInradius_CollapsesToSolid`. - **Degenerate subpaths.** A single point (or a path collapsed to one effective point after duplicate-vertex simplification) renders as a cap-shaped mark: round creates a full circle, square creates an axis-aligned width-by-width square, and butt creates nothing. This same diff --git a/docs/design/introduction.md b/docs/design/introduction.md index ea260fbb..b7846afa 100644 --- a/docs/design/introduction.md +++ b/docs/design/introduction.md @@ -86,6 +86,58 @@ software items, specifically: system/bundled fallback font when none is embedded. `CanvasNetPdf` depends on this `CanvasNet` system's `Canvas`, `Geometry`, `Drawing`, `Fonts`, and `Codecs` subsystems — see _CanvasNetPdf System Design_ (`canvas-net-pdf.md`) +- **CanvasNetPptx (System)** — A separate, independently-distributed software system providing + PowerPoint (`.pptx`) presentation-rendering support, containing a single unit, `PptxDocument`. + This feature was delivered incrementally across Phases 1a–1f, each additive to the last: Phase + 1a implemented the underlying OOXML (Office Open XML) package layer — opening a `.pptx` file as + a ZIP archive, resolving `[Content_Types].xml`'s default and part-specific override content-type + mappings, and resolving package-level and per-part relationships (including relative-target + traversal). Phase 1b added the presentation/theme/master/layout/slide model and a placeholder + property-inheritance resolver — parsing `ppt/presentation.xml`'s declared slide size and ordered + slide list (exposed as public `SlideCount`/`SlideSize` members), resolving each slide master's + theme (color/font scheme), structurally parsing each master/layout/slide's placeholder shapes, + and implementing the verified ECMA-376 placeholder-matching algorithm. Phase 1c added DrawingML + shape geometry (position/rotation/flip transform resolution, group child-coordinate-space + composition, preset and custom geometry resolution) and paint resolution (solid/gradient fills, + line styles). Phase 1d added DrawingML text layout and rendering — structural text parsing, an + attribute-level run/paragraph property-inheritance resolver, word-wrap/alignment/vertical- + anchor/autofit layout, and glyph-ink text rendering. Phase 1e added `` picture-shape + decoding/cropping/compositing, `` table structure/cell-rect/paint resolution, and + recursive, full `` shape-tree parsing (including nested `` enumeration). + Phase 1f (the current release) added the public, slide-level `Render` API — a document-order + walk of a slide's full shape tree dispatching each node to the already-verified Phase 1c/1d/1e + resolvers and painters — followed by subsequent Phase 2 Follow-Ups adding slide/layout/master + `` background-fill resolution ahead of that walk, ``/`` + bullet/numbering rendering, and `` connector-shape rendering. Radial/path gradients, + full text justification, `spAutoFit` shape-resize behavior, kerning, text clipping on overflow, + nested tables, and table auto-sizing/banding remain explicitly deferred. Pattern fill (a + documented subset of 30 of the 54 named ECMA-376 `ST_PresetPatternVal` preset names, including + background fills; the remaining 24 are deferred) and picture fill are both implemented — see + _CanvasNetPptx System Design_ (`canvas-net-pptx.md`) for the full supported/deferred boundary. + `CanvasNetPptx` depends on the `CanvasNet` system's `Canvas`, `Geometry`, + `Drawing`, `Fonts`, and `Codecs` subsystems (for the `Rgba32` color type, path geometry/ + stroking, font/glyph resolution, and image decoding used to resolve and render shape/text/ + picture content), and on the `CanvasNetCharts` system (for parsing and rendering embedded + `` charts — see `OpenXmlChartParser`/`ChartRenderer` below) — see + _CanvasNetPptx System Design_ (`canvas-net-pptx.md`) +- **CanvasNetCharts (System)** — A separate, independently-distributed software system providing + chart support, containing two subsystems: `ChartModel`, containing a single unit, + `ChartDocument` — the public, immutable, validating chart data model (`Chart`/`ChartSeries`/ + `ChartAxis`/`ChartLegend`/`ChartTitle`/`ChartType`), the `ChartBuilder` fluent construction API, + and a `ChartRenderer` unit that paints a `Chart` onto a core `Surface`; and `OpenXmlChart`, + containing a single unit, `OpenXmlChartParser`, which parses an OOXML `chart1.xml` part into + the `ChartModel` data model. The `CanvasNetPptx` system integrates `CanvasNetCharts` into its + own slide rendering, automatically parsing and rendering embedded `` charts — + see _CanvasNetPptx System Design_ (`canvas-net-pptx.md`) for that integration's detail. Bar, + column, line, pie, doughnut, and area OOXML chart types are supported; radar, scatter, bubble, + stock, surface, 3-D, "of pie", and multi-chart-type ("combo") OOXML charts remain explicitly + deferred (see _OpenXmlChartParser Unit Design_ (`open-xml-chart-parser.md`) for the complete + supported/deferred boundary). `CanvasNetCharts` depends on this `CanvasNet` + system's `Canvas`, `Drawing`, `Geometry`, `Fonts`, and `Rendering` subsystems (for the `Rgba32` + color type and pixel buffer, path filling/stroking and tile-paint primitives, transforms and + rectangles, TrueType text layout/metrics, and the bundled Liberation Sans fallback font), and + must never reference `CanvasNetSvg`, `CanvasNetPdf`, `CanvasNetPptx`, or `CanvasNetVsdx` — see + _CanvasNetCharts System Design_ (`canvas-net-charts.md`) The following OTS items are also covered: diff --git a/docs/reqstream/canvas-net-charts.yaml b/docs/reqstream/canvas-net-charts.yaml new file mode 100644 index 00000000..9113b1a0 --- /dev/null +++ b/docs/reqstream/canvas-net-charts.yaml @@ -0,0 +1,160 @@ +--- +# Software System Requirements for CanvasNetCharts +# +# These requirements describe the externally visible behavior of the DemaConsulting.CanvasNet.Charts +# package (namespace DemaConsulting.CanvasNet.Charts) from the perspective of its consumers, without +# specifying implementation details. +# +# CanvasNetCharts is an independent software system, distributed as its own NuGet package, +# depending on the core CanvasNet system (docs/reqstream/canvas-net.yaml) for its Canvas +# subsystem's Rgba32/Surface units and, as of Phase 2, its Drawing/Geometry/Fonts/Rendering +# subsystems - see docs/design/canvas-net-charts.md's Dependencies section for details. +# +# CanvasNetCharts is being delivered incrementally. Phase 1 shipped the public, immutable chart +# data model (Chart/ChartSeries/ChartAxis/ChartLegend/ChartTitle/ChartType) and the ChartBuilder +# ergonomic construction API. Phase 2 added a ChartRenderer that paints a Chart onto a core +# Surface. Phase 3 (these requirements) adds a format-agnostic OpenXmlChartParser that produces a +# Chart directly from a raw c:chartSpace/c:chart XElement/XDocument, reading only cached values - +# it has no OPC/ZIP packaging knowledge and no dependency on any host document-format package. +# Phase 4 will integrate locating/opening a chart1.xml OPC part and invoking this parser into +# PptxDocument's own slide rendering. These later-phase requirements will be added to this file +# (and new per-unit files) when each phase lands, mirroring canvas-net-pptx.yaml's own per-phase +# header-comment convention. + +sections: + - title: Chart Model API + requirements: + - id: CanvasNetCharts-Lib-ChartModel + title: >- + The library shall provide an immutable, validating data model (Chart, ChartSeries, + ChartAxis, ChartLegend, ChartTitle, and the ChartType enumeration) describing a chart's + kind, data series, optional category/value axes, optional legend, optional title, and an + optional default color palette, rejecting any invalid construction argument (a null + required argument, an empty or whitespace-only name/title, a non-finite numeric value, a + series/category-axis length mismatch, or similar) at construction time rather than + silently coercing it, and defensively copying every caller-supplied collection so that + mutating the caller's original collection after construction has no effect on the + constructed instance. + tags: [system] + justification: | + Later phases (a renderer and an OOXML chart1.xml parser) both need a single, already + validated, immutable in-memory representation of "what chart to draw" that cannot be + mutated out from under them after construction, and that fails closed on invalid data + rather than propagating it into rendering or serialization logic. + children: + - CanvasNetCharts-ChartModel-ChartConstruction + - CanvasNetCharts-ChartModel-SeriesAxisConsistency + - CanvasNetCharts-ChartModel-SeriesValidation + - CanvasNetCharts-ChartModel-AxisValidation + - CanvasNetCharts-ChartModel-LegendValidation + - CanvasNetCharts-ChartModel-TitleValidation + - CanvasNetCharts-ChartModel-DefensiveCopies + - CanvasNetCharts-ChartModel-TypeEnumeration + tests: + - Constructor_AllMembers_SetsAllMembers + - Constructor_MutatingOriginalSeriesList_DoesNotAffectStoredSnapshot + + - id: CanvasNetCharts-Lib-ChartBuilder + title: >- + The library shall provide a fluent ChartBuilder API for constructing a Chart, where every + chainable method either stores already-valid caller state or delegates construction of a + model type to that type's own validating constructor (never duplicating a validation + rule), and where calling Build before a chart type and at least one series have been + configured throws InvalidOperationException with a message identifying what is missing. + tags: [system] + justification: | + Constructing a Chart's full constructor argument list directly is verbose for the common + case; callers need a more ergonomic, discoverable, step-by-step way to assemble one + without the builder itself becoming a second, divergent place where validation rules + could drift from the model's own rules. + children: + - CanvasNetCharts-ChartModel-BuilderFluentChaining + - CanvasNetCharts-ChartModel-BuilderDelegatesValidation + - CanvasNetCharts-ChartModel-BuilderIncompleteBuildFails + tests: + - Build_FullConfiguration_ProducesChartRoundTrippingBuilderInputs + - Build_TypeNeverSet_ThrowsInvalidOperationException + - Build_NoSeriesAdded_ThrowsInvalidOperationException + + - title: Chart Rendering API + requirements: + - id: CanvasNetCharts-Lib-ChartRenderer + title: >- + The library shall render a validated Chart onto a new core CanvasNet Surface, via a + caller-specified pixel width/height or a caller-specified physical width/height/DPI, + dispatching every defined ChartType to a dedicated painter that also paints axes (for + category-based types), a legend, a title, and opt-in per-point data labels, failing + closed with a documented exception on a null chart or invalid dimensions, and otherwise + degrading gracefully (never throwing) for an extreme render-target size, a legend + overflowing its band, an over-long label, or a single-category Pie/Doughnut's + full-circle wedge. + tags: [system] + justification: | + A validated Chart is only useful once a consumer can turn it into visible pixels; this + is the one externally visible capability Phase 2 adds, and it must behave predictably + across every chart kind, decoration, and data shape the Phase 1 model itself supports. + children: + - CanvasNetCharts-ChartModel-ChartRendering + tests: + - Render_EveryChartType_ProducesCorrectlySizedSurface + - Render_PixelOverloadNullChart_ThrowsArgumentNullException + - Render_DpiOverload_ComputesExpectedPixelDimensions + - Render_ManySeriesGroupedColumn_NarrowsBarsRatherThanOverlapping + + - id: CanvasNetCharts-Lib-ChartRenderOptions + title: >- + The library shall provide a ChartRenderOptions settings bag (background color, an + optional font override, per-element default font sizes, and an optional color-palette + override) with a Default singleton, applied by ChartRenderer whenever a caller omits the + corresponding render option, series/point color, or chart-level color palette, resolving + colors via Chart.ColorPalette, then ChartRenderOptions.ColorPalette, then a documented + 10-entry default color palette, in that fixed order. + tags: [system] + justification: | + A caller who supplies only the minimal Chart data (no colors, no render options) must + still receive a legible, fully deterministic, well-styled rendering rather than an error + or an arbitrary/undocumented appearance; a caller who does want control over styling + needs a single, extensible, documented settings surface to do so. + children: + - CanvasNetCharts-ChartModel-ChartRenderingDefaults + tests: + - Render_CustomBackgroundColor_ClearsToThatColor + - Render_SeriesWithoutExplicitColor_ResolvesToDefaultPaletteInOrder + - Render_ChartColorPaletteOverride_TakesPriorityOverDefaultPalette + + - title: OpenXML Chart Parsing API + requirements: + - id: CanvasNetCharts-Lib-OpenXmlChartParser + title: >- + The library shall provide a format-agnostic OpenXmlChartParser that parses a raw ECMA-376 + DrawingML-Charts c:chartSpace (or bare c:chart) XElement/XDocument into a validated Chart, + reading only cached values (never recomputing from a c:f formula), supporting + barChart (bar/column, classified by barDir), lineChart, pieChart, doughnutChart, and + areaChart, extracting series names/categories/values, chart/axis titles, and legend + presence/position, and throwing a dedicated ChartUnsupportedFeatureException - carrying + a short, stable Feature token - for a recognized-but-unimplemented chart kind (radar, + bubble, scatter, stock, surface, 3-D, or "of pie"), a combo chart, a plot area with no + recognized chart-type element, or a series with no cached value. + tags: [system] + justification: | + Before chart rendering can be integrated into any host document format (such as + PptxDocument in a later phase), there must be a single, well-tested, format-agnostic + component that turns a real chart1.xml part's XML content into the already-validated + Chart the Phase 2 renderer consumes, without taking on any packaging- or host-format- + specific knowledge that would make it unusable by a future non-PowerPoint host. + children: + - CanvasNetCharts-OpenXmlChart-ChartTypeClassification + - CanvasNetCharts-OpenXmlChart-SeriesDataExtraction + - CanvasNetCharts-OpenXmlChart-DecorationExtraction + - CanvasNetCharts-OpenXmlChart-UnsupportedFeatureRejection + - CanvasNetCharts-OpenXmlChart-EndToEndValidation + tests: + - Parse_BarChartWithBarDirection_ClassifiesAsBar + - Parse_LineChart_ClassifiesAsLine + - Parse_PieChart_ClassifiesAsPieWithPointLabels + - Parse_DoughnutChart_ClassifiesAsDoughnut + - Parse_AreaChart_ClassifiesAsArea + - Parse_RealFixtureBarChartExcerpt_ParsesExpectedShape + - Parse_SeriesWithUncachedValues_ThrowsWithUncachedValuesFeatureToken + - Parse_ComboChart_ThrowsWithComboChartFeatureToken + - Parse_EmptyPlotArea_ThrowsWithNoChartTypeFeatureToken diff --git a/docs/reqstream/canvas-net-charts/chart-model.yaml b/docs/reqstream/canvas-net-charts/chart-model.yaml new file mode 100644 index 00000000..5521a1bb --- /dev/null +++ b/docs/reqstream/canvas-net-charts/chart-model.yaml @@ -0,0 +1,288 @@ +--- +# Software Subsystem Requirements for ChartModel +# +# These requirements decompose the CanvasNetCharts system's data-model responsibility into the +# ChartModel subsystem: validating, immutable representations of a chart's shape (kind, series, +# axes, legend, title, and color palette) and the builder that assembles them. Each requirement +# here links down to the ChartDocument unit requirements that implement it. + +sections: + - title: CanvasNetCharts Requirements + sections: + - title: ChartModel Requirements + requirements: + - id: CanvasNetCharts-ChartModel-ChartConstruction + title: >- + The ChartModel subsystem shall construct a validated Chart from a chart type and at + least one series, with optional category axis, value axis, legend, title, and color + palette, rejecting an undefined ChartType value, a null or empty series collection, a + null series entry, and an empty color palette. + justification: | + A Chart is the root aggregate every later rendering/parsing phase consumes; it must + be impossible to construct one in a structurally invalid state. + children: + - CanvasNetCharts-ChartModel-ChartDocument-ChartConstruction + - CanvasNetCharts-ChartModel-ChartDocument-ChartTypeValidation + - CanvasNetCharts-ChartModel-ChartDocument-SeriesCollectionValidation + - CanvasNetCharts-ChartModel-ChartDocument-ColorPaletteValidation + tests: + - Constructor_MinimalMembers_SetsRequiredAndLeavesOptionalsUnset + - Constructor_AllMembers_SetsAllMembers + - Constructor_UndefinedType_ThrowsArgumentOutOfRangeException + - Constructor_NullSeries_ThrowsArgumentNullException + - Constructor_EmptySeries_ThrowsArgumentException + - Constructor_NullSeriesEntry_ThrowsArgumentException + - Constructor_EmptyColorPalette_ThrowsArgumentException + + - id: CanvasNetCharts-ChartModel-SeriesAxisConsistency + title: >- + The ChartModel subsystem shall validate, for a category-based chart type (Bar, + Column, Line, or Area) whose category axis declares category labels, that every + series' value count exactly equals that label count, and shall not apply this check + to a proportional-share chart type (Pie or Doughnut) or when no category labels are + declared. + justification: | + A category-based chart's series values are plotted one-for-one against the category + axis's labels; a length mismatch would silently misalign data from its intended + category when later rendered, so it must fail at construction instead. + children: + - CanvasNetCharts-ChartModel-ChartDocument-CategoryLengthValidation + tests: + - Constructor_CategoryBasedTypeWithMatchingLength_Succeeds + - Constructor_CategoryBasedTypeWithMismatchedLength_ThrowsArgumentException + - Constructor_ProportionalType_IsExemptFromLengthCheck + - Constructor_CategoryAxisWithNoLabels_DoesNotTriggerLengthCheck + + - id: CanvasNetCharts-ChartModel-SeriesValidation + title: >- + The ChartModel subsystem shall construct a validated ChartSeries from a name and at + least one finite value, with an optional per-point color override, an optional + per-point label override, and an optional series-level color, rejecting a null or + empty/whitespace-only name, a null, empty, or non-finite-containing values + collection, and a per-point color or label collection whose count does not exactly + equal the values count. + justification: | + A series with a blank name, no data points, a non-finite value, or mismatched + per-point overrides cannot be meaningfully plotted and must be rejected before it + reaches any later rendering phase. + children: + - CanvasNetCharts-ChartModel-ChartDocument-SeriesValidation + - CanvasNetCharts-ChartModel-ChartDocument-SeriesNameValidation + - CanvasNetCharts-ChartModel-ChartDocument-ValuesValidation + - CanvasNetCharts-ChartModel-ChartDocument-PerPointCollectionValidation + tests: + - Constructor_NameAndValuesOnly_SetsMembersAndLeavesOptionalsUnset + - Constructor_NullName_ThrowsArgumentNullException + - Constructor_EmptyOrWhitespaceName_ThrowsArgumentException + - Constructor_NullValues_ThrowsArgumentNullException + - Constructor_EmptyValues_ThrowsArgumentException + - Constructor_NonFiniteValueEntry_ThrowsArgumentException + - Constructor_PointColorsCountMismatch_ThrowsArgumentException + - Constructor_PointLabelsCountMismatch_ThrowsArgumentException + - Constructor_NullPointLabelEntry_ThrowsArgumentException + + - id: CanvasNetCharts-ChartModel-AxisValidation + title: >- + The ChartModel subsystem shall construct a validated ChartAxis from an optional + ordered label collection, an optional finite value range (minimum, maximum, tick + interval), and an optional title, rejecting a null label entry, a non-finite + minimum/maximum/tick interval, a minimum not strictly less than the maximum when both + are supplied, a non-positive tick interval, and an empty or whitespace-only title. + justification: | + An axis with an inverted or non-finite range, a non-positive tick spacing, or a null + label cannot be meaningfully rendered and must be rejected before it reaches any + later rendering phase. + children: + - CanvasNetCharts-ChartModel-ChartDocument-AxisValidation + - CanvasNetCharts-ChartModel-ChartDocument-AxisLabelValidation + - CanvasNetCharts-ChartModel-ChartDocument-AxisRangeValidation + - CanvasNetCharts-ChartModel-ChartDocument-AxisTitleValidation + tests: + - Constructor_LabelsOnly_SetsLabelsAndLeavesRangeUnset + - Constructor_ValueRangeOnly_SetsRangeAndLeavesLabelsUnset + - Constructor_LabelsAndRangeAndTitle_SetsAllMembers + - Constructor_NullLabelEntry_ThrowsArgumentException + - Constructor_NonFiniteMinimum_ThrowsArgumentOutOfRangeException + - Constructor_NonFiniteMaximum_ThrowsArgumentOutOfRangeException + - Constructor_MinimumEqualToMaximum_ThrowsArgumentOutOfRangeException + - Constructor_MinimumGreaterThanMaximum_ThrowsArgumentOutOfRangeException + - Constructor_InvalidTickInterval_ThrowsArgumentOutOfRangeException + - Constructor_WhitespaceTitle_ThrowsArgumentException + + - id: CanvasNetCharts-ChartModel-LegendValidation + title: >- + The ChartModel subsystem shall construct a validated ChartLegend from an optional + placement position (defaulting to Right) and an optional visibility flag (defaulting + to true), rejecting an undefined ChartLegendPosition value. + justification: | + A legend with an out-of-range placement value cannot be meaningfully rendered and + must be rejected before it reaches any later rendering phase. + children: + - CanvasNetCharts-ChartModel-ChartDocument-LegendValidation + tests: + - Constructor_Defaults_IsVisibleAndRightPositioned + - Constructor_EachDefinedPosition_IsAccepted + - Constructor_IsVisibleFalse_IsStored + - Constructor_UndefinedPosition_ThrowsArgumentOutOfRangeException + + - id: CanvasNetCharts-ChartModel-TitleValidation + title: >- + The ChartModel subsystem shall construct a validated ChartTitle from non-null, + non-empty, non-whitespace-only text and an optional finite, strictly positive + font-size hint, rejecting any value outside those bounds. + justification: | + A title with blank text or a non-positive font size cannot be meaningfully rendered + and must be rejected before it reaches any later rendering phase. + children: + - CanvasNetCharts-ChartModel-ChartDocument-TitleValidation + tests: + - Constructor_TextOnly_SetsTextAndLeavesFontSizeUnset + - Constructor_TextAndFontSize_SetsBothMembers + - Constructor_NullText_ThrowsArgumentNullException + - Constructor_EmptyText_ThrowsArgumentException + - Constructor_WhitespaceText_ThrowsArgumentException + - Constructor_InvalidFontSize_ThrowsArgumentOutOfRangeException + + - id: CanvasNetCharts-ChartModel-DefensiveCopies + title: >- + The ChartModel subsystem shall defensively copy every caller-supplied collection + argument (series, color palette, values, point colors, point labels, and axis + labels) into an immutable snapshot at construction time, so that mutating the + caller's original collection after construction has no effect on the constructed + instance. + justification: | + A model type whose internal state could still be mutated by a caller after + construction would break the "fully validated, immutable" guarantee every later + rendering/parsing phase relies on. + children: + - CanvasNetCharts-ChartModel-ChartDocument-DefensiveCopies + tests: + - Constructor_MutatingOriginalSeriesList_DoesNotAffectStoredSnapshot + - Constructor_MutatingOriginalColorPaletteList_DoesNotAffectStoredSnapshot + - Constructor_MutatingOriginalValuesList_DoesNotAffectStoredSnapshot + - Constructor_MutatingOriginalPointColorsList_DoesNotAffectStoredSnapshot + - Constructor_MutatingOriginalPointLabelsList_DoesNotAffectStoredSnapshot + - Constructor_MutatingOriginalLabelsList_DoesNotAffectStoredSnapshot + + - id: CanvasNetCharts-ChartModel-TypeEnumeration + title: >- + The ChartModel subsystem shall define exactly the Bar, Column, Line, Pie, Doughnut, + and Area chart kinds for Phase 1, each a fully documented ChartType member. + justification: | + Every chart-type-dependent validation rule in this subsystem (for example, the + category-length check) and every later rendering/parsing phase dispatches on this + fixed, well-documented set of chart kinds. + children: + - CanvasNetCharts-ChartModel-ChartDocument-TypeEnumeration + tests: + - AllV1Members_AreDefined + - UndefinedValue_IsNotDefined + - UndefinedValue_RejectedByChartConstructor + + - id: CanvasNetCharts-ChartModel-BuilderFluentChaining + title: >- + The ChartModel subsystem shall return the same ChartBuilder instance from every one + of its chainable configuration methods, enabling fluent method chaining. + justification: | + A fluent builder's defining usability characteristic is that every configuration + step can be chained onto the previous one without an intermediate local variable. + children: + - CanvasNetCharts-ChartModel-ChartDocument-BuilderFluentChaining + tests: + - FluentMethods_EachReturnSameBuilderInstance + - Build_WithMultipleSeriesAddedViaChartSeriesOverload_PreservesOrder + + - id: CanvasNetCharts-ChartModel-BuilderDelegatesValidation + title: >- + The ChartModel subsystem's ChartBuilder shall delegate all data-shape validation to + the model type constructors it calls, so that an invalid argument passed to any + ChartBuilder method surfaces the identical exception the corresponding model + constructor would throw directly. + justification: | + Duplicating validation logic between ChartBuilder and the model constructors would + risk the two drifting out of sync over time; a single source of truth for each rule + avoids that risk entirely. + children: + - CanvasNetCharts-ChartModel-ChartDocument-BuilderDelegatesValidation + tests: + - AddSeries_InvalidName_PropagatesChartSeriesConstructorException + - AddSeries_NullChartSeries_PropagatesArgumentNullException + - WithCategoryAxis_NullChartAxis_PropagatesArgumentNullException + - WithValueAxis_NullChartAxis_PropagatesArgumentNullException + - WithValueAxis_InvalidRange_PropagatesChartAxisConstructorException + - WithLegend_NullChartLegend_PropagatesArgumentNullException + - WithTitle_WhitespaceText_PropagatesChartTitleConstructorException + - WithColorPalette_Null_ThrowsArgumentNullException + - Build_SeriesLengthMismatchedWithCategoryAxis_PropagatesChartConstructorException + + - id: CanvasNetCharts-ChartModel-ChartRendering + title: >- + The ChartModel subsystem shall render a validated Chart onto a core CanvasNet + Surface, for a caller-specified pixel size or a caller-specified physical size and + DPI, dispatching every defined ChartType (Bar, Column, Line, Area, Pie, Doughnut) to + a dedicated painter that also paints axes (for category-based types), a legend (at + any position), a title, and opt-in per-point data labels, correctly handling + negative values, a single-category Pie/Doughnut's full-circle wedge, and a large + series/point count's legend/bar-group layout. + justification: | + A validated Chart is only useful once it can actually be turned into visible pixels; + this is the ChartModel subsystem's rendering responsibility, exercised end-to-end for + every chart kind, decoration, and data shape the model itself supports. + children: + - CanvasNetCharts-ChartModel-ChartRenderer-PixelRender + - CanvasNetCharts-ChartModel-ChartRenderer-DpiRender + - CanvasNetCharts-ChartModel-ChartRenderer-ChartTypeDispatch + - CanvasNetCharts-ChartModel-ChartRenderer-PieDoughnutWedgeLayout + - CanvasNetCharts-ChartModel-ChartRenderer-TitleRendering + - CanvasNetCharts-ChartModel-ChartRenderer-LegendRendering + - CanvasNetCharts-ChartModel-ChartRenderer-LegendOverflowByOmission + - CanvasNetCharts-ChartModel-ChartRenderer-AxisRendering + - CanvasNetCharts-ChartModel-ChartRenderer-NegativeValueHandling + - CanvasNetCharts-ChartModel-ChartRenderer-DataLabelRendering + - CanvasNetCharts-ChartModel-ChartRenderer-SinglePointSeries + - CanvasNetCharts-ChartModel-ChartRenderer-LongLabelTruncation + - CanvasNetCharts-ChartModel-ChartRenderer-RenderTargetSizeExtremes + - CanvasNetCharts-ChartModel-ChartRenderer-ManySeriesGroupedLayout + tests: + - Render_EveryChartType_ProducesCorrectlySizedSurface + - Render_WithTitle_PaintsContentInTitleBand + - Render_LegendTop_PaintsSwatchesInTopBand + - Render_WithAxes_PaintsNonBackgroundContentInReservedMargins + - Render_PointWithDataLabel_PaintsVisibleTextNearPoint + - Render_Column_NegativeAndPositiveValues_PaintsBothAboveAndBelowZeroBaseline + - Render_Pie_SingleCategory_FillsEntireCircleWithoutDegenerateArc + - Render_VerySmallRenderTargets_RenderSuccessfully + - Render_ManySeriesGroupedColumn_NarrowsBarsRatherThanOverlapping + + - id: CanvasNetCharts-ChartModel-ChartRenderingDefaults + title: >- + The ChartModel subsystem shall apply documented default rendering styling (an opaque + white background, the bundled fallback font, and a documented 10-entry default color + palette) whenever a caller omits the corresponding render option, series/point + color, or chart-level color palette, resolving colors in a fixed, documented + precedence order. + justification: | + A caller who supplies only the minimal Chart data (no colors, no render options) + must still receive a legible, fully deterministic, well-styled rendering rather than + an error or an arbitrary/undocumented appearance. + children: + - CanvasNetCharts-ChartModel-ChartRenderer-DefaultStyling + tests: + - Render_CustomBackgroundColor_ClearsToThatColor + - Render_SeriesWithoutExplicitColor_ResolvesToDefaultPaletteInOrder + - Render_ChartColorPaletteOverride_TakesPriorityOverDefaultPalette + - Render_OptionsColorPaletteOverride_UsedWhenChartSuppliesNone + + - id: CanvasNetCharts-ChartModel-BuilderIncompleteBuildFails + title: >- + The ChartModel subsystem's ChartBuilder shall throw InvalidOperationException from + Build when no chart type has been set, or when no series has been added, rather than + constructing a Chart from incomplete configuration. + justification: | + Silently defaulting an unset chart type or an empty series list would produce a + Chart the caller never intended; failing closed surfaces the omission immediately. + children: + - CanvasNetCharts-ChartModel-ChartDocument-BuilderIncompleteBuildFails + tests: + - Build_TypeNeverSet_ThrowsInvalidOperationException + - Build_NoSeriesAdded_ThrowsInvalidOperationException diff --git a/docs/reqstream/canvas-net-charts/chart-model/chart-document.yaml b/docs/reqstream/canvas-net-charts/chart-model/chart-document.yaml new file mode 100644 index 00000000..960a8f9d --- /dev/null +++ b/docs/reqstream/canvas-net-charts/chart-model/chart-document.yaml @@ -0,0 +1,298 @@ +--- +# Software Unit Requirements for ChartDocument +# +# These requirements describe the design-level behavior of the ChartDocument unit: the +# Chart/ChartSeries/ChartAxis/ChartLegend/ChartTitle/ChartType model types and the ChartBuilder +# fluent construction API, all in the DemaConsulting.CanvasNet.Charts namespace. + +sections: + - title: CanvasNetCharts Requirements + sections: + - title: ChartModel Requirements + sections: + - title: ChartDocument Requirements + requirements: + - id: CanvasNetCharts-ChartModel-ChartDocument-ChartConstruction + title: >- + The Chart class shall construct from a ChartType, a non-empty, non-null-containing + series collection, and optional category axis, value axis, legend, title, and + color palette, exposing each as a read-only property. + justification: | + Chart is the root aggregate of the data model; every later consumer needs a + single, fully validated, read-only view of a chart's complete configuration. + tests: + - Constructor_MinimalMembers_SetsRequiredAndLeavesOptionalsUnset + - Constructor_AllMembers_SetsAllMembers + + - id: CanvasNetCharts-ChartModel-ChartDocument-ChartTypeValidation + title: >- + The Chart class shall reject a ChartType value that is not a defined member of + the enumeration with ArgumentOutOfRangeException. + justification: | + An undefined chart kind has no meaningful rendering behavior and must fail at + construction rather than propagate to a later rendering phase. + tests: + - Constructor_UndefinedType_ThrowsArgumentOutOfRangeException + + - id: CanvasNetCharts-ChartModel-ChartDocument-SeriesCollectionValidation + title: >- + The Chart class shall reject a null series argument with ArgumentNullException, an + empty series collection with ArgumentException, and a series collection + containing a null entry with ArgumentException. + justification: | + A chart with zero series or a missing series entry cannot be meaningfully + rendered and must be rejected before it reaches any later rendering phase. + tests: + - Constructor_NullSeries_ThrowsArgumentNullException + - Constructor_EmptySeries_ThrowsArgumentException + - Constructor_NullSeriesEntry_ThrowsArgumentException + + - id: CanvasNetCharts-ChartModel-ChartDocument-CategoryLengthValidation + title: >- + The Chart class shall, for ChartType.Bar, ChartType.Column, ChartType.Line, and + ChartType.Area, when a category axis with non-null Labels is supplied, reject a + series whose Values count does not exactly equal the category axis's label count + with ArgumentException, and shall not apply this check for ChartType.Pie, + ChartType.Doughnut, a null category axis, or a category axis with null Labels. + justification: | + A category-based chart type plots each series value against the matching + category label by position; a length mismatch would silently misalign data when + later rendered. + tests: + - Constructor_CategoryBasedTypeWithMatchingLength_Succeeds + - Constructor_CategoryBasedTypeWithMismatchedLength_ThrowsArgumentException + - Constructor_ProportionalType_IsExemptFromLengthCheck + - Constructor_CategoryAxisWithNoLabels_DoesNotTriggerLengthCheck + + - id: CanvasNetCharts-ChartModel-ChartDocument-ColorPaletteValidation + title: >- + The Chart class shall reject a non-null, empty color palette argument with + ArgumentException. + justification: | + An explicitly supplied but empty color palette cannot provide any color and + indicates a caller mistake that should fail immediately rather than silently + fall back to a renderer default. + tests: + - Constructor_EmptyColorPalette_ThrowsArgumentException + + - id: CanvasNetCharts-ChartModel-ChartDocument-SeriesValidation + title: >- + The ChartSeries class shall construct from a non-null, non-empty/whitespace-only + name and a non-null, non-empty, all-finite values collection, with an optional + per-point color collection, an optional per-point label collection, and an + optional series-level color, exposing each as a read-only property. + justification: | + Each series needs its own validated name and data points, independent of the + Chart that will eventually contain it, so series-level invariants cannot be + bypassed by constructing a series outside a Chart. + tests: + - Constructor_NameAndValuesOnly_SetsMembersAndLeavesOptionalsUnset + - Constructor_AllMembers_SetsAllMembers + + - id: CanvasNetCharts-ChartModel-ChartDocument-SeriesNameValidation + title: >- + The ChartSeries class shall reject a null name argument with + ArgumentNullException, and an empty or whitespace-only name argument with + ArgumentException. + justification: | + A series with a blank name cannot be meaningfully labeled on a legend or tooltip + and must be rejected before it reaches any later rendering phase. + tests: + - Constructor_NullName_ThrowsArgumentNullException + - Constructor_EmptyOrWhitespaceName_ThrowsArgumentException + + - id: CanvasNetCharts-ChartModel-ChartDocument-ValuesValidation + title: >- + The ChartSeries class shall reject a null values argument with + ArgumentNullException, an empty values collection with ArgumentException, and a + values collection containing a non-finite (NaN or infinite) entry with + ArgumentException. + justification: | + A series with no data points or a non-finite value cannot be meaningfully + plotted and must be rejected before it reaches any later rendering phase. + tests: + - Constructor_NullValues_ThrowsArgumentNullException + - Constructor_EmptyValues_ThrowsArgumentException + - Constructor_NonFiniteValueEntry_ThrowsArgumentException + + - id: CanvasNetCharts-ChartModel-ChartDocument-PerPointCollectionValidation + title: >- + The ChartSeries class shall reject a non-null per-point color collection whose + count does not exactly equal the values count with ArgumentException, and a + non-null per-point label collection whose count does not exactly equal the + values count, or that contains a null entry, with ArgumentException. + justification: | + A per-point override collection whose length does not match the series' own + value count, or that contains a null label, cannot be unambiguously paired with + a specific data point. + tests: + - Constructor_PointColorsCountMismatch_ThrowsArgumentException + - Constructor_PointLabelsCountMismatch_ThrowsArgumentException + - Constructor_NullPointLabelEntry_ThrowsArgumentException + + - id: CanvasNetCharts-ChartModel-ChartDocument-AxisValidation + title: >- + The ChartAxis class shall construct from an optional ordered label collection, an + optional finite minimum, an optional finite maximum, an optional finite, + strictly positive tick interval, and an optional non-empty/whitespace-only + title, exposing each as a read-only property, and used for either a category + axis (via Labels) or a value axis (via Minimum/Maximum/TickInterval), or both. + justification: | + A single axis type covering both category and value axis use keeps the model + simple while still supporting a chart that needs both kinds of axis. + tests: + - Constructor_LabelsOnly_SetsLabelsAndLeavesRangeUnset + - Constructor_ValueRangeOnly_SetsRangeAndLeavesLabelsUnset + - Constructor_LabelsAndRangeAndTitle_SetsAllMembers + + - id: CanvasNetCharts-ChartModel-ChartDocument-AxisLabelValidation + title: >- + The ChartAxis class shall reject a labels collection containing a null entry with + ArgumentException, while accepting an empty-string label entry as a legitimate + blank category label. + justification: | + A null label has no meaningful text to render, while an empty string is a + legitimate, intentionally blank category label a caller may supply. + tests: + - Constructor_EmptyStringLabel_IsAccepted + - Constructor_NullLabelEntry_ThrowsArgumentException + + - id: CanvasNetCharts-ChartModel-ChartDocument-AxisRangeValidation + title: >- + The ChartAxis class shall reject a non-finite minimum or maximum with + ArgumentOutOfRangeException, a minimum not strictly less than the maximum when + both are supplied with ArgumentOutOfRangeException, and a non-finite or + non-positive tick interval with ArgumentOutOfRangeException. + justification: | + An inverted, zero-width, non-finite, or non-positively-spaced value-axis range + cannot be meaningfully rendered and must be rejected before it reaches any later + rendering phase. + tests: + - Constructor_NonFiniteMinimum_ThrowsArgumentOutOfRangeException + - Constructor_NonFiniteMaximum_ThrowsArgumentOutOfRangeException + - Constructor_MinimumEqualToMaximum_ThrowsArgumentOutOfRangeException + - Constructor_MinimumGreaterThanMaximum_ThrowsArgumentOutOfRangeException + - Constructor_InvalidTickInterval_ThrowsArgumentOutOfRangeException + + - id: CanvasNetCharts-ChartModel-ChartDocument-AxisTitleValidation + title: >- + The ChartAxis class shall reject an empty or whitespace-only title argument with + ArgumentException. + justification: | + An axis title with no visible text indicates a caller mistake and should fail + rather than silently render as blank. + tests: + - Constructor_WhitespaceTitle_ThrowsArgumentException + - Constructor_EmptyTitle_ThrowsArgumentException + + - id: CanvasNetCharts-ChartModel-ChartDocument-LegendValidation + title: >- + The ChartLegend class shall construct from an optional ChartLegendPosition + (defaulting to Right) and an optional visibility flag (defaulting to true), + exposing each as a read-only property, and reject an undefined + ChartLegendPosition value with ArgumentOutOfRangeException. + justification: | + A legend's placement must be one of a known, finite set of positions a renderer + understands how to lay out. + tests: + - Constructor_Defaults_IsVisibleAndRightPositioned + - Constructor_EachDefinedPosition_IsAccepted + - Constructor_IsVisibleFalse_IsStored + - Constructor_UndefinedPosition_ThrowsArgumentOutOfRangeException + + - id: CanvasNetCharts-ChartModel-ChartDocument-TitleValidation + title: >- + The ChartTitle class shall construct from non-null, non-empty, non-whitespace-only + text and an optional finite, strictly positive font-size hint, exposing each as a + read-only property, and reject a null text argument with + ArgumentNullException, an empty or whitespace-only text argument with + ArgumentException, and a non-finite or non-positive font size with + ArgumentOutOfRangeException. + justification: | + A title with blank text or a non-positive font size cannot be meaningfully + rendered and must be rejected before it reaches any later rendering phase. + tests: + - Constructor_TextOnly_SetsTextAndLeavesFontSizeUnset + - Constructor_TextAndFontSize_SetsBothMembers + - Constructor_NullText_ThrowsArgumentNullException + - Constructor_EmptyText_ThrowsArgumentException + - Constructor_WhitespaceText_ThrowsArgumentException + - Constructor_InvalidFontSize_ThrowsArgumentOutOfRangeException + + - id: CanvasNetCharts-ChartModel-ChartDocument-DefensiveCopies + title: >- + The Chart, ChartSeries, and ChartAxis classes shall copy every caller-supplied + collection argument into an independent, immutable snapshot at construction + time, such that a subsequent mutation of the caller's original collection has no + observable effect on the constructed instance's exposed property. + justification: | + Exposing a caller-supplied collection directly, or a shallow wrapper over it, + would let a caller mutate state the model type's constructor already validated, + breaking the immutability guarantee later phases rely on. + tests: + - Constructor_MutatingOriginalSeriesList_DoesNotAffectStoredSnapshot + - Constructor_MutatingOriginalColorPaletteList_DoesNotAffectStoredSnapshot + - Constructor_MutatingOriginalValuesList_DoesNotAffectStoredSnapshot + - Constructor_MutatingOriginalPointColorsList_DoesNotAffectStoredSnapshot + - Constructor_MutatingOriginalPointLabelsList_DoesNotAffectStoredSnapshot + - Constructor_MutatingOriginalLabelsList_DoesNotAffectStoredSnapshot + + - id: CanvasNetCharts-ChartModel-ChartDocument-BuilderFluentChaining + title: >- + The ChartBuilder class shall return the same instance (this) from OfType, + AddSeries, WithCategoryAxis, WithValueAxis, WithLegend, WithTitle, and + WithColorPalette, enabling fluent method chaining, and shall preserve the order + series were added in via AddSeries. + justification: | + Chaining every configuration call onto the previous one, in the order the + caller wrote them, is ChartBuilder's defining usability characteristic. + tests: + - FluentMethods_EachReturnSameBuilderInstance + - Build_WithMultipleSeriesAddedViaChartSeriesOverload_PreservesOrder + + - id: CanvasNetCharts-ChartModel-ChartDocument-BuilderDelegatesValidation + title: >- + The ChartBuilder class shall perform no independent data-shape validation beyond + reporting an incomplete configuration from Build; every other invalid argument + passed to AddSeries, WithCategoryAxis, WithValueAxis, WithLegend, WithTitle, or + WithColorPalette, or to Build itself, shall surface the identical exception the + corresponding model constructor throws. + justification: | + A single source of truth for each validation rule, enforced by the model + constructors ChartBuilder itself calls, prevents ChartBuilder's own rules from + drifting out of sync with the model's rules over time. + tests: + - AddSeries_InvalidName_PropagatesChartSeriesConstructorException + - AddSeries_NullChartSeries_PropagatesArgumentNullException + - WithCategoryAxis_NullChartAxis_PropagatesArgumentNullException + - WithValueAxis_NullChartAxis_PropagatesArgumentNullException + - WithValueAxis_InvalidRange_PropagatesChartAxisConstructorException + - WithLegend_NullChartLegend_PropagatesArgumentNullException + - WithTitle_WhitespaceText_PropagatesChartTitleConstructorException + - WithColorPalette_Null_ThrowsArgumentNullException + - Build_SeriesLengthMismatchedWithCategoryAxis_PropagatesChartConstructorException + + - id: CanvasNetCharts-ChartModel-ChartDocument-BuilderIncompleteBuildFails + title: >- + The ChartBuilder class shall throw InvalidOperationException from Build when + OfType was never called, and shall throw InvalidOperationException from Build + when no series was added via AddSeries. + justification: | + Build must not silently default an unset chart type or an empty series list; + failing closed surfaces the omission to the caller immediately. + tests: + - Build_TypeNeverSet_ThrowsInvalidOperationException + - Build_NoSeriesAdded_ThrowsInvalidOperationException + + - id: CanvasNetCharts-ChartModel-ChartDocument-TypeEnumeration + title: >- + The ChartType enumeration shall define exactly the Bar, Column, Line, Pie, + Doughnut, and Area members for Phase 1, each fully documented. + justification: | + Later phases (rendering, OOXML parsing) dispatch on ChartType; its Phase 1 + member set must be fixed and well-documented so those phases know exactly which + chart kinds to support. + tests: + - AllV1Members_AreDefined + - UndefinedValue_IsNotDefined + - UndefinedValue_RejectedByChartConstructor diff --git a/docs/reqstream/canvas-net-charts/chart-model/chart-renderer.yaml b/docs/reqstream/canvas-net-charts/chart-model/chart-renderer.yaml new file mode 100644 index 00000000..b5b3417e --- /dev/null +++ b/docs/reqstream/canvas-net-charts/chart-model/chart-renderer.yaml @@ -0,0 +1,249 @@ +--- +# Software Unit Requirements for ChartRenderer +# +# These requirements describe the design-level behavior of the ChartRenderer unit: the public +# pixel-rendering entry point (plus ChartRenderOptions and ChartColorPalette) that paints a +# validated Chart onto a core CanvasNet Surface, all in the DemaConsulting.CanvasNet.Charts +# namespace. + +sections: + - title: CanvasNetCharts Requirements + sections: + - title: ChartModel Requirements + sections: + - title: ChartRenderer Requirements + requirements: + - id: CanvasNetCharts-ChartModel-ChartRenderer-PixelRender + title: >- + The ChartRenderer class shall render a Chart onto a new Surface of a + caller-specified pixel width and height, rejecting a null chart with + ArgumentNullException and an out-of-range width or height by propagating + Surface's own ArgumentOutOfRangeException. + justification: | + A pixel-dimensioned Render overload is the simplest, most direct entry point for + a caller that already knows the exact output size it needs (for example, a fixed + thumbnail), and must fail predictably on invalid input rather than silently + clamping it. + tests: + - Render_PixelOverloadNullChart_ThrowsArgumentNullException + - Render_PixelOverloadInvalidSize_ThrowsArgumentOutOfRangeException + + - id: CanvasNetCharts-ChartModel-ChartRenderer-DpiRender + title: >- + The ChartRenderer class shall render a Chart onto a new Surface sized from a + caller-specified physical width, height, and DPI (pixel dimensions computed as + round(inches * dpi)), rejecting a null chart with ArgumentNullException, a + non-finite or non-positive width, height, or dpi with + ArgumentOutOfRangeException, and a computed pixel dimension exceeding Surface's + own maximum with ArgumentOutOfRangeException. + justification: | + A physical-size, DPI-driven overload mirrors how other core CanvasNet document + renderers (for example PdfDocument.Render) let a caller reason in print-oriented + units rather than raw pixels, even though a Chart itself carries no intrinsic + physical page size of its own. + tests: + - Render_DpiOverloadNullChart_ThrowsArgumentNullException + - Render_DpiOverloadInvalidInputs_ThrowsArgumentOutOfRangeException + - Render_DpiOverloadExceedsMaxDimension_ThrowsArgumentOutOfRangeException + - Render_DpiOverload_ComputesExpectedPixelDimensions + + - id: CanvasNetCharts-ChartModel-ChartRenderer-ChartTypeDispatch + title: >- + The ChartRenderer class shall render every defined ChartType value (Bar, Column, + Line, Area, Pie, Doughnut) to a correctly sized Surface, dispatching each to its + dedicated painter. + justification: | + Every chart kind the data model defines must have a working, exercised + rendering path; an unimplemented or incorrectly dispatched chart type would + silently fail to paint. + tests: + - Render_EveryChartType_ProducesCorrectlySizedSurface + - Render_Bar_FullScaleBarPaintsInteriorPixel + - Render_Column_FullScaleBarPaintsInteriorPixel + - Render_Line_StrokesVisibleSegmentBetweenPoints + - Render_Area_FillsRegionAndStrokesTopEdge + + - id: CanvasNetCharts-ChartModel-ChartRenderer-PieDoughnutWedgeLayout + title: >- + The ChartRenderer class shall render a Pie or Doughnut chart's single series as + wedges whose angular extent is proportional to each point's share of the series + total, including a single-category (100%-share) series rendering as one + full-circle wedge rather than an invisible, degenerate zero-length arc, and a + Doughnut's inner radius remaining unpainted (the background shows through the + hole). + justification: | + A naive, unsplit 360-degree arc whose start and end points coincide is a + zero-length no-op in the underlying arc-to-bezier conversion; a single-category + Pie/Doughnut (an entirely plausible, simple real-world chart) must still render + visibly instead of silently vanishing. + tests: + - Render_Pie_SingleCategory_FillsEntireCircleWithoutDegenerateArc + - Render_Pie_TwoEqualCategories_SplitsIntoRightAndLeftWedges + - Render_Doughnut_SingleCategory_FillsRingAndLeavesHoleUnpainted + + - id: CanvasNetCharts-ChartModel-ChartRenderer-DefaultStyling + title: >- + The ChartRenderer class shall clear its target Surface to + ChartRenderOptions.BackgroundColor (an opaque white default, overridable + including to fully transparent) before painting any chart content, and shall + resolve each series' or point's color, when unspecified, from Chart.ColorPalette, + then ChartRenderOptions.ColorPalette, then ChartColorPalette.Default, in that + order, wrapping by index via modulo. + justification: | + A caller who supplies neither explicit series/point colors nor a color palette + must still receive a legible, distinguishable, fully deterministic default + rendering; the color-resolution precedence order must be well-defined so results + do not depend on incidental construction order. + tests: + - Render_CustomBackgroundColor_ClearsToThatColor + - Render_TransparentBackgroundColor_ClearsToTransparent + - Render_SeriesWithoutExplicitColor_ResolvesToDefaultPaletteInOrder + - Render_ChartColorPaletteOverride_TakesPriorityOverDefaultPalette + - Render_OptionsColorPaletteOverride_UsedWhenChartSuppliesNone + + - id: CanvasNetCharts-ChartModel-ChartRenderer-TitleRendering + title: >- + The ChartRenderer class shall, when Chart.Title is supplied, paint its text + centered within a reserved title band above the plot area, reducing the plot + area's available height accordingly, and shall reserve no title band at all when + Chart.Title is null. + justification: | + A title is a common, expected piece of chart decoration; it must be visible, and + must not overlap or be overlapped by the plot area's own content. + tests: + - Render_WithTitle_PaintsContentInTitleBand + - Render_WithTitle_PushesPlotContentDownComparedToNoTitle + + - id: CanvasNetCharts-ChartModel-ChartRenderer-LegendRendering + title: >- + The ChartRenderer class shall, when Chart.Legend is supplied, visible, and + positioned Top, Bottom, Left, or Right, paint one color-swatch-and-label entry + per series (for category-based chart types) or per point (for Pie/Doughnut) + within a reserved legend band on the corresponding side of the plot area, + reducing the plot area's available size accordingly, and shall reserve no legend + band at all when Chart.Legend is null, not visible, or positioned None. + justification: | + A legend lets a viewer map each series/point's color back to its name; its + position must be respected, and its absence must not reserve wasted plot-area + space. + tests: + - Render_LegendTop_PaintsSwatchesInTopBand + - Render_LegendBottom_PaintsSwatchesInBottomBand + - Render_LegendLeft_PaintsSwatchesInLeftBand + - Render_LegendRight_PaintsSwatchesInRightBand + - Render_LegendRight_NarrowsPlotAreaComparedToNoLegend + - Render_LegendPositionNone_RendersSuccessfullyWithoutLegendBand + - Render_LegendNotVisible_RendersSuccessfullyWithoutLegendBand + + - id: CanvasNetCharts-ChartModel-ChartRenderer-LegendOverflowByOmission + title: >- + The ChartRenderer class shall, when a legend has more entries than fit within + its reserved band, omit any entry that would start beyond the band's available + extent, rather than overflowing into the plot area, writing off-surface, or + throwing an exception. + justification: | + A chart with an unusually large series/point count must still render + successfully and legibly; silently dropping the entries that do not fit is + preferable to corrupting the rest of the rendered output. + tests: + - Render_ManySeries_LegendDegradesByOmissionWithoutError + + - id: CanvasNetCharts-ChartModel-ChartRenderer-AxisRendering + title: >- + The ChartRenderer class shall, for Bar, Column, Line, and Area chart types, paint + value-axis gridlines, tick marks, and tick labels, and category-axis tick marks + and tick labels, within the plot area's reserved axis margins. + justification: | + Axis gridlines and labels give a viewer the scale and category identity needed + to read a category-based chart's data points meaningfully, not merely view + uncalibrated shapes. + tests: + - Render_WithAxes_PaintsNonBackgroundContentInReservedMargins + + - id: CanvasNetCharts-ChartModel-ChartRenderer-NegativeValueHandling + title: >- + The ChartRenderer class shall, for Bar, Column, Line, and Area chart types whose + series values span both negative and positive numbers, paint bars extending both + above/below (Column) or left/right (Bar) of a zero baseline, and paint Line/Area + content successfully crossing that baseline, rather than clipping or throwing. + justification: | + Real-world category data (for example, profit/loss by quarter) routinely + includes negative values; a renderer that assumed an all-positive value range + would silently misrender or crash on such data. + tests: + - Render_Column_NegativeAndPositiveValues_PaintsBothAboveAndBelowZeroBaseline + - Render_Bar_NegativeAndPositiveValues_PaintsBothLeftAndRightOfZeroBaseline + - Render_Line_NegativeAndPositiveValues_RendersSuccessfully + - Render_Area_NegativeAndPositiveValues_RendersSuccessfully + + - id: CanvasNetCharts-ChartModel-ChartRenderer-DataLabelRendering + title: >- + The ChartRenderer class shall, for a series whose ChartSeries.PointLabels is + supplied, paint each point's label text near that point, and shall paint no + label text at all for a series whose PointLabels is null. + justification: | + Data labels are an opt-in decoration (not every chart needs every value spelled + out on top of its shapes); the renderer must honor that opt-in/opt-out choice + precisely per series. + tests: + - Render_PointWithDataLabel_PaintsVisibleTextNearPoint + - Render_PointWithoutDataLabel_RendersDeterministicallyWithNoLabelArtifacts + + - id: CanvasNetCharts-ChartModel-ChartRenderer-SinglePointSeries + title: >- + The ChartRenderer class shall render a Bar, Column, Line, Area, Pie, or Doughnut + chart whose single series contains exactly one data point successfully, without + exception. + justification: | + A one-point series (or a one-category Pie/Doughnut) is a legitimate minimal + chart a caller may construct; it must not be treated as a degenerate error case. + tests: + - Render_Bar_SingleDataPoint_RendersSuccessfully + - Render_Column_SingleDataPoint_RendersSuccessfully + - Render_Line_SingleDataPoint_RendersSuccessfully + - Render_Area_SingleDataPoint_RendersSuccessfully + - Render_Pie_SingleValue_RendersFullCircleWedge + - Render_Doughnut_SingleValue_RendersFullCircleWedge + + - id: CanvasNetCharts-ChartModel-ChartRenderer-LongLabelTruncation + title: >- + The ChartRenderer class shall truncate a category-axis label or legend entry + label that does not fit within its allotted band's measured width, rather than + overflowing into an adjacent region or throwing an exception. + justification: | + A real-world chart's category or series names are caller-supplied, arbitrary- + length text; an unusually long label must degrade gracefully rather than corrupt + the surrounding layout. + tests: + - Render_VeryLongCategoryLabel_RendersSuccessfullyWithoutOverflow + - Render_VeryLongLegendLabel_RendersSuccessfullyWithoutOverflow + + - id: CanvasNetCharts-ChartModel-ChartRenderer-RenderTargetSizeExtremes + title: >- + The ChartRenderer class shall render every ChartType successfully at the + smallest possible 1x1 pixel target, at a handful of other minimal target sizes + up to roughly 10x10 pixels (defensively skipping title/legend/axis bands once + reserving them would leave a non-positive plot area), and at the largest + (8192x8192) Surface-supported target, in every case without throwing an + exception. + justification: | + A render target's size is entirely caller-controlled and may be arbitrarily + small or at the library's own documented maximum; every painter must check its + own target rectangle's size before building any path rather than assuming a + "reasonable" minimum size. + tests: + - Render_OnePixelByOnePixelTarget_RendersSuccessfullyForEveryChartType + - Render_VerySmallRenderTargets_RenderSuccessfully + - Render_MaximumDimensionRenderTarget_RendersSuccessfully + + - id: CanvasNetCharts-ChartModel-ChartRenderer-ManySeriesGroupedLayout + title: >- + The ChartRenderer class shall, for a grouped Bar/Column chart with a large + practical series count, narrow each series' bar width within its shared category + band rather than letting bars overlap one another or throwing an exception. + justification: | + A category-based chart's number of series is caller-controlled and may be large; + the per-category bar group must degrade by narrowing, remaining visually + distinguishable, instead of producing an unreadable overlapping result. + tests: + - Render_ManySeriesGroupedColumn_NarrowsBarsRatherThanOverlapping diff --git a/docs/reqstream/canvas-net-charts/open-xml-chart.yaml b/docs/reqstream/canvas-net-charts/open-xml-chart.yaml new file mode 100644 index 00000000..565390a4 --- /dev/null +++ b/docs/reqstream/canvas-net-charts/open-xml-chart.yaml @@ -0,0 +1,137 @@ +--- +# Software Subsystem Requirements for OpenXmlChart +# +# These requirements decompose the CanvasNetCharts system's Phase 3 parsing responsibility into +# the OpenXmlChart subsystem: a format-agnostic parser that turns a raw ECMA-376 DrawingML-Charts +# c:chartSpace/c:chart XElement/XDocument into a validated ChartModel Chart. Each requirement here +# links down to the OpenXmlChartParser unit requirements that implement it. + +sections: + - title: CanvasNetCharts Requirements + sections: + - title: OpenXmlChart Requirements + requirements: + - id: CanvasNetCharts-OpenXmlChart-ChartTypeClassification + title: >- + The OpenXmlChart subsystem shall classify a plot area's single recognized chart-type + element into the corresponding ChartType (Bar or Column for barChart, by its barDir + value; Line for lineChart; Pie for pieChart; Doughnut for doughnutChart; Area for + areaChart). + justification: | + Every other extraction step (series/category/value mapping, category-axis vs. + point-label routing) depends on first knowing which ChartModel ChartType the chart + being parsed maps to. + children: + - CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-BarChartClassification + - CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-OtherChartTypeClassification + tests: + - Parse_BarChartWithBarDirection_ClassifiesAsBar + - Parse_BarChartWithColumnDirection_ClassifiesAsColumn + - Parse_BarChartWithNoBarDirection_DefaultsToColumn + - Parse_LineChart_ClassifiesAsLine + - Parse_AreaChart_ClassifiesAsArea + - Parse_PieChart_ClassifiesAsPieWithPointLabels + - Parse_DoughnutChart_ClassifiesAsDoughnut + + - id: CanvasNetCharts-OpenXmlChart-SeriesDataExtraction + title: >- + The OpenXmlChart subsystem shall extract each c:ser element's cached series name + (from c:tx, defaulting to a 1-based "Series N" name when absent), cached values + (from c:val's c:numCache, honoring c:ptCount/sparse c:pt gaps as 0.0), and cached + categories (from c:cat's c:strCache or c:numCache, the latter converted to + invariant-culture strings), routing categories to the chart's shared category axis + for a category-based ChartType and to each series' own point labels otherwise. + justification: | + A chart's actual plotted data - its series names, values, and categories - is the + one thing a parser must extract correctly for the resulting Chart to be + meaningful; silently misrouting or miscounting any of these would silently corrupt + every later rendering. + children: + - CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-SeriesNameExtraction + - CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-ValueExtraction + - CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-CategoryExtraction + tests: + - Parse_SeriesWithCachedName_ExtractsName + - Parse_SeriesWithNoTx_DefaultsToSeriesIndexName + - Parse_SeriesWithLiteralTxValue_ExtractsName + - Parse_NumericCategories_ConvertsToInvariantCultureStrings + - Parse_SparseNumCache_MissingIndexDefaultsToZero + - Parse_MultipleSeries_ParsesAllInOrder + - Parse_CategoryBasedChart_ExtractsCategoryAxisLabels + + - id: CanvasNetCharts-OpenXmlChart-DecorationExtraction + title: >- + The OpenXmlChart subsystem shall extract a chart's cached title text (from c:title's + c:tx, mapping an absent or textless title to null), legend presence and position + (from c:legend/c:legendPos, mapping each of OOXML's defined positions to the nearest + ChartModel ChartLegendPosition and defaulting to Right), and category/value axis + titles and value-axis range (from c:catAx/c:valAx's own c:title/c:scaling/ + c:majorUnit). + justification: | + A chart's decorative elements (title, legend, axis titles/range) are each optional + in OOXML but, when present, must be faithfully carried into the ChartModel shape the + Phase 2 renderer already knows how to paint. + children: + - CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-TitleExtraction + - CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-LegendExtraction + - CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-AxisExtraction + tests: + - Parse_TitleWithCachedText_ExtractsTitle + - Parse_TitleWithRichText_ConcatenatesRuns + - Parse_TitlePresentWithNoTx_MapsToNullTitle + - Parse_NoTitleElement_MapsToNullTitle + - Parse_NoLegendElement_MapsToNullLegend + - Parse_LegendPosition_MapsToExpectedChartLegendPosition + - Parse_LegendWithNoPosition_DefaultsToRight + - Parse_CatAxTitle_ExtractsCategoryAxisTitle + - Parse_ValAx_ExtractsRangeAndTitle + - Parse_NoValAx_MapsToNullValueAxis + + - id: CanvasNetCharts-OpenXmlChart-UnsupportedFeatureRejection + title: >- + The OpenXmlChart subsystem shall throw ChartUnsupportedFeatureException - carrying a + short, stable Feature token - when a plot area contains no recognized chart-type + element, more than one recognized chart-type element (a combo chart), a single + recognized-but-unimplemented chart-type element (radar, scatter, bubble, stock, + surface, 3-D, or "of pie"), or a series whose c:val has no cached c:numCache. + justification: | + A caller must be able to distinguish, via a stable, machine-matchable token, exactly + which recognized-but-out-of-scope feature caused a parse to fail, rather than + receiving only a generic or malformed-XML-shaped exception. + children: + - CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-UnsupportedChartTypeRejection + - CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-ComboChartRejection + - CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-UncachedValuesRejection + - CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-ArgumentValidation + - CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-ChartUnsupportedFeatureException + tests: + - Parse_UnsupportedChartType_ThrowsWithExpectedFeatureToken + - Parse_ComboChart_ThrowsWithComboChartFeatureToken + - Parse_EmptyPlotArea_ThrowsWithNoChartTypeFeatureToken + - Parse_SeriesWithUncachedValues_ThrowsWithUncachedValuesFeatureToken + - Parse_NullDocument_ThrowsArgumentNullException + - Parse_NullElement_ThrowsArgumentNullException + - Parse_DocumentWithNoRoot_ThrowsArgumentException + - Parse_ElementWithNoChartChild_ThrowsArgumentException + - Parse_ChartWithNoPlotArea_ThrowsArgumentException + + - id: CanvasNetCharts-OpenXmlChart-EndToEndValidation + title: >- + The OpenXmlChart subsystem's parsed output shall be proven consumable, without any + further adaptation, by ChartRenderer.Render for every supported ChartType, and shall + be proven to correctly parse a trimmed, styling-stripped excerpt derived from a + real-world chart1.xml part into the exact expected Chart shape, with that parsed + Chart also rendering successfully. + justification: | + Data-shape assertions against synthetic, hand-authored fixtures alone cannot prove + this parser's output is actually usable by the Phase 2 renderer, nor that it + correctly handles a genuine PowerPoint-authored chart1.xml part's real-world shape; + both must be proven end-to-end, for every supported chart kind and at least one real + fixture, not merely assumed from passing unit-level assertions. + children: + - CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-RealFixtureEndToEnd + - CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-RenderRoundTrip + tests: + - Parse_RealFixtureBarChartExcerpt_ParsesExpectedShape + - Parse_ThenRender_RealFixtureBarChartExcerpt_ProducesCorrectlySizedSurface + - Parse_ThenRender_EveryChartType_ProducesCorrectlySizedSurface diff --git a/docs/reqstream/canvas-net-charts/open-xml-chart/open-xml-chart-parser.yaml b/docs/reqstream/canvas-net-charts/open-xml-chart/open-xml-chart-parser.yaml new file mode 100644 index 00000000..8c2ea522 --- /dev/null +++ b/docs/reqstream/canvas-net-charts/open-xml-chart/open-xml-chart-parser.yaml @@ -0,0 +1,251 @@ +--- +# Software Unit Requirements for OpenXmlChartParser +# +# These requirements describe the design-level behavior of the OpenXmlChartParser unit: the +# OpenXmlChartParser static class and its ChartUnsupportedFeatureException, both in the +# DemaConsulting.CanvasNet.Charts.OpenXml namespace. + +sections: + - title: CanvasNetCharts Requirements + sections: + - title: OpenXmlChart Requirements + sections: + - title: OpenXmlChartParser Requirements + requirements: + - id: CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-ArgumentValidation + title: >- + The OpenXmlChartParser class shall reject a null XDocument/XElement argument to + either Parse overload with ArgumentNullException, a document with no root + element with ArgumentException, an element that is neither a c:chart element nor + a c:chartSpace element containing one with ArgumentException, and a c:chart + element with no c:plotArea child with ArgumentException, while accepting either a + full c:chartSpace root or a bare c:chart element directly. + justification: | + A caller - including this unit's own test suite - must be able to pass either a + full chart1.xml document's root or a bare, hand-authored c:chart fragment, and + must receive a clear, documented exception rather than a NullReferenceException + or an unhelpful XML-shape error for a structurally invalid input. + tests: + - Parse_NullDocument_ThrowsArgumentNullException + - Parse_NullElement_ThrowsArgumentNullException + - Parse_DocumentWithNoRoot_ThrowsArgumentException + - Parse_ElementWithNoChartChild_ThrowsArgumentException + - Parse_ChartWithNoPlotArea_ThrowsArgumentException + - Parse_ChartSpaceWrapper_ParsesSameAsBareChart + - Parse_Document_ParsesSameAsElement + + - id: CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-BarChartClassification + title: >- + The OpenXmlChartParser class shall classify a c:barChart element as + ChartType.Bar when its c:barDir value is "bar", and as ChartType.Column for a + "col" value or when c:barDir is absent. + justification: | + OOXML's own barChart element covers both the bar and column ChartModel kinds, + distinguished only by this one nested element; a missing c:barDir must still + resolve to ECMA-376's own documented "col" default rather than failing. + tests: + - Parse_BarChartWithBarDirection_ClassifiesAsBar + - Parse_BarChartWithColumnDirection_ClassifiesAsColumn + - Parse_BarChartWithNoBarDirection_DefaultsToColumn + + - id: CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-OtherChartTypeClassification + title: >- + The OpenXmlChartParser class shall classify a c:lineChart element as + ChartType.Line, a c:pieChart element as ChartType.Pie, a c:doughnutChart element + as ChartType.Doughnut, and a c:areaChart element as ChartType.Area, mapping a + Pie/Doughnut series' c:cat categories to that series' own ChartSeries + PointLabels rather than a shared Chart CategoryAxis. + justification: | + Pie and Doughnut have no category-axis concept in the ChartModel data model + (their values represent proportional wedge shares); their per-wedge labels must + instead be routed to each series' own PointLabels, exactly as the Phase 2 + renderer's own legend-entry resolution already expects. + tests: + - Parse_LineChart_ClassifiesAsLine + - Parse_AreaChart_ClassifiesAsArea + - Parse_PieChart_ClassifiesAsPieWithPointLabels + - Parse_DoughnutChart_ClassifiesAsDoughnut + + - id: CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-SeriesNameExtraction + title: >- + The OpenXmlChartParser class shall extract a series' name from its c:tx element's + cached c:strRef/c:strCache text, falling back to a literal c:tx/c:v value when no + c:strRef is present, and to a 1-based "Series N" default name when c:tx is + absent or carries no usable text. + justification: | + A series name is used directly as a legend entry by the Phase 2 renderer; it + must resolve to a sensible, deterministic value for every schema-legal shape of + c:tx, including its complete absence. + tests: + - Parse_SeriesWithCachedName_ExtractsName + - Parse_SeriesWithNoTx_DefaultsToSeriesIndexName + - Parse_SeriesWithLiteralTxValue_ExtractsName + + - id: CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-ValueExtraction + title: >- + The OpenXmlChartParser class shall extract a series' values from its c:val + element's cached c:numCache, sized by c:ptCount and defaulting a sparse c:pt + gap's missing index to 0.0, parsing every c:ser element present under the + classified chart-type element, in document order. + justification: | + A chart's plotted values are its single most important piece of data; a sparse + cache (a legitimate OOXML shape when a cell's value happens to be exactly zero) + must not be misinterpreted as missing data. + tests: + - Parse_SparseNumCache_MissingIndexDefaultsToZero + - Parse_MultipleSeries_ParsesAllInOrder + + - id: CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-CategoryExtraction + title: >- + The OpenXmlChartParser class shall extract category labels from a c:cat + element's cached c:strCache text or c:numCache numeric values (the latter + formatted via CultureInfo.InvariantCulture), populating the shared Chart + CategoryAxis from the first series' categories for a category-based ChartType. + justification: | + A numeric category axis (for example, a year or a numbered bucket) is just as + schema-legal as a string one; both must resolve to the same string-labeled + ChartAxis shape the Phase 2 renderer already expects, using a culture-invariant + format so the parsed label text is deterministic regardless of the host + machine's locale. + tests: + - Parse_NumericCategories_ConvertsToInvariantCultureStrings + - Parse_CategoryBasedChart_ExtractsCategoryAxisLabels + + - id: CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-TitleExtraction + title: >- + The OpenXmlChartParser class shall extract a chart's title text from c:title's + c:tx element, honoring a cached c:strRef/c:strCache, a rich-text c:rich body + (concatenating every descendant a:t run), or a literal c:v, and shall map an + absent c:title, a c:title with no c:tx child, or empty/whitespace-only resolved + text, to a null Chart.Title. + justification: | + PowerPoint commonly leaves a styled-but-textless auto-title placeholder in a + chart's XML even when the user never typed a title; such a placeholder must + resolve to no title at all, not a blank/whitespace ChartTitle the Phase 2 + renderer would still reserve a title band for. + tests: + - Parse_TitleWithCachedText_ExtractsTitle + - Parse_TitleWithRichText_ConcatenatesRuns + - Parse_TitlePresentWithNoTx_MapsToNullTitle + - Parse_NoTitleElement_MapsToNullTitle + + - id: CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-LegendExtraction + title: >- + The OpenXmlChartParser class shall map an absent c:legend element to a null + Chart.Legend, and a present c:legend element's c:legendPos value ("t"/"b"/"l"/ + "r") to the corresponding ChartLegendPosition, approximating "tr" (top-right, not + representable by the five-value ChartLegendPosition enumeration) and any + missing/unrecognized c:legendPos to Right. + justification: | + ChartLegendPosition's five-value enumeration cannot losslessly represent every + OOXML-legal legend position; the nearest documented approximation must be used + rather than throwing or silently defaulting to an undocumented value. + tests: + - Parse_NoLegendElement_MapsToNullLegend + - Parse_LegendPosition_MapsToExpectedChartLegendPosition + - Parse_LegendWithNoPosition_DefaultsToRight + + - id: CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-AxisExtraction + title: >- + The OpenXmlChartParser class shall extract a category axis title from c:catAx's + c:title, and a value axis's minimum/maximum/tick interval/title from c:valAx's + c:scaling/c:min, c:scaling/c:max, c:majorUnit, and c:title respectively, mapping + an absent c:valAx, or one carrying none of those four values, to a null + Chart.ValueAxis. + justification: | + An explicit axis range/title, when present in the source chart, must survive + parsing so the Phase 2 renderer paints the same range/title the chart's + original author configured, rather than falling back to an auto-computed range. + tests: + - Parse_CatAxTitle_ExtractsCategoryAxisTitle + - Parse_ValAx_ExtractsRangeAndTitle + - Parse_NoValAx_MapsToNullValueAxis + + - id: CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-UnsupportedChartTypeRejection + title: >- + The OpenXmlChartParser class shall throw ChartUnsupportedFeatureException with a + dedicated Feature token for each of radarChart, scatterChart, bubbleChart, + stockChart, surfaceChart/surface3DChart, bar3DChart/line3DChart/pie3DChart/ + area3DChart, and ofPieChart, and shall throw ChartUnsupportedFeatureException + with Feature "charts-openxml-no-chart-type" when a plot area contains no + recognized chart-type element at all. + justification: | + Every one of these chart kinds is schema-legal OOXML this phase deliberately + does not implement; a caller must receive a specific, stable, documented token + per kind rather than a generic failure, so it can distinguish, log, or message + each unsupported case distinctly. + tests: + - Parse_UnsupportedChartType_ThrowsWithExpectedFeatureToken + - Parse_EmptyPlotArea_ThrowsWithNoChartTypeFeatureToken + + - id: CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-ComboChartRejection + title: >- + The OpenXmlChartParser class shall throw ChartUnsupportedFeatureException with + Feature "charts-openxml-combo-chart" when a plot area contains more than one + recognized chart-type element. + justification: | + A combo chart (for example, columns plus an overlaid line series) has no + single ChartType this phase's model can represent; it must be rejected + explicitly rather than arbitrarily picking one of its constituent chart-type + elements and silently discarding the others' series. + tests: + - Parse_ComboChart_ThrowsWithComboChartFeatureToken + + - id: CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-UncachedValuesRejection + title: >- + The OpenXmlChartParser class shall throw ChartUnsupportedFeatureException with + Feature "charts-openxml-uncached-values" when a series' c:val element has a + c:numRef with no cached c:numCache child. + justification: | + This parser reads only cached values and never recalculates from a c:f formula; + a series with no cache at all has nothing for this parser to plot and must fail + explicitly rather than silently producing an empty or zero-filled series. + tests: + - Parse_SeriesWithUncachedValues_ThrowsWithUncachedValuesFeatureToken + + - id: CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-ChartUnsupportedFeatureException + title: >- + The ChartUnsupportedFeatureException class shall derive from IOException, expose + a Feature string property defaulting to string.Empty, and provide the same five + parallel constructors (parameterless, message-only, message-and-inner-exception, + feature-and-message, and feature-message-and-inner-exception) as the shape this + type mirrors elsewhere in the CanvasNet family, without referencing that other + type or any host document-format package. + justification: | + A caller handling both a malformed-XML IOException-rooted failure and a + well-formed-but-unsupported-feature failure needs a predictable, documented + exception shape; this type is deliberately re-declared locally so Charts never + takes a dependency on a host document-format package merely to reuse an + exception type. + tests: + - Parse_SeriesWithUncachedValues_ThrowsWithUncachedValuesFeatureToken + - Parse_UnsupportedChartType_ThrowsWithExpectedFeatureToken + - Parse_ComboChart_ThrowsWithComboChartFeatureToken + - Parse_EmptyPlotArea_ThrowsWithNoChartTypeFeatureToken + + - id: CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-RealFixtureEndToEnd + title: >- + The OpenXmlChartParser class shall correctly parse a trimmed, styling-stripped + excerpt derived from a real-world chart1.xml part (a stacked column chart, three + series, four categories, a styled-but-textless title, title-less axes, and a + bottom legend) into the exact expected Chart shape, and that parsed Chart shall + render successfully via ChartRenderer. + justification: | + Synthetic, hand-authored fixtures alone cannot prove this parser handles a real + PowerPoint-authored chart1.xml part's actual shape; at least one test must be + anchored to genuine real-world output, not just an invented approximation of the + ECMA-376 schema. + tests: + - Parse_RealFixtureBarChartExcerpt_ParsesExpectedShape + - Parse_ThenRender_RealFixtureBarChartExcerpt_ProducesCorrectlySizedSurface + + - id: CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-RenderRoundTrip + title: >- + The OpenXmlChartParser class's output, for every supported ChartType, shall be + directly accepted by ChartRenderer.Render and produce a correctly sized Surface. + justification: | + Parsing is only useful if its output is actually consumable by the Phase 2 + renderer without any further adaptation; this must be proven for every + supported chart kind, not assumed from passing data-shape assertions alone. + tests: + - Parse_ThenRender_EveryChartType_ProducesCorrectlySizedSurface diff --git a/docs/reqstream/canvas-net-pdf/pdf-document.yaml b/docs/reqstream/canvas-net-pdf/pdf-document.yaml index dd5e0cfc..293e65fc 100644 --- a/docs/reqstream/canvas-net-pdf/pdf-document.yaml +++ b/docs/reqstream/canvas-net-pdf/pdf-document.yaml @@ -771,7 +771,9 @@ sections: XObject, decoding a bare /Filter /DCTDecode image via Codecs.JpegCodec.Load or interpreting raw DeviceGray/DeviceRGB/DeviceCMYK 8-bit samples, and compositing the decoded image onto the destination surface through the current transformation - matrix (nearest-neighbor sampling, treating every image as fully opaque); an + matrix (nearest-neighbor sampling, treating every image as fully opaque, and + alpha-blending each sampled source pixel over the existing destination pixel via + standard Porter-Duff "over" compositing rather than overwriting it outright); an unsupported /ColorSpace or an unsupported /BitsPerComponent shall throw UnsupportedImageFeatureException rather than being silently skipped or mis-rendered, and a malformed operand count/type or an undeclared XObject name @@ -781,6 +783,12 @@ sections: them decoded and correctly positioned, and need every explicitly out-of-scope construct (unsupported color spaces/bit depths) to fail closed with a distinguishable exception rather than being silently skipped or mis-rendered. + Alpha-blending (rather than raw-overwriting) a sampled source pixel over the + existing destination pixel is required so a source pixel with a non-opaque alpha + channel lets existing destination content show through correctly instead of being + replaced by whatever RGB value happens to be stored alongside that non-opaque + alpha - the same shared compositing helper DemaConsulting.CanvasNet.Pptx's own + PaintPicture reuses. tests: - PdfDocument_Images_DoOperator_DeviceGrayFlateDecode_PlacesExpectedPixels - PdfDocument_Images_DoOperator_DeviceRgbFlateDecode_PlacesExpectedPixels @@ -788,6 +796,7 @@ sections: - PdfDocument_Images_DoOperator_DctDecodeJpeg_PlacesExpectedPixels - PdfDocument_Images_UnsupportedBitsPerComponent_ThrowsUnsupportedImageFeatureException - PdfDocument_Images_UnsupportedColorSpace_ThrowsUnsupportedImageFeatureException + - CompositeImageOntoSurface_SourceImageWithAlphaChannel_AlphaBlendsOntoExistingBackground - id: CanvasNetPdf-PdfDocument-FormXObjects title: >- @@ -1225,7 +1234,15 @@ sections: substituted (see CanvasNetPdf-PdfDocument-SymbolZapfDingbatsFallback for the dedicated /BaseFont Symbol/ZapfDingbats substitution path, the sole exception to this fail-closed rule), and every resolved fallback TrueTypeFont instance shall be - cached by (file path, face index) for the process lifetime. + cached by (file path, face index) for the process lifetime. This ordinary (non- + Symbol/non-ZapfDingbats) branch shall additionally return a 2-element candidate + list - the resolved primary match followed by the bundled Liberation flavor- + matched fallback font - reusing the same first-match-wins composite glyph-lookup + mechanism ResolvedSimpleFont.Resolve already provides for the Symbol/ZapfDingbats + path, so a codepoint missing from the primary match's own coverage falls through to + the bundled fallback's glyph for that codepoint instead of painting .notdef, without + regressing a fully-covering primary match (consulted first, unaffected when it + already covers every codepoint in use). justification: | A symbol/dingbats font's glyphs are not addressed by any standard Latin-text encoding, so silently substituting an unrelated text font for one would render @@ -1233,11 +1250,19 @@ sections: simple font, by contrast, can be reasonably approximated by a same-flavor substitute, and resolving a fallback font requires directory-scanning and parsing real font files, which must not be repeated for every occurrence of the same - substitute font within or across renders. + substitute font within or across renders. A single resolved substitute font can + itself have incomplete glyph coverage (for example a narrow installed system font + standing in for a /BaseFont hint it only partially matches); extending the already- + existing, already-tested multi-candidate mechanism (rather than introducing a + second, parallel one) closes this residual "tofu box"/missing-glyph gap for the + ordinary substitution path at negligible cost, exactly mirroring the parallel + per-character glyph-coverage fallback added to CanvasNet.Pptx's text-layout engine + for the same underlying feature gap. tests: - PdfDocument_BuildResolvedFont_Standard14NoEmbeddedFont_ResolvesViaFallback - PdfDocument_BuildResolvedFont_NonStandard14FlagsOnlyUnmatchedFamily_ResolvesViaBundledFallback - PdfDocument_BuildResolvedFont_SymbolicFlagWithoutEmbeddedFont_ThrowsSymbolicNotEmbeddedException + - PdfDocument_BuildResolvedFont_PrimaryMatchMissesCodepoint_FallsBackToBundledLiberationSans - CanvasNetPdf_SystemIntegration_RenderStandard14HelveticaWithoutEmbeddedFont_PaintsVisibleGlyphInk - CanvasNetPdf_SystemIntegration_RenderOtherSymbolicFontWithoutEmbeddedFont_ThrowsUnsupportedImageFeatureException diff --git a/docs/reqstream/canvas-net-pptx.yaml b/docs/reqstream/canvas-net-pptx.yaml new file mode 100644 index 00000000..d1acd414 --- /dev/null +++ b/docs/reqstream/canvas-net-pptx.yaml @@ -0,0 +1,410 @@ +--- +# cspell:ignore ooxml pptx srgb xfrm prst cust paintable bodyPr pPr rPr txBody txStyles lstStyle +# cspell:ignore defRPr normAutofit noAutofit spAutoFit lnSpcReduction fontScale grpSp spTree tbl +# cspell:ignore srcRect tcPr +# +# Software System Requirements for CanvasNetPptx +# +# These requirements describe the externally visible behavior of the DemaConsulting.CanvasNet.Pptx +# package (namespace DemaConsulting.CanvasNet.Pptx) from the perspective of its consumers, without +# specifying implementation details. +# +# CanvasNetPptx is an independent software system, distributed as its own NuGet package, +# depending on the core CanvasNet system (docs/reqstream/canvas-net.yaml) - see +# docs/design/canvas-net-pptx.md's Dependencies section for details. As of Phase 1b, the Canvas +# subsystem's Rgba32 type is used to resolve a theme's color scheme. As of Phase 1c, the Geometry +# subsystem's Path/PathBuilder/PathCommand types, the Drawing subsystem's PathStroker/StrokeStyle +# and Gradient/LinearGradient/GradientStop types, and Rgba32's color-space math are additionally +# used to resolve shape geometry, fills, and stroked outlines. As of Phase 1d, the Fonts +# subsystem's TrueTypeFont/SystemFontCatalog types and the Drawing subsystem's PathFiller type are +# additionally used to resolve text layout and paint glyph ink onto a core Surface. As of Phase 1e, +# the Codecs subsystem's PngCodec/JpegCodec/BmpCodec/TiffCodec/GifCodec types are additionally used +# to decode a picture shape's embedded raster image. +# +# CanvasNetPptx was delivered incrementally across Phases 1a-1f, each additive to the last. Phase +# 1a implemented the underlying OOXML (Office Open XML) package layer: opening a .pptx file as a +# ZIP archive, resolving [Content_Types].xml (default extension mappings and part-specific +# overrides), and resolving package-level (_rels/.rels) and per-part relationships, including +# relative-target resolution. Phase 1b added the presentation/theme/master/layout/slide model and +# a placeholder property-inheritance resolver: parsing ppt/presentation.xml's declared slide size +# and ordered slide list (exposed as public SlideCount/SlideSize members), resolving each slide +# master's theme, structurally parsing placeholder shapes, and the verified ECMA-376 +# placeholder-matching algorithm. Phase 1c added DrawingML shape geometry and paint resolution: +# resolving a shape's transform (position, size, rotation, flip) and a group shape's +# child coordinate-space composition; resolving preset geometry (approximately 24 +# common presets) and custom path geometry into the core Path type; and resolving +# / (linear)/ fills, DrawingML color transforms (lumMod/lumOff/ +# shade/tint/alpha), and stroke outlines via the core PathStroker. Phase 1d added DrawingML +# text body parsing (//////), attribute-level +# run/paragraph property inheritance through the placeholder/layout/master (including the master's +# own title/body/other style buckets)/theme chain, word-wrap/alignment/vertical- +# anchor/autofit text layout, and glyph-ink rendering onto a core Surface. Phase 1e added dedicated +# picture-shape support (content-type dispatch, crop-rectangle resolution, and +# compositing), table support (structure/cell parsing reusing the Phase 1c/1d fill/border/ +# text-body resolvers, merge-aware cell-rect resolution, and cell fill/border/text painting), and +# recursive, full shape-tree parsing across a slide's own /// +# elements (including nested child-transform composition). Phase 1f added the public, +# slide-level Render API: a document-order walk of a slide's full shape tree, dispatching each +# shape/picture/table node to the already-verified Phase 1c/1d/1e resolvers and painters onto a +# Surface of the requested pixel dimensions. Subsequent Phase 2 Follow-Ups added slide/layout/ +# master background-fill resolution ahead of that shape-tree walk, / +# bullet/numbering rendering, and connector-shape rendering. Pattern and picture fills, +# radial/path gradients, full text justification, spAutoFit shape-resize behavior, kerning, text +# clipping on overflow, nested tables, and table auto-sizing/banding remain unimplemented - see +# docs/design/canvas-net-pptx.md for the full current feature set and its documented, +# still-unimplemented scope. As of Phase 4, a declaring a chart graphic-data kind +# is resolved and rendered by delegating to the sibling CanvasNetCharts system's +# OpenXmlChartParser/ChartRenderer, rather than always throwing PptxUnsupportedFeatureException; +# SmartArt/diagram and OLE-object graphic frames remain unsupported. + +sections: + - title: CanvasNetPptx Requirements + requirements: + - id: CanvasNetPptx-Open + title: >- + The library shall parse a well-formed OOXML/.pptx package exactly once, from either a + stream or a file path, resolving its [Content_Types].xml content-type mappings and its + package-level relationships, and reject a null stream/path, an empty or whitespace-only + path, or a malformed package structure (an unreadable/corrupt ZIP, or a package missing + [Content_Types].xml or _rels/.rels) with the documented exception contract. + tags: [system] + justification: | + Callers need a single, reliable entry point that turns raw .pptx bytes into a usable, + in-memory package representation, and must be able to distinguish a programming error + (null/empty argument) from malformed input (corrupt or incomplete package structure). + children: + - CanvasNetPptx-PptxDocument-OpenStream + - CanvasNetPptx-PptxDocument-OpenPath + - CanvasNetPptx-PptxDocument-ZipValidation + - CanvasNetPptx-PptxDocument-ContentTypesValidation + - CanvasNetPptx-PptxDocument-PackageRelationshipsValidation + - CanvasNetPptx-PptxDocument-MediaPartSizeValidation + - CanvasNetPptx-PptxDocument-CaseSensitivePartNames + - CanvasNetPptx-PptxDocument-Dispose + tests: + - CanvasNetPptx_SystemIntegration_PptxOpen_SucceedsOnWellFormedPackage + - CanvasNetPptx_SystemIntegration_PptxOpenValidationNull_NullStreamThrowsArgumentNullException + - CanvasNetPptx_SystemIntegration_PptxOpenValidationEmptyPath_EmptyPathThrowsArgumentException + + - id: CanvasNetPptx-ResolvePart + title: >- + The library shall resolve any part's content type, giving a part-specific + [Content_Types].xml override precedence over the default extension-based mapping, and + shall reject a request for a part the package does not contain or whose content type + cannot be resolved by either mapping. + tags: [system] + justification: | + Later phases need a reliable way to discover what kind of content a given package part + holds before attempting to parse it, honoring the same override-wins precedence rule + every OPC-consuming tool must apply. + children: + - CanvasNetPptx-PptxDocument-ContentTypeOverride + - CanvasNetPptx-PptxDocument-ContentTypeDefault + tests: + - CanvasNetPptx_SystemIntegration_PptxOpen_SucceedsOnWellFormedPackage + + - id: CanvasNetPptx-ResolveRelationship + title: >- + The library shall resolve a named relationship from a given source part (or the package + root) to its target part path, correctly applying OPC's relative-target resolution rules + (including a "../" parent-directory traversal segment), and shall reject an unknown + relationship ID, an external-target relationship, or a target that would escape the + package root. + tags: [system] + justification: | + Every OOXML presentation part is discovered by following a chain of relationships + (package root -> ppt/presentation.xml -> each slide -> its layout, and so on); this + resolution must be correct, including the common case of a part referencing a sibling + part via a relative, parent-traversing path. + children: + - CanvasNetPptx-PptxDocument-RelationshipResolution + - CanvasNetPptx-PptxDocument-RelativeTargetTraversal + tests: + - CanvasNetPptx_SystemIntegration_PptxOpen_SucceedsOnWellFormedPackage + + - id: CanvasNetPptx-Presentation + title: >- + The library shall, upon opening a .pptx package, resolve its presentation part and + expose its declared slide count and slide size, converting the slide size to pixels at + a caller-supplied resolution, and shall reject a package that is not a navigable + presentation with a valid slide size and at least one declared slide. + tags: [system] + justification: | + Callers (and later rendering phases) need the presentation's overall slide size and + slide count without navigating the package's relationship graph themselves, and a + malformed or non-presentation package must fail closed at Open() time. + children: + - CanvasNetPptx-PptxDocument-PresentationParsing + - CanvasNetPptx-PptxDocument-PresentationValidation + - CanvasNetPptx-PptxDocument-SlideSizePixelConversion + tests: + - CanvasNetPptx_SystemIntegration_PptxOpenPresentation_SlideCountAndSizeResolveEndToEnd + - CanvasNetPptx_SystemIntegration_PptxOpenValidationEmptySlideList_ThrowsInvalidDataException + + - id: CanvasNetPptx-Theme + title: >- + The library shall, upon resolving a slide master's theme part, resolve that theme's + 12-slot color scheme (, including both srgbClr and sysClr entries) and its + major/minor font scheme (Latin, East Asian, and complex-script typefaces) into concrete + values usable by placeholder-property resolution and later rendering phases. + tags: [system] + justification: | + A placeholder's effective properties may reference a theme color by scheme slot or a + theme font by major/minor role rather than an explicit value; resolving the theme's + color and font schemes up front lets later phases look up concrete values without + re-parsing the theme XML for every placeholder that resolves to it. + children: + - CanvasNetPptx-PptxDocument-ThemeColorScheme + - CanvasNetPptx-PptxDocument-ThemeFontScheme + tests: + - CanvasNetPptx_SystemIntegration_PptxOpen_SucceedsOnWellFormedPackage + + - id: CanvasNetPptx-PlaceholderInheritance + title: >- + The library shall structurally parse the placeholder shapes declared on a slide's + master, layout, and slide (recording each one's type and idx, defaulting to "obj"/0 when + omitted) and resolve a slide placeholder's effective shape and text-body properties by + independently falling through slide, then matched layout, then matched master for each + property category, applying the verified ECMA-376 idx-then-remapped-type placeholder + matching algorithm at each hop. + tags: [system] + justification: | + A slide's placeholders rarely declare their own complete formatting; real PowerPoint + behavior inherits unset shape and text properties from the owning layout and, beyond + that, the owning master, and callers (and later rendering phases) need this resolved + chain rather than re-implementing OOXML's placeholder-matching rules themselves. + children: + - CanvasNetPptx-PptxDocument-MasterPlaceholderParsing + - CanvasNetPptx-PptxDocument-LayoutPlaceholderParsing + - CanvasNetPptx-PptxDocument-SlidePlaceholderParsing + - CanvasNetPptx-PptxDocument-PlaceholderInheritanceIdxMatch + - CanvasNetPptx-PptxDocument-PlaceholderInheritanceTypeMatch + - CanvasNetPptx-PptxDocument-PlaceholderInheritancePerCategory + - CanvasNetPptx-PptxDocument-EffectivePlaceholderTypeForTextStyle + - CanvasNetPptx-PptxDocument-PlaceholderXfrmGeometryInheritance + - CanvasNetPptx-PptxDocument-TxBodyListStyleLevelInheritance + tests: + - CanvasNetPptx_SystemIntegration_PptxOpen_SucceedsOnWellFormedPackage + + - id: CanvasNetPptx-ShapeGeometryAndPaint + title: >- + The library shall resolve a DrawingML shape's position, size, rotation, and flip into a + single transform (including a group shape's child-coordinate-space composition); + resolve its preset or custom geometry into a concrete path sized to the shape's own + extent; resolve its fill (solid, linear gradient, or none) and color, including + scheme-color lookup through the resolved theme and the lumMod/lumOff/shade/tint/alpha + color-transform pipeline; and resolve and realize its stroke outline via the core + path-stroking machinery - rejecting any unsupported preset geometry, gradient kind, or + fill kind with a distinguishable exception rather than producing incorrect output. + tags: [system] + justification: | + A .pptx presentation's visual content is overwhelmingly expressed as DrawingML shapes + whose geometry and paint are declared relative to the shape's own local coordinate + space and the presentation's theme; resolving these into concrete, paintable path and + color data is the prerequisite every later rendering phase needs, and must fail closed, + rather than silently rendering incorrect output, for the DrawingML constructs this phase + does not yet implement. + children: + - CanvasNetPptx-PptxDocument-ShapeFrameTransform + - CanvasNetPptx-PptxDocument-GroupChildTransform + - CanvasNetPptx-PptxDocument-PresetGeometry + - CanvasNetPptx-PptxDocument-CustomGeometry + - CanvasNetPptx-PptxDocument-FillResolution + - CanvasNetPptx-PptxDocument-ColorResolution + - CanvasNetPptx-PptxDocument-ColorTransforms + - CanvasNetPptx-PptxDocument-GradientFill + - CanvasNetPptx-PptxDocument-LineStyleResolution + - CanvasNetPptx-PptxDocument-PictureFillResolution + - CanvasNetPptx-PptxDocument-PatternFillResolution + - CanvasNetPptx-PptxDocument-ShapeStyleReference + tests: + - CanvasNetPptx_SystemIntegration_GeometryAndPaint_FreeformShapeResolvesEndToEnd + - CanvasNetPptx_SystemIntegration_GeometryAndPaint_GroupShapeChildTransformComposesEndToEnd + + - id: CanvasNetPptx-TextLayoutAndRendering + title: >- + The library shall parse a shape's DrawingML text body into paragraphs and runs; resolve + each run's and paragraph's effective properties attribute-by-attribute through the + placeholder/layout/master (including the master's own titleStyle/bodyStyle/otherStyle + text-style buckets)/theme inheritance chain; word-wrap, align, vertically anchor, and + (for the common fontScale/lnSpcReduction-bearing and attribute-less shrink-to-fit cases) + autofit the resulting text within the shape's own bounding box; and paint the laid-out + glyphs onto a core Surface by composing each glyph's own transform with the shape's own + frame transform and resolving typefaces through the core system font catalog. + tags: [system] + justification: | + Text is the dominant content type in real-world presentations; without resolving its + inherited properties correctly and laying it out and painting it onto a concrete + rendering surface, no later full-slide rendering phase of this feature could produce + visually correct output for the overwhelming majority of real .pptx content. + children: + - CanvasNetPptx-PptxDocument-TextBodyParsing + - CanvasNetPptx-PptxDocument-ParagraphRunParsing + - CanvasNetPptx-PptxDocument-TextPropertyInheritance + - CanvasNetPptx-PptxDocument-TextStylesInheritance + - CanvasNetPptx-PptxDocument-TextLayoutWordWrap + - CanvasNetPptx-PptxDocument-TextVerticalAnchor + - CanvasNetPptx-PptxDocument-TextAutofit + - CanvasNetPptx-PptxDocument-TextRendering + - CanvasNetPptx-PptxDocument-TextAlignmentWidthUnderWrapNone + - CanvasNetPptx-PptxDocument-UnderlineRendering + - CanvasNetPptx-PptxDocument-TabStopExpansion + tests: + - CanvasNetPptx_SystemIntegration_TextLayoutAndRender_TitlePlaceholderResolvesMasterTitleStyleEndToEnd + - CanvasNetPptx_SystemIntegration_TextLayoutAndRender_NonPlaceholderShapeResolvesMasterOtherStyleEndToEnd + + - id: CanvasNetPptx-PictureShapes + title: >- + The library shall resolve a picture shape's embedded raster image (decoding its content + type via the shared image-decoding infrastructure), resolve its declared crop rectangle + against the decoded image's own pixel bounds, and composite the resulting, cropped image + onto a rendering surface at the shape's own resolved frame. + tags: [system] + justification: | + Embedded images are common in real-world presentations (photographs, logos, diagrams + captured as pictures); without resolving and compositing a picture shape's own embedded + raster content, a rendered slide containing one would be visibly incomplete. + children: + - CanvasNetPptx-PptxDocument-PictureDecodeAndCrop + - CanvasNetPptx-PptxDocument-PicturePainting + - CanvasNetPptx-PptxDocument-PictureGeometryClipping + - CanvasNetPptx-PptxDocument-PictureStrokeOutline + tests: + - CanvasNetPptx_SystemIntegration_Images_PictureDecodesAndRendersEndToEnd + + - id: CanvasNetPptx-TableShapes + title: >- + The library shall parse a table shape's declared rows, columns, and cells (resolving + merged-cell spans into a single merge-aware cell rectangle per cell), and paint each + cell's resolved fill, border, and text content onto a rendering surface at that cell's + own resolved rectangle. + tags: [system] + justification: | + Tabular data is common in real-world presentations; without resolving a table's own + structure and painting each cell's own content at the correct rectangle, a rendered + slide containing one would show incorrect or missing tabular content. + children: + - CanvasNetPptx-PptxDocument-TableParsing + - CanvasNetPptx-PptxDocument-TableCellRectResolution + - CanvasNetPptx-PptxDocument-TablePainting + - CanvasNetPptx-PptxDocument-TableStyleResolution + tests: + - CanvasNetPptx_SystemIntegration_Tables_GraphicFrameParsesAndRendersCellFillEndToEnd + + - id: CanvasNetPptx-ShapeTreeEnumeration + title: >- + The library shall recursively enumerate every recognized shape, picture, table, and + group element declared in a slide's own shape tree, at any nesting depth, composing each + nested group's own child-coordinate-space transform with its ancestors' transforms so + every enumerated node's effective position is correct relative to the slide. + tags: [system] + justification: | + Real-world presentations commonly group shapes, including nested groups, to move and + resize related content together; without recursively enumerating every nesting level and + correctly composing each level's own transform, a rendered slide would omit or + mis-position any shape nested inside a group. + children: + - CanvasNetPptx-PptxDocument-ShapeTree + - CanvasNetPptx-PptxDocument-GroupChildTransform + tests: + - CanvasNetPptx_SystemIntegration_ShapeTree_NestedGroupEnumeratesAndComposesTransformEndToEnd + + - id: CanvasNetPptx-SlideBackgroundFill + title: >- + The library shall resolve and paint a slide's own, or (falling back in order) its + layout's or its master's own, declared background fill across the full slide ahead of + that slide's own shape-tree walk, reusing the existing DrawingML fill-resolution + pipeline (solid, linear-gradient, and theme-indexed background-fill-list references). + tags: [system] + justification: | + A branded, non-white background fill is a highly visible real-world presentation + feature; without resolving and painting it, a rendered slide would always show a plain, + uniform background regardless of what the file itself declares. + children: + - CanvasNetPptx-PptxDocument-SlideBackgroundFill + tests: + - PptxDocument_Render_SldBackgroundFixture_RendersAndPaintsSlideBackgroundFill + + - id: CanvasNetPptx-MasterLayoutShapeRendering + title: >- + The library shall render a slide's own master's and own layout's non-placeholder shapes + back-to-front in document order - master shapes first, then layout shapes, then the + slide's own shapes on top - immediately after painting the resolved background, using + the same per-shape-type paint dispatch a slide-level shape already uses, while never + rendering a master's or layout's own placeholder shapes directly. + tags: [system] + justification: | + Real-world presentations frequently place decoration (a logo, a rule, a watermark) + directly on a slide master's or layout's own shape tree, outside any placeholder; + without rendering these, a rendered slide would omit visible, authored content. + children: + - CanvasNetPptx-PptxDocument-MasterLayoutShapeRendering + tests: + - Render_MasterNonPlaceholderPicture_PaintsOnEverySlideUsingThatMasterResolvingOwnRels + + - id: CanvasNetPptx-BulletsAndNumbering + title: >- + The library shall parse and resolve a paragraph's bullet/numbering properties (type, + color, font, and size, each independently inherited through the placeholder/master + chain) and shall format, sequence, and paint the resulting bullet or auto-number glyph + alongside its paragraph's own text. + tags: [system] + justification: | + A bulleted or numbered paragraph is one of the most common real-world PPTX constructs; + without parsing, resolving, and painting bullet/numbering markup, such paragraphs would + render as plain, unmarked text, a visually obvious gap against a real PowerPoint export. + children: + - CanvasNetPptx-PptxDocument-BulletProperties + - CanvasNetPptx-PptxDocument-BulletRendering + tests: + - PptxDocument_Render_PhInheritPropsFixture_Slide0PaintsSlide1RendersWithoutError + + - id: CanvasNetPptx-ConnectorShapes + title: >- + The library shall dispatch a connector shape to its own shape-tree node in the same + document-order walk as every other shape kind, resolving its preset connector geometry, + its per-attribute stroke style (merging its own line properties with its sibling shape- + style reference), and its resolved arrowhead markers. + tags: [system] + justification: | + A connector shape - the straight/elbow/curved lines PowerPoint draws between other + shapes in flowcharts and diagrams - is a common real-world construct; without resolving + and painting it, a slide containing one would render with that connector entirely + absent, a severe fidelity gap for diagram-heavy content. + children: + - CanvasNetPptx-PptxDocument-ConnectorRendering + tests: + - PptxDocument_Render_ShpConnectorPropsFixture_ConnectorsPaintVisibleLines + + - id: CanvasNetPptx-ChartGraphicFrames + title: >- + The library shall resolve a graphic frame declaring a chart by delegating to the + sibling DemaConsulting.CanvasNet.Charts package's parser and renderer, compositing the + resulting chart onto the slide surface at the graphic frame's own resolved frame. + tags: [system] + justification: | + A graphic frame may declare a chart rather than a table; without resolving and + rendering it via the already-verified Charts package, no presentation containing a + chart could ever render past that slide. + children: + - CanvasNetPptx-PptxDocument-ChartGraphicFrames + tests: + - Render_ChartOnlySlide_PaintsVisibleContent + + - id: CanvasNetPptx-RenderApi + title: >- + The library shall render a single slide, by index, onto a new surface sized either to + caller-supplied pixel dimensions or to the slide's own aspect ratio at a caller-supplied + resolution in dots per inch, by walking that slide's full shape tree in document order + and dispatching each node to its own resolved paint. + tags: [system] + justification: | + Callers need a single, public entry point that turns a parsed presentation's slide into a + concrete, paintable surface without re-implementing the shape-tree walk and per-node-kind + dispatch themselves, and must be able to request output sized either in exact pixels or + at a target print/display resolution. + children: + - CanvasNetPptx-PptxDocument-RenderPublicApi + - CanvasNetPptx-PptxDocument-SlideRendering + tests: + - CanvasNetPptx_SystemIntegration_Render_PixelAndDpiOverloadsProduceConsistentOutputEndToEnd diff --git a/docs/reqstream/canvas-net-pptx/platform-requirements.yaml b/docs/reqstream/canvas-net-pptx/platform-requirements.yaml new file mode 100644 index 00000000..61826bdd --- /dev/null +++ b/docs/reqstream/canvas-net-pptx/platform-requirements.yaml @@ -0,0 +1,74 @@ +--- +# cspell:ignore pptx +# +# System-Level Platform Requirements +# +# These requirements specify the platforms and frameworks the CanvasNetPptx package must support. +# They ensure the package works across the required execution environments - identical to the +# CanvasNet system it depends on (see docs/design/canvas-net-pptx.md's Platform Support section). +# +# Test links can include a source filter prefix (e.g. "windows@", "ubuntu@", "net8.0@") to restrict +# which test results count as evidence for a requirement. This is critical for platform and framework +# requirements - removing these filters invalidates the evidence-based proof. +# +# Source filter prefixes: +# windows@TestName - proves the test passed on a Windows platform +# ubuntu@TestName - proves the test passed on a Linux (Ubuntu) platform +# macos@TestName - proves the test passed on a macOS platform +# net8.0@TestName - proves the test passed under the .NET 8 runtime +# net9.0@TestName - proves the test passed under the .NET 9 runtime +# net10.0@TestName - proves the test passed under the .NET 10 runtime + +sections: + - title: Platform Support + requirements: + - id: CanvasNetPptx-Platform-Windows + title: The library shall build and run on Windows platforms. + tags: [system] + justification: | + DEMA Consulting libraries must support Windows as a major development platform. + tests: + # Tests link to "windows" to ensure results come from Windows platform + - "windows@CanvasNetPptx_SystemIntegration_PptxOpen_SucceedsOnWellFormedPackage" + + - id: CanvasNetPptx-Platform-Linux + title: The library shall build and run on Linux platforms. + tags: [system] + justification: | + DEMA Consulting libraries must support Linux for CI/CD and containerized environments. + tests: + # Tests link to "ubuntu" to ensure results come from Linux platform + - "ubuntu@CanvasNetPptx_SystemIntegration_PptxOpen_SucceedsOnWellFormedPackage" + + - id: CanvasNetPptx-Platform-MacOS + title: The library shall build and run on macOS platforms. + tags: [system] + justification: | + DEMA Consulting libraries must support macOS for developers using Apple platforms. + tests: + # Tests link to "macos" to ensure results come from macOS platform + - "macos@CanvasNetPptx_SystemIntegration_PptxOpen_SucceedsOnWellFormedPackage" + + - id: CanvasNetPptx-Platform-Net8 + title: The library shall support .NET 8 runtime. + tags: [system] + justification: | + .NET 8 is an LTS release providing long-term stability for enterprise users. + tests: + - "net8.0@CanvasNetPptx_SystemIntegration_PptxOpen_SucceedsOnWellFormedPackage" + + - id: CanvasNetPptx-Platform-Net9 + title: The library shall support .NET 9 runtime. + tags: [system] + justification: | + .NET 9 support enables users to leverage the latest .NET features. + tests: + - "net9.0@CanvasNetPptx_SystemIntegration_PptxOpen_SucceedsOnWellFormedPackage" + + - id: CanvasNetPptx-Platform-Net10 + title: The library shall support .NET 10 runtime. + tags: [system] + justification: | + .NET 10 support ensures the library remains compatible with the latest .NET ecosystem. + tests: + - "net10.0@CanvasNetPptx_SystemIntegration_PptxOpen_SucceedsOnWellFormedPackage" diff --git a/docs/reqstream/canvas-net-pptx/pptx-document.yaml b/docs/reqstream/canvas-net-pptx/pptx-document.yaml new file mode 100644 index 00000000..1273afa9 --- /dev/null +++ b/docs/reqstream/canvas-net-pptx/pptx-document.yaml @@ -0,0 +1,2154 @@ +--- +# Software Unit Requirements for PptxDocument +# +# cspell:ignore ooxml pptx srgb xfrm prst cust patt bodyPr normAutofit noAutofit spAutoFit pPr +# cspell:ignore rPr txBody txStyles lstStyle defRPr ctrTitle lIns tIns rIns bIns algn marL lnSpc +# cspell:ignore spcBef spcAft justLow lnSpcReduction fontScale lvl ordinally mistargeted unrenderable +# cspell:ignore pythonpptx Autoshape aiden0z Aiden aiden misrendered bgRef bgFillStyleLst phClr +# cspell:ignore tblStyle wholeTbl tcStyle tcBdr insideH insideV bandRow firstRow band1H band2H +# +# These requirements describe the design-level behavior of the PptxDocument unit. +# +# Packaging note: PptxDocument is distributed as the separate DemaConsulting.CanvasNet.Pptx NuGet +# package (namespace DemaConsulting.CanvasNet.Pptx), which references the core +# DemaConsulting.CanvasNet package. PptxDocument is the sole unit of the CanvasNetPptx system. + +sections: + - title: CanvasNetPptx Requirements + sections: + - title: PptxDocument Requirements + requirements: + - id: CanvasNetPptx-PptxDocument-OpenStream + title: >- + The PptxDocument class shall expose a static Open(Stream) method that buffers the + entire supplied stream into memory (without disposing or requiring it to remain + seekable afterward), constructs the OOXML package layer from the buffered bytes, + rejects a null stream with ArgumentNullException, and bounds the number of bytes + read from the supplied stream to a fixed maximum package size (256 MiB), rejecting a + stream exceeding that bound with InvalidDataException rather than buffering it in + full. + justification: | + Callers need to open a package from any stream source (file, network, in-memory) + without CanvasNetPptx retaining a dependency on the caller's stream lifetime. A + caller-supplied stream is not necessarily trusted or even finite (for example a + network stream under adversarial control, or a non-terminating stream), so buffering + it in full with no upper bound would let a single Open(Stream) call exhaust available + memory or block indefinitely; a fixed, documented size bound fails the call closed + with a clear exception instead. + tests: + - PptxDocument_Open_WellFormedMinimalPackage_Succeeds + - PptxDocument_Open_NullStream_ThrowsArgumentNullException + - PptxDocument_Open_NonTerminatingOversizedStream_ThrowsInvalidDataException + + - id: CanvasNetPptx-PptxDocument-OpenPath + title: >- + The PptxDocument class shall expose a static Open(string) method that validates the + path is neither null nor empty/whitespace-only (throwing ArgumentNullException or + ArgumentException respectively), then opens and delegates to Open(Stream). + justification: | + Callers most commonly have a file path rather than an already-open stream, and must + receive argument-validation errors distinguishable from a missing-file error. + tests: + - PptxDocument_Open_NullPath_ThrowsArgumentNullException + - PptxDocument_Open_WhitespacePath_ThrowsArgumentException + + - id: CanvasNetPptx-PptxDocument-ZipValidation + title: >- + The PptxDocument class shall open the buffered bytes as a read-only ZipArchive and + reject an unreadable or corrupt (non-ZIP) stream with InvalidDataException. + justification: | + A .pptx file is fundamentally a ZIP archive; a corrupt or non-ZIP input must fail + closed with a clear, documented exception type rather than an unrelated BCL + exception leaking through. + tests: + - PptxDocument_Open_CorruptNonZipStream_ThrowsInvalidDataException + + - id: CanvasNetPptx-PptxDocument-ContentTypesValidation + title: >- + The PptxDocument class shall require the package to contain a well-formed + [Content_Types].xml part whose root is a Types element in the OPC content-types + namespace, parsing its Default (extension-to-content-type) and Override + (part-specific) entries - each of which must declare its required attribute(s) + (Extension/ContentType for Default, PartName/ContentType for Override) - and reject + a package missing this part, an oversized part exceeding a bounded character budget, + a part with a malformed root element, or a Default/Override element with a missing + required attribute, all with InvalidDataException. + justification: | + Every valid OPC package must declare [Content_Types].xml; its absence means the + package is fundamentally malformed and must be rejected before any later phase + attempts to interpret any part's content. A malformed root or incomplete + Default/Override element must fail closed (per Open()'s documented contract) rather + than silently being skipped, and an attacker-controlled, unbounded XML part (an "XML + bomb") must not be allowed to exhaust memory while parsing. + tests: + - PptxDocument_Open_MissingContentTypes_ThrowsInvalidDataException + - PptxDocument_Open_OversizedContentTypesPart_ThrowsInvalidDataException + - PptxDocument_Open_ContentTypesWrongRoot_ThrowsInvalidDataException + - PptxDocument_Open_ContentTypesDefaultMissingRequiredAttribute_ThrowsInvalidDataException + - PptxDocument_Open_ContentTypesOverrideMissingRequiredAttribute_ThrowsInvalidDataException + + - id: CanvasNetPptx-PptxDocument-MediaPartSizeValidation + title: >- + The PptxDocument class shall bound a single part's decompressed byte size (as read + by GetPartBytes) to a fixed maximum (256 MiB), enforced incrementally during the + copy itself rather than trusting the ZIP entry's declared length, and reject a part + whose decompressed content exceeds that bound with InvalidDataException. + justification: | + A single OPC part's compressed-to-decompressed ratio is entirely attacker-controlled + (a "zip bomb") - a small, highly-compressible media part can pass the whole + package's own compressed-size bound (MaxPackageBytes) easily while still + decompressing to an unbounded size through GetPartBytes, exhausting memory. Bounding + decompressed size incrementally during the copy, rather than trusting any declared + length, closes this gap without weakening support for any realistic media part. + tests: + - PptxDocument_GetPartBytes_OversizedDecompressedEntry_ThrowsInvalidDataException + - PptxDocument_GetPartBytes_WellFormedPart_ReturnsExactBytes + + - id: CanvasNetPptx-PptxDocument-PackageRelationshipsValidation + title: >- + The PptxDocument class shall require the package to contain a well-formed + package-level _rels/.rels relationships part whose root is a Relationships element + in the OPC relationships namespace, parsing it eagerly at Open() time, and reject a + package missing this part, a part with a malformed root element, or a Relationship + element missing a required attribute (Id, Type, or Target), all with + InvalidDataException. + justification: | + The package-level relationships part is the sole entry point for discovering the + presentation's root part in later phases; its absence means the package cannot be + navigated at all. A malformed root or an incomplete Relationship element must fail + closed rather than silently being skipped, which would otherwise let a malformed + package appear navigable when it is not. + tests: + - PptxDocument_Open_MissingPackageRelationships_ThrowsInvalidDataException + - PptxDocument_Open_PackageRelsWrongRoot_ThrowsInvalidDataException + - PptxDocument_Open_RelationshipMissingRequiredAttribute_ThrowsInvalidDataException + + - id: CanvasNetPptx-PptxDocument-CaseSensitivePartNames + title: >- + The PptxDocument class shall compare OPC part names ordinally (case-sensitively), + treating two part names differing only by case as distinct parts, rejecting an + incorrectly-cased reserved part name (for example "[content_types].xml") as if the + reserved part were absent, and rejecting a package containing two ZIP entries with + exactly identical names, all with InvalidDataException where applicable. + justification: | + OPC (ECMA-376 Part 2) defines part-name comparison as ordinal; treating part names + case-insensitively would incorrectly merge two legitimately distinct parts (for + example "ppt/slides/Slide1.xml" and "ppt/slides/slide1.xml") or incorrectly accept + a malformed reserved-name part that must instead be rejected as absent. + tests: + - PptxDocument_ResolvePart_PartNamesDifferingOnlyByCase_ResolveAsDistinctParts + - PptxDocument_Open_WrongCaseContentTypesPartName_ThrowsInvalidDataException + - PptxDocument_Open_DuplicateEntryNames_ThrowsInvalidDataException + + - id: CanvasNetPptx-PptxDocument-ContentTypeOverride + title: >- + The PptxDocument class shall resolve a part's content type using its + [Content_Types].xml Override entry (keyed by exact part path) when one is declared + for that part, in preference to any Default extension-based mapping. + justification: | + OPC's own content-type resolution rules require an Override to take precedence over + a Default mapping for the same part; getting this precedence wrong would + misidentify a part's real content type. + tests: + - PptxDocument_ResolvePart_OverrideContentType_TakesPrecedenceOverDefaultExtension + + - id: CanvasNetPptx-PptxDocument-ContentTypeDefault + title: >- + The PptxDocument class shall resolve a part with no Override entry via + [Content_Types].xml's Default extension-based mapping, and reject a request to + resolve a part the package does not contain, or whose content type cannot be + resolved by either mapping, with InvalidDataException. + justification: | + Most parts in a real .pptx package (nearly every slide, layout, and master) rely + solely on the Default extension mapping rather than an individual Override entry. + tests: + - PptxDocument_ResolvePart_NoOverride_ResolvesDefaultExtensionContentType + - PptxDocument_ResolvePart_UnknownPart_ThrowsInvalidDataException + + - id: CanvasNetPptx-PptxDocument-RelationshipResolution + title: >- + The PptxDocument class shall resolve a named relationship ID from a given source + part's own relationships part (or, for the empty source part path, the package-level + _rels/.rels), reject an unknown relationship ID, and reject a relationship whose + TargetMode is External, all with InvalidDataException. + justification: | + Relationship resolution is the sole navigation mechanism for discovering a package's + part graph; an unknown ID or an unsupported external target must fail closed rather + than silently returning an incorrect or unusable path. + tests: + - PptxDocument_Open_WellFormedMinimalPackage_Succeeds + - PptxDocument_ResolveRelationship_UnknownRelationshipId_ThrowsInvalidDataException + - PptxDocument_ResolveRelationship_ExternalTargetMode_ThrowsInvalidDataException + + - id: CanvasNetPptx-PptxDocument-RelativeTargetTraversal + title: >- + The PptxDocument class shall resolve a relationship's relative Target value relative + to the source part's own directory (not the package root), correctly applying a + leading "../" parent-directory traversal segment, and reject a target that would + traverse past the package root with InvalidDataException. + justification: | + Real-world .pptx packages universally reference sibling parts (for example a slide + referencing its layout) using a relative, parent-traversing Target value; incorrect + resolution would misidentify the target part entirely. + tests: + - PptxDocument_ResolveRelationship_RelativeTargetWithParentTraversal_ResolvesCorrectTarget + - PptxDocument_ResolveRelationship_TargetEscapesPackageRoot_ThrowsInvalidDataException + + - id: CanvasNetPptx-PptxDocument-Dispose + title: >- + The PptxDocument class shall make Dispose idempotent (safe to call more than once), + releasing the underlying ZipArchive and its backing buffer. + justification: | + Standard, predictable IDisposable behavior matches every other disposable type in + CanvasNet, so double-disposing (a common pattern with using declarations and manual + disposal both present) never throws. + tests: + - PptxDocument_Dispose_CalledTwice_DoesNotThrow + + - id: CanvasNetPptx-PptxDocument-PresentationParsing + title: >- + The PptxDocument class shall, at Open() time, locate ppt/presentation.xml via the + package root's /officeDocument relationship, parse its declared slide size into a + public SlideSize property, resolve its ordered slide list into a public SlideCount + property, and expose a GetSlideSizeInPixels(float dpi) conversion method, all guarded + by ObjectDisposedException once the document is disposed. + justification: | + Callers need the presentation's overall slide size and slide count without + navigating the package's relationship graph themselves, and need a pixel-resolution + conversion ahead of any later rendering phase reusing the same arithmetic. + tests: + - PptxDocument_Open_MultiSlidePresentation_SlideCountAndSlideSizeResolveCorrectly + - PptxDocument_GetSlideSizeInPixels_ValidDpi_ComputesExpectedPixelSize + - PptxDocument_GetSlideSizeInPixels_NonPositiveDpi_ThrowsArgumentOutOfRangeException + - CanvasNetPptx_SystemIntegration_PptxOpenPresentation_SlideCountAndSizeResolveEndToEnd + + - id: CanvasNetPptx-PptxDocument-PresentationValidation + title: >- + The PptxDocument class shall reject, with InvalidDataException, a package with no + /officeDocument relationship, a presentation part whose root is not a PresentationML + element, a presentation missing a valid (including a + missing, non-numeric, or non-positive cx/cy attribute, or a cx/cy that would + overflow int once converted to pixels), a presentation whose is missing + or declares zero slides, and a whose resolved relationship Type does not + identify a slide part. + justification: | + A malformed or non-presentation package must fail closed at Open() time rather than + later surfacing as a confusing null-reference or structural failure when a caller + first reads SlideCount/SlideSize, calls GetSlideSizeInPixels, or navigates to a + mistargeted "slide" that is actually a theme or other arbitrary part. + tests: + - PptxDocument_Open_MissingOfficeDocumentRelationship_ThrowsInvalidDataException + - PptxDocument_Open_PresentationMissingSldSz_ThrowsInvalidDataException + - PptxDocument_Open_PresentationEmptySlideList_ThrowsInvalidDataException + - PptxDocument_Open_PresentationNonNumericSldSz_ThrowsInvalidDataException + - PptxDocument_Open_SlideIdRelationshipWrongType_ThrowsInvalidDataException + - PptxDocument_GetSlideSizeInPixels_OversizedDimension_ThrowsInvalidDataException + - CanvasNetPptx_SystemIntegration_PptxOpenValidationEmptySlideList_ThrowsInvalidDataException + + - id: CanvasNetPptx-PptxDocument-SlideSizePixelConversion + title: >- + The PptxDocument class shall convert its EMU-unit SlideSize to a pixel size at a + caller-supplied DPI via GetSlideSizeInPixels(float dpi), rounding each dimension to + the nearest pixel, and reject a non-positive or non-finite dpi with + ArgumentOutOfRangeException. + justification: | + A later rendering phase needs a pixel-dimensioned Canvas.Surface to paint onto; + providing this conversion now, already tested, avoids re-deriving the EMU-to-pixel + arithmetic when that phase arrives. + tests: + - PptxDocument_GetSlideSizeInPixels_ValidDpi_ComputesExpectedPixelSize + - PptxDocument_GetSlideSizeInPixels_NonPositiveDpi_ThrowsArgumentOutOfRangeException + + - id: CanvasNetPptx-PptxDocument-ThemeColorScheme + title: >- + The PptxDocument class shall resolve a theme part's 12-slot color scheme + () into concrete Rgba32 values, resolving an + directly and an via its cached lastClr value, + and reject a color value that is not exactly six hexadecimal digits (for example an + eight-digit AARRGGBB value) with InvalidDataException. + justification: | + A placeholder's effective properties may reference a theme color by scheme slot + rather than an explicit RGB value; resolving the scheme up front lets a later + rendering phase look up a concrete color without re-parsing the theme XML. OOXML's + own color-value attributes are always exactly six hex digits; an eight-digit value + must be rejected rather than silently misread with its first byte as alpha. + tests: + - PptxDocument_GetTheme_SrgbClrColorScheme_ResolvesRgba32Values + - PptxDocument_GetTheme_SysClrColorScheme_ResolvesLastClrValue + - PptxDocument_GetTheme_SrgbClrEightDigitValue_ThrowsInvalidDataException + + - id: CanvasNetPptx-PptxDocument-ThemeFontScheme + title: >- + The PptxDocument class shall resolve a theme part's font scheme () + into major (heading) and minor (body) typeface collections, each naming the Latin, + East Asian, and complex-script typeface. + justification: | + A placeholder's effective text properties may reference a theme's major/minor font + rather than an explicit typeface name; resolving the font scheme up front supports + that lookup without re-parsing the theme XML in a later phase. + tests: + - PptxDocument_GetTheme_FontScheme_ResolvesMajorMinorTypefaces + + - id: CanvasNetPptx-PptxDocument-MasterPlaceholderParsing + title: >- + The PptxDocument class shall structurally parse a slide master's immediate + placeholder shapes (type and idx, defaulting to "obj"/0 when omitted) and resolve + its theme part via the master's own /theme relationship. + justification: | + A slide layout's placeholders may inherit effective properties from the owning + master's placeholders of a matching type, and a master's theme supplies the + scheme-level color/font context for the whole master/layout/slide chain beneath it. + tests: + - PptxDocument_GetMaster_PlaceholderShapes_ParsedWithTypeAndIdx + + - id: CanvasNetPptx-PptxDocument-LayoutPlaceholderParsing + title: >- + The PptxDocument class shall structurally parse a slide layout's immediate + placeholder shapes (type and idx, defaulting to "obj"/0 when omitted) and resolve + its owning master's part path via the layout's own /slideMaster relationship. + justification: | + A slide's placeholders are matched against the owning layout's placeholders by idx; + the layout's own master part path is needed to continue the inheritance chain to + the master level when a layout-level match is found. + tests: + - PptxDocument_GetLayout_PlaceholderShapes_ParsedWithDefaultTypeAndIdx + + - id: CanvasNetPptx-PptxDocument-SlidePlaceholderParsing + title: >- + The PptxDocument class shall expose GetSlide(int slideIndex), range-checked against + SlideCount with ArgumentOutOfRangeException, structurally parsing the slide's + immediate placeholder shapes (excluding non-placeholder shapes) and resolving its + owning layout's part path via the slide's own /slideLayout relationship. + justification: | + A caller (or a later rendering phase) needs to enumerate a specific slide's + placeholder shapes and locate its layout to begin the property-inheritance + resolution chain. + tests: + - PptxDocument_GetSlide_ImmediatePlaceholderShapes_ParsedStructurally + - PptxDocument_GetSlide_IndexOutOfRange_ThrowsArgumentOutOfRangeException + + - id: CanvasNetPptx-PptxDocument-PlaceholderInheritanceIdxMatch + title: >- + The PptxDocument class shall resolve a slide placeholder's matching layout + placeholder by idx alone (type is not consulted at this hop), with a miss (no layout + placeholder shares the slide placeholder's idx) short-circuiting the remainder of + the inheritance chain rather than being retried by type. + justification: | + This is the verified ECMA-376 §19.3.1.36 slide-to-layout placeholder matching rule + (confirmed against the python-pptx reference implementation), distinct from a + naive "type+idx exact match" framing. + tests: + - PptxDocumentInheritance_ResolvePlaceholderProperties_IdxMatchAtLayout_UsesLayoutSpPr + - PptxDocumentInheritance_ResolvePlaceholderProperties_NoIdxMatchAtLayout_FallsThroughToMasterOnly + + - id: CanvasNetPptx-PptxDocument-PlaceholderInheritanceTypeMatch + title: >- + The PptxDocument class shall, only when a layout-level idx match was found, resolve + that matched layout placeholder's matching master placeholder by type alone (idx is + not consulted at this hop), remapping the layout placeholder's type through a fixed + table (body/chart/bitmap/orgChart/mediaClip/obj/pic/subTitle/tbl/clipArt/dgm/media to + "body"; ctrTitle to "title"; every other type maps to itself) before matching. + justification: | + This is the verified ECMA-376 §19.3.1.36 layout-to-master placeholder matching rule; + getting the remap table or the "only runs after a layout match" short-circuit wrong + would resolve an incorrect or spurious master-level property fragment. + tests: + - PptxDocumentInheritance_ResolvePlaceholderProperties_TypeMatchAtMaster_IgnoresMismatchedIdx + - PptxDocumentInheritance_ResolvePlaceholderProperties_RemappedBodyLikeType_MatchesMasterBodyPlaceholder + + - id: CanvasNetPptx-PptxDocument-EffectivePlaceholderTypeForTextStyle + title: >- + The PptxDocument class shall resolve a slide placeholder's effective type for master + text-style-bucket selection (titleStyle/bodyStyle/otherStyle) and default-typeface + selection as its own declared value when explicitly present, + otherwise the idx-matched layout placeholder's type (PlaceholderInheritanceIdxMatch's + own hop-1 match), otherwise its own schema-defaulted type ("obj") as a last resort, + rather than always using the slide placeholder's own raw, schema-defaulted type. + justification: | + A slide that omits type is a valid, common OOXML pattern meaning "inherit the + idx-matched layout placeholder's type" - exactly as PlaceholderXfrmGeometryInheritance + already resolves for position/size/geometry via the same idx-matched layout + placeholder. Using the slide placeholder's own raw, schema-defaulted type ("obj") + directly for text-style-bucket selection instead incorrectly collapsed an + omitted-type title placeholder into the bodyStyle bucket rather than titleStyle, + confirmed via a synthetic repro of a real-world regression (a "Section Header" + layout's title placeholder rendering at its master's bodyStyle font size instead of + titleStyle - roughly 1/3 PowerPoint's own ground-truth size). + tests: + - ResolveEffectiveRunProperties_SectionHeaderTitlePlaceholderWithOmittedType_ResolvesMasterTitleStyleNotBodyStyle + - Render_SectionHeaderTitleWithOmittedType_PaintsIdenticallyToExplicitTitleType + + - id: CanvasNetPptx-PptxDocument-PlaceholderInheritancePerCategory + title: >- + The PptxDocument class shall resolve a slide placeholder's effective and + / property fragments independently (slide, then matched + layout, then matched master, taking the first element present at all), and carry + the resolved theme through unchanged as context rather than selecting it via the + same fallback. For (fill/line resolution), an empty element still counts + as present, stopping the fallback for that category; /geometry + (PlaceholderXfrmGeometryInheritance) and level overrides + (TxBodyListStyleLevelInheritance) are the two named exceptions, each resolved via + its own independent, non-whole-element walk that continues past a tier's own + empty-but-present element. + justification: | + Each property category's inheritance chain is independent in real PowerPoint + behavior (a slide may override its fill without overriding its text style, or vice + versa); merging them into a single fallback decision would resolve incorrect + effective properties for one or both categories. + tests: + - PptxDocumentInheritance_ResolvePlaceholderProperties_PerCategoryIndependentFallthrough_SlideOverridesFillButNotText + - PptxDocumentInheritance_ResolvePlaceholderProperties_NoMatchAtAnyLevel_ReturnsNullForBothCategories + + - id: CanvasNetPptx-PptxDocument-PlaceholderXfrmGeometryInheritance + title: >- + The PptxDocument class shall resolve a placeholder's effective element, and + separately its effective geometry-bearing element (the first , slide + then matched layout then matched master, that itself declares an or + child), each independently of PlaceholderInheritancePerCategory's own + whole--element fallback, continuing the slide -> matched layout -> matched + master walk past a tier's own empty-but-present that declares neither, and + shall skip painting the shape silently, rather than throwing, when no tier anywhere + in the chain resolves one or the other. + justification: | + A placeholder's own is frequently present but empty (self-closing + , declaring no and no /), deliberately + relying on its matched layout/master placeholder for position, size, and geometry - + extremely common in real-world .pptx files not authored by actual PowerPoint + (confirmed against samplelib.com's own general-purpose sample deck). Because + PlaceholderInheritancePerCategory's own effective- fallback only advances + past a null reference (an empty-but-present element still "wins"), it never reaches + the layout/master's own populated for such a placeholder, silently + discarding the whole shape when ShapeFrameTransform then finds no , or + crashing with InvalidDataException once geometry resolution is reached. Unlike + fill/line resolution (FillResolution's own absent-fill-means-no-op behavior), + geometry resolution has no graceful fallback for "no geometry anywhere" - a real + master placeholder always declares one per ECMA-376's standard master content, so + resolving it via this independent, per-element (not per-whole-) walk is both + safe and necessary for conformance with real-world files, mirroring the per-attribute + (not per-whole-element) resolution TextPropertyInheritance already establishes for + run/paragraph properties. + tests: + - Render_PlaceholderShapeWithEmptySpPr_InheritsXfrmAndGeometryFromLayout + - PptxDocument_Render_SamplelibSamplePresentationFixture_EverySlidePaintsContent + + - id: CanvasNetPptx-PptxDocument-TxBodyListStyleLevelInheritance + title: >- + The PptxDocument class shall resolve a placeholder's effective / + element by walking slide -> matched layout -> matched master and selecting the first + tier whose own element itself declares at least one (N = 1..9) + level-override child, independently of PlaceholderInheritancePerCategory's own + whole-element fallback, continuing past a tier's own empty-but-present + that declares no level child at all, rather than letting that empty element "win" and + block the layout/master's real level overrides. + justification: | + A slide-level placeholder frequently declares an empty, self-closing (no + children at all) while its matched layout placeholder declares a real + override (for example a large title defRPr sz) - confirmed against a real-world deck + ("ERF IWF Breadboard Peer Review.pptx", a ctrTitle placeholder) where this + empty-but-present element silently blocked the layout's 60pt title-size override, + causing text to render at a smaller, further-downstream fallback size (~28pt) instead. + This is the exact same bug class PlaceholderXfrmGeometryInheritance already fixed for + /geometry (an empty, non-descriptive element incorrectly "winning" + PlaceholderInheritancePerCategory's own element-presence fallback) applied to the + category - TextPropertyInheritance's GetLevelDefRPr/GetLevelElement helpers + index this element by level, so an empty with no level children causes + those lookups to silently fail for every level exactly as if the element were entirely + absent, making "treat empty as absent" both correct and necessary for this category. + tests: + - PptxDocumentInheritance_ResolvePlaceholderProperties_SlideLstStyleEmptyWithNoLevelOverride_FallsThroughToLayoutLevelOverride + - PptxDocumentInheritance_ResolvePlaceholderProperties_SlideLstStyleHasLevelOverride_SlideWinsOverLayout + - ResolveEffectiveRunProperties_CtrTitlePlaceholderWithEmptySlideLstStyle_ResolvesLayoutDefRPrSizeNotMasterTitleStyleFallback + - Render_CtrTitlePlaceholderWithEmptySlideLstStyle_PaintsIdenticallyToAbsentLstStyle + + - id: CanvasNetPptx-PptxDocument-ShapeFrameTransform + title: >- + The PptxDocument class shall resolve a shape's element (off/ext position and + size, an optional rot rotation in 60,000ths of a degree about the shape's own center, + defaulting to zero when omitted, and optional flipH/flipV mirroring about the shape's + own center) into a single composed transform placing the shape's local coordinate + space into its parent's coordinate space, and reject a missing off/ext child element, + a non-numeric off/ext attribute (x/y/cx/cy), or a non-numeric rot attribute with + InvalidDataException. + justification: | + Every DrawingML shape's geometry is authored in its own local coordinate space; a + correct, single composed transform is the foundation every later geometry and paint + resolution in this phase, and any future rendering phase, builds on. A malformed + (non-numeric) off/ext attribute must fail with the documented InvalidDataException + contract, not an unrelated raw conversion-failure exception. + tests: + - ResolveShapeFrame_PlainOffsetAndSize_MapsLocalOriginToOffset + - ResolveShapeFrame_MissingOff_ThrowsInvalidDataException + - ResolveShapeFrame_MissingExt_ThrowsInvalidDataException + - ResolveShapeFrame_Rotation90Degrees_RotatesAboutOwnCenterClockwise + - ResolveShapeFrame_MissingRot_DefaultsToZeroRotation + - ResolveShapeFrame_FlipHorizontal_MirrorsAboutOwnCenterX + - ResolveShapeFrame_FlipVertical_MirrorsAboutOwnCenterY + - ResolveShapeFrame_InvalidRotAttribute_ThrowsInvalidDataException + - ResolveShapeFrame_NonNumericOffAttribute_ThrowsInvalidDataException + - ResolveShapeFrame_NonNumericExtAttribute_ThrowsInvalidDataException + - CanvasNetPptx_SystemIntegration_GeometryAndPaint_FreeformShapeResolvesEndToEnd + + - id: CanvasNetPptx-PptxDocument-GroupChildTransform + title: >- + The PptxDocument class shall resolve a group shape's () own element + into a child-space-to-group-space transform, scaling and translating a child + coordinate declared in the group's chOff/chExt child coordinate space into the + group's own off/ext box, defaulting to an identity child coordinate space when + chOff/chExt are omitted (but requiring both attributes, failing closed with + InvalidDataException, whenever chOff/chExt IS present but incomplete), so a child + shape's own resolved frame transform composes correctly through its owning group's + transform chain. + justification: | + A group shape's children are authored in a child-specific coordinate space distinct + from the group's own placement in its parent; composing a child's transform without + correctly scaling through the group's chOff/chExt would misplace every shape nested + in a group. A present-but-incomplete chOff/chExt is malformed input, not an "absent + element" the schema-default fallback is meant to cover, and must fail closed rather + than silently substituting a default. + tests: + - ResolveGroupChildTransform_IdentityChildSpace_MatchesGroupFrameDirectly + - ResolveGroupChildTransform_ScaledChildSpace_ScalesChildCoordinatesIntoGroupBox + - ResolveGroupChildTransform_OffsetChildSpace_TranslatesChildOriginBeforeScaling + - ResolveGroupChildTransform_ChildOfChild_ComposesThroughBothTransforms + - ResolveGroupChildTransform_MissingChOffChExt_DefaultsToIdentityChildSpace + - ResolveGroupChildTransform_ChOffPresentWithMissingAttribute_ThrowsInvalidDataException + - ResolveGroupChildTransform_ChExtPresentWithMissingAttribute_ThrowsInvalidDataException + - CanvasNetPptx_SystemIntegration_GeometryAndPaint_GroupShapeChildTransformComposesEndToEnd + + - id: CanvasNetPptx-PptxDocument-PresetGeometry + title: >- + The PptxDocument class shall resolve a shape's element into a + concrete geometric path sized to the shape's own width/height, supporting a fixed set + of approximately 28 common preset geometry names (including rect, roundRect, ellipse, + triangle, and a practical subset of arrows, stars, curved arrows, and other basic + shapes), and shall reject a preset name outside this supported set with + PptxUnsupportedFeatureException (feature token "pptx-preset-geometry") rather than + producing incorrect geometry, and reject a missing geometry element (neither + nor ) or a missing its prst attribute with + InvalidDataException. + justification: | + DrawingML's preset geometry vocabulary is large; implementing every preset is out of + scope for this phase, but silently rendering an unsupported preset as the wrong shape + would be worse than a clear, distinguishable failure a caller can catch and handle. + The four curved-arrow presets (curvedRightArrow/curvedLeftArrow/curvedUpArrow/ + curvedDownArrow) were added in a real-world-corpus hardening pass after + curvedUpArrow was found to crash rendering of a real-world slide; the architecture's + existing Mirror/RotateQuarter derivation pattern (already used for + upArrow/downArrow/leftArrow) made deriving all four cheaply and naturally from one + canonical builder. + tests: + - ResolveShapeGeometry_PrstGeomRect_ReturnsRectanglePath + - ResolveShapeGeometry_NeitherPrstGeomNorCustGeom_ThrowsInvalidDataException + - ResolveShapeGeometry_UnsupportedPreset_ThrowsPptxUnsupportedFeatureException + - ResolveShapeGeometry_PrstGeomMissingPrstAttribute_ThrowsInvalidDataException + - PptxPresetGeometry_Build_EachSupportedPreset_ProducesPathWithinDeclaredBounds + - PptxPresetGeometry_Build_UpArrow_PointsUpward + - PptxPresetGeometry_Build_DownArrow_PointsDownward + - PptxPresetGeometry_Build_UnsupportedPreset_ThrowsWithFeatureToken + - PptxPresetGeometry_Build_NonPositiveSize_ReturnsEmptyPath + - PptxPresetGeometry_Build_CurvedUpArrow_PointsUpwardWithoutThrowing + - PptxPresetGeometry_Build_CurvedArrowSiblings_EachPointsTowardItsOwnDeclaredEdge + - Render_CurvedUpArrowShape_RendersWithoutThrowingAndPaintsInk + + - id: CanvasNetPptx-PptxDocument-CustomGeometry + title: >- + The PptxDocument class shall resolve a shape's element by parsing every + under its , interpreting moveTo/lnTo/cubicBezTo/quadBezTo/close + commands into the equivalent core Path commands, scaling each path's own w/h + coordinate space (defaulting to the shape's own extent when omitted) to the shape's + declared extent, and resolving a with no to an empty path. + justification: | + Many real-world DrawingML shapes (especially those hand-drawn or imported from other + authoring tools) use an explicit custom geometry rather than a named preset; without + this parser, such shapes could not be resolved to any geometry at all. + tests: + - ResolveCustomGeometry_MoveLineClose_ProducesClosedTriangle + - ResolveCustomGeometry_CubicBezTo_ProducesCubicBezierCommand + - ResolveCustomGeometry_QuadBezTo_ProducesQuadraticBezierCommand + - ResolveCustomGeometry_PathCoordinateSpaceScaledToShapeExtent + - ResolveCustomGeometry_NoPathLst_ReturnsEmptyPath + + - id: CanvasNetPptx-PptxDocument-FillResolution + title: >- + The PptxDocument class shall resolve a shape's fill from its into a closed + PptxPaint value: an explicit , no recognized fill child element, or a null + all resolve to PptxNoFill; resolves to a PptxSolidFill via + color resolution; resolves to a PptxGradientFill; resolves + to a PptxImageFill (see CanvasNetPptx-PptxDocument-PictureFillResolution below) when + a blip-image resolver is supplied, or is rejected with + PptxUnsupportedFeatureException (feature token "pptx-picture-fill") when it is not; + and naming a covered preset resolves to a PptxPatternFill + (see CanvasNetPptx-PptxDocument-PatternFillResolution below), an uncovered prst is + rejected with PptxUnsupportedFeatureException (feature token "pptx-pattern-fill"), + and a non-conformant, attribute-less with no prst at all resolves to + PptxNoFill rather than either of those. + justification: | + Pattern fill is now resolved for a documented preset subset (the remainder still + requires tiling/procedural machinery this project does not yet implement for every + named ECMA-376 ST_PresetPatternVal value); rejecting an uncovered preset explicitly, + rather than silently falling back to PptxNoFill, lets a caller distinguish + "genuinely unfilled" from "fill kind not yet supported". + tests: + - ResolveFill_NullParent_ReturnsNoFill + - ResolveFill_ExplicitNoFill_ReturnsNoFill + - ResolveFill_NoRecognizedFillChild_ReturnsNoFill + - ResolveFill_SolidFillSrgbClr_ReturnsResolvedSolidFill + - ResolveFill_SolidFillSchemeClr_ResolvesThroughTheme + - ResolveFill_GradFillLinear_ReturnsResolvedGradientFill + - ResolveFill_PatternFillUnsupportedPreset_ThrowsPptxUnsupportedFeatureException + - ResolveFill_PictureFill_ThrowsPptxUnsupportedFeatureException + - ResolveFill_BarePatternFillWithNoPrstAttribute_ReturnsNoFill + + - id: CanvasNetPptx-PptxDocument-PictureFillResolution + title: >- + The PptxDocument class shall resolve a shape/paragraph fill's (used as a + fill-definition child, distinct from a picture shape's own ) + into a PptxImageFill wrapping the decoded raster image (reusing the existing + blip-decoding/relationship-resolution logic unchanged) and an image-pixel-space-to- + local-EMU-space transform derived from the 's own (consulted + first when present, mapping the image's own native 96-DPI pixel size through the + tile's tx/ty EMU offsets and sx/sy percentage scale factors) or + (optionally cropped by a nested 's l/t/r/b percentage insets before being + stretched to exactly fill the shape, with a degenerate crop - edges summing to 100% + or more on an axis - falling back to an uncropped full stretch on that axis); every + FillPaint-style switch over PptxPaint values (shape fills, table-cell fills, + line/stroke fills, slide background fills, and connector fills) paints a resolved + PptxImageFill via a TilePaint constructed from its Image/ImageToLocalTransform, + composed with the same shape-to-surface transform already applied to + PptxGradientFill. A linked-only (, no r:embed) or Microsoft + SVG-only-fallback blip behaves identically to how the pre-existing picture- + shape decoding already handles those cases (unchanged exception behavior). + justification: | + used as a shape/paragraph fill (for example a rounded-rectangle "photo + frame" autoshape) is a common real-world DrawingML construct - the Python-pptx test + corpus's own pythonpptx-dml-fill.pptx fixture exercises the variant + specifically - and rejecting it outright (the prior CanvasNetPptx-PptxDocument- + FillResolution behavior) left such shapes unrenderable even though the same decoding + machinery already used for picture shapes is directly reusable for it. + Implementing both and uniformly through one PptxImageFill/ + TilePaint pair (rather than two bespoke code paths) keeps the painting side identical + to PptxGradientFill's own established shape-to-surface composition pattern. + tests: + - ResolveFill_BlipFillWithResolver_ReturnsPptxImageFill + - ResolveImageFillTransform_TileWithNonTrivialOffsetAndScale_AppliesBothToEachAxis + - ResolveImageFillTransform_StretchWithFillRectInsets_CropsBeforeStretching + - ResolveImageFillTransform_DegenerateFillRect_FallsBackToFullStretch + - PptxDocument_Render_DmlFillFixture_Slide0RendersPictureAndPatternFillShapesWithoutThrowing + - CanvasNetPptx_SystemIntegration_Images_TileBlipFillShapeDecodesAndRendersEndToEnd + + - id: CanvasNetPptx-PptxDocument-PatternFillResolution + title: >- + The PptxDocument class shall resolve a shape/paragraph fill's + naming a covered ECMA-376 ST_PresetPatternVal preset (30 of the 54 named values - + the horizontal/vertical stripe, diagonal-stripe, cross-hatch, and percentage/dot- + density families, plus the two fixture-mandatory divot/wave presets) into a + PptxPatternFill wrapping the preset name and its resolved fgClr/bgClr colors + (defaulting to black/white respectively when either child is absent), resolved via + the same ResolveColor helper already uses; every FillPaint-style + switch over PptxPaint values that handles a PptxPatternFill synthesizes a small + procedural repeating tile (via PptxPatternTileRenderer, one covered pixel rule per + preset) and paints it as a TilePaint, composed with the shape/connector/line/ + arrowhead's own local-to-surface transform (PptxDocument.Render.cs's own + RenderShape/RenderPicture/RenderConnector fill and stroke-outline call sites, and + PaintArrowhead's own two arrowhead-paint call sites, all route through a shared + FillPaintComposingLocalTransform helper for this purpose, narrowly scoped to + PptxPatternFill alone, to compose that transform correctly - see its own remarks for + the pre-existing, codebase-wide transform-composition gap this narrowly addresses). + A line/stroke fill's own shares the identical + ResolveFill resolver used for shape fills, so it is equally resolvable and equally + covered by this same transform-composition fix. An uncovered preset is rejected with + PptxUnsupportedFeatureException (feature token "pptx-pattern-fill"); a + non-conformant, attribute-less with no prst at all resolves to + PptxNoFill instead (see CanvasNetPptx-PptxDocument-FillResolution above). The + slide/layout/master background fill is now also covered by this same + transform-composition fix: PptxDocument.Render.cs's own Render method composes the + slide's own EMU-to-pixel baseTransform into the background fill's synthesized tile + via the same FillPaintComposingLocalTransform helper. + justification: | + used as a shape's own background fill (for example a diagonal-stripe or + dot-density "texture" autoshape) is a common real-world DrawingML construct - the + Python-pptx test corpus's own pythonpptx-dml-fill.pptx fixture exercises both the + divot and wave presets specifically - and rejecting every preset outright (the prior + CanvasNetPptx-PptxDocument-FillResolution behavior) left such shapes unrenderable + even though a procedural tile-synthesis rule is straightforward for a large, common + preset subset. Implementing all 54 named presets pixel-perfectly to PowerPoint's own + rendering is out of this project's scope; a documented, narrower covered subset (with + the remainder still rejected explicitly, not silently misinterpreted) keeps this + feature's own footprint bounded while unblocking the real fixture. + tests: + - ResolveFill_PatternFillCoveredPreset_ReturnsPptxPatternFill + - ResolveFill_PatternFillMissingFgBgClr_DefaultsToBlackOnWhite + - ResolveFill_PatternFillUnsupportedPreset_ThrowsPptxUnsupportedFeatureException + - ResolveFill_BarePatternFillWithNoPrstAttribute_ReturnsNoFill + - RenderTile_Horz_AlternatesFullRows + - RenderTile_Vert_AlternatesFullColumns + - RenderTile_LtHorz_SparseThinRows + - RenderTile_DkHorz_DominantRows + - RenderTile_LtVert_SparseThinColumns + - RenderTile_DkVert_DominantColumns + - RenderTile_DnDiag_FollowsXPlusYModuloRule + - RenderTile_UpDiag_FollowsXMinusYModuloRule + - RenderTile_WdDnDiag_ProducesWideBands + - RenderTile_Cross_FgAtHorizontalOrVerticalLines + - RenderTile_DiagCross_FgAtEitherDiagonal + - RenderTile_PercentageFamily_BothColorsPresent + - RenderTile_PercentageFamily_FgPixelCountIncreasesWithPercent + - RenderTile_Divot_BothColorsPresent + - RenderTile_Wave_BothColorsPresent + - RenderTile_EveryCoveredPreset_ProducesTileSizeSquareSurface + - PptxDocument_Render_DmlFillFixture_Slide0RendersPictureAndPatternFillShapesWithoutThrowing + - PptxDocument_Render_DmlFillFixture_Slide1PatternFillShapesRenderAndBareFillIsTransparent + - ResolveLineStyle_PatternFillCoveredPreset_ReturnsLineStyleWithPptxPatternFill + - Render_ShapeWithPatternFillStroke_PaintsCorrectlyTransformedStripesNotFlatColor + - Render_ConnectorWithPatternFillStroke_PaintsCorrectlyTransformedStripesNotFlatColor + - Render_SlideLevelPatternFillBackground_PaintsCorrectlyTransformedStripesNotFlatColor + + - id: CanvasNetPptx-PptxDocument-ColorResolution + title: >- + The PptxDocument class shall resolve a DrawingML color-definition element to a + concrete Rgba32 value: directly, via its + lastClr attribute, by looking the named slot up in the + supplied PptxTheme's color scheme (reusing the existing PptxTheme/PptxColorScheme + resolver rather than re-resolving the theme), including the bg1/tx1/bg2/tx2 + background/text aliases, which are first mapped through the effective + / color map (the slide master's own required , + overridden if present by the slide's own or otherwise the slide layout's own + ) to their own mapped slot name before that slot is looked up in the + theme's color scheme - never a fixed bg1->Light1/tx1->Dark1/bg2->Light2/tx2->Dark2 + mapping; and / to their standard RGB + values (the only two ECMA-376 ST_PresetColorVal names this package resolves). An + unrecognized schemeClr slot name throws InvalidDataException, any other, + unsupported color-definition element kind (including every ST_PresetColorVal name + other than white/black) throws PptxUnsupportedFeatureException (feature token + "pptx-color-kind"), and an srgbClr/sysClr hex value whose length (after stripping an + optional leading '#') is not exactly six hex digits - for example an 8-digit # + AARRGGBB-style value - throws InvalidDataException rather than being passed to the + underlying hex parser. + justification: | + Scheme color resolution must build directly on Phase 1b's already-verified + PptxTheme/PptxColorScheme resolver rather than duplicating its 12-slot lookup table, + so both phases stay in sync automatically if that table ever changes. Per ECMA-376, + bg1/tx1/bg2/tx2 are themselves an indirection, not a fixed mapping: a slide master's + own is required and may map them to any of the theme's dk1/lt1/dk2/lt2 + slots, and a slide layout or slide may further override that mapping via its own + - a non-identity map (most commonly a "dark" layout/master variant) + was previously rendered with every bg1/tx1/bg2/tx2 scheme color silently wrong + because the mapping was hard-coded rather than consulted, so this indirection step + must happen before, not instead of, the existing theme-slot lookup. OOXML's + srgbClr/sysClr val attribute is specified as exactly six RGB hex digits; the + underlying Rgba32 hex parser also accepts an 8-digit # AARRGGBB form and would + silently treat the first byte as alpha for a malformed 8-digit value, so the + six-digit form must be enforced before parsing to fail closed rather than silently + produce a wrong, unintended-alpha color. support is deliberately narrowed + to white/black (the only two names this package's own real fixture corpus - a + pattern fill's own bgClr - requires), rather than implementing the full ~140-name + ECMA-376 preset-color table, to keep this companion fix minimally scoped. + tests: + - ResolveColor_SrgbClr_ParsesHexDirectly + - ResolveColor_SysClr_UsesLastClrAttribute + - ResolveColor_SchemeClrOrdinarySlots_ResolveToMatchingThemeSlot + - ResolveColor_SchemeClrBackgroundTextAliases_MapToExpectedSlot + - ResolveColor_SchemeClrUnrecognizedSlot_ThrowsInvalidDataException + - ResolveColor_UnsupportedColorKind_ThrowsPptxUnsupportedFeatureException + - ResolveColor_SrgbClrEightDigitValue_ThrowsInvalidDataException + - ResolveColor_SysClrEightDigitLastClrValue_ThrowsInvalidDataException + - ResolveColor_PrstClrWhite_ReturnsWhite + - ResolveColor_PrstClrBlack_ReturnsBlack + - ResolveColor_PrstClrUnsupportedName_ThrowsPptxUnsupportedFeatureException + - ResolveColor_PrstClrMissingValAttribute_ThrowsInvalidDataException + + - id: CanvasNetPptx-PptxDocument-ColorTransforms + title: >- + The PptxDocument class shall apply a DrawingML color-definition element's + lumMod/lumOff/shade/tint/alpha child transforms to its resolved base color in a + fixed pipeline order - lumMod and lumOff applied together first in HSL luminance + space, then shade and tint in RGB space, then alpha replacing the alpha channel + outright - interpreting each val attribute as OOXML's thousandths-of-a-percent unit + (100000 = 100%). + justification: | + Real-world themes and shapes very commonly apply one or more of these transforms + (for example a "lighter" or "darker" accent-color shade) rather than referencing a + raw color directly; without this pipeline, such shapes would resolve to the wrong, + untransformed color. + tests: + - ResolveColor_Alpha_SetsAlphaChannel + - ResolveColor_Shade_DarkensProportionally + - ResolveColor_Tint_LightensProportionally + - ResolveColor_LumMod_DecreasesLuminance + - ResolveColor_LumOff_IncreasesLuminance + - ResolveColor_CombinedLumModLumOffShadeTintAlpha_AppliesFixedPipelineOrder + + - id: CanvasNetPptx-PptxDocument-GradientFill + title: >- + The PptxDocument class shall resolve a element's gradient + stops and its linear-direction angle (in 60,000ths of a degree) into a core + LinearGradient spanning the shape's own width/height, and shall reject a + with no child (for example a path/radial gradient) with + PptxUnsupportedFeatureException (feature token "pptx-gradient-path"), a + with no with InvalidDataException, and a element with a non-numeric + pos attribute with InvalidDataException. + justification: | + Linear gradients are the most common DrawingML gradient fill in practice; radial and + path gradients require a different, not-yet-implemented core gradient paint type, so + rejecting them explicitly avoids silently rendering them as an incorrect linear + approximation. A malformed (non-numeric) gradient-stop position must fail with the + documented InvalidDataException contract, not an unrelated raw conversion-failure + exception. + tests: + - ResolveGradientFill_LinearZeroDegrees_PointsAlongPositiveX + - ResolveGradientFill_MissingLin_ThrowsPptxUnsupportedFeatureException + - ResolveGradientFill_MissingGsLst_ThrowsInvalidDataException + - ResolveGradientFill_NonNumericGsPos_ThrowsInvalidDataException + + - id: CanvasNetPptx-PptxDocument-LineStyleResolution + title: >- + The PptxDocument class shall resolve a shape's stroke element into a + PptxLineStyle (width in EMU, resolved fill, and an optional dash array derived from + ), resolving to no stroke (null) when is null, its w + attribute is explicitly present and non-positive (a genuinely-absent w instead + defaults to the OOXML conventional default stroke width, 9525 EMU / 0.75pt), or its + resolved fill is PptxNoFill, and shall realize a resolved PptxLineStyle against a + shape's path into a stroked outline path via the core PathStroker, mirroring the PDF + renderer's own StrokeStyle/PathStroker call pattern, using a flatten tolerance scaled + to the shape's own local-to-surface transform so curved geometry at native, + large-magnitude PPTX EMU coordinates is neither over-tessellated nor falsely + collapsed to a solid fill. + justification: | + A shape's visible outline is produced by stroking its geometric path with the + resolved line style, not merely resolving the line style's data in isolation; reusing + the already-tested core PathStroker exactly as the PDF renderer already does avoids + a second, divergent stroking implementation for this package. A genuinely-absent w + attribute must not collapse to the same "no stroke" outcome as an explicit w="0": + real-world documents commonly declare an with only a color/fill, relying on + PowerPoint's own default stroke weight for an un-set w, and previously rendered with + no visible outline at all - a visual-fidelity bug fixed by defaulting a genuinely- + absent w to PowerPoint's own observed default stroke width instead. Separately, a + curved preset geometry (ellipse, roundRect) stroked at a full-size slide's own native + EMU coordinate magnitude, using the core stroker's pixel-tuned default flatten + tolerance, over-tessellated by roughly 200x and falsely triggered the shared + StrokeOutliner's inner-ring collapse detector (see the Drawing subsystem's own + PathStroker requirements), rendering a noFill shape's thin outline as a solid-filled + interior instead of a ring - a confirmed real-world regression fixed by deriving the + flatten tolerance from the shape's own rendering scale instead. + tests: + - ResolveLineStyle_NullElement_ReturnsNull + - ResolveLineStyle_ZeroWidth_ReturnsNull + - ResolveLineStyle_NoFillLine_ReturnsNull + - ResolveLineStyle_WidthAndSolidFill_ResolvesWidthAndColor + - ResolveLineStyle_DashPresets_ProduceNonNullDashArray + - ResolveLineStyle_SolidPreset_ProducesNullDashArray + - ResolveLineStyle_ExplicitZeroWidth_ReturnsNull + - ResolveLineStyle_ExplicitNegativeWidth_ReturnsNull + - ResolveLineStyle_MissingWidthAttribute_ResolvesDefaultWidth + - Render_EllipseWithNoFillAndLnMissingWidthButRecognizedColor_PaintsDefaultWidthStroke + - ResolveStrokeOutline_RectangleWithSolidLine_ProducesNonEmptyOutline + - Render_FullSlideScaleEllipseWithNoFillAndLnMissingWidth_RendersThinRingNotSolidDisc + - Render_FullSlideScaleRoundRectWithNoFillAndLnMissingWidth_RendersThinOutlineNotSolidFill + - CanvasNetPptx_SystemIntegration_GeometryAndPaint_FreeformShapeResolvesEndToEnd + + - id: CanvasNetPptx-PptxDocument-TextBodyParsing + title: >- + The PptxDocument class shall parse a shape's element into an ordered list + of paragraphs, each with its own raw, unresolved paragraph properties and ordered + runs, and shall parse its element into resolved vertical anchor (t/ctr/b, + defaulting to t), word-wrap mode (square/none, defaulting to square), four insets + (lIns/tIns/rIns/bIns, defaulting to 91440/45720/91440/45720 EMU when absent), and the + raw autofit child element (noAutofit/normAutofit/spAutoFit), retained unparsed for + the layout engine to interpret; an empty with no recognized child is a valid, + empty paragraph (a blank line), not an error. + justification: | + Text layout and rendering cannot begin without first structurally parsing the + DrawingML text-body element tree into a form later phases of this feature (property + inheritance, word-wrap, rendering) can consume without re-parsing raw XML. + tests: + - ParseBodyProperties_Absent_ResolvesSchemaDefaults + - ParseBodyProperties_AnchorCtr_ResolvesMiddle + - ParseBodyProperties_AnchorB_ResolvesBottom + - ParseBodyProperties_AnchorT_ResolvesTop + - ParseBodyProperties_WrapNone_ResolvesWrapNone + - ParseBodyProperties_CustomInsets_ResolvesDeclaredValues + - ParseBodyProperties_AutofitElement_IsRetainedUnparsed + - ParseTextBody_NoParagraphs_ResolvesEmptyParagraphList + - ParseTextBody_SingleParagraphSingleRun_ParsesBodyAndParagraph + - ParseParagraph_NoChildren_ResolvesEmptyRunList + + - id: CanvasNetPptx-PptxDocument-ParagraphRunParsing + title: >- + The PptxDocument class shall parse a paragraph's element (algn, marL, indent, + lnSpc, spcBef, spcAft, and defRPr, each retained raw/unresolved) and its lvl attribute + (clamped to the valid [0,8] level range per the OOXML schema's ten-level style + vocabulary), defaulting every field/level to its schema default when or the + individual attribute is absent, shall parse a run's element (retained + raw/unresolved) and its text, defaulting text to the empty string when is + absent, and shall parse a paragraph's run children and explicit + line-break children into a single ordered item list preserving their original + document order (rather than selecting only children and discarding every + ), so a later layout pass can turn each preserved into an explicit line + boundary. + justification: | + Resolving effective run/paragraph properties (CanvasNetPptx-PptxDocument- + TextPropertyInheritance) requires each paragraph's/run's own raw property fragment to + already be extracted and level-clamped, independent of the inheritance chain itself. + is DrawingML's explicit line-break element (distinct from word-wrap-induced + line breaks); discarding it during parsing would make it structurally impossible for + any later layout stage to honor an author-intended forced line break, so document + order between runs and breaks must be preserved from the parse stage onward. + tests: + - ParseParagraphProperties_Absent_ResolvesLevelZeroAndNullOptionals + - ParseParagraphProperties_LevelAttribute_ClampsToZeroToEight + - ParseParagraphProperties_FullyPopulated_ResolvesEveryField + - ParseRun_NoText_ResolvesEmptyString + - ParseRun_WithTextAndRPr_ResolvesBoth + - ParseParagraph_RunBreakRun_PreservesBreakInDocumentOrder + + - id: CanvasNetPptx-PptxDocument-TextPropertyInheritance + title: >- + The PptxDocument class shall resolve a run's effective character properties + (typeface, size, bold, italic, underline, color) and a paragraph's effective + properties (alignment, left margin, indent, line spacing) attribute-by-attribute + through the chain run/paragraph defRPr -> placeholder level-indexed lstStyle -> + master level-indexed txStyles bucket -> theme/hard-coded default, each attribute + independently taking its own first non-null value rather than falling back at the + level of the whole run/paragraph, shall normalize algn="just"/"justLow" to "l" (full + text justification is explicitly out of scope this phase), and shall resolve an + value naming one of the six reserved DrawingML theme-font + tokens (+mj-lt/+mn-lt, +mj-ea/+mn-ea, +mj-cs/+mn-cs) through the supplied PptxTheme's + FontScheme (major/minor Latin/EastAsian/ComplexScript font) at whichever inheritance + tier the token is declared, rather than returning the literal token string as the + resolved family name. + justification: | + Real-world DrawingML text commonly overrides a single attribute (for example making + one word bold) without overriding every other attribute at the same level; an + element-level (not attribute-level) fallback would incorrectly lose every other + attribute's own, more specific resolution for that run/paragraph. DrawingML permits + an to name a theme-font token instead of a literal family + name so that body text tracks the theme's own major/minor font choice; returning the + token string unresolved would make font lookup miss the theme's actual font and fall + back to a bundled default font even when the theme itself defines the intended one. + tests: + - ResolveEffectiveRunProperties_RunOverride_WinsOverEveryOtherTier + - ResolveEffectiveRunProperties_ParagraphDefRPrOnly_WinsWhenRunDeclaresNothing + - ResolveEffectiveRunProperties_PlaceholderLevelStyleOnly_WinsWhenRunAndParagraphDeclareNothing + - ResolveEffectiveRunProperties_NoneDeclared_ResolvesHardCodedDefaults + - ResolveEffectiveRunProperties_UnderlineAttribute_ResolvesExpectedStyle + - ResolveEffectiveRunProperties_RunSolidFillSrgbClr_ResolvesExplicitColor + - ResolveEffectiveParagraphProperties_NoneDeclared_ResolvesHardCodedDefaults + - ResolveEffectiveParagraphProperties_Alignment_NormalizesJustificationToLeft + - ResolveEffectiveParagraphProperties_FixedLineSpacing_ResolvesPointsToEmu + - ResolveEffectiveParagraphProperties_PlaceholderLevelStyleOnly_WinsWhenParagraphDeclaresNothing + - ResolveEffectiveRunProperties_ThemeFontToken_ResolvesThroughFontScheme + - ResolveEffectiveRunProperties_LiteralTypeface_PassesThroughUnchanged + + - id: CanvasNetPptx-PptxDocument-TextStylesInheritance + title: >- + The PptxDocument class shall parse a slide master's own element (a + direct, sibling-of- child of , not nested inside it) into its + schema-optional titleStyle/bodyStyle/otherStyle buckets, and shall select, for a + given shape's placeholder type, titleStyle for title/ctrTitle placeholder types, + otherStyle for a non-placeholder shape, and bodyStyle otherwise, consulting the + selected bucket's level-indexed entry in the run/paragraph property-inheritance + chain (CanvasNetPptx-PptxDocument-TextPropertyInheritance) after the placeholder's + own lstStyle and before the theme/hard-coded default. + justification: | + A slide master's own default text styling (distinct from any one placeholder's own + lstStyle override) is the next fallback tier ECMA-376 defines before the theme/ + hard-coded default; without parsing and consulting it, text with no placeholder-level + style override would skip straight to an incorrect default. + tests: + - GetMaster_TxStylesSiblingOfCSld_ParsesAllThreeStyles + - ResolveEffectiveRunProperties_MasterTxStylesOnly_WinsWhenEverythingAboveIsAbsent + - ResolveEffectiveRunProperties_MasterTitleStyle_SelectedForTitlePlaceholderType + - ResolveEffectiveRunProperties_MasterOtherStyle_SelectedForNonPlaceholderShape + - ResolveEffectiveParagraphProperties_MasterTxStylesOnly_WinsWhenEverythingAboveIsAbsent + - CanvasNetPptx_SystemIntegration_TextLayoutAndRender_TitlePlaceholderResolvesMasterTitleStyleEndToEnd + - CanvasNetPptx_SystemIntegration_TextLayoutAndRender_NonPlaceholderShapeResolvesMasterOtherStyleEndToEnd + + - id: CanvasNetPptx-PptxDocument-TextLayoutWordWrap + title: >- + The PptxDocument class shall word-wrap a text body's resolved runs into lines bounded + by the owning shape's own width (less its horizontal insets), tokenizing each run's + text into whitespace/non-whitespace tokens (preserving multi-space runs as their own + token, not collapsing them) and greedily packing tokens onto each line - treating + only whitespace (and explicit line-break items) as legal wrap points, so consecutive + non-whitespace tokens spanning two or more adjacent runs (a single word split across + a formatting-run boundary, with no whitespace between them) are packed as one + wrap-atomic unit that is never itself broken mid-word - placing a single token or + word-spanning-runs unit that alone exceeds the available width alone on its own + (overflowing) line rather than looping indefinitely, shall resolve horizontal + alignment (left/center/right, using each line's own margin/indent) against the + available width, shall force a new line at each preserved explicit line-break + item (in addition to word-wrap-induced line breaks), and shall select the + tallest-run candidate for each line's natural height/baseline by comparing each + run's font metrics (ascender, descender, line gap) scaled by that run's own font. + UnitsPerEm and its own resolved SizeEmu, rather than by comparing raw, unscaled + font-design-unit metrics across runs that may use different fonts and/or sizes. + justification: | + Word-wrap is the foundational text-layout behavior every other layout concern + (vertical anchor, autofit) builds on; an incorrect or infinite-looping wrap algorithm + would make every other text-layout feature unusable. Wrapping at a non-whitespace + run boundary would be a visible layout defect indistinguishable from a tokenizer bug: + since each run is tokenized independently of its neighbors, a single word spelled + across two formatting runs (for example a bold "Hel" run immediately followed by a + plain "lo" run spelling "Hello", with no whitespace between them - a routine + consequence of a PowerPoint author changing formatting mid-word) would otherwise be + eligible to wrap at that internal run boundary even though no whitespace exists + there, producing an incorrect line break a real document could trigger at any + width. is DrawingML's explicit line-break element and must force a line + boundary distinct from word-wrap's own token-packing decisions, or an + author-intended forced break would be silently dropped and its following text would + incorrectly run on into the same line. Raw font-design-unit metrics are not directly + comparable across runs using different fonts or point sizes (for example a font with + an unusually tall em-box can report larger raw ascender/descender/line-gap values + despite rendering at a visually smaller size); comparing unscaled values can + therefore select the wrong run as "tallest" and produce an incorrect line + height/baseline for that line. + tests: + - ResolveTextLayout_WordWrap_NarrowWidth_WrapsAtTokenBoundary + - ResolveTextLayout_WordWrap_SingleTokenWiderThanAvailableWidth_PlacedAloneOnOwnLine + - ResolveTextLayout_WordWrap_WordSplitAcrossRuns_DoesNotWrapAtRunBoundary + - ResolveTextLayout_AlignCenter_CentersLineWithinAvailableWidth + - ResolveTextLayout_AlignRight_RightAlignsLineAgainstAvailableWidth + - ResolveTextLayout_RunBreakRun_ForcesSecondLine + - ResolveTextLayout_TallestFontComparison_ScalesByUnitsPerEmAndSizeEmu_NotRawFontUnits + + - id: CanvasNetPptx-PptxDocument-TextAlignmentWidthUnderWrapNone + title: >- + The PptxDocument class shall position horizontally-aligned (center/right) lines of a + wrap="none" text body against the same finite, real width - the owning shape's own + declared width, less its horizontal insets - that a wrap="square" text body's own + alignment uses, independently of the effectively-infinite width used only to + suppress word-wrapping for a wrap="none" body's own line-breaking decision. + justification: | + wrap="none" is implemented by widening the width passed to the word-wrap + line-breaking step to an effectively-infinite value, suppressing any wrap decision. + Reusing that same infinite width for PositionLines's own center/right alignment + math (rather than the shape's real, finite declared width) positioned every glyph of + a centered or right-aligned wrap="none" paragraph at an astronomical X offset, far + outside the shape and the rendered surface entirely - a silent, incorrect-output + failure on text alignment (TextLayoutWordWrap's own already-implemented feature), + discovered rendering pythonpptx-txt-font-props.pptx's own real-world centered, + wrap="none" underline-demo slide, which painted zero pixels before this fix. + tests: + - ResolveTextLayout_AlignCenter_WrapNone_StillCentersAgainstShapesDeclaredWidth + - PptxDocument_Render_TxtFontPropsFixture_RendersEverySlideWithVisibleContent + + - id: CanvasNetPptx-PptxDocument-TextVerticalAnchor + title: >- + The PptxDocument class shall position a text body's word-wrapped lines vertically + per its resolved anchor (t/ctr/b) and total resolved block height within the owning + shape's own height (less its vertical insets), with no clipping applied when the + block overflows the available height (text positioned exactly as computed, even past + the shape's own box). + justification: | + Vertical anchoring is a required, independently observable text-layout behavior + (PowerPoint commonly anchors title/body placeholders differently); an incorrect + anchor calculation would visibly misplace every anchored text body. + tests: + - ResolveTextLayout_AnchorTop_PositionsFirstBaselineAtAscentFromTop + - ResolveTextLayout_AnchorMiddle_CentersBlockVerticallyWithinAvailableHeight + - ResolveTextLayout_AnchorBottom_PositionsBlockAtBottomOfAvailableHeight + + - id: CanvasNetPptx-PptxDocument-TextAutofit + title: >- + The PptxDocument class shall apply a text body's resolved autofit policy in three + tiers: no scaling at all for noAutofit, an absent autofit element, or spAutoFit + (shape-resize autofit, deliberately not implemented this phase); a normAutofit with + one or both of its fontScale/lnSpcReduction attributes present, applying each present + attribute's own stored value verbatim and independently, using that attribute's own + spec-defined neutral default (fontScale 100%, lnSpcReduction 0%/no reduction) for + whichever of the two attributes is absent, rather than requiring both attributes to + be present before applying either; and an attribute-less normAutofit (neither + attribute present) running a bounded, deterministic 10%-step shrink loop (at most 9 + iterations, floored at 10% scale) that stops at the first scale whose naturally + laid-out height fits the available height. + justification: | + PowerPoint's own "shrink text on overflow" autofit behavior is commonly authored + without explicit fontScale/lnSpcReduction values (PowerPoint itself computes and + persists them lazily); a bounded, deterministic shrink loop reproduces the same + user-visible effect without depending on PowerPoint's own unpublished exact algorithm. + The two normAutofit attributes are independently optional per the OOXML schema - + PowerPoint may persist only one of the two (for example only a computed fontScale); + requiring both before applying either would discard a validly persisted single + attribute entirely and fall through to the attribute-less shrink loop instead of + honoring the author's/PowerPoint's own already-computed factor. A subsequent + real-world-corpus investigation of a reported subtitle "invisible sliver" symptom + confirmed the shrink loop does not over-shrink content that genuinely fits a + realistically-sized box (see the companion design document's "Genuine bug 3" entry + for the full investigation and its inconclusive result for that specific symptom). + tests: + - ResolveTextLayout_NoAutofit_AppliesNoScalingEvenWhenOverflowing + - ResolveTextLayout_SpAutoFit_AppliesNoScaling + - ResolveTextLayout_NormAutofitWithExplicitAttributes_AppliesStoredFactorsVerbatim + - ResolveTextLayout_NormAutofitAttributeLess_ShrinkLoopConvergesOnFirstFittingScale + - ResolveTextLayout_NormAutofitFontScaleOnly_AppliesStoredFontScaleVerbatim + - ResolveTextLayout_NormAutofitLnSpcReductionOnly_AppliesStoredReductionWithNeutralFontScale + - ResolveTextLayout_SubtitlePlaceholderWithAttributeLessNormAutofit_DoesNotOverShrinkContentThatFits + + - id: CanvasNetPptx-PptxDocument-TextRendering + title: >- + The PptxDocument class shall paint a resolved text layout's glyphs onto a core + Surface by composing each glyph's own unit-to-EMU scale (with a negated Y component, + since TrueType glyph outlines use a y-up font coordinate system while CanvasNet's + shape-local/surface space is y-down), its resolved baseline origin, and the owning + shape's own frame transform into a single glyph-to-surface matrix, re-issuing the + glyph's outline through that matrix and filling it with the glyph's resolved color + via the core PathFiller (mirroring the PDF renderer's own glyph-painting pattern), + skipping a glyph whose outline has no subpaths (for example a whitespace glyph) + entirely rather than painting an empty fill, and shall resolve a DrawingML typeface + name hint to a concrete TrueTypeFont via the core SystemFontCatalog (always passing + serif/fixed-pitch false, since DrawingML never signals either classification), + falling back to a bundled font when no installed font matches. Independently of a + run's own primary font resolution, each non-whitespace character shall additionally + be resolved for per-character glyph coverage: a character missing from its run's + primary font (GetGlyphIndex returning .notdef) falls back to a single bundled + fallback font (keyed by bold/italic only), with that character alone painting from + whichever font actually resolved a non-.notdef glyph for it, independent of any + neighboring character's own resolution. + justification: | + Painting resolved glyph ink onto a Surface is this phase's terminal, user-visible + output; reusing the already-verified core PathFiller/SystemFontCatalog building + blocks (exactly as the PDF renderer already does) avoids a second, divergent + glyph-painting/font-resolution implementation for this package. A positive (rather + than negated) Y scale on the glyph-outline transform would paint every glyph upside + down and below the baseline instead of right-side-up and above it, since TrueType + outline coordinates are y-up but this package's shape-local/surface space is y-down + (see core Path.Rectangle's own doc comments for the authoritative convention); a + symmetric glyph outline cannot expose this regression class, since flipping it about + its own center leaves it visually/structurally identical, so an asymmetric synthetic + glyph outline is required to detect it. Before per-character glyph-coverage fallback, + a run was permanently bound to its single resolved primary font for every character - + a reported "tofu box" (missing-glyph) rendering defect for a character immediately + following a character requiring a different resolved typeface (for example two + distinct DrawingML runs produced by PowerPoint's own "Insert Symbol"/AutoCorrect + tagging one inserted character's run with an explicit typeface override) demonstrated + this real feature gap, not any form of cross-character state corruption (the cmap + glyph lookup is a pure, stateless closure over immutable arrays and a run's resolved + font never changes mid-run); resolving each character's coverage independently against + a single bundled fallback font closes this gap without introducing a full font- + linking/script-itemization pipeline (see the design doc's documented limitation). + tests: + - PaintTextLayout_IdentityTransform_PaintsGlyphAtExpectedLocationWithResolvedColor + - PaintTextLayout_TranslatedShapeTransform_ShiftsPaintedLocation + - PaintTextLayout_EmptyOutlineGlyph_PaintsNothing + - PaintTextLayout_AsymmetricGlyph_PaintsInkAboveBaselineNotBelow + - ResolveTextFont_AnyFamilyHint_ResolvesNonNullFont + - ResolveTextLayout_PlusMinusFollowedByDegree_BothNeitherInPrimary_BothIndependentlyResolveFallback + - ResolveTextLayout_PlusMinusAlone_ResolvesFallbackGlyph + - ResolveTextLayout_PlusMinusFollowedByDegree_PrimaryCoversOnlyDegree_DegreeStaysOnPrimaryFont + - CanvasNetPptx_SystemIntegration_TextLayoutAndRender_TitlePlaceholderResolvesMasterTitleStyleEndToEnd + - CanvasNetPptx_SystemIntegration_TextLayoutAndRender_NonPlaceholderShapeResolvesMasterOtherStyleEndToEnd + + - id: CanvasNetPptx-PptxDocument-PictureDecodeAndCrop + title: >- + The PptxDocument class shall resolve a shape's / into a + decoded core Surface by dispatching on the embedded relationship's resolved part + content type (mirroring the SVG codec's own raster-decoder dispatch), rejecting a + with only an r:link with PptxUnsupportedFeatureException (feature token + "pptx-image-link"), rejecting a with neither an r:embed nor an r:link + attribute but carrying a recognized Microsoft SVG extension fallback + (/) with + PptxUnsupportedFeatureException (feature token "pptx-image-svg-only"), rejecting a + with neither an embed/link attribute nor any recognized extension fallback + with InvalidDataException, rejecting an unsupported content type with + PptxUnsupportedFeatureException (feature token "pptx-image-format"), and shall + resolve a 's optional into a normalized, scaled-fraction + PptxSrcRect crop rectangle (each edge defaulting to zero/full-extent when its own + attribute is absent). + justification: | + A dedicated picture shape is the only in-scope picture-rendering construct + this phase (an ordinary shape's own / background fill remains + explicitly deferred - see this unit's own remarks); decoding its referenced media + part eagerly, by content type, mirrors the already-verified SVG codec dispatch + pattern rather than inventing a second, divergent raster-decode entry point. A linked + (external-file) image has no embedded bytes this package can read without performing + file-system I/O outside the supplied package stream, so it is deliberately + unsupported rather than silently treated as a missing/blank image. A real PowerPoint + "Insert Icon"-style picture may declare only a Microsoft SVG extension fallback (no + raster r:embed/r:link at all) - this is a well-formed, valid OOXML construct this + package does not yet decode, and a regression fix corrected this case from being + misclassified as InvalidDataException (malformed data) to the dedicated + "pptx-image-svg-only" PptxUnsupportedFeatureException token; a with neither + an embed/link attribute nor any recognized extension still throws + InvalidDataException unchanged (genuinely malformed - ECMA-376 requires at least one + of these). 's scaled-fraction percentages (in thousandths of a percent) + must be parsed exactly as OOXML defines them, or a crop that is supposed to trim a + small edge sliver would instead crop away most or all of the image. + tests: + - ResolveSrcRect_NoSrcRectElement_ReturnsNull + - ResolveSrcRect_AllEdgesPresent_ParsesScaledFractions + - ResolveSrcRect_SomeEdgesAbsent_DefaultsToZero + - ResolveSrcRect_NonNumericEdgeAttribute_ThrowsInvalidDataException + - ResolveSrcRect_NullBlipFillElement_ThrowsArgumentNullException + - ResolvePictureSurface_EmbeddedPng_DecodesSurfaceWithExpectedPixel + - ResolvePictureSurface_NoBlipElement_ThrowsInvalidDataException + - ResolvePictureSurface_BlipMissingEmbedAndLink_ThrowsInvalidDataException + - ResolvePictureSurface_LinkedBlipOnly_ThrowsPptxUnsupportedFeatureExceptionWithImageLinkToken + - ResolvePictureSurface_SvgOnlyBlipExtension_ThrowsPptxUnsupportedFeatureExceptionWithSvgOnlyToken + - ResolvePictureSurface_UnsupportedContentType_ThrowsPptxUnsupportedFeatureExceptionWithImageFormatToken + - ResolvePictureSurface_NullArguments_ThrowsArgumentNullException + + - id: CanvasNetPptx-PptxDocument-PicturePainting + title: >- + The PptxDocument class shall paint a decoded picture Surface onto a destination + Surface by composing the picture's own crop-rectangle-to-unit-square mapping with + the shape's resolved frame transform into a single image-to-surface matrix, sampling + the source image with nearest-neighbor filtering for each destination pixel whose + inverse-mapped source coordinate falls within the image bounds, alpha-blending each + sampled source pixel over the existing destination pixel via standard Porter-Duff + "over" compositing (rather than overwriting it outright), and painting no pixels at + all when the composed transform is singular (non-invertible), without applying any + Y-axis flip to the sampled image (unlike a y-up rendering target such as PDF, this + package's surface space is already y-down, matching the image's own top-down pixel + row order). + justification: | + Nearest-neighbor sampling composed through a single matrix keeps picture painting + consistent with this package's existing shape-transform/geometry conventions (see + Phase 1c's ResolveShapeFrame) without introducing a second coordinate-mapping scheme + just for images. A singular transform (for example a zero-area shape frame) has no + well-defined inverse to sample through, so painting nothing is the only fail-safe + behavior; explicitly omitting the (1-v) row flip that an equivalent y-up target would + require is a deliberate, documented divergence from the PDF renderer's own pattern, + not an oversight - applying it here would paint every picture upside down. + Alpha-blending (rather than raw-overwriting) a sampled source pixel over the existing + destination pixel is required because a source pixel with a non-opaque (including + fully transparent) alpha channel must let existing destination content show through + correctly; a confirmed real-world logo image with an unassociated-alpha white matte + previously painted a solid white rectangle instead of a transparent background + before this was fixed. + tests: + - PaintPicture_IdentityTransformNoCrop_PaintsImagePixelsUnflipped + - PaintPicture_TopBottomAsymmetricImage_PaintsWithoutVerticalFlip + - PaintPicture_WithSrcRectCroppingLeftHalf_SamplesOnlyRightHalf + - PaintPicture_TranslatedTransform_ShiftsPaintedFootprint + - PaintPicture_DegenerateTransform_PaintsNothing + - PaintPicture_NullSurfaceOrImage_ThrowsArgumentNullException + - PaintPicture_SourceImageWithAlphaChannel_AlphaBlendsOntoExistingBackground + - PaintPicture_EllipseClipPath_CornerUnpaintedCenterPainted + - PaintPicture_NullClipPath_PaintsFullRectangleUnclipped + + - id: CanvasNetPptx-PptxDocument-PictureGeometryClipping + title: >- + The PptxDocument class shall clip a picture's painted image to the path + resolved from its own / (or ) geometry, reusing the + same preset/custom-geometry resolution already used for auto-shapes, while + preserving the original unclipped full-bounding-box-rectangle behavior when + declares no geometry at all or explicitly declares the "rect" preset. + justification: | + A confirmed real-world corpus fixture ("ERF IWF and Reagent Probe Breadboard Motion + System Overview.pptx") contains picture shapes whose own + declares a non-rect preset (for example "ellipse"); PowerPoint's own rendering + visibly crops the embedded image to that shape, while this package previously + painted every picture as a plain, unclipped full rectangle regardless of its own + declared geometry, producing a visibly incorrect render against real-world decks. + Resolving the clip path via the same PptxPresetGeometry.Build/ResolveCustomGeometry + dispatch ResolveShapeGeometry already uses for an auto-shape - rather than a second, + divergent geometry resolver - keeps this fix consistent with the package's existing + geometry-resolution contract, including its fail-closed behavior for an unsupported + preset or a malformed missing its own "prst" attribute. Returning no + clip (null) for an absent geometry element or an explicit "rect" preset preserves + every pre-existing, already-shipped render unchanged - clipping to a "rect" + preset would be a pure no-op, and a geometry-less is schema-valid, not + malformed, unlike an auto-shape with no geometry at all. + tests: + - ResolvePictureClipPath_RectPreset_ReturnsNull + - ResolvePictureClipPath_NoGeometryElement_ReturnsNull + - ResolvePictureClipPath_EllipsePreset_ReturnsNonNullPath + - ResolvePictureClipPath_RoundRectPreset_ReturnsNonNullPath + - ResolvePictureClipPath_CustGeom_ReturnsNonNullPath + - ResolvePictureClipPath_PrstGeomMissingPrstAttribute_ThrowsInvalidDataException + - ResolvePictureClipPath_UnsupportedPreset_ThrowsPptxUnsupportedFeatureException + - ResolvePictureClipPath_NullSpPrElement_ThrowsArgumentNullException + - Render_PictureEllipseGeometry_ClipsImageToEllipticalRegion + - Render_PictureRectGeometry_PaintsFullBoundingBoxUnclipped + - Render_PictureNoPrstGeom_PaintsFullBoundingBoxUnclipped + - Render_PictureRoundRectGeometry_ClipsImageCorners + - Render_PictureEllipseGeometry_NearSquareRealWorldAspect_ClipsAllFourBoundingBoxCorners + + - id: CanvasNetPptx-PptxDocument-PictureStrokeOutline + title: >- + The PptxDocument class shall stroke a picture's own / as a + complete, correctly-closed outline around its resolved preset/custom geometry (or + the implicit/explicit full bounding-box rectangle when neither is declared), reusing + the same line-style resolution and stroke-outline primitives already used for an + auto-shape's own . + justification: | + A confirmed real-world bug report's own fixture declared a red on an + ellipse-cropped photo; PowerPoint's own rendering shows a complete red outline + framing the cropped photo, while this package's RenderPicture never read or painted + ANY 's own at all - 0% of the ellipse's perimeter rendered any stroke + color, not merely the partial arc the report described. A prior investigation pass + incorrectly concluded the report's entire underlying complaint was "a visual + illusion" caused by the embedded photo's own background color, and made no source + change; a from-scratch, independently re-verified investigation (direct code + inspection plus a freshly rendered repro) proved the missing stroke is a real, + 100%-of-perimeter defect, unrelated to the image-content clip concern (which remains + correctly unaffected - see CanvasNetPptx-PptxDocument-PictureGeometryClipping). + Resolving the stroke geometry via a new ResolvePictureGeometryPath helper - which, + unlike ResolvePictureClipPath, always returns a concrete path rather than collapsing + "no geometry"/"rect" to null - and reusing ResolveShapeLineStyle/ResolveStrokeOutline + unchanged (the exact same resolvers RenderShape already uses for an auto-shape's own + ) keeps this fix consistent with the package's existing line-style-resolution + contract, with zero new stroke-resolution logic. + tests: + - ResolvePictureGeometryPath_RectPreset_ReturnsNonNullRectanglePath + - ResolvePictureGeometryPath_NoGeometryElement_ReturnsImplicitRectanglePath + - ResolvePictureGeometryPath_EllipsePreset_ReturnsNonNullPath + - ResolvePictureGeometryPath_CustGeom_ReturnsNonNullPath + - ResolvePictureGeometryPath_PrstGeomMissingPrstAttribute_ThrowsInvalidDataException + - ResolvePictureGeometryPath_UnsupportedPreset_ThrowsPptxUnsupportedFeatureException + - ResolvePictureGeometryPath_NullSpPrElement_ThrowsArgumentNullException + - Render_PictureEllipseGeometry_WithRedLnStroke_RendersCompleteClosedEllipseOutline + - Render_PictureNoPrstGeom_WithLnStroke_StrokesRectangleOutline + + - id: CanvasNetPptx-PptxDocument-TableParsing + title: >- + The PptxDocument class shall parse a 's // + into a PptxTable (column widths from /, rows from + with required height attributes, cells from with gridSpan/rowSpan/ + hMerge/vMerge attributes defaulting to 1/1/false/false when absent), rejecting a + whose uri does not end in "/table" with + PptxUnsupportedFeatureException (feature token "pptx-graphic-frame-kind") and a + missing , , or height, or a non-numeric gridCol + width/gridSpan/rowSpan attribute, with InvalidDataException, and shall resolve each + cell's fill and line styles (///) and text body + by reusing the already-verified Phase 1c/1d ResolveFill/ResolveLineStyle/ + ParseTextBody resolvers verbatim. + justification: | + A can declare graphic data kinds other than a table (for example an + embedded chart or OLE object); this phase only implements tables, so any other kind + is a deliberately unsupported, well-formed construct rather than a malformed one. + Reusing ResolveFill/ResolveLineStyle/ParseTextBody verbatim for cell-level properties + keeps a table cell's own fill/border/text behavior in lock-step with an ordinary + shape's own fill/border/text behavior, rather than maintaining a second, divergent + resolution path solely for table cells. + tests: + - ParseTable_WellFormedTable_ParsesColumnsAndRows + - ParseTable_MissingGraphicData_ThrowsInvalidDataException + - ParseTable_NonTableGraphicFrameKind_ThrowsPptxUnsupportedFeatureExceptionWithGraphicFrameKindToken + - ParseTable_MissingTblGrid_ThrowsInvalidDataException + - ParseTable_GridColNonNumericWidth_ThrowsInvalidDataException + - ParseTable_TrMissingHeight_ThrowsInvalidDataException + - ParseTableCell_NoAttributes_DefaultsToSpanOneNoMerge + - ParseTableCell_AttributesPresent_ParsesSpanAndMergeFlags + - ParseTableCell_NonNumericGridSpan_ThrowsInvalidDataException + - ParseTableCell_TcPrWithFillAndBorders_ResolvesFillAndBorders + - ParseTableCell_NoTxBody_TextBodyIsNull + - ParseTableCell_WithTxBody_ParsesTextBody + + - id: CanvasNetPptx-PptxDocument-TableCellRectResolution + title: >- + The PptxDocument class shall resolve each of a PptxTable's non-merge-continuation + cells (a cell with neither hMerge nor vMerge set) into a PptxResolvedTableCell + carrying its own EMU-space rectangle, computed by accumulating each physical + -entry's own single grid column's width when advancing the running column + offset (so that a spanning cell's own trailing continuation entries are never + double-counted), while the surviving cell's own stored rectangle width/height spans + the full gridSpan/rowSpan range of columns/rows it covers, and shall silently skip + any merge-continuation cell (it contributes no PptxResolvedTableCell of its own). + justification: | + Per ECMA-376's table content model, each XML entry - whether a real cell or an + hMerge/vMerge continuation placeholder - represents exactly one physical grid column; + only the governing (non-continuation) cell of a merged region owns a rectangle wide/ + tall enough to span that whole region. Advancing the running column offset by the + merged cell's own full spanned width, rather than by each physical entry's own single + column width, would double-count the columns already covered by that cell's trailing + continuation entries, corrupting every subsequent cell's computed left edge in the + same row. + tests: + - ResolveCellRects_SimpleGrid_ComputesCumulativeOffsets + - ResolveCellRects_MergeContinuationCells_AreSkipped + - ResolveCellRects_HorizontalMerge_ComputesSpannedWidth + - ResolveCellRects_VerticalMerge_ComputesSpannedHeight + + - id: CanvasNetPptx-PptxDocument-TablePainting + title: >- + The PptxDocument class shall paint a PptxTable onto a destination Surface by, for + each resolved cell rectangle, filling its own resolved fill (solid or gradient, via + the core PathFiller, transforming the cell's core Path.Rectangle through the table's + own shape transform first), stroking each side with a resolved line style whose own + width/dash/cap matches the Phase 1c stroke-outline convention, and painting the + cell's own text body (when present) via the already-verified Phase 1d + ResolveTextLayout/PaintTextLayout pipeline constrained to the cell's own rectangle, + and shall paint nothing for an empty cell (no fill, no borders, no text). + justification: | + Reusing the core PathFiller for cell fills and the Phase 1d text-layout/rendering + pipeline for cell text keeps a table cell's painted appearance consistent with how an + ordinary shape's own fill and text are painted elsewhere in this package, rather than + introducing a third, divergent painting code path solely for tables. An empty cell + (no fill, no border, no text) is a common, valid table shape and must not paint any + visible pixels at all. + tests: + - PaintTable_SolidFilledCell_PaintsFillColorAcrossCellRectangle + - PaintTable_CellWithTopBorder_PaintsStrokedLineAtTopEdge + - PaintTable_CellWithText_PaintsGlyphInkInsideCellRectangle + - PaintTable_EmptyCell_PaintsNothing + + - id: CanvasNetPptx-PptxDocument-TableStyleResolution + title: >- + The PptxDocument class shall resolve a /'s against + the presentation package's own ppt/tableStyles.xml part (tolerating every cause of + "not found" - no /tableStyles relationship, a missing/malformed part, or no matching + styleId - by falling back to today's plain, style-less cell-only rendering without + throwing) and shall apply the matched 's base tier to every + cell, its / tiers alternating by non-header row parity when + (a tier missing its own falling through to the next + lower tier rather than to "no fill"), and its tier overriding both for + the header row when , with a cell's own explicit + fill/border always taking final precedence over every style tier, reusing the + existing ResolveFill/ResolveLineStyle resolvers for every style-tier color (including + schemeClr) unchanged. + justification: | + A confirmed real-world corpus fixture ("ERF IWF and Reagent Probe Breadboard Motion + System Overview.pptx", slide 23) declares a with {GUID}; PowerPoint's own + rendering shows a shaded header row, alternating banded row fills, and style-defined + cell borders, while this package's Phase 1e TableParsing never resolved + at all, rendering only a bare, mostly-invisible grid of thin border + lines with no shading - a visibly incorrect render confirmed against a real-world + ground-truth PowerPoint COM export. The row-parity convention (a styled header row + excluded from band-row counting; applied to the first row after the + header, alternating with thereafter) was confirmed against that same + ground-truth render and matches ECMA-376's own documented convention for a styled, + banded table. Mirroring ResolveShapeLineStyle's own established "explicit always wins + over style/theme fallback" precedence pattern, rather than inventing a divergent one + solely for tables, and reusing ResolveFill/ResolveLineStyle verbatim for every + style-tier color keeps this fix consistent with the package's existing + fill/line-style-resolution contract, with zero new color-resolution logic. + firstCol/lastCol/lastRow/corner-cell style parts, column banding (band1V/band2V), + and table-style-driven font color remain explicitly deferred - each a separable, + independently-scoped refinement with no bearing on the primary header-row/row-banding + defect this fix addresses. + tests: + - ParseTable_FirstRowTblPrWithMatchingTableStyle_HeaderRowCellUsesFirstRowStyleFill + - ParseTable_BandRowTblPrWithMatchingTableStyle_AlternatesBand1HAndBand2HFillStartingAfterHeaderRow + - ParseTable_TblPrWithNoTableStyleId_FallsBackToPlainCellOnlyBorderAndNoFill + - ParseTable_TblPrWithUnresolvableTableStyleId_FallsBackToPlainCellOnlyBorderAndNoFillWithoutThrowing + - ParseTableCell_TcPrExplicitFillAndBorder_OverridesTableStyleFillAndBorder + - ParseTableCell_InteriorColumnBorder_UsesInsideVNotLeftRightTcBdrEdge + + - id: CanvasNetPptx-PptxDocument-ChartGraphicFrames + title: >- + The PptxDocument class shall resolve a 's / + whose uri ends in "/chart" by resolving its relationship to the + referenced chart part, parsing that part via the sibling + DemaConsulting.CanvasNet.Charts package's OpenXmlChartParser.Parse, and - when + actually rendering the slide - rendering/compositing the resulting Chart via + DemaConsulting.CanvasNet.Charts.ChartRenderer onto the slide surface at the graphic + frame's own resolved frame, reusing the existing picture-compositing primitive + exactly as a composites its own decoded image, while rejecting a missing + attribute or any other malformed chart part with InvalidDataException + and wrapping any ChartUnsupportedFeatureException raised while parsing a + recognized-but-unsupported chart construct as a PptxUnsupportedFeatureException whose + own Feature token is "pptx-chart-" followed by the wrapped exception's own Feature + value. + justification: | + A may declare a chart rather than a table; before this phase, every + chart graphic-data kind fell through TableParsing's own "not a table" + PptxUnsupportedFeatureException path, so no presentation containing a chart could + ever render past that slide. DemaConsulting.CanvasNet.Charts (Phases 1-3) already + independently verified chart-model construction, OOXML-chart parsing, and pixel + rendering; this requirement's own responsibility is purely resolving the chart + part's relationship and invoking that already-verified parser/renderer, not + re-implementing chart parsing or rendering. Prefixing the wrapped feature token with + "pptx-chart-" keeps a chart-specific deferred-feature token distinguishable, in + CanvasNetPptx's own exception contract, from every other "pptx-*" feature token this + package already raises, while still surfacing the Charts package's own, more specific + Feature value (for example "charts-openxml-radar-chart") rather than discarding it. + This dependency is strictly one-directional - DemaConsulting.CanvasNet.Charts must + never reference DemaConsulting.CanvasNet.Pptx (see docs/design/canvas-net-charts.md's + Dependencies section) - so CanvasNetPptx depends on CanvasNetCharts exactly as it + already depends on the core CanvasNet system, never the reverse. + tests: + - ParseShapeTree_GraphicFrameWithSupportedChart_DispatchesToChartNode + - ParseShapeTree_GraphicFrameWithTable_StillDispatchesToTableNode + - ParseShapeTree_GraphicFrameWithRadarChart_ThrowsPptxUnsupportedFeatureExceptionWithWrappedFeatureToken + - ParseChart_ChartElementWithNoRelationshipId_ThrowsInvalidDataException + - ParseChart_GraphicDataWithNoChartChild_ThrowsInvalidDataException + - Render_ChartOnlySlide_PaintsVisibleContent + - PptxDocument_Render_Aiden0zChartAndComplexFixture_Slide0AndSlide1PaintContent + - PptxDocument_Render_SamplelibSamplePresentationFixture_EverySlidePaintsContent + - PptxDocument_Render_PythonPptxChartLineFixture_RendersSuccessfully + - PptxDocument_Render_PythonPptxChartUnsupportedRadarFixture_ThrowsPptxUnsupportedFeatureException + - PptxDocument_Render_ShpShapesFixture_Chart2NodeHasNonNullChart + + - id: CanvasNetPptx-PptxDocument-ShapeTree + title: >- + The PptxDocument class shall recursively parse a (or, for a nested group, + a 's own direct children, which are not wrapped in a nested per + ECMA-376's CT_GroupShape content model) into a closed PptxShapeTreeNode hierarchy, + dispatching a to PptxSpShapeNode (resolving its placeholder, if any, via the + Phase 1b placeholder-detection logic extracted into TryParsePlaceholder), a + to PptxPictureShapeNode, a to PptxGraphicFrameShapeNode (eagerly + parsing its table via ParseTable, against a theme lazily resolved - only when a + table is actually encountered - via the slide's own layout/master/theme relationship + chain), and a to PptxGroupShapeNode (resolving its own child-coordinate- + space transform via the already-verified Phase 1c ResolveGroupChildTransform and + recursing into its own children), and a to PptxConnectorShapeNode (wrapping + the raw element for a later rendering pass - see CanvasNetPptx-PptxDocument- + ConnectorRendering below), silently skipping any other, truly unrecognized element + kind (for example , , or ), and shall expose + the result as PptxSlide.ShapeTree alongside the existing, unchanged + PptxSlide.Placeholders flat list. + justification: | + Earlier phases only enumerated a slide's immediate placeholder shapes, excluding + freeform shapes and never recursing into groups at all; a full shape-tree walk is the + structural foundation a future per-slide Render API needs to paint every shape in + document order, including nested groups' own children with their own composed + transforms. Deferring theme resolution until a table is actually encountered avoids + forcing every slide - including the overwhelming majority with no tables at all - to + walk its layout/master/theme relationship chain merely to build its shape tree. + Silently skipping unrecognized element kinds (rather than rejecting them) matches the + existing Phase 1b precedent of excluding non-placeholder shapes from the flat + placeholder list - a tree-walk tolerance, not a fail-closed feature rejection. + tests: + - ParseShapeTree_FreeformShape_ProducesSpShapeNodeWithNullPlaceholder + - ParseShapeTree_PlaceholderShape_ProducesSpShapeNodeWithResolvedPlaceholder + - ParseShapeTree_Picture_ProducesPictureShapeNode + - ParseShapeTree_GraphicFrameTable_ProducesGraphicFrameShapeNodeWithParsedTable + - ParseShapeTree_NoGraphicFrame_NeverInvokesThemeResolver + - ParseShapeTree_UnrecognizedElements_SilentlySkipped + - ParseShapeTree_Connector_ProducesConnectorShapeNode + - ParseShapeTree_Group_ProducesGroupShapeNodeWithDirectSiblingChildren + - ParseShapeTree_NestedGroups_EachResolveOwnChildTransformAndChildren + - ParseShapeTree_GroupMissingXfrm_ThrowsInvalidDataException + - ParseShapeTree_GraphicFrameInsideGroup_ParsesTableUsingPropagatedThemeResolver + - TryParsePlaceholder_FreeformShape_ReturnsNull + - TryParsePlaceholder_NoTypeOrIdxAttributes_DefaultsToObjAndZero + - TryParsePlaceholder_NonNumericIdx_ThrowsInvalidDataException + + - id: CanvasNetPptx-PptxDocument-SlideRendering + title: >- + The PptxDocument class shall render a slide's full PptxSlide.ShapeTree onto a + destination Surface by walking it recursively in document order with an + accumulating transform seeded from the base EMU-to-pixel scale, composing a + PptxGroupShapeNode's own already-resolved ChildTransform into that accumulator + before recursing into its children, resolving a PptxSpShapeNode's effective shape + properties (via ResolvePlaceholderProperties for a placeholder, or directly from its + own spPr for a freeform shape) and painting its geometry/fill/stroke and, when + present, its own slide-level text body via the Phase 1d text pipeline, painting a + PptxPictureShapeNode's embedded image via the Phase 1e picture pipeline, and + painting a PptxGraphicFrameShapeNode's already-parsed table via PaintTable using the + graphic frame's own direct p:xfrm child, while silently skipping a shape, picture, or + graphic frame whose geometry resolves no a:xfrm/p:xfrm element anywhere in its own + ancestry, or a picture with no p:blipFill. + justification: | + Every geometry/paint/text/image/table primitive this requirement composes was + already independently verified by Phase 1c/1d/1e's own unit tests; this requirement's + own responsibility is purely the shape-tree walk and per-node-kind dispatch that + wires those primitives together in the correct document order and coordinate space, + which no earlier phase exercised end-to-end. A placeholder shape's text is read from + the slide-level shape element's own txBody, never from a layout/master placeholder's + text, because ResolvePlaceholderProperties only ever carries styling, never content - + conflating the two would silently render stale layout boilerplate text instead of a + slide's own authored content. Skipping a position-less shape or an image-less + picture silently, rather than throwing, matches ParseShapeTree's own established + tolerant-tree-walk precedent (see CanvasNetPptx-PptxDocument-ShapeTree above); + treating a malformed-but-unused construct as fatal would make an otherwise-renderable + slide unrenderable over a single unused, incidental shape. + tests: + - Render_SingleFullSlideShape_PaintsFillColorAcrossSurface + - Render_EmptyShapeTree_RendersOnlyBackground + - Render_SmallPixelDimensionsAgainstLargeEmuSlideSize_DoesNotTruncateTransformToZero + - Render_TwoOverlappingShapes_LaterShapePaintsOnTopInDocumentOrder + - Render_ShapeWithNoXfrm_SkippedSilently + - Render_NestedGroup_ComposesChildTransformIntoExpectedSurfaceLocation + - Render_DoublyNestedGroups_EachComposeOwnChildTransform + - Render_Picture_PaintsEmbeddedImageAtExpectedLocation + - Render_PictureMissingBlipFill_SkippedSilently + - Render_Table_PaintsCellFillAcrossCellRectangle + - Render_GraphicFrameMissingXfrm_SkippedSilently + - Render_NonPlaceholderShapeWithText_PaintsGlyphInkInsideShapeRectangle + - Render_PlaceholderShape_ReadsTextFromSlideLevelShapeNotLayout + - Render_PlaceholderShapeWithEmptySpPr_InheritsXfrmAndGeometryFromLayout + - PptxDocument_Render_BlankSlideFixtures_RendersWithoutError + - PptxDocument_Render_ShpAutoshapePropsFixture_PaintsVisibleContent + - PptxDocument_Render_ShpGroupShapeFixture_RendersWithoutError + - PptxDocument_Render_ShpPictureFixture_RendersEverySlideWithVisibleContent + - PptxDocument_Render_TblCellFixture_RendersEverySlideWithVisibleContent + - PptxDocument_Render_TxtFontPropsFixture_RendersEverySlideWithVisibleContent + - PptxDocument_Render_TxtTextFrameFixture_RendersEverySlideWithVisibleContent + - PptxDocument_Render_ShpShapesFixture_Slide0ThrowsUnsupportedFeatureSlide1PaintsContent + - PptxDocument_Render_SamplelibSamplePresentationFixture_EverySlidePaintsContent + - PptxDocument_Render_SldBackgroundFixture_RendersAndPaintsSlideBackgroundFill + - PptxDocument_Render_PhInheritPropsFixture_Slide0PaintsSlide1RendersWithoutError + - PptxDocument_Render_PhUnpopulatedPlaceholdersFixture_RendersEverySlideWithoutError + - PptxDocument_Render_TxtFitTextFixture_PaintsVisibleContent + - PptxDocument_Render_ShpConnectorPropsFixture_ConnectorsPaintVisibleLines + - PptxDocument_Render_DmlFillFixture_Slide0RendersPictureAndPatternFillShapesWithoutThrowing + - PptxDocument_Render_DmlFillFixture_Slide1PatternFillShapesRenderAndBareFillIsTransparent + - PptxDocument_Render_DmlLineFixture_RendersEverySlideWithVisibleContent + - PptxDocument_Render_Aiden0zChartAndComplexFixture_Slide0AndSlide1PaintContent + - PptxDocument_Render_Aiden0zImageCropCssResetFixture_PaintsVisibleContentAndBackgroundFill + - PptxDocument_Render_Aiden0zTableStaleFrameFixture_PaintsVisibleContentDespiteFrameSizeMismatch + + - id: CanvasNetPptx-PptxDocument-RenderPublicApi + title: >- + The PptxDocument class shall expose public Render(int slideIndex, int width, int + height, PptxRenderOptions? options = null) and Render(int slideIndex, float dpi, + PptxRenderOptions? options = null) overloads that each validate a disposed document, + an out-of-range slideIndex, non-positive width/height, and a non-positive or + non-finite dpi by throwing ObjectDisposedException or ArgumentOutOfRangeException + respectively before any rendering work begins, shall compute the dpi overload's own + pixel width/height from the slide's own SlideSize EMU dimensions using the same + EMU-per-inch convention as DemaConsulting.CanvasNet.Pdf.PdfDocument's own DPI + overload, and shall clear the destination Surface to options.BackgroundColor (or + PptxRenderOptions.Default's opaque white when options is null) before painting any + shape. + justification: | + Validating every argument before any rendering work begins - rather than allowing a + later internal resolver to throw a less specific or less helpfully-named exception - + keeps this API's own failure modes consistent with every other public entry point in + this codebase (see CanvasNetPptx-PptxDocument's own earlier public-API requirements). + Reusing PdfDocument's own DPI-to-pixel-dimension convention keeps the two sibling + format renderers' public APIs symmetric for a caller already familiar with one of + them. Clearing to a caller-supplied background color before painting - defaulting to + opaque white - mirrors PdfRenderOptions's own convention and gives a caller painting + a slide with transparent or partially-covered shapes a predictable, non-garbage + background rather than undefined pixel contents. + tests: + - Render_DefaultOptions_ClearsUnpaintedAreaToOpaqueWhite + - Render_CustomBackgroundColor_ClearsToThatColor + - Render_FullyTransparentBackgroundColor_ClearsToTransparent + - Render_NegativeSlideIndex_ThrowsArgumentOutOfRangeException + - Render_SlideIndexAtSlideCount_ThrowsArgumentOutOfRangeException + - Render_NonPositiveWidth_ThrowsArgumentOutOfRangeException + - Render_NonPositiveHeight_ThrowsArgumentOutOfRangeException + - Render_DisposedDocument_ThrowsObjectDisposedException + - Render_Dpi_NonPositiveDpi_ThrowsArgumentOutOfRangeException + - Render_Dpi_NonFiniteDpi_ThrowsArgumentOutOfRangeException + - Render_Dpi_NegativeSlideIndex_ThrowsArgumentOutOfRangeException + - Render_Dpi_SlideIndexAtSlideCount_ThrowsArgumentOutOfRangeException + - Render_DpiOverload_ComputesExpectedPixelDimensionsFromSlideSize + + - id: CanvasNetPptx-PptxDocument-SlideBackgroundFill + title: >- + The PptxDocument class's Render methods shall resolve and paint a slide's own + p:cSld/p:bg background fill - falling back to its layout's, then its master's, own + p:bg when the slide declares none - across the full slide immediately before the + shape-tree walk begins, reusing the existing ResolveFill/ResolveColor DrawingML + fill-resolution pipeline (solid, linear-gradient, and p:bgRef theme-indexed fills via + a:fmtScheme/a:bgFillStyleLst with phClr substitution) rather than duplicating it, and + shall fall back unchanged to PptxRenderOptions.BackgroundColor only when none of + slide/layout/master declare a p:bg at all. + justification: | + A branded, non-white background fill is the single highest-visual-impact gap between + CanvasNetPptx's rendered output and a real PowerPoint export - before this + requirement, every slide rendered against a plain, uniform BackgroundColor regardless + of what the file itself declared, even though two real fixture files in this + project's own corpus (pythonpptx-sld-background.pptx, added specifically for this + feature, and aiden0z-image-crop-css-reset.pptx) declare one. Resolving slide, then + layout, then master in "first element present at all wins" order mirrors this unit's + own established placeholder-property-inheritance precedent (see + CanvasNetPptx-PptxDocument-PlaceholderInheritancePerCategory above) exactly, rather than + introducing a second, bespoke inheritance rule. Reusing ResolveFill/ResolveColor + rather than re-implementing fill resolution for backgrounds keeps every future + fill-resolution bug fix or feature addition automatically applicable to backgrounds + too, and keeps this requirement's own verification scope limited to the + background-specific resolution/dispatch logic layered on top. A pattern or picture + background fill is explicitly out of scope for this requirement - it is rejected with + PptxUnsupportedFeatureException, inherited unchanged from ResolveFill's own + pre-existing Phase 1c boundary (see CanvasNetPptx-PptxDocument-FillResolution above) - + rather than silently dropped or misrendered. + tests: + - Render_SlideLevelSolidBackground_PaintsAcrossFullSlide + - Render_SlideLevelBackgroundWithShape_ShapePaintsOnTopOfBackground + - Render_LayoutLevelSolidBackground_PaintsWhenSlideDeclaresNone + - Render_MasterLevelSolidBackground_PaintsWhenNeitherSlideNorLayoutDeclaresOne + - Render_SlideLevelBackground_TakesPriorityOverLayoutAndMasterBackgrounds + - Render_LayoutLevelBackground_TakesPriorityOverMasterBackgroundWhenSlideDeclaresNone + - Render_NoBackgroundAnywhere_FallsBackToOptionsBackgroundColor + - Render_SlideLevelThemeIndexedBackgroundReference_ResolvesBgFillStyleListEntryWithPhClrSubstitution + - Render_SlideLevelThemeIndexedBackgroundReferenceIdxZero_PaintsNoBackground + - ResolveSlideBackgroundFill_AllThreeTiersNull_ReturnsNull + - ResolveSlideBackgroundFill_SlideDeclaresOne_ResolvesSlideOwnBackground + - ResolveSlideBackgroundFill_SlideDeclaresNoneLayoutDoes_ResolvesLayoutOwnBackground + - ResolveSlideBackgroundFill_SlideAndLayoutDeclareNoneMasterDoes_ResolvesMasterOwnBackground + - ResolveSlideBackgroundFill_EmptySlideBackgroundStillWinsOverLayout_MatchesPlaceholderInheritancePrecedent + - ResolveSlideBackgroundFill_BgElementWithNeitherBgPrNorBgRef_ThrowsInvalidDataException + - ResolveSlideBackgroundFill_BgRefIdxZero_ReturnsNoFill + - ResolveSlideBackgroundFill_BgRefIdxOneThousand_ReturnsNoFill + - ResolveSlideBackgroundFill_BgRefIdxInFillStyleListRange_ThrowsPptxUnsupportedFeatureException + - ResolveSlideBackgroundFill_BgRefIdxResolvesBgFillStyleListEntryWithPhClrSubstitution + - ResolveSlideBackgroundFill_BgRefIdxSecondEntry_ResolvesSecondBgFillStyleListEntry + - ResolveSlideBackgroundFill_BgRefResolution_DoesNotMutateThemeBgFillStyleList + - ResolveSlideBackgroundFill_BgRefIdxPastEndOfBgFillStyleList_ThrowsInvalidDataException + - ResolveSlideBackgroundFill_BgRefWithNoIdxAttribute_ThrowsInvalidDataException + - ResolveSlideBackgroundFill_BgRefWithNonNumericIdxAttribute_ThrowsInvalidDataException + - ResolveSlideBackgroundFill_BgRefWithNoColorChild_ResolvesEntryWithDefaultPhClr + - GetTheme_NoFmtScheme_BgFillStyleListIsEmpty + - GetTheme_FmtSchemeWithNoBgFillStyleLst_BgFillStyleListIsEmpty + - GetTheme_FmtSchemeWithBgFillStyleLst_ParsesEntriesInDocumentOrder + - PptxDocument_Render_SldBackgroundFixture_RendersAndPaintsSlideBackgroundFill + - PptxDocument_Render_Aiden0zImageCropCssResetFixture_PaintsVisibleContentAndBackgroundFill + + - id: CanvasNetPptx-PptxDocument-MasterLayoutShapeRendering + title: >- + The PptxDocument class's Render methods shall render a slide's own master's and own + layout's non-placeholder shapes (pictures, autoshapes, groups, and freeform shapes) + back-to-front in document order - master shapes first, then layout shapes, then the + slide's own shapes unchanged on top - immediately after painting the resolved + background and before the existing slide shape-tree walk, using the exact same + per-shape-type paint dispatch a slide-level shape already uses, while never + rendering a master's or layout's own placeholder shapes directly, and shall catch a + PptxUnsupportedFeatureException thrown while painting a single master/layout shape + (including one nested inside a group) and skip only that shape, continuing to render + every other master/layout and slide shape, while a slide's own shape throwing the + same exception continues to hard-fail Render unchanged. + justification: | + A real PowerPoint deck frequently places decoration (a logo, a rule, a watermark) + directly on a slide master's or slide layout's own shape tree, outside any + placeholder - before this requirement, CanvasNetPptx walked a master's or layout's + own p:cSld/p:spTree only far enough to find placeholder shapes for the + PlaceholderInheritancePerCategory chain (and, after the SlideBackgroundFill + requirement above, to resolve p:bg) - any non-placeholder sibling shape was silently + never painted, leaving a visible gap between CanvasNetPptx's rendered output and a + real PowerPoint export. Reusing the existing per-shape-type paint dispatch (the same + RenderNode/RenderShape/RenderPicture/RenderGraphicFrame functions a slide-level shape + already uses) rather than introducing a parallel master/layout-specific rendering + path keeps every future shape-rendering bug fix or feature addition automatically + applicable to master/layout shapes too. A master/layout's own placeholder shapes + must never be painted directly - they are real PowerPoint's own "Click to + edit…" prompt/edit-mode-only text, never shown on an actual exported slide - so + RenderNode gained a skipPlaceholderShapes flag, set only for the two new master/ + layout loops, leaving the pre-existing slide-shape-tree loop's own behavior + unchanged. A slide only ever walks its own layout's and that layout's own master's + shape tree (the same slide -> layout -> master relationship already resolved for + placeholder inheritance and background), never an unrelated layout's or master's + shapes. A master/layout shape that itself uses an already-deferred feature (an + unsupported preset geometry, a chart/OLE graphic frame, an unsupported raster + picture format, and so on) hits the exact same PptxUnsupportedFeatureException + boundary a slide-level shape already hits. A regression fix corrected the original + implementation, which did + not contain this exception: without containment, a single already-deferred feature + used by one decorative master/layout shape aborted rendering for every slide using + that master/layout - a severe regression versus the pre-existing behavior where + master/layout shape trees were never walked at all. RenderNode now catches + PptxUnsupportedFeatureException at each leaf dispatch point, gated on the same + skipPlaceholderShapes flag, so only the single failing master/layout shape is + skipped and rendering continues; a slide's own shape (skipPlaceholderShapes == false) + is never caught here and continues to hard-fail Render exactly as before this fix. + tests: + - Render_MasterNonPlaceholderPicture_PaintsOnEverySlideUsingThatMasterResolvingOwnRels + - Render_LayoutNonPlaceholderAutoshape_PaintsOnlyOnSlidesUsingThatLayout + - Render_MasterAndSlideShapesOverlap_SlideShapePaintsOnTopOfMasterShape + - Render_MasterAndLayoutShapesOverlap_LayoutShapePaintsOnTopOfMasterShape + - Render_MasterPlaceholderShape_DoesNotRenderItsOwnPromptContent + - Render_LayoutPlaceholderShape_DoesNotRenderItsOwnPromptContent + - Render_MasterNonPlaceholderPictureWithUnsupportedFormat_SkipsThatShapeAndStillRendersSlideContent + - Render_SlideOwnPictureWithUnsupportedFormat_StillThrowsPptxUnsupportedFeatureException + - PptxDocument_Render_Aiden0zChartAndComplexFixture_Slide0AndSlide1PaintContent + + - id: CanvasNetPptx-PptxDocument-BulletProperties + title: >- + The PptxDocument class shall parse a paragraph's a:buNone/a:buAutoNum/a:buChar + bullet-type choice, and its independent a:buClrTx/a:buClr color, + a:buFontTx/a:buFont font, and a:buSzTx/a:buSzPct/a:buSzPts size choices, and shall + resolve each of these four choice-groups independently through the same + placeholder/master attribute-level inheritance chain already established for + run/paragraph properties (own paragraph, then placeholder level override, then + master level override, then a hard-coded default), with an explicit a:buNone or the + complete absence of bullet markup anywhere in the chain resolving to no bullet at + all, a:buAutoNum's type/startAt defaulting per the ECMA-376 schema + (arabicPeriod/1) when omitted, and the three "follow text" sentinels + (a:buClrTx/a:buFontTx/a:buSzTx, and the complete absence of color/font/size markup) + resolving to the paragraph's own first run's effective color/typeface/size. + justification: | + A bulleted or numbered paragraph is one of the most common real-world PPTX + constructs, and before this requirement CanvasNetPptx neither parsed nor resolved + any bullet markup at all - every such paragraph rendered as plain, unmarked text, a + visually obvious gap against a real PowerPoint export. Resolving each of the four + independent OOXML choice-groups (type/color/font/size) on its own inheritance chain, + rather than resolving one monolithic "bullet properties" object wholesale from + whichever single tier wins, is required because a real-world paragraph commonly + declares only its own bullet character while deliberately relying on its + placeholder/master level style for the bullet's color/font/size (or the reverse) - + mirroring exactly how algn/marL/indent/lnSpc are already each resolved independently + by ResolveEffectiveParagraphProperties (see + CanvasNetPptx-PptxDocument-PlaceholderInheritancePerCategory above), rather than + introducing a second, bespoke, coarser-grained inheritance rule specific to bullets. + tests: + - ParseParagraphProperties_Absent_ResolvesEmptyBulletProperties + - ParseBulletProperties_BuNone_CapturesTypeElement + - ParseBulletProperties_BuChar_CapturesTypeElement + - ParseBulletProperties_BuAutoNum_CapturesTypeElement + - ParseBulletProperties_ColorFontSizeModifiers_CapturesEachIndependently + - ParseBulletProperties_FollowTextModifiers_CapturesEachIndependently + - ResolveEffectiveParagraphProperties_NoBulletMarkupAnywhere_ResolvesNone + - ResolveEffectiveParagraphProperties_OwnBuChar_WinsOverEveryOtherTier + - ResolveEffectiveParagraphProperties_OwnBuNone_SuppressesInheritedBullet + - ResolveEffectiveParagraphProperties_PlaceholderLevelBuAutoNum_WinsWhenParagraphDeclaresNothing + - ResolveEffectiveParagraphProperties_MasterBodyStyleBuChar_WinsWhenEverythingAboveIsAbsent + - ResolveEffectiveParagraphProperties_BuAutoNumNoAttributes_ResolvesSchemaDefaults + - ResolveEffectiveParagraphProperties_OwnTypeOnly_InheritsColorFontSizeIndependently + - ResolveEffectiveParagraphProperties_BuClrTxOrAbsent_FollowsFirstRunColor + - ResolveEffectiveParagraphProperties_BuClrTxRunLessParagraph_FallsBackToDark1 + - ResolveEffectiveParagraphProperties_BuFontTxOrAbsent_FollowsFirstRunFont + - ResolveEffectiveParagraphProperties_BuSzPct_ResolvesFractionOfFirstRunSize + - ResolveEffectiveParagraphProperties_BuSzPts_ResolvesAbsoluteSizeIndependentOfRunSize + - ResolveEffectiveParagraphProperties_SldNumDtFtrPlaceholderType_SuppressesMasterBodyStyleBullet + - ResolveEffectiveParagraphProperties_SldNumDtFtrPlaceholderTypeWithOwnBuChar_StillResolvesOwnBullet + + - id: CanvasNetPptx-PptxDocument-BulletRendering + title: >- + The PptxDocument class's text layout engine shall format an auto-numbered bullet's + rendered string for the arabicPeriod, arabicParenR, arabicPlain, alphaLcPeriod, + alphaUcPeriod, alphaLcParenR, alphaUcParenR, romanLcPeriod, romanUcPeriod, + romanLcParenR, and romanUcParenR ST_TextAutonumberScheme values (gracefully omitting + only that one paragraph's bullet glyph, without aborting rendering, for any other + scheme value), shall maintain one running counter and last-used-scheme per indent + level so consecutive same-level auto-numbered paragraphs sequence correctly and a + nested sub-list's own counter resets independently of, and without disturbing, its + parent level's own counter, shall paint a resolved bullet glyph at the paragraph's + own marL+indent hanging-indent gutter position, shall position a bulleted + paragraph's own first-line run text starting at marL (not marL+indent) whenever that + line has a resolved bullet, clamped so the text never shares an X range with the + bullet glyph it is painted beside regardless of the paragraph's resolved indent, + shall additionally reserve a minimum trailing gap - one space character's own + advance width, measured in the bullet's own resolved font/size - beyond an + auto-numbered marker's own measured width specifically (not for a buChar marker, + whose already-correct "touching, zero extra gap" behavior is unaffected), so a + multi-character auto-number marker (e.g. "12.") reads as visibly separated from the + paragraph's own text rather than merely touching it, matching PowerPoint's own + tab-stop-like spacing after a number marker, leaving a non-bulleted paragraph's own + first-line indent behavior unchanged, and shall suppress the bullet glyph entirely + (while still advancing the auto-number counter state) for a paragraph with no + run children at all, so a blank/spacer paragraph that merely inherits a list's + bullet properties never renders a stray, isolated bullet glyph with no accompanying + text. + justification: | + Formatting and positioning a bullet glyph requires layout-engine support distinct + from, and layered on top of, the choice-group parsing/inheritance resolution above: + auto-numbering requires per-shape sequence state that single-paragraph property + resolution alone cannot provide, and correct gutter positioning requires the + paragraph's own text to be deliberately excluded from the indent it would otherwise + apply to itself. The latter was found, during this requirement's own implementation, + to be a previously-undocumented, closely-related rendering defect: a bulleted + paragraph's first-line text was already incorrectly shifted into the + hanging-indent gutter reserved for the bullet glyph (treating indent as an + additional first-line text indent rather than the gutter width ECMA-376, and every + real PowerPoint export, intends) - fixing this alongside adding the bullet glyph + itself is necessary, not merely cosmetic, since a bullet glyph painted correctly in + the gutter while its own paragraph's text remains shifted into that same gutter would + still look wrong relative to a genuine PowerPoint export. An unrecognized + auto-number scheme degrades gracefully (omitting only that one bullet) rather than + throwing PptxUnsupportedFeatureException, matching this codebase's own established + convention for an optional, nullable-returning resolver, since a single unsupported + bullet scheme in an otherwise-ordinary real-world deck must never abort rendering of + every other slide/shape/paragraph. A further visual-fidelity defect was found after + this requirement's initial implementation: painting a bullet glyph for any paragraph + with resolved, non-None bullet properties - regardless of whether that paragraph had + any run content at all - rendered a stray, isolated bullet glyph on a run-less + blank/spacer paragraph, something no genuine PowerPoint export ever does. The fix + gates only the glyph itself on the paragraph having at least one run, leaving the + auto-number counter's own advancement untouched, so a run-less auto-numbered + paragraph still consumes its own position in the sequence even though nothing is + painted for it - matching real PowerPoint's own counter semantics. A further bug was + found against real-world content, reported as a buAutoNum marker appearing to render + stacked/overlapping its own paragraph's first word: investigation (direct code + inspection plus temporary numeric probes) refuted the plausible duplicate-paint and + type-precedence-merge hypotheses - there is exactly one bullet-glyph-painting call + site, and a paragraph's own buAutoNum fully supersedes an inherited master buChar + default, never merging the two - and instead confirmed a bullet-gutter/text-start-X + collision in PositionLines: when a bulleted paragraph's resolved IndentEmu was zero + or not negative enough to clear the bullet glyph's own rendered width, the bullet + and the paragraph's own first-line text painted at the same, or an overlapping, X + coordinate - a positioning defect affecting buChar and buAutoNum bullets alike, + confirmed numerically on a real-world slide (marL="320040", lvl="1", no indent + attribute, a plain non-placeholder TextBox, buAutoNum type="arabicPeriod"). The fix + clamps the bulleted first line's own text-start-X to never fall short of the + bullet's own gutter position plus its own measured width, a no-op whenever the + existing gutter already clears the bullet's own width. A follow-up real-world report + found that same clamp, while correct for buChar, still insufficient for buAutoNum: + CanvasNet rendered an auto-numbered marker immediately touching the paragraph's own + first word (e.g. "1.3 custom tip geometries"), whereas real PowerPoint shows a + clear, word-space-sized gap after the marker. Investigation confirmed + BuildBulletGlyphs already measures a multi-character auto-number marker's full + string width correctly (refuting a suspected single-glyph-width regression) - the + gap was the prior fix's own documented "just touching, zero extra padding" + simplification, applied uniformly to every bullet kind. The fix adds a + BulletGlyphsResult.TrailingGapEmu - one space character's own advance width, + measured in the bullet's own resolved font/size - gated to PptxBulletKind.AutoNum + only (zero for Char/None, leaving the already-verified buChar behavior unaffected), + folded additively into the existing clamp. + tests: + - FormatAutoNumber_SupportedTypes_FormatsExpectedString + - FormatAutoNumber_UnsupportedType_ReturnsNull + - ResolveTextLayout_NonBulletedParagraph_FirstLineIndentStillAppliesToTextX + - ResolveTextLayout_BulletedParagraph_TextStartsAtMarLGutterHoldsBullet + - ResolveTextLayout_BuNone_PaintsNoGlyphBeyondRunText + - ResolveTextLayout_ConsecutiveAutoNumParagraphs_SequencesCounterAcrossParagraphs + - ResolveTextLayout_NestedThenReturnToShallowerLevel_ResumesShallowerCounterRestartsDeeperLevel + - ResolveTextLayout_UnsupportedAutoNumType_SkipsOnlyThatBulletGracefully + - PaintTextLayout_BulletedParagraph_PaintsBulletGlyphToSurface + - BuildLines_ParagraphWithBulletPropertiesAndNoRuns_PaintsNoBulletGlyph + - BuildLines_OtherwiseIdenticalParagraphWithOneRun_StillPaintsBulletGlyph + - BuildLines_RunlessAutoNumberParagraph_CounterStillAdvancesForSubsequentParagraph + - PptxDocument_Render_Aiden0zChartAndComplexFixture_Slide0AndSlide1PaintContent + - ResolveTextLayout_BulletedParagraphWithZeroIndent_TextClearsBulletWidth + - PaintTextLayout_BulletedParagraphWithZeroIndent_BulletAndTextInkDoNotOverlap + - ResolveTextLayout_MasterBuCharDefault_OwnBuAutoNumOverride_PaintsOnlyAutoNumberMarker + - ResolveTextLayout_BulletedParagraphWithZeroIndent_AutoNumTwoDigitMarker_TextClearsBulletWidthPlusGap + - PaintTextLayout_BulletedParagraphWithZeroIndent_AutoNumMarker_BulletAndTextHaveVisibleGap + + - id: CanvasNetPptx-PptxDocument-ShapeStyleReference + title: >- + The PptxDocument class shall resolve a shape's own / + and shape-style references - as produced by PowerPoint's "Shape + Styles" gallery - into a concrete fill/line style, indexing the theme's own + // directly, 1-based, with no offset + (idx="0" meaning "none"; idx outside [1,3], or indexing past either list's own + entry count, throwing InvalidDataException), substituting the fillRef/lnRef's own + color-definition child for the matched style-list entry's own token(s), and shall give priority to an explicit fill-definition child + (on the shape's own , or on its own , including an explicit + ) over its corresponding style reference. For the line side specifically, + a present that declares no recognized fill-definition child of its own keeps + its own width/dash but defers only its color to the style (falling back to + "no stroke" when no style color is available); a fully absent fill/line element + defers to the style reference entirely for both width and color. + justification: | + A shape styled purely through PowerPoint's own Shape Styles gallery (rather than an + explicit, user-applied fill/line) declares that choice solely via , which + this unit previously parsed nowhere at all - such a shape silently rendered with no + fill and no stroke whatsoever, a genuine visual-fidelity defect rather than a + gracefully-deferred feature boundary. Unlike 's own background-specific + +1000/+1001 offset matrix into bgFillStyleLst, an ordinary fillRef/lnRef indexes + fillStyleLst/lnStyleLst directly, since a real theme's own fmtScheme always declares + exactly 3 entries in each list and no "other half" concept applies here; rejecting an + out-of-range idx with InvalidDataException (rather than silently clamping it) keeps + this resolver consistent with every other idx-validating resolver already in this + unit. An explicit fill-definition child (including ) must win over a style + reference because real PowerPoint itself allows a user to override an otherwise + gallery-styled shape's own fill/line without clearing its , so both can + legitimately coexist on the same shape. However, a real-world shape commonly declares + an that carries only width/dash/cap attributes with no recognized + fill-definition child at all, relying entirely on its / for its own + visible color - treating such a fill-less as if it had "explicitly won" over + the style (the original, buggy policy) silently discarded that color and rendered no + stroke at all, the opposite of PowerPoint's own rendering. The line side's resolver + therefore keeps the shape's own width/dash (never pulled from style) but defers only + its color to the style , falling back to "no stroke" when no style color is + available - the same pre-existing default every other fill-less-line call site in + this unit already produces, and the same default ResolveConnectorLineStyle already + establishes as precedent for a connector's own, broader per-attribute merge; no new + default color is invented, since OOXML's own schema default for an absent or idx="0" + style reference is "no line at all", not a specific color. + tests: + - ResolveShapeStyleFill_NullStyleElement_ReturnsNoFill + - ResolveShapeStyleFill_NoFillRef_ReturnsNoFill + - ResolveShapeStyleFill_IdxZero_ReturnsNoFill + - ResolveShapeStyleFill_FillRefIdxOne_ResolvesFillStyleListEntryZeroNoOffset + - ResolveShapeStyleFill_FillRefWithSchemeClrPhClr_SubstitutesFillRefOwnColor + - ResolveShapeStyleFill_IdxOutOfRange_ThrowsInvalidDataException + - ResolveShapeStyleFill_IdxPastEndOfEmptyFillStyleList_ThrowsInvalidDataException + - ResolveShapeStyleFill_FillRefWithNoIdxAttribute_ThrowsInvalidDataException + - ResolveShapeStyleLineStyle_NullStyleElement_ReturnsNull + - ResolveShapeStyleLineStyle_NoLnRef_ReturnsNull + - ResolveShapeStyleLineStyle_IdxZero_ReturnsNull + - ResolveShapeStyleLineStyle_LnRefIdxOne_ResolvesLnStyleListEntryZeroNoOffset + - ResolveShapeStyleLineStyle_LnRefWithSchemeClrPhClr_SubstitutesLnRefOwnColor + - ResolveShapeStyleLineStyle_IdxOutOfRange_ThrowsInvalidDataException + - ResolveShapeStyleLineStyle_LnRefWithNoIdxAttribute_ThrowsInvalidDataException + - GetTheme_NoFmtScheme_FillStyleListAndLnStyleListDefaultToEmpty + - GetTheme_FmtSchemeWithNoFillStyleLstOrLnStyleLst_BothDefaultToEmpty + - GetTheme_FmtSchemeWithFillStyleLstAndLnStyleLst_ParsesEntriesInDocumentOrder + - Render_ShapeWithOnlyStyleFillRefNoExplicitSpPrFill_RendersResolvedTintedAccentColor + - Render_ShapeWithBothExplicitSpPrFillAndStyleFillRef_ExplicitFillWins + - Render_ShapeWithOnlyStyleLnRefNoExplicitLn_RendersResolvedStrokeColor + - Render_ShapeWithExplicitLnNoFillAndStyleLnRef_ExplicitLnWins + - ResolveShapeLineStyle_LnElementNull_DefersToStyleLineStyle + - ResolveShapeLineStyle_LnHasExplicitSolidFill_OwnFillWinsOverStyle + - ResolveShapeLineStyle_LnHasExplicitNoFill_ResolvesNullRegardlessOfStyle + - ResolveShapeLineStyle_LnHasWidthButNoFillChild_KeepsOwnWidthUsesStyleColor + - ResolveShapeLineStyle_LnHasWidthAndOwnDashButNoFillChild_KeepsOwnDashArray + - ResolveShapeLineStyle_LnHasWidthButNoFillChildAndNoStyleElement_ResolvesNullDefault + - ResolveShapeLineStyle_LnHasWidthButNoFillChildAndStyleLnRefIdxZero_ResolvesNullDefault + - ResolveShapeLineStyle_LnHasNoWidthAndNoFillChild_ResolvesNullRegardlessOfStyle + - Render_ShapeWithLnWidthOnlyNoFillChildAndStyleLnRef_UsesOwnWidthAndStyleColor + + - id: CanvasNetPptx-PptxDocument-ConnectorRendering + title: >- + The PptxDocument class shall dispatch a connector shape to its own + PptxConnectorShapeNode in the same document-order shape-tree walk as every other + shape kind, resolving its preset connector geometry (line/straightConnector1 as an + open two-point diagonal; bentConnector2-5/curvedConnector2-5 as an elbow polyline, + optionally corner-rounded; any other, unimplemented preset name failing gracefully + for that one shape only, not the whole slide render) even when its own + declares a zero width and/or zero height, shall paint its effective stroke by + merging its own with its sibling / fallback independently + per attribute (width, paint, dash) rather than as an all-or-nothing choice, shall + paint a fill only when the connector's own explicitly declares one, and + shall paint a resolved / arrowhead (recognizing + triangle/stealth/diamond/oval/arrow type values, sized relative to the connector's + own stroke width via its w/len size keywords) oriented along the connector's own + line direction at the corresponding endpoint. + justification: | + A connector shape - the straight/elbow/curved lines PowerPoint draws + between other shapes in flowcharts and diagrams - was previously never represented + in the parsed shape tree at all, so a slide containing one rendered with that + connector simply absent; for a real-world flowchart/diagram slide this is a severe, + highly visible fidelity gap, not a gracefully-deferred rare feature. Dispatching + through the exact same ParseShapeTree/RenderNode walk every other shape + kind already uses (rather than a parallel, connector-specific tree/render path) + keeps a connector's z-order among its siblings, and its own group-nested transform + composition when nested inside a , correct for free, with no special-casing + needed anywhere in the walk itself. The preset-geometry zero-size bypass is a + targeted, connector-scoped fix: a real-world connector frequently declares a + perfectly horizontal or vertical line via a zero-height or zero-width - + confirmed in the pythonpptx-shp-connector-props.pptx fixture's own second slide - + and PptxPresetGeometry's own pre-existing "non-positive bounding box renders as + Path.Empty" guard (correct and unchanged for every other, non-connector preset) would + otherwise silently discard such a connector's line entirely. The per-attribute line- + style merge (rather than RenderShape's own simpler "any present wins outright + over " policy) was proven necessary by that same real fixture: its own + connector declares a bare - no width, no fill + - relying entirely on its sibling / for its actual visible color + and width; under the ordinary shape policy this would resolve to an invisible line. + A connector paints no fill by default (unlike an ordinary autoshape) because a real + connector line is visually just a stroked path with no interior area to fill, and + PowerPoint itself only rarely has a user explicitly add one. Arrowhead size-keyword + scaling is a documented, reasonable visual approximation relative to the connector's + own stroke width, since PowerPoint publishes no normative EMU/pixel table for its own + sm/med/lg size keywords. An exotic or future connector preset name this phase does + not implement degrades to "this one connector paints nothing" (via a dedicated, + unconditional inner try/catch around connector geometry resolution specifically, in + addition to the standard skipPlaceholderShapes-gated outer catch every node kind + already has) rather than aborting the rest of the slide's own render - satisfying the + same graceful, single-shape-scoped degradation policy already established for + master/layout decorative shapes, now extended to a slide's own connectors as well. + tests: + - ParseShapeTree_Connector_ProducesConnectorShapeNode + - PptxPresetGeometry_Build_StraightConnectorPresets_ProducesOpenDiagonal + - PptxPresetGeometry_Build_StraightConnectorWithZeroWidthOrHeight_StillProducesLine + - PptxPresetGeometry_Build_OrdinaryPresetWithZeroHeight_StillDegradesToEmpty + - PptxPresetGeometry_Build_BentConnectorPresets_ReachesEndpointAsOpenPolyline + - PptxPresetGeometry_Build_CurvedConnectorPresets_ReachesEndpoint + - ResolveShapeFrame_StraightConnectorWithFlipCombination_MapsEndpointsCorrectly + - ResolveConnectorLineStyle_OwnLnExplicitNoFill_AlwaysWinsOverStyle + - ResolveConnectorLineStyle_OwnLnHasNoWidth_FallsBackToStyleWidth + - ResolveConnectorLineStyle_OwnLnHasWidthButNoFill_FallsBackToStylePaint + - ResolveConnectorLineStyle_OwnLnHasExplicitWidthAndFill_OwnValuesWin + - ResolveConnectorLineStyle_NoOwnWidthAndNoStyle_ResolvesToNull + - ResolveConnectorLineStyle_OwnLnHasExplicitDash_UsesOwnDashArray + - ResolveArrowhead_RecognizedTypeValue_ResolvesExpectedKind + - ResolveArrowhead_NoLnElement_ReturnsNull + - ResolveArrowhead_NoMatchingEndElement_ReturnsNull + - ResolveArrowhead_NoneOrUnrecognizedType_ReturnsNull + - ResolveArrowhead_NoWOrLenAttributes_DefaultsToMed + - PptxArrowheadGeometry_Build_FilledKinds_TipAtOriginBodyExtendsBackward + - PptxArrowheadGeometry_Build_Oval_IsClosedAndCenteredNearOrigin + - PptxArrowheadGeometry_Build_Arrow_IsOpenChevron + - PptxArrowheadGeometry_Build_NonPositiveLineWidth_ReturnsEmpty + - PptxArrowheadGeometry_Build_SizeKeys_LargeIsBiggerThanSmall + - ComputeEndpointsAndTangents_HorizontalStraightConnector_TangentsPointAlongPositiveX + - ComputeEndpointsAndTangents_VerticalStraightConnector_TangentsPointAlongPositiveY + - ComputeEndpointsAndTangents_DiagonalStraightConnector_TangentsPointAtFortyFiveDegrees + - ComputeEndpointsAndTangents_BentConnector_FinalSegmentTangentPointsAlongLastDirection + - Render_StraightConnector_PaintsDiagonalLineAtExpectedPixels + - Render_ZeroHeightHorizontalConnector_PaintsLineWithoutDegradingToEmpty + - Render_ConnectorWithTailArrowhead_PaintsArrowheadNearEndpoint + - PptxDocument_Render_ShpConnectorPropsFixture_ConnectorsPaintVisibleLines + + - id: CanvasNetPptx-PptxDocument-UnderlineRendering + title: >- + The PptxDocument class shall resolve a text run's effective value + into one of four underline styles (none/single/double/other, where every ECMA-376 + variant other than none/sng/dbl - heavy, dotted, dottedHeavy, dash, dashHeavy, + dashLong, dashLongHeavy, dotDash, dotDashHeavy, dotDotDash, dotDotDashHeavy, wavy, + wavyHeavy, wavyDbl, and any unrecognized value - maps to other), following the same + four-tier run/paragraph-defRPr/lstStyle-level/master-txStyles inheritance chain as + every other text property, shall resolve the underline's own color independently + from an explicit / child when present, defaulting to the + run's own already-resolved text-fill color whenever no child exists + (including when an explicit is present), and shall paint a visible, + solid-filled rectangle beneath each underlined run spanning its own measured glyph + width (continuous across interior spaces within one run, and not merged across a + run boundary even when both runs are underlined), positioned and sized proportional + to the run's own font size, with a double-underline style painting two thinner, + gapped rectangles instead of one. + justification: | + A real-world corpus fixture ("ERF IWF Breadboard Motion System Overview.pptx", + slide 23 "Configurations") contains text runs declaring that + PowerPoint's own ground-truth rendering shows with a visible single underline, while + CanvasNet rendered no underline at all: GetUnderline parsed the u attribute into a + boolean PptxEffectiveRunProperties.Underline, but nothing downstream (layout or + paint) ever consumed it, silently dropping an explicitly-authored, visible text + decoration. Modeling the resolved value as a style enum (rather than restoring the + prior boolean) lets every other ECMA-376 u variant resolve to a defined, intentional + behavior (a single solid line, documented as a pragmatic simplification) instead of + either crashing on an unrecognized value or silently dropping it exactly as before. + Resolving the underline's own color independently of the run's text color (rather + than always matching the text color outright) is required because PowerPoint itself + allows an author to declare a differently-colored underline via ; defaulting + to the run's own text color whenever no child exists (including the + explicit "inherit text color" marker ) matches PowerPoint's own default + behavior for a run that does not override its underline color. Building one + contiguous span per underlined run (not merged across a run boundary, but continuous + across an interior space within the same run) matches PowerPoint's own visual + behavior: an underline never bridges a run boundary even when both runs happen to + be underlined, but a single run's own interior whitespace is visibly underlined, not + skipped. Deriving thickness/offset as a documented, SizeEmu-proportional + approximation (rather than parsing the OpenType post table's own + underlinePosition/underlineThickness fields) is an explicit, pragmatic + simplification for this phase - true font-metric-derived values are deferred to a + later phase. + tests: + - ResolveEffectiveRunProperties_NoneDeclared_ResolvesHardCodedDefaults + - ResolveEffectiveRunProperties_UnderlineAttribute_ResolvesExpectedStyle + - ResolveEffectiveRunProperties_ExplicitUFill_OverridesTextColor + - ResolveEffectiveRunProperties_NoUnderlineFillMarkup_DefaultsUnderlineColorToTextColor + - ResolveEffectiveRunProperties_ExplicitUFillTx_DefaultsUnderlineColorToTextColor + - ResolveTextLayout_UnderlinedRun_EmitsOneSegmentSpanningRunWidth + - ResolveTextLayout_NonUnderlinedRun_EmitsNoSegment + - ResolveTextLayout_TwoRunsOnlyFirstUnderlined_EmitsExactlyOneSegment + - ResolveTextLayout_UnderlinedRunWithInteriorSpace_SpanCoversSpace + - PaintTextLayout_SingleUnderlineSegment_PaintsStrokeBelowBaselineAtExpectedPositionAndColor + - PaintTextLayout_NoUnderlineSegments_PaintsNoExtraInk + - PaintTextLayout_DoubleUnderlineSegment_DoesNotThrowAndPaintsInk + - PaintTextLayout_OtherUnderlineSegment_DoesNotThrowAndPaintsInk + + - id: CanvasNetPptx-PptxDocument-TabStopExpansion + title: >- + The PptxDocument class shall expand a literal U+0009 TAB character within a run's + text to the next default OOXML tab stop (a fixed 914400 EMU, 1 inch, interval) when + word-wrapping and rendering a text body's lines, isolating each tab character as its + own single-character token (never merged with adjacent whitespace of another kind, + nor with another adjacent tab), computing its effective width dynamically from the + current running horizontal position at that point in the line - both for the + word-wrap fit/overflow decision and for the rendered cursor position - rather than + measuring it via the run's own font glyph metrics, and shall always advance the + cursor by at least a minimal, non-zero amount even when it already sits exactly on a + tab-stop boundary. + justification: | + A real-world corpus fixture ("ERF IWF Breadboard Peer Review.pptx", slide 9 + "Requirements", the single highest-divergence slide in a full 73-slide pixel-diff + pass) contains 19 paragraphs whose "numbers" are literal typed digits separated from + their sentence text by a literal tab character, not a buAutoNum bullet. Tokenize/ + MeasureTokenWidthEmu/BuildLines previously treated a tab exactly like any other + whitespace character, resolving it via the shared ResolveGlyph helper against the + run's own primary font; most fonts have no cmap entry for U+0009, so this resolved to + glyph index 0 (.notdef), whose advance width is typically 0 or near-zero, jamming the + numbered-list text together instead of PowerPoint's wide, consistent column-like gap, + and silently corrupting word-wrap width calculations for any line containing a tab. + This was previously flagged as a distinct, pre-existing, out-of-scope defect in the + Bullets and Numbering follow-up and deferred to a separate unit of work. The chosen + 914400 EMU default interval is confirmed two independent ways: the corpus file's own + slide master and the presentation's own both + declare defTabSz="914400" on every list level, and independent ground-truth pixel + measurement of the rendered slide (three different bullet-number widths - "1", "2", + "10") each showed the following sentence text starting within 1px of the position a + single 914400-EMU tab-stop expansion predicts. Computing a tab's width dynamically + from the current position (rather than a fixed, precomputed advance) is required + because a tab stop's effective width is inherently position-dependent - the same tab + character expands by a different amount depending on how much text precedes it on + the line - and the word-wrap fit decision must use this same expanded width (not the + near-zero glyph-measured width) or an early tab in a long line would silently + under-count the line's true occupied width and mis-place the wrap point. Explicit + tab-stop support remains out of scope: no slide in the investigated corpus + (slide 9, its layout, or its master) declares anywhere, so + default-interval-only expansion fully resolves the observed defect without requiring + new tabLst parsing/inheritance infrastructure. + tests: + - ResolveTextLayout_TabCharacter_ExpandsToNextDefaultTabStop + - ResolveTextLayout_TabCharacter_WrapDecisionUsesExpandedWidthNotGlyphWidth + - ResolveTextLayout_MultipleTabCharacters_EachAdvancesToItsOwnNextTabStop diff --git a/docs/reqstream/canvas-net.yaml b/docs/reqstream/canvas-net.yaml index a6051b5a..729a77ec 100644 --- a/docs/reqstream/canvas-net.yaml +++ b/docs/reqstream/canvas-net.yaml @@ -202,6 +202,25 @@ sections: tests: - CanvasNet_SystemIntegration_ParseHexColorAndSetSurfacePixel_ReturnsExpectedPixel + - id: CanvasNet-Lib-PixelCompositing + title: >- + The library shall provide a single-pixel Porter-Duff "over" alpha-compositing helper on + Rgba32, identical to Surface's own per-row CompositeOver formula, exposed cross-assembly + for a sibling renderer package's own one-sampled-pixel-at-a-time image-compositing + loops. + tags: [system] + justification: | + Both sibling renderer packages (DemaConsulting.CanvasNet.Pptx and + DemaConsulting.CanvasNet.Pdf) composite a decoded source image onto a destination + Surface one sampled pixel at a time; sharing one tested helper rather than duplicating + the blending math independently in both assemblies avoids a second, divergent + implementation of the same formula drifting out of sync with Surface's own row-based + CompositeOver formula. + children: + - CanvasNet-Canvas-PixelCompositing + tests: + - Rgba32_CompositeOver_PartiallyTransparentForegroundOverOpaqueBackground_BlendsExactly + - title: BMP Codec API requirements: - id: CanvasNet-Lib-BmpSave diff --git a/docs/reqstream/canvas-net/canvas.yaml b/docs/reqstream/canvas-net/canvas.yaml index 5e8105e9..370469e0 100644 --- a/docs/reqstream/canvas-net/canvas.yaml +++ b/docs/reqstream/canvas-net/canvas.yaml @@ -396,6 +396,24 @@ sections: - Rgba32_Parse_6HexUppercase_ReturnsExpectedRgbaWithAlpha255 - Rgba32_TryParse_ValidInput_ReturnsTrueAndSetsResult + - id: CanvasNet-Canvas-PixelCompositing + title: >- + The Canvas subsystem shall provide a single-pixel Porter-Duff "over" alpha- + compositing helper on Rgba32, identical to Surface's own per-row CompositeOver + formula, exposed cross-assembly for a sibling renderer package's own + one-sampled-pixel-at-a-time image-compositing loops. + justification: | + Both sibling renderer packages (DemaConsulting.CanvasNet.Pptx and + DemaConsulting.CanvasNet.Pdf) composite a decoded source image onto a destination + Surface one sampled pixel at a time; sharing one tested helper rather than + duplicating the blending math independently in both assemblies avoids a second, + divergent implementation of the same formula drifting out of sync with Surface's + own row-based CompositeOver formula. + children: + - CanvasNet-Canvas-Rgba32-CompositeOver + tests: + - Rgba32_CompositeOver_PartiallyTransparentForegroundOverOpaqueBackground_BlendsExactly + - id: CanvasNet-Canvas-Disposal title: >- The Canvas subsystem shall provide explicit resource-lifetime management for its diff --git a/docs/reqstream/canvas-net/canvas/rgba32.yaml b/docs/reqstream/canvas-net/canvas/rgba32.yaml index 4f99d90d..80a73c30 100644 --- a/docs/reqstream/canvas-net/canvas/rgba32.yaml +++ b/docs/reqstream/canvas-net/canvas/rgba32.yaml @@ -67,3 +67,29 @@ sections: - Rgba32_TryParse_ValidInput_ReturnsTrueAndSetsResult - Rgba32_TryParse_InvalidInput_ReturnsFalseAndSetsResultToDefault - Rgba32_TryParse_Null_ReturnsFalse + + - id: CanvasNet-Canvas-Rgba32-CompositeOver + title: >- + The Rgba32 struct shall provide an internal single-pixel CompositeOver helper + computing the standard Porter-Duff "over" alpha-compositing formula (straight + alpha in and out, outA = fgA + bgA * (1 - fgA), each color channel outC = + (fgC * fgA + bgC * bgA * (1 - fgA)) / outA when outA != 0 else 0, + round-half-away-from-zero, clamped to [0, 255]) identical to Surface's own + per-row CompositeOver formula, exposed cross-assembly to + DemaConsulting.CanvasNet.Pptx and DemaConsulting.CanvasNet.Pdf for their own + one-sampled-pixel-at-a-time image-compositing loops. + justification: | + Both sibling renderer packages composite a decoded source image onto a + destination Surface one sampled pixel at a time; sharing one tested helper + rather than duplicating the blending math independently in both assemblies + avoids a second, divergent implementation of the same formula drifting out of + sync with Surface's own CompositeOver(Rgba32) row-based formula. This fixes a + confirmed real-world bug where both renderers previously overwrote the + destination pixel outright with the raw sampled source pixel, ignoring its + alpha channel entirely. + tests: + - Rgba32_CompositeOver_FullyTransparentForeground_ReturnsBackgroundUnchanged + - Rgba32_CompositeOver_FullyOpaqueForeground_ReturnsForegroundExactly + - Rgba32_CompositeOver_PartiallyTransparentForegroundOverOpaqueBackground_BlendsExactly + - Rgba32_CompositeOver_BothBackgroundAndForegroundPartiallyTransparent_BlendsExactly + - Rgba32_CompositeOver_BothBackgroundAndForegroundFullyTransparent_ReturnsZeroedResult diff --git a/docs/reqstream/canvas-net/drawing/path-stroker.yaml b/docs/reqstream/canvas-net/drawing/path-stroker.yaml index d7e2833e..d54c3c69 100644 --- a/docs/reqstream/canvas-net/drawing/path-stroker.yaml +++ b/docs/reqstream/canvas-net/drawing/path-stroker.yaml @@ -72,15 +72,34 @@ sections: title: >- The PathStroker class shall stroke a closed contour as a shell whose exterior and interior boundaries remain unfilled outside the stroke band and within the - original contour's interior. + original contour's interior, regardless of the contour's coordinate magnitude or + tessellation density, so a thin stroke on a curved contour never falsely + collapses to a solid fill merely because fine tessellation at large coordinate + magnitude amplifies floating-point noise past a fixed absolute tolerance, and so a + genuine half-width-exceeds-inradius collapse is still correctly detected at + realistic tessellation densities regardless of the contour's overall size. justification: | A closed stroked outline is visually a ring, not a solid expansion of the entire shape, so the returned path must preserve the hole between the outer and - inner boundaries. + inner boundaries. This must hold independent of scale: a curved shape flattened + to many short, finely-tessellated segments at large coordinate magnitude (for + example, native PPTX EMU-space geometry) must not have its inner ring falsely + discarded by collapse detection tuned only for small/pixel-scale coordinates, + while a genuine thick-stroke-exceeds-inradius case must still correctly collapse + to a solid fill. The collapse detector's tolerance must also scale with each + edge's own local tessellation density (not merely the contour's overall + coordinate magnitude), since the raw backwards-offset-edge signal it compares + against shrinks proportionally to tessellation density independent of how far the + half-width exceeds the inradius; otherwise a small shape at a realistic + tessellation density stroked only slightly over its own inradius would silently + retain an invalid inner ring instead of collapsing to a solid fill. tests: - PathStroker_Stroke_ClosedRectangle_FillsRingLeavingInteriorAndExteriorUnfilled - StrokeOutliner_Outline_ClosedSquareHalfWidthExceedsInradius_ProducesNoInvalidHole - StrokeOutliner_Outline_ClosedSquareHalfWidthNearButBelowInradius_ProducesValidHole + - StrokeOutliner_Outline_LargeCoordinateFinelyTessellatedClosedContour_ProducesValidThinRing + - StrokeOutliner_Outline_LargeCoordinateFinelyTessellatedSmallCircleThickStroke_CollapsesToSolid + - StrokeOutliner_Outline_SmallCircleRealisticTessellation_HalfWidthJustOverInradius_CollapsesToSolid - id: CanvasNet-Drawing-PathStroker-DegenerateClosedContours title: >- diff --git a/docs/sysml2/model/canvas-net-charts.sysml b/docs/sysml2/model/canvas-net-charts.sysml new file mode 100644 index 00000000..5c6a68af --- /dev/null +++ b/docs/sysml2/model/canvas-net-charts.sysml @@ -0,0 +1,19 @@ +package CanvasNet { + part def CanvasNetChartsSystem { + doc /* CanvasNetCharts is a chart data-model, pixel-rendering, and OOXML-chart-parsing system, distributed as the separate DemaConsulting.CanvasNet.Charts NuGet package, which depends on the core CanvasNet system's Canvas, Drawing, Geometry, Fonts, and Rendering subsystems only. */ + + comment designRef /* Design: docs/design/canvas-net-charts.md */ + comment verificationRef /* Verification: docs/verification/canvas-net-charts.md */ + comment reqRef /* Requirements: docs/reqstream/canvas-net-charts.yaml */ + comment /* CanvasNetCharts depends on the sibling CanvasNet system's Canvas (Rgba32 color type and the Surface pixel buffer ChartRenderer paints onto and returns), Drawing (path filling/stroking), Geometry (transforms/rectangles), Fonts (TrueType text layout/metrics), and Rendering (the bundled fallback font) subsystems only - modeled below as non-owning `dependency` relationships to those existing subsystem parts (not an ordinary `part` usage, which would incorrectly model them as owned children of CanvasNetChartsSystem), since both systems live in this same git repository (though each is distributed as its own separate NuGet package - CanvasNetCharts is not folded into the CanvasNet package) rather than being third-party (OTS) or Shared Package dependencies (both of which are reserved for a package produced by a different repository). CanvasNetCharts must never reference CanvasNetPptx, CanvasNetPdf, CanvasNetSvg, or CanvasNetVsdx - this is a hard architectural constraint, not a phase-specific limitation - see the CanvasNetCharts System Design document's Dependencies section. */ + + part chartModel : ChartModel; + part openXmlChart : OpenXmlChart; + + dependency usesCanvas from CanvasNetChartsSystem to Canvas; + dependency usesDrawing from CanvasNetChartsSystem to Drawing; + dependency usesGeometry from CanvasNetChartsSystem to Geometry; + dependency usesFonts from CanvasNetChartsSystem to Fonts; + dependency usesRendering from CanvasNetChartsSystem to Rendering; + } +} diff --git a/docs/sysml2/model/canvas-net-charts/chart-model.sysml b/docs/sysml2/model/canvas-net-charts/chart-model.sysml new file mode 100644 index 00000000..9c546fc0 --- /dev/null +++ b/docs/sysml2/model/canvas-net-charts/chart-model.sysml @@ -0,0 +1,12 @@ +package CanvasNet { + part def ChartModel { + doc /* Public, immutable, validating chart data model (Chart, ChartSeries, ChartAxis, ChartLegend, ChartTitle, ChartType) and the ChartBuilder fluent construction API, plus (as of Phase 2) a pixel-rendering engine (ChartRenderer). As of this release, its constituent units are ChartDocument and ChartRenderer. */ + + comment designRef /* Design: docs/design/canvas-net-charts/chart-model.md */ + comment verificationRef /* Verification: docs/verification/canvas-net-charts/chart-model.md */ + comment reqRef /* Requirements: docs/reqstream/canvas-net-charts/chart-model.yaml */ + + part chartDocument : ChartDocument; + part chartRenderer : ChartRenderer; + } +} diff --git a/docs/sysml2/model/canvas-net-charts/chart-model/chart-document.sysml b/docs/sysml2/model/canvas-net-charts/chart-model/chart-document.sysml new file mode 100644 index 00000000..844b7f30 --- /dev/null +++ b/docs/sysml2/model/canvas-net-charts/chart-model/chart-document.sysml @@ -0,0 +1,11 @@ +package CanvasNet { + part def ChartDocument { + doc /* The Chart, ChartSeries, ChartAxis, ChartLegend, ChartTitle, ChartType, ChartLegendPosition, and ChartBuilder public types: a fully construction-time-validated, immutable chart data model, with a fluent builder API that delegates all validation to the model constructors. */ + + comment sourceRef /* Source: src/DemaConsulting.CanvasNet.Charts/Chart.cs, src/DemaConsulting.CanvasNet.Charts/ChartSeries.cs, src/DemaConsulting.CanvasNet.Charts/ChartAxis.cs, src/DemaConsulting.CanvasNet.Charts/ChartLegend.cs, src/DemaConsulting.CanvasNet.Charts/ChartTitle.cs, src/DemaConsulting.CanvasNet.Charts/ChartType.cs, src/DemaConsulting.CanvasNet.Charts/ChartBuilder.cs */ + comment testRef /* Test: test/DemaConsulting.CanvasNet.Charts.Tests/ChartTests.cs, test/DemaConsulting.CanvasNet.Charts.Tests/ChartSeriesTests.cs, test/DemaConsulting.CanvasNet.Charts.Tests/ChartAxisTests.cs, test/DemaConsulting.CanvasNet.Charts.Tests/ChartLegendTests.cs, test/DemaConsulting.CanvasNet.Charts.Tests/ChartTitleTests.cs, test/DemaConsulting.CanvasNet.Charts.Tests/ChartTypeTests.cs, test/DemaConsulting.CanvasNet.Charts.Tests/ChartBuilderTests.cs */ + comment designRef /* Design: docs/design/canvas-net-charts/chart-model/chart-document.md */ + comment verificationRef /* Verification: docs/verification/canvas-net-charts/chart-model/chart-document.md */ + comment reqRef /* Requirements: docs/reqstream/canvas-net-charts/chart-model/chart-document.yaml */ + } +} diff --git a/docs/sysml2/model/canvas-net-charts/chart-model/chart-renderer.sysml b/docs/sysml2/model/canvas-net-charts/chart-model/chart-renderer.sysml new file mode 100644 index 00000000..12d93a6f --- /dev/null +++ b/docs/sysml2/model/canvas-net-charts/chart-model/chart-renderer.sysml @@ -0,0 +1,11 @@ +package CanvasNet { + part def ChartRenderer { + doc /* The ChartRenderer, ChartRenderOptions, and ChartColorPalette public types: a pixel-rendering engine that paints a validated Chart onto a new core CanvasNet Canvas.Surface, dispatching per ChartType to paint bars/columns/lines/areas/pie or doughnut wedges, plus axes, a legend, a title, and opt-in data labels. */ + + comment sourceRef /* Source: src/DemaConsulting.CanvasNet.Charts/ChartRenderer.cs, src/DemaConsulting.CanvasNet.Charts/ChartRenderer.Layout.cs, src/DemaConsulting.CanvasNet.Charts/ChartRenderer.CategoryChart.cs, src/DemaConsulting.CanvasNet.Charts/ChartRenderer.PieChart.cs, src/DemaConsulting.CanvasNet.Charts/ChartRenderer.Legend.cs, src/DemaConsulting.CanvasNet.Charts/ChartRenderer.Title.cs, src/DemaConsulting.CanvasNet.Charts/ChartRenderOptions.cs, src/DemaConsulting.CanvasNet.Charts/ChartColorPalette.cs */ + comment testRef /* Test: test/DemaConsulting.CanvasNet.Charts.Tests/ChartRendererTests.cs, test/DemaConsulting.CanvasNet.Charts.Tests/ChartRendererAxisLegendTitleTests.cs, test/DemaConsulting.CanvasNet.Charts.Tests/ChartRendererEdgeCaseTests.cs */ + comment designRef /* Design: docs/design/canvas-net-charts/chart-model/chart-renderer.md */ + comment verificationRef /* Verification: docs/verification/canvas-net-charts/chart-model/chart-renderer.md */ + comment reqRef /* Requirements: docs/reqstream/canvas-net-charts/chart-model/chart-renderer.yaml */ + } +} diff --git a/docs/sysml2/model/canvas-net-charts/open-xml-chart.sysml b/docs/sysml2/model/canvas-net-charts/open-xml-chart.sysml new file mode 100644 index 00000000..2cf8589a --- /dev/null +++ b/docs/sysml2/model/canvas-net-charts/open-xml-chart.sysml @@ -0,0 +1,11 @@ +package CanvasNet { + part def OpenXmlChart { + doc /* Format-agnostic OOXML c:chartSpace/c:chart XML parsing, producing a validated ChartModel Chart instance. As of this release, its sole constituent unit is OpenXmlChartParser. */ + + comment designRef /* Design: docs/design/canvas-net-charts/open-xml-chart.md */ + comment verificationRef /* Verification: docs/verification/canvas-net-charts/open-xml-chart.md */ + comment reqRef /* Requirements: docs/reqstream/canvas-net-charts/open-xml-chart.yaml */ + + part openXmlChartParser : OpenXmlChartParser; + } +} diff --git a/docs/sysml2/model/canvas-net-charts/open-xml-chart/open-xml-chart-parser.sysml b/docs/sysml2/model/canvas-net-charts/open-xml-chart/open-xml-chart-parser.sysml new file mode 100644 index 00000000..5f90fe84 --- /dev/null +++ b/docs/sysml2/model/canvas-net-charts/open-xml-chart/open-xml-chart-parser.sysml @@ -0,0 +1,11 @@ +package CanvasNet { + part def OpenXmlChartParser { + doc /* The OpenXmlChartParser static class and its ChartUnsupportedFeatureException: parses a raw c:chartSpace/c:chart XML element into a validated Chart, reading only cached values (c:numCache/c:strCache) and rejecting an unsupported chart kind or data shape. */ + + comment sourceRef /* Source: src/DemaConsulting.CanvasNet.Charts/OpenXml/OpenXmlChartParser.cs, src/DemaConsulting.CanvasNet.Charts/OpenXml/ChartUnsupportedFeatureException.cs */ + comment testRef /* Test: test/DemaConsulting.CanvasNet.Charts.Tests/OpenXmlChartParserTests.cs */ + comment designRef /* Design: docs/design/canvas-net-charts/open-xml-chart/open-xml-chart-parser.md */ + comment verificationRef /* Verification: docs/verification/canvas-net-charts/open-xml-chart/open-xml-chart-parser.md */ + comment reqRef /* Requirements: docs/reqstream/canvas-net-charts/open-xml-chart/open-xml-chart-parser.yaml */ + } +} diff --git a/docs/sysml2/model/canvas-net-pptx.sysml b/docs/sysml2/model/canvas-net-pptx.sysml new file mode 100644 index 00000000..45d1978c --- /dev/null +++ b/docs/sysml2/model/canvas-net-pptx.sysml @@ -0,0 +1,20 @@ +package CanvasNet { + part def CanvasNetPptxSystem { + doc /* CanvasNetPptx is a PowerPoint (.pptx) presentation-rendering system, distributed as the separate DemaConsulting.CanvasNet.Pptx NuGet package. This feature was delivered incrementally across Phases 1a-1f, each additive to the last. Phase 1a implemented the underlying OOXML (Office Open XML) package layer: opening a .pptx file as a read-only ZipArchive, resolving [Content_Types].xml (default extension mappings and part-specific overrides), and resolving package-level (_rels/.rels) and per-part relationships, including relative-target resolution (for example "../slideLayouts/slideLayout1.xml"). Phase 1b added the presentation/theme/master/layout/slide model and the placeholder property-inheritance resolver: ppt/presentation.xml's declared slide size and ordered slide list, each slide master's theme (color/font scheme, resolved into DemaConsulting.CanvasNet.Canvas.Rgba32 values), structural placeholder-shape parsing for masters/layouts/slides, and the verified ECMA-376 placeholder matching algorithm. Phase 1c added DrawingML shape geometry and paint resolution: shape transform (position/rotation/flip, group child-coordinate-space composition), preset/custom geometry resolved into the core Geometry.Path type, and fill/stroke resolution into core Canvas paint types plus a stroked outline realized via the core Drawing.PathStroker. Phase 1d added DrawingML text body parsing, attribute-level run/paragraph property inheritance (including a slide master's own titleStyle/bodyStyle/otherStyle text-style buckets), word-wrap/alignment/vertical-anchor/autofit text layout, and glyph-ink rendering onto a core Canvas.Surface, resolving typefaces through the core Fonts subsystem and filling glyph outlines via the core Drawing.PathFiller. Phase 1e added dedicated picture-shape decoding/cropping/compositing (dispatching to the core Codecs subsystem), table structure/cell-rect/paint resolution, and recursive, full shape-tree parsing (including nested child-transform composition). Phase 1f added the public, slide-level Render API: a document-order walk of a slide's full shape tree dispatching each node to the already-verified Phase 1c/1d/1e resolvers and painters onto a Canvas.Surface, followed by subsequent Phase 2 Follow-Ups adding slide/layout/master background-fill resolution ahead of that walk, / bullet/numbering rendering, and connector-shape rendering. Pattern/picture background fill, radial/path gradients, full group-shape rendering semantics beyond transform composition, full text justification, shape-resize autofit, kerning, text clipping on overflow, nested tables, and table auto-sizing/banding remain explicitly deferred. */ + + comment testRef /* Test: test/DemaConsulting.CanvasNet.Pptx.Tests/PptxSystemIntegrationTests.cs */ + comment designRef /* Design: docs/design/canvas-net-pptx.md */ + comment verificationRef /* Verification: docs/verification/canvas-net-pptx.md */ + comment reqRef /* Requirements: docs/reqstream/canvas-net-pptx.yaml */ + comment /* CanvasNetPptx depends on the sibling CanvasNet system (referenced via a ProjectReference from its own csproj, matching the architecture convention every package-bounded system in this repository follows). As of Phase 1b, CanvasNetPptx uses the Canvas subsystem's Rgba32 type to represent a theme's resolved color scheme. As of Phase 1c, CanvasNetPptx additionally uses the Geometry subsystem (Path/PathBuilder, to build and compose resolved shape geometry) and the Drawing subsystem (PathStroker/StrokeStyle, to realize a resolved stroke's outline). As of Phase 1d, CanvasNetPptx additionally uses the Fonts subsystem (TrueTypeFont/SystemFontCatalog, to resolve a DrawingML typeface name hint and extract glyph outlines) and the Drawing subsystem's PathFiller (to paint resolved glyph ink onto a Canvas.Surface). As of Phase 1e, CanvasNetPptx additionally uses the Codecs subsystem (PngCodec/JpegCodec/BmpCodec/TiffCodec/GifCodec, dispatched by content type in PptxDocument.Images.cs, to decode a 's embedded raster image) - each modeled below as a non-owning `dependency` relationship to the existing subsystem part def (not an ordinary `part` usage, which would incorrectly model it as an owned child of CanvasNetPptxSystem), mirroring CanvasNetPdf's and CanvasNetSvg's own precedent of adding a `dependency` edge only at the phase that actually first uses the referenced subsystem (for example CanvasNetPdf's Fonts dependency was added only at its own Phase 4, not at Phase 1). As of Phase 4, CanvasNetPptx additionally depends on the sibling CanvasNetCharts system (its own separate package, DemaConsulting.CanvasNet.Charts) - OpenXmlChartParser to parse a 's referenced chart#.xml OPC part into a Chart, and ChartRenderer to render that Chart before compositing it onto the slide surface - modeled below as a `dependency` edge to that sibling system's own part def (CanvasNetChartsSystem), not a `CanvasNet` subsystem; this dependency is strictly one-directional, since CanvasNetCharts must never reference CanvasNetPptx (see docs/design/canvas-net-charts.md's own Dependencies section). */ + + part pptxDocument : PptxDocument; + + dependency usesCanvas from CanvasNetPptxSystem to Canvas; + dependency usesGeometry from CanvasNetPptxSystem to Geometry; + dependency usesDrawing from CanvasNetPptxSystem to Drawing; + dependency usesFonts from CanvasNetPptxSystem to Fonts; + dependency usesCodecs from CanvasNetPptxSystem to Codecs; + dependency usesCharts from CanvasNetPptxSystem to CanvasNetChartsSystem; + } +} diff --git a/docs/sysml2/model/canvas-net-pptx/pptx-document.sysml b/docs/sysml2/model/canvas-net-pptx/pptx-document.sysml new file mode 100644 index 00000000..f4ed24d7 --- /dev/null +++ b/docs/sysml2/model/canvas-net-pptx/pptx-document.sysml @@ -0,0 +1,14 @@ +package CanvasNet { + /* cspell:ignore xfrm prst cust bodyPr pPr rPr txBody txStyles lstStyle defRPr normAutofit noAutofit spAutoFit lnSpcReduction fontScale */ + /* cspell:ignore srcRect blipFill tblGrid gridCol tcPr hMerge vMerge gridSpan rowSpan grpSp grpSpPr cxnSp graphicFrame pattFill spTree */ + /* cspell:ignore tblStyle wholeTbl tcStyle tcBdr insideH insideV bandRow firstRow band1H band2H */ + part def PptxDocument { + doc /* OOXML (Office Open XML) package parser and presentation-rendering codec, delivered incrementally across Phases 1a-1f, each additive to the last. Phase 1a implements the package layer: opens a .pptx file as a read-only ZipArchive, resolves [Content_Types].xml's default extension mappings and part-specific overrides, and resolves package-level (_rels/.rels) and per-part relationships (including relative-target resolution with ".." traversal). Phase 1b adds the presentation/theme/master/layout/slide model: parses ppt/presentation.xml's declared slide size and ordered slide list, resolves each slide master's theme (color/font scheme), structurally parses each master/layout/slide's placeholder shapes, and implements the verified ECMA-376 placeholder property-inheritance resolver (slide -> layout matched by idx alone, layout -> master matched by type alone via a fixed remapping table). Phase 1c adds DrawingML shape geometry and paint resolution: a shape's position/rotation/flip transform, a group's chOff/chExt child-coordinate-space transform composition, preset geometry (28 supported names, including a later-hardening-pass-added curved-arrow family) and custom path-command parsing resolved into the core Path type, and // fill plus stroke resolution into core paint types - including scheme-color lookup through the resolved theme, the lumMod/lumOff/shade/tint/alpha color-transform pipeline, and a stroked outline realized via the core PathStroker. Phase 1d adds DrawingML text body parsing (//////), attribute-level run/paragraph property inheritance through the placeholder/layout/master (including the master's own titleStyle/bodyStyle/otherStyle buckets)/theme chain, word-wrap/alignment/vertical-anchor/autofit text layout, and glyph-ink rendering onto a core Surface via the core Fonts.TrueTypeFont/SystemFontCatalog and Drawing.PathFiller, mirroring the PDF renderer's own glyph-painting pattern. Phase 1e adds dedicated picture-shape support (content-type dispatch mirroring CanvasNetSvg's SvgCodec.ResolveRasterDecoder, crop-rectangle resolution, and y-down nearest-neighbor compositing that deliberately omits the PDF renderer's own y-up vertical flip), table support (structure/cell parsing reusing the Phase 1c/1d fill/border/text-body resolvers verbatim, merge-aware cell-rect resolution, and cell fill/border/text painting), and recursive, full shape-tree parsing across a slide's own /// elements (including nested child-transform composition) with lazy, invoke-on-demand theme resolution so a shape tree with no never resolves a theme at all. Phase 1f adds the public, slide-level rendering API: PptxDocument.Render(int slideIndex, int width, int height, PptxRenderOptions? options) and a DPI convenience overload, which walk a slide's full PptxSlide.ShapeTree recursively in document order with an accumulating transform, dispatching each node kind to the already-verified Phase 1c/1d/1e resolvers/painters verbatim - composing a group's own already-resolved child transform, resolving an ordinary/placeholder shape's geometry/fill/stroke/text (a placeholder's own text is always read from the slide-level shape's own txBody, never from its matched layout placeholder), compositing a picture's embedded image, and painting a graphic frame's own table - clearing the destination Surface to PptxRenderOptions.BackgroundColor (default opaque white) before painting, and silently skipping a shape/picture/graphic-frame with no resolvable xfrm or a picture with no blipFill, mirroring ParseShapeTree's own tolerant-tree-walk precedent. With Phase 1f, the planned PPTX 1.0 feature set was complete. A Phase 2 Follow-Up pass then closed the single highest-visual-impact gap left by Phase 1f: a slide's own / background fill is now resolved - falling back to its layout's, then its master's, own when the slide declares none, then to PptxRenderOptions.BackgroundColor only when none of the three declare one at all - and painted across the full slide before the shape-tree walk, reusing the existing Phase 1c ResolveFill/ResolveColor pipeline verbatim (including a new, optional phClr-override parameter threaded through it for theme-indexed resolution against a new PptxTheme.BgFillStyleList, parsed from /). Solid-color fills and theme-indexed fills are fully supported; a linear gradient background fill is best-effort (inheriting Phase 1c's own linear-only boundary unchanged); a pattern or picture background fill remains explicitly deferred (inheriting ResolveFill's own pre-existing PptxUnsupportedFeatureException boundary unchanged). A non-placeholder shape's own background fill via / inside (picture/pattern fill remains scoped to a dedicated shape's own ), picture effects/shadows, nested tables, table auto-sizing to fit overflowing cell content (each row's resolved height is taken verbatim from its declared value, with no growth to accommodate overflowing cell content), table style/banding (), connector shapes, group-level style cascading beyond transform composition, radial/path gradients, adjustment-value parsing, bullets/numbering, full text justification, shape-resize autofit, kerning, and text clipping on overflow remain out of scope; none of these is a currently planned phase, and any of them remaining important is a candidate for a future, corpus-driven hardening pass (pptx-phase-2), not a scheduled increment. A further Phase 2 Follow-Up pass then closed the master/layout decorative-shape gap: a slide's own master and layout each contribute their own full shape tree - rendered beneath the slide's own shape tree, in master-then-layout-then-slide order, with each matched placeholder shape skipped (a placeholder's own geometry/fill/stroke/text is painted exactly once, from the slide-level shape, via the existing placeholder-inheritance resolver) so only genuine, non-placeholder decorative shapes contributed by the master/layout are painted - reusing the existing Phase 1c-1e geometry/paint/text/picture/table resolvers verbatim. A further Phase 2 Follow-Up pass closed the bullets/numbering gap: a paragraph's character, auto-number marker, or explicit absence is now parsed and resolved through the same four-tier placeholder/layout/master inheritance chain established for other paragraph properties, formatted into its rendered string for the eleven most common Latin-numeral auto-number schemes, and measured and painted at the paragraph's own gutter without ever overlapping its text. A further Phase 2 Follow-Up pass closed the connector-shapes gap: a is now parsed into its own shape-tree node alongside every other shape kind, and the public rendering API paints its resolved straight/elbow/curved line - merging its own line style with its style-reference fallback - and orients an optional stroked or filled arrowhead at either end; an unrecognized connector preset degrades to that one connector being invisible rather than aborting the slide. A subsequent regression fix corrected two real-file rendering failures this pass introduced: a master/layout decorative shape (including one nested inside a group) that itself throws PptxUnsupportedFeatureException while painting is now caught and only that single shape is skipped, so one already-deferred feature (for example an EMF picture) no longer aborts rendering for the rest of the slide - a slide's own shape still hard-fails unchanged; and a declaring only a Microsoft SVG extension fallback (/, the "Insert Icon" SVG-with-no-raster-fallback pattern) is now correctly classified as PptxUnsupportedFeatureException (feature token "pptx-image-svg-only") rather than misclassified as a malformed-data InvalidDataException. A further bug fix corrected a real-world-verified compositing defect confirmed against a user-supplied deck: Phase 1e's own picture compositing loop previously overwrote each destination pixel outright with the raw sampled source pixel, ignoring the source image's own alpha channel entirely, so a PNG logo with genuine RGBA transparency rendered with a solid, incorrect matte rectangle instead of showing the slide background through its transparent area; the loop now alpha-blends each sampled source pixel over the existing destination pixel using the standard Porter-Duff "over" formula, via a new internal, single-pixel Rgba32.CompositeOver(Rgba32, Rgba32) helper shared cross-assembly with (and formula-identical to) CanvasNetPdf's own CompositeImageOntoSurface fix for the same defect. A further Phase 2 Follow-Up pass fixed a color-map-indirection defect: bg1/tx1/bg2/tx2 scheme-color resolution previously hard-coded a fixed bg1->Light1/tx1->Dark1/bg2->Light2/tx2->Dark2 mapping, ignoring the slide master's own required and any layout/slide override; these four aliases now correctly indirect through the effective, per-slide color map (slide overriding layout overriding the master's own , with an marker at any tier falling through to the next) before the existing theme-slot lookup, closing the gap for any non-identity (for example dark/inverted) master or layout. A further bug fix corrected ResolveLineStyle's own stroke-width resolution: a genuinely-absent w attribute previously collapsed to the same "no stroke" outcome as an explicit w="0", so a real-world that declares only a color (relying on PowerPoint's own default stroke weight) rendered with no visible outline at all; a genuinely-absent w now resolves to PowerPoint's own observed default stroke width (9525 EMU / 0.75pt, an application-level convention rather than a formal XSD schema default), while an explicitly-present w="0" or negative w still resolves to no stroke exactly as before - scoped narrowly to ResolveLineStyle itself, leaving ResolveShapeLineStyle's and ResolveConnectorLineStyle's own separate width-merge logic unchanged. A further bug fix corrected a bullet-gutter/text-start-X collision confirmed against a real-world buAutoNum paragraph (marL="320040", lvl="1", no indent attribute, no placeholder/master tier contributing one): PositionLines now clamps a bulleted first line's own text-start-X to never fall short of the bullet glyph's own gutter position plus its own measured width, so the bullet and the paragraph's own text never paint at an overlapping X coordinate, regardless of the paragraph's resolved indent. A further bug fix corrected an underline-rendering defect confirmed against a real-world corpus fixture: a text run's resolved (and every other ECMA-376 underline-style value) was parsed into an effective property that nothing downstream ever consumed, so an explicitly-authored underline rendered with no underline at all; underline style/color now resolve through the same four-tier inheritance chain as every other text property, and PositionLines/PaintTextLayout now emit and paint a per-run underline span - continuous across a run's own interior whitespace but never bridging a run boundary, with thickness/offset approximated proportional to the run's own font size - closing the gap. A further Phase 2 Follow-Up pass closed the table-style/banding gap: a new PptxDocument.TableStyles.cs resolves a /'s against the presentation package's own ppt/tableStyles.xml part (tolerating every "not found" cause by falling back to today's plain, style-less cell rendering without throwing) and applies the matched 's base tier to every cell, its / tiers alternating by non-header row parity when (a tier missing its own fill falling through to the next lower tier rather than to "no fill"), and its tier overriding both for the header row when , with a cell's own explicit fill/border always taking final precedence over every style tier - mirroring ResolveShapeLineStyle's own established "explicit always wins" precedence pattern and reusing the existing ResolveFill/ResolveLineStyle resolvers verbatim for every style-tier color, including schemeClr. firstCol/lastCol/lastRow/corner-cell style parts, column banding (band1V/band2V), and table-style-driven font color remain explicitly deferred. A further Phase 2 Follow-Up pass closed the default-tab-stop-expansion gap confirmed against a real-world corpus slide (literal digit-plus-tab-plus-sentence "numbered list" paragraphs, not buAutoNum bullets): a literal U+0009 TAB run character previously measured via the shared ResolveGlyph font-glyph lookup (resolving to a near-zero .notdef advance for most fonts' own missing cmap entry) now expands to the next default 914400-EMU (1 inch) tab stop instead, isolated as its own single-character token and computed dynamically from the running cursor position for both the word-wrap fit decision and the rendered cursor expansion; explicit tab-stop support remains out of scope, since no corpus slide declares one. */ + + comment sourceRef /* Source: src/DemaConsulting.CanvasNet.Pptx/PptxDocument.cs, src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Package.cs, src/DemaConsulting.CanvasNet.Pptx/NamespaceDoc.cs, src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Presentation.cs, src/DemaConsulting.CanvasNet.Pptx/PptxSlideSize.cs, src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Theme.cs, src/DemaConsulting.CanvasNet.Pptx/PptxTheme.cs, src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Masters.cs, src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Layouts.cs, src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Slides.cs, src/DemaConsulting.CanvasNet.Pptx/PptxPlaceholder.cs, src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Inheritance.cs, src/DemaConsulting.CanvasNet.Pptx/PptxPlaceholderProperties.cs, src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Geometry.cs, src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Paint.cs, src/DemaConsulting.CanvasNet.Pptx/PptxPresetGeometry.cs, src/DemaConsulting.CanvasNet.Pptx/PptxShapeFrame.cs, src/DemaConsulting.CanvasNet.Pptx/PptxPaint.cs, src/DemaConsulting.CanvasNet.Pptx/PptxLineStyle.cs, src/DemaConsulting.CanvasNet.Pptx/PptxUnsupportedFeatureException.cs, src/DemaConsulting.CanvasNet.Pptx/PptxTextBody.cs, src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Text.cs, src/DemaConsulting.CanvasNet.Pptx/PptxEffectiveTextProperties.cs, src/DemaConsulting.CanvasNet.Pptx/PptxDocument.TextInheritance.cs, src/DemaConsulting.CanvasNet.Pptx/PptxTextLayout.cs, src/DemaConsulting.CanvasNet.Pptx/PptxDocument.TextLayout.cs, src/DemaConsulting.CanvasNet.Pptx/PptxDocument.TextRender.cs, src/DemaConsulting.CanvasNet.Pptx/PptxPicture.cs, src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Images.cs, src/DemaConsulting.CanvasNet.Pptx/PptxTable.cs, src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Tables.cs, src/DemaConsulting.CanvasNet.Pptx/PptxDocument.TableStyles.cs, src/DemaConsulting.CanvasNet.Pptx/PptxShapeTree.cs, src/DemaConsulting.CanvasNet.Pptx/PptxDocument.ShapeTree.cs, src/DemaConsulting.CanvasNet.Pptx/PptxRenderOptions.cs, src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Render.cs, src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Background.cs */ + comment testRef /* Test: test/DemaConsulting.CanvasNet.Pptx.Tests/PptxDocumentTests.cs, test/DemaConsulting.CanvasNet.Pptx.Tests/PptxSystemIntegrationTests.cs, test/DemaConsulting.CanvasNet.Pptx.Tests/PptxGeometryTests.cs, test/DemaConsulting.CanvasNet.Pptx.Tests/PptxPaintTests.cs, test/DemaConsulting.CanvasNet.Pptx.Tests/PptxTextTests.cs, test/DemaConsulting.CanvasNet.Pptx.Tests/PptxTextLayoutTests.cs, test/DemaConsulting.CanvasNet.Pptx.Tests/PptxTextRenderTests.cs, test/DemaConsulting.CanvasNet.Pptx.Tests/PptxImagesTests.cs, test/DemaConsulting.CanvasNet.Pptx.Tests/PptxTablesTests.cs, test/DemaConsulting.CanvasNet.Pptx.Tests/PptxGroupsTests.cs, test/DemaConsulting.CanvasNet.Pptx.Tests/PptxRenderTests.cs, test/DemaConsulting.CanvasNet.Pptx.Tests/PptxBackgroundTests.cs, test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixturesCorpusTests.cs, test/DemaConsulting.CanvasNet.Pptx.Tests/PptxColorMapTests.cs */ + comment designRef /* Design: docs/design/canvas-net-pptx/pptx-document.md */ + comment verificationRef /* Verification: docs/verification/canvas-net-pptx/pptx-document.md */ + comment reqRef /* Requirements: docs/reqstream/canvas-net-pptx/pptx-document.yaml */ + } +} diff --git a/docs/sysml2/model/canvas-net/canvas/rgba32.sysml b/docs/sysml2/model/canvas-net/canvas/rgba32.sysml index 582f4d95..bb07dee4 100644 --- a/docs/sysml2/model/canvas-net/canvas/rgba32.sysml +++ b/docs/sysml2/model/canvas-net/canvas/rgba32.sysml @@ -1,6 +1,6 @@ package CanvasNet { part def Rgba32 { - doc /* 32-bit RGBA pixel value type. Exposes Parse(string) and TryParse(string?, out) methods that accept #RRGGBB (alpha=255) and #AARRGGBB hex color literals, case-insensitive. Rejects short forms, missing '#', wrong length, and non-hex characters. */ + doc /* 32-bit RGBA pixel value type. Exposes Parse(string) and TryParse(string?, out) methods that accept #RRGGBB (alpha=255) and #AARRGGBB hex color literals, case-insensitive. Rejects short forms, missing '#', wrong length, and non-hex characters. Also exposes an internal single-pixel CompositeOver(Rgba32, Rgba32) Porter-Duff "over" alpha-blending helper, reused cross-assembly by DemaConsulting.CanvasNet.Pptx's PaintPicture and DemaConsulting.CanvasNet.Pdf's CompositeImageOntoSurface. */ comment sourceRef /* Source: src/DemaConsulting.CanvasNet/Canvas/Rgba32.cs */ comment testRef /* Test: test/DemaConsulting.CanvasNet.Tests/Canvas/Rgba32Tests.cs */ diff --git a/docs/sysml2/views/design-views.sysml b/docs/sysml2/views/design-views.sysml index e0585460..4ad14316 100644 --- a/docs/sysml2/views/design-views.sysml +++ b/docs/sysml2/views/design-views.sysml @@ -20,6 +20,22 @@ package CanvasNet { expose CanvasNetPdfSystem; } + view CanvasNetPptxView { + expose CanvasNetPptxSystem; + } + + view CanvasNetChartsView { + expose CanvasNetChartsSystem; + } + + view ChartModelView { + expose ChartModel; + } + + view OpenXmlChartView { + expose OpenXmlChart; + } + view CanvasView { expose Canvas; } diff --git a/docs/user_guide/introduction.md b/docs/user_guide/introduction.md index a7442dac..c031790d 100644 --- a/docs/user_guide/introduction.md +++ b/docs/user_guide/introduction.md @@ -1784,6 +1784,273 @@ the same row-vector composition convention as `Gradient.WithTransform`. not finite, or when `xStep`/`yStep` is not finite or is zero; thrown by `WithTransform` when the composed transform has a non-finite component. +### Chart + +`Chart` and its companion types (`ChartSeries`, `ChartAxis`, `ChartLegend`, `ChartTitle`, +`ChartType`, `ChartLegendPosition`) are distributed via the separate +`DemaConsulting.CanvasNet.Charts` NuGet package (namespace `DemaConsulting.CanvasNet.Charts`), +which references the core `DemaConsulting.CanvasNet` package - see the Installation section of +the project README. + +`Chart` is an immutable, fully validated description of a chart: its `ChartType`, one or more +`ChartSeries`, and optional `CategoryAxis`/`ValueAxis` (both `ChartAxis`), `Legend` +(`ChartLegend`), `Title` (`ChartTitle`), and default `ColorPalette`. Every constructor argument is +validated eagerly and every caller-supplied collection is defensively copied, so a constructed +`Chart` can be trusted by a renderer without re-validation. + +```csharp +public enum ChartType { Bar, Column, Line, Pie, Doughnut, Area } + +public enum ChartLegendPosition { Top, Bottom, Left, Right, None } + +public sealed class ChartSeries +{ + public ChartSeries( + string name, + IReadOnlyList values, + IReadOnlyList? pointColors = null, + IReadOnlyList? pointLabels = null, + Rgba32? color = null); + + public string Name { get; } + public IReadOnlyList Values { get; } + public IReadOnlyList? PointColors { get; } + public IReadOnlyList? PointLabels { get; } + public Rgba32? Color { get; } +} + +public sealed class ChartAxis +{ + public ChartAxis( + IReadOnlyList? labels = null, + float? minimum = null, + float? maximum = null, + float? tickInterval = null, + string? title = null); + + public IReadOnlyList? Labels { get; } + public float? Minimum { get; } + public float? Maximum { get; } + public float? TickInterval { get; } + public string? Title { get; } +} + +public sealed class ChartLegend +{ + public ChartLegend(ChartLegendPosition position = ChartLegendPosition.Right, bool isVisible = true); + + public ChartLegendPosition Position { get; } + public bool IsVisible { get; } +} + +public sealed class ChartTitle +{ + public ChartTitle(string text, float? fontSize = null); + + public string Text { get; } + public float? FontSize { get; } +} + +public sealed class Chart +{ + public Chart( + ChartType type, + IReadOnlyList series, + ChartAxis? categoryAxis = null, + ChartAxis? valueAxis = null, + ChartLegend? legend = null, + ChartTitle? title = null, + IReadOnlyList? colorPalette = null); + + public ChartType Type { get; } + public IReadOnlyList Series { get; } + public ChartAxis? CategoryAxis { get; } + public ChartAxis? ValueAxis { get; } + public ChartLegend? Legend { get; } + public ChartTitle? Title { get; } + public IReadOnlyList? ColorPalette { get; } +} +``` + +For `Bar`, `Column`, `Line`, and `Area` (the category-based chart kinds), when `CategoryAxis` has +non-null `Labels`, every series' `Values.Count` must exactly equal that label count - `Pie` and +`Doughnut` are exempt, since their single series' values represent proportional wedge shares +rather than per-category values. + +**Exceptions:** + +- `ArgumentNullException`: Thrown when a required reference-type argument (`name`/`values` on + `ChartSeries`; `text` on `ChartTitle`; `series` on `Chart`) is null. +- `ArgumentException`: Thrown for a blank name/title/text, an empty or null-containing values/ + series/labels collection, a non-finite values entry, a per-point color/label collection whose + count does not match `Values.Count`, or a series/category-axis label count mismatch. +- `ArgumentOutOfRangeException`: Thrown for an undefined `ChartType`/`ChartLegendPosition` value, + a non-finite axis minimum/maximum/font size, a tick interval that is not finite or not positive, + or a minimum not strictly less than a supplied maximum. + +### ChartBuilder + +`ChartBuilder` is a fluent, mutable-until-`Build` API for assembling a `Chart` without +constructing every nested type by hand. It performs no independent data-shape validation of its +own beyond `Build()` itself: every other invalid argument surfaces the identical exception the +corresponding model type's constructor would throw. + +```csharp +public sealed class ChartBuilder +{ + public ChartBuilder OfType(ChartType type); + public ChartBuilder AddSeries(ChartSeries series); + public ChartBuilder AddSeries( + string name, + IReadOnlyList values, + IReadOnlyList? pointColors = null, + IReadOnlyList? pointLabels = null, + Rgba32? color = null); + public ChartBuilder WithCategoryAxis(ChartAxis axis); + public ChartBuilder WithCategoryAxis(IReadOnlyList labels, string? title = null); + public ChartBuilder WithValueAxis(ChartAxis axis); + public ChartBuilder WithValueAxis( + float? minimum = null, float? maximum = null, float? tickInterval = null, string? title = null); + public ChartBuilder WithLegend(ChartLegend legend); + public ChartBuilder WithLegend(ChartLegendPosition position = ChartLegendPosition.Right, bool isVisible = true); + public ChartBuilder WithTitle(string text, float? fontSize = null); + public ChartBuilder WithColorPalette(IReadOnlyList colorPalette); + + public Chart Build(); +} +``` + +Every chainable method returns the same `ChartBuilder` instance (`this`), enabling fluent method +chaining; `AddSeries` preserves insertion order across multiple calls. + +**Exceptions:** + +- `InvalidOperationException`: Thrown by `Build()` when `OfType` was never called, or when no + series was added via `AddSeries`. +- Every other exception documented above for the corresponding model type's own constructor. + +### ChartRenderer + +`ChartRenderer` is distributed in the same `DemaConsulting.CanvasNet.Charts` package as `Chart`. +It is a public static entry point that paints a `Chart` onto a new core `CanvasNet` +`Canvas.Surface`, dispatching per `ChartType` to paint bars/columns/lines/areas/pie or doughnut +wedges, plus axes (for the category-based chart kinds), a legend (at any `ChartLegendPosition`), +a title, and opt-in per-point data labels (`ChartSeries.PointLabels`). + +```csharp +public static class ChartRenderer +{ + public static Surface Render(Chart chart, int width, int height, ChartRenderOptions? options = null); + + public static Surface Render( + Chart chart, + float widthInches, + float heightInches, + float dpi, + ChartRenderOptions? options = null); +} +``` + +The first overload renders directly to a caller-specified pixel size. The second computes pixel +dimensions as `round(widthInches * dpi)`/`round(heightInches * dpi)` and delegates to the first - +a `Chart` carries no intrinsic physical page size of its own to scale from (unlike a PDF page or +PPTX slide), so this overload exists purely for callers who prefer to reason in physical +units/DPI the same way `PdfDocument.Render(int, float, PdfRenderOptions?)` does. + +A caller with no explicit series/point colors, color palette, or render options still receives a +fully deterministic, legible rendering: `ChartRenderer` resolves each series'/point's color, when +unspecified, from `Chart.ColorPalette`, then `ChartRenderOptions.ColorPalette`, then +`ChartColorPalette.Default` (a documented, fixed 10-entry categorical palette), wrapping by index +via modulo; text is drawn with the bundled Liberation Sans Regular fallback font unless +`ChartRenderOptions.Font` overrides it. A render target too small to reserve a title/legend/axis +band simply skips that band rather than throwing; a legend with more entries than fit its band +omits the entries that do not fit; an over-long category/legend label is truncated to fit. + +**Exceptions:** + +- `ArgumentNullException`: Thrown when `chart` is null. +- `ArgumentOutOfRangeException`: Thrown when `width`/`height` is outside `Surface`'s own supported + range (propagated from `Surface`'s own constructor); thrown by the DPI overload when + `widthInches`/`heightInches`/`dpi` is not finite or not positive, or when the computed pixel + width/height would exceed `Surface.MaxDimension`. + +### ChartRenderOptions + +`ChartRenderOptions` groups every optional rendering setting `ChartRenderer.Render` accepts, as a +single, extensible options parameter - mirroring `PptxRenderOptions`'s own init-property/ +`Default`-singleton shape. + +```csharp +public sealed class ChartRenderOptions +{ + public static readonly ChartRenderOptions Default; + + public Rgba32 BackgroundColor { get; init; } // default: opaque white + public TrueTypeFont? Font { get; init; } // default: null (bundled fallback font) + public float TitleFontSize { get; init; } // default: 18 + public float AxisFontSize { get; init; } // default: 11 + public float LegendFontSize { get; init; } // default: 11 + public float DataLabelFontSize { get; init; } // default: 10 + public IReadOnlyList? ColorPalette { get; init; } // default: null +} +``` + +Passing `null` (or omitting the `options` parameter entirely) to either `ChartRenderer.Render` +overload uses `ChartRenderOptions.Default`. + +### OpenXmlChartParser + +`OpenXmlChartParser` is distributed in the same `DemaConsulting.CanvasNet.Charts` package as +`Chart`, in the `DemaConsulting.CanvasNet.Charts.OpenXml` namespace. It is a public static entry +point that parses a raw ECMA-376 DrawingML-Charts `c:chartSpace` (or bare `c:chart`) +`System.Xml.Linq` element into a validated `Chart`, directly consumable by `ChartRenderer.Render` +with no further adaptation. It is format-agnostic: it has no knowledge of OPC/ZIP packaging or +any host document format (for example, PresentationML/`.pptx`) - a caller is responsible for +first locating and opening the relevant `chart#.xml` part and handing this parser only the +resulting XML content. + +```csharp +public static class OpenXmlChartParser +{ + public static Chart Parse(XDocument chartDocument); + public static Chart Parse(XElement chartSpaceOrChartElement); +} +``` + +`OpenXmlChartParser` reads only cached values (`c:numCache`/`c:strCache`) - it never recomputes a +value from a sibling `c:f` formula - and supports `c:barChart` (bar/column, classified by +`c:barDir`), `c:lineChart`, `c:pieChart`, `c:doughnutChart`, and `c:areaChart`, extracting series +names/categories/values, the chart title, axis titles/range, and legend presence/position. + +#### Exceptions + +- `ArgumentNullException`: Thrown when `chartDocument`/`chartSpaceOrChartElement` is null. +- `ArgumentException`: Thrown for a document with no root element, an element that is neither a + `c:chart` element nor one containing one, or a `c:chart` with no `c:plotArea` child. +- `ChartUnsupportedFeatureException`: Thrown - carrying a short, stable `Feature` token - for a + recognized-but-unimplemented chart kind (radar, bubble, scatter, stock, surface, 3-D, or "of + pie"), a combo chart (more than one recognized chart-type element in one plot area), a plot + area with no recognized chart-type element at all, or a series whose value has no cached + `c:numCache`. + +```csharp +public class ChartUnsupportedFeatureException : IOException +{ + public ChartUnsupportedFeatureException(); + public ChartUnsupportedFeatureException(string message); + public ChartUnsupportedFeatureException(string message, Exception innerException); + public ChartUnsupportedFeatureException(string feature, string message); + public ChartUnsupportedFeatureException(string feature, string message, Exception innerException); + + public string Feature { get; } // default: string.Empty +} +``` + +When rendering a `.pptx` presentation via the separate `DemaConsulting.CanvasNet.Pptx` package's +`PptxDocument.Render`, an embedded `` chart is automatically located, parsed via +`OpenXmlChartParser`, rendered via `ChartRenderer`, and composited onto the slide - no extra +caller code is required beyond calling `PptxDocument.Render` itself. + # Examples ## Example 1: Surface Pixel Access @@ -2147,6 +2414,81 @@ using var surfaceSvg = SvgCodec.Load(loadStream, 64, 64); Console.WriteLine(surfaceSvg[32, 32].A); // Output: 255 (well inside the filled rectangle) ``` +## Example 14: Building and Rendering a Chart + +```csharp +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Charts; + +// Build a validated Chart fluently: a two-category Column chart with one series. +var chart = new ChartBuilder() + .OfType(ChartType.Column) + .WithCategoryAxis(["Q1", "Q2"]) + .WithValueAxis(minimum: 0f, maximum: 10f) + .AddSeries("Revenue", [4.0, 9.0], color: new Rgba32(31, 119, 180, 255)) + .WithTitle("Quarterly Revenue") + .WithLegend(ChartLegendPosition.Bottom) + .Build(); + +// Render it onto a new 400x300 surface using every rendering default. +using var chartSurface = ChartRenderer.Render(chart, 400, 300); +Console.WriteLine($"{chartSurface.Width}x{chartSurface.Height}"); // Output: 400x300 +``` + +## Example 15: Parsing and Rendering an OOXML Chart + +```csharp +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Charts; +using DemaConsulting.CanvasNet.Charts.OpenXml; +using System.Xml.Linq; + +// Parse a c:chartSpace/c:chart XML document (for example, a chart1.xml OPC part a host +// document-format library has already located and opened). This hand-authored fragment mirrors +// the shape a real PowerPoint chart part's bar chart would carry. +var chartDocument = XDocument.Parse(""" + + + + + + + Revenue + + + + + Q1 + Q2 + + + + + + + + 4.0 + 9.0 + + + + + + + + + + """); +var parsedChart = OpenXmlChartParser.Parse(chartDocument); +Console.WriteLine(parsedChart.Type); // Output: Column + +// The parsed Chart is directly consumable by ChartRenderer, exactly like a Chart built via +// ChartBuilder. +using var parsedChartSurface = ChartRenderer.Render(parsedChart, 400, 300); +Console.WriteLine($"{parsedChartSurface.Width}x{parsedChartSurface.Height}"); // Output: 400x300 +``` + ## Rendering (transform-aware Canvas, text, and shapes) The `Rendering` subsystem adds a transform-aware `Canvas` wrapper over `Surface`, along with diff --git a/docs/verification/canvas-net-charts.md b/docs/verification/canvas-net-charts.md new file mode 100644 index 00000000..d952675e --- /dev/null +++ b/docs/verification/canvas-net-charts.md @@ -0,0 +1,75 @@ +# System Verification Design + +This document describes the system-level verification strategy for CanvasNetCharts. + +## Verification Approach + +`CanvasNetCharts` contains two subsystems: `ChartModel` +(containing `ChartDocument` and `ChartRenderer`) and `OpenXmlChart` (containing +`OpenXmlChartParser`). Every `ChartRenderer` test necessarily exercises both `ChartModel` units +together: each test builds its input `Chart` via `ChartBuilder` (or the model constructors +directly) before rendering it, so `ChartRenderer`'s own unit-level test suite already provides +the cross-unit integration coverage a dedicated system-level test would otherwise exist to +provide (compare `CanvasNetSvg`, whose single `SvgCodec` unit itself integrates several of the +core `CanvasNet` system's subsystems — see _CanvasNetSvg System Verification_, +`canvas-net-svg.md`). Several of `OpenXmlChartParser`'s own tests additionally feed its parsed +`Chart` output directly into `ChartRenderer.Render`, providing the one cross-subsystem +integration this release's two subsystems share, within that same unit-level test suite. System- +level verification within `CanvasNetCharts` itself is therefore provided entirely by +`ChartDocument`'s, `ChartRenderer`'s, and `OpenXmlChartParser`'s own comprehensive unit-level test +suites — see +_ChartDocument Unit Verification_ (`canvas-net-charts/chart-model/chart-document.md`), +_ChartRenderer Unit Verification_ (`canvas-net-charts/chart-model/chart-renderer.md`), and +_OpenXmlChartParser Unit Verification_ +(`canvas-net-charts/open-xml-chart/open-xml-chart-parser.md`) — which exercise every public +type's constructor and every `ChartRenderer.Render`/`OpenXmlChartParser.Parse` overload directly +through their own public API, with no mocking or stubbing required. + +System-level, cross-system integration coverage — exercising the complete pipeline from an opened +PPTX chart part, through `OpenXmlChartParser.Parse`, to a rendered and composited `Surface` — is +provided by `CanvasNetPptx`'s own `PptxChartsTests.cs` and fixture-corpus tests, which open a real +``-referenced `chart#.xml` OPC part and exercise the full pipeline end to end; see +_CanvasNetPptx System Verification_ (`canvas-net-pptx.md`) for that integration's own verification +design. + +## Test Environment + +- **Framework**: xUnit v3 running under the .NET SDK +- **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline +- **Dependencies**: No external services, databases, or network access required +- **Isolation**: Each test method constructs its own test fixtures; no shared state between tests + +## External Interface Simulation + +The system has no external network, service, or file-system interfaces as of this release: every +public API accepts and returns only in-memory values (`Chart`, `ChartSeries`, `ChartAxis`, +`ChartLegend`, `ChartTitle`, `ChartType`, `ChartLegendPosition`, `ChartRenderOptions`, +`System.Xml.Linq`'s `XDocument`/`XElement`, `ChartUnsupportedFeatureException`, and the core +`CanvasNet` system's `Rgba32`/`Surface`). `OpenXmlChartParser` reads a caller-supplied in-memory +XML element directly - it performs no file-system or network access of its own to obtain it, so +no real untrusted-input channel is simulated; its tests instead construct the XML fragment +in-process via `System.Xml.Linq` object construction (or, for one scenario, embed a real-fixture- +derived excerpt as an inline fragment - see _OpenXmlChartParser Unit Verification_). `ChartRenderer`'s +tests verify pixel output by sampling specific `Surface` coordinates (and, where another +always-present painted feature such as a gridline could contaminate an absolute region scan, by a +differential pixel-count comparison between the same chart rendered with and without the feature +under test) — the same coordinate-sampling convention `CanvasNetSvg`/`CanvasNetPdf`/ +`CanvasNetPptx`'s own render tests already use. No simulation of external interfaces is required. + +## System-Level Test Scenarios + +See _ChartModel Subsystem Verification_ (`canvas-net-charts/chart-model.md`), +_ChartDocument Unit Verification_ (`canvas-net-charts/chart-model/chart-document.md`), +_ChartRenderer Unit Verification_ (`canvas-net-charts/chart-model/chart-renderer.md`), +_OpenXmlChart Subsystem Verification_ (`canvas-net-charts/open-xml-chart.md`), and +_OpenXmlChartParser Unit Verification_ +(`canvas-net-charts/open-xml-chart/open-xml-chart-parser.md`) for the complete set of test +scenarios, each already exercising the system's full public API surface directly (there is no +additional system-level wrapping or composition to verify beyond what the unit-level tests +already exercise, given the shape described above). + +## Acceptance Criteria + +A system-level test run passes when every scenario referenced above passes without error or +exception beyond those explicitly asserted. Any unexpected exception, wrong exception type, or +wrong return/property value constitutes a failure. diff --git a/docs/verification/canvas-net-charts/chart-model.md b/docs/verification/canvas-net-charts/chart-model.md new file mode 100644 index 00000000..13be31ba --- /dev/null +++ b/docs/verification/canvas-net-charts/chart-model.md @@ -0,0 +1,37 @@ +## ChartModel Subsystem Verification Design + +This document describes the subsystem-level verification strategy for the `ChartModel` +subsystem, as of this release containing two units: `ChartDocument` and `ChartRenderer`. + +### Verification Approach + +The `ChartModel` subsystem is verified entirely through its two units' own unit tests (see +_ChartDocument Unit Verification Design_, `chart-model/chart-document.md`, and +_ChartRenderer Unit Verification Design_, `chart-model/chart-renderer.md`). No separate +subsystem-level tests otherwise exist, since every `ChartRenderer` test already builds its input +`Chart` via `ChartDocument`'s own public API (`ChartBuilder` or the model constructors directly), +providing the cross-unit coverage a dedicated subsystem-level test would otherwise exist to +provide; the subsystem-level requirements reuse both units' own test evidence directly. + +### Test Environment + +- **Framework**: xUnit v3 running under the .NET SDK +- **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline +- **Mocking**: None required; `ChartDocument`'s only dependency is the core `CanvasNet` system's + `Canvas` subsystem's `Rgba32` unit, and `ChartRenderer`'s dependencies are the core `CanvasNet` + system's `Canvas`/`Drawing`/`Geometry`/`Fonts`/`Rendering` subsystems — all in-house, + already-verified units + +### Acceptance Criteria + +The `ChartModel` subsystem's verification passes when every unit test scenario described in +_ChartDocument Unit Verification Design_ (`chart-model/chart-document.md`) and +_ChartRenderer Unit Verification Design_ (`chart-model/chart-renderer.md`) passes without error +or unexpected exception. + +### Test Scenarios + +The `ChartModel` subsystem's test scenarios are those named in +_ChartDocument Unit Verification Design_ (`chart-model/chart-document.md`) and +_ChartRenderer Unit Verification Design_ (`chart-model/chart-renderer.md`); see those documents +for the complete list. diff --git a/docs/verification/canvas-net-charts/chart-model/chart-document.md b/docs/verification/canvas-net-charts/chart-model/chart-document.md new file mode 100644 index 00000000..b0ecce0d --- /dev/null +++ b/docs/verification/canvas-net-charts/chart-model/chart-document.md @@ -0,0 +1,253 @@ +### ChartDocument Unit Verification Design + +This document describes the unit-level verification strategy for the `Chart`, `ChartSeries`, +`ChartAxis`, `ChartLegend`, `ChartTitle`, `ChartType`, `ChartLegendPosition`, and `ChartBuilder` +types. + +#### Verification Approach + +The `ChartDocument` unit is verified through unit tests that exercise each type's constructor (or, +for `ChartBuilder`, its fluent methods and `Build()`) directly, asserting on the resulting +instance's exposed properties and on thrown exception types. Because `ChartDocument`'s only +dependency is the core `CanvasNet` system's `Rgba32` value type (an in-house, already-verified +unit, not an external service), no mocking or stubbing is required. Tests supply controlled +constructor arguments — valid combinations, every documented invalid-argument case, and +mutable collection instances used to prove defensive copying — and assert on the constructed +instance's properties or the thrown exception's type. + +Unit tests reside in `ChartTests.cs`, `ChartSeriesTests.cs`, `ChartAxisTests.cs`, +`ChartLegendTests.cs`, `ChartTitleTests.cs`, `ChartTypeTests.cs`, and `ChartBuilderTests.cs` +within the `DemaConsulting.CanvasNet.Charts.Tests` project. + +#### Test Environment + +- **Framework**: xUnit v3 running under the .NET SDK +- **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline +- **Mocking**: None required; `ChartDocument`'s only dependency is the in-house `Rgba32` unit +- **Isolation**: Each test method constructs its own instances and, where relevant, its own + mutable source `List`; no shared state between tests + +#### Unit-Level Test Scenarios + +##### CanvasNetCharts-ChartModel-ChartDocument-ChartConstruction: Chart Constructs From Required and Optional Members + +**Tests**: `ChartTests.Constructor_MinimalMembers_SetsRequiredAndLeavesOptionalsUnset`, +`ChartTests.Constructor_AllMembers_SetsAllMembers` + +Constructs a `Chart` with only its required `ChartType` and series, asserting every optional +property (`CategoryAxis`, `ValueAxis`, `Legend`, `Title`, `ColorPalette`) is `null`; and +separately constructs a `Chart` supplying every optional member, asserting each is stored and +returned by reference (`CategoryAxis`/`ValueAxis`/`Legend`/`Title` via `Assert.Same`, +`ColorPalette` via value equality). + +##### CanvasNetCharts-ChartModel-ChartDocument-ChartTypeValidation: Chart Rejects an Undefined ChartType + +**Test**: `ChartTests.Constructor_UndefinedType_ThrowsArgumentOutOfRangeException` + +Constructs a `Chart` with an out-of-range `(ChartType)99` cast value. Asserts +`ArgumentOutOfRangeException` is thrown. + +##### CanvasNetCharts-ChartModel-ChartDocument-SeriesCollectionValidation: Chart Rejects an Invalid Series Collection + +**Tests**: `ChartTests.Constructor_NullSeries_ThrowsArgumentNullException`, +`ChartTests.Constructor_EmptySeries_ThrowsArgumentException`, +`ChartTests.Constructor_NullSeriesEntry_ThrowsArgumentException` + +Constructs a `Chart` with, respectively, a `null` series argument, an empty series collection, +and a series collection containing a `null` entry alongside a valid one. Asserts +`ArgumentNullException` for the `null` argument case and `ArgumentException` for the empty and +null-entry cases. + +##### CanvasNetCharts-ChartModel-ChartDocument-CategoryLengthValidation: Chart Validates Series/Category-Axis Length Consistency + +**Tests**: `ChartTests.Constructor_CategoryBasedTypeWithMatchingLength_Succeeds`, +`ChartTests.Constructor_CategoryBasedTypeWithMismatchedLength_ThrowsArgumentException`, +`ChartTests.Constructor_ProportionalType_IsExemptFromLengthCheck`, +`ChartTests.Constructor_CategoryAxisWithNoLabels_DoesNotTriggerLengthCheck` + +Constructs a `Chart` for each category-based `ChartType` (`Bar`, `Column`, `Line`, `Area`) with a +series whose `Values.Count` matches its category axis's label count (asserting success) and with +a mismatched count (asserting `ArgumentException`); separately constructs a `Chart` for each +proportional-share `ChartType` (`Pie`, `Doughnut`) with a mismatched count (asserting success, +proving the check does not apply), and a category-based `Chart` whose category axis has no +labels (asserting success, proving a value-only axis does not trigger the check). + +##### CanvasNetCharts-ChartModel-ChartDocument-ColorPaletteValidation: Chart Rejects an Empty Color Palette + +**Test**: `ChartTests.Constructor_EmptyColorPalette_ThrowsArgumentException` + +Constructs a `Chart` with a non-null, empty `colorPalette` argument. Asserts +`ArgumentException` is thrown. + +##### CanvasNetCharts-ChartModel-ChartDocument-SeriesValidation: ChartSeries Constructs From Required and Optional Members + +**Tests**: `ChartSeriesTests.Constructor_NameAndValuesOnly_SetsMembersAndLeavesOptionalsUnset`, +`ChartSeriesTests.Constructor_AllMembers_SetsAllMembers` + +Constructs a `ChartSeries` with only its required name and values, asserting every optional +property (`PointColors`, `PointLabels`, `Color`) is `null`; and separately constructs a +`ChartSeries` supplying every optional member, asserting each is stored and returned correctly. + +##### CanvasNetCharts-ChartModel-ChartDocument-SeriesNameValidation: ChartSeries Rejects an Invalid Name + +**Tests**: `ChartSeriesTests.Constructor_NullName_ThrowsArgumentNullException`, +`ChartSeriesTests.Constructor_EmptyOrWhitespaceName_ThrowsArgumentException` + +Constructs a `ChartSeries` with a `null` name, and separately with an empty string and a +whitespace-only string (via `[Theory]`). Asserts `ArgumentNullException` for the `null` case and +`ArgumentException` for the empty/whitespace cases. + +##### CanvasNetCharts-ChartModel-ChartDocument-ValuesValidation: ChartSeries Rejects an Invalid Values Collection + +**Tests**: `ChartSeriesTests.Constructor_NullValues_ThrowsArgumentNullException`, +`ChartSeriesTests.Constructor_EmptyValues_ThrowsArgumentException`, +`ChartSeriesTests.Constructor_NonFiniteValueEntry_ThrowsArgumentException` + +Constructs a `ChartSeries` with, respectively, a `null` values argument, an empty values +collection, and a values collection containing `NaN`/`PositiveInfinity`/`NegativeInfinity` (via +`[Theory]`). Asserts `ArgumentNullException` for the `null` case and `ArgumentException` for the +empty and non-finite cases. + +##### CanvasNetCharts-ChartModel-ChartDocument-PerPointCollectionValidation: ChartSeries Rejects Mismatched Per-Point Collections + +**Tests**: `ChartSeriesTests.Constructor_PointColorsCountMismatch_ThrowsArgumentException`, +`ChartSeriesTests.Constructor_PointLabelsCountMismatch_ThrowsArgumentException`, +`ChartSeriesTests.Constructor_NullPointLabelEntry_ThrowsArgumentException` + +Constructs a `ChartSeries` with a `pointColors` collection whose count does not match `values`'s +count, a `pointLabels` collection whose count does not match, and a `pointLabels` collection +containing a `null` entry. Asserts `ArgumentException` for each case. + +##### CanvasNetCharts-ChartModel-ChartDocument-AxisValidation: ChartAxis Constructs From Labels, Range, and Title + +**Tests**: `ChartAxisTests.Constructor_LabelsOnly_SetsLabelsAndLeavesRangeUnset`, +`ChartAxisTests.Constructor_ValueRangeOnly_SetsRangeAndLeavesLabelsUnset`, +`ChartAxisTests.Constructor_LabelsAndRangeAndTitle_SetsAllMembers` + +Constructs a `ChartAxis` with only labels (asserting `Minimum`/`Maximum`/`TickInterval`/`Title` +are `null`), with only a value range (asserting `Labels` is `null`), and with every member +supplied (asserting each is stored). + +##### CanvasNetCharts-ChartModel-ChartDocument-AxisLabelValidation: ChartAxis Validates Label Entries + +**Tests**: `ChartAxisTests.Constructor_EmptyStringLabel_IsAccepted`, +`ChartAxisTests.Constructor_NullLabelEntry_ThrowsArgumentException` + +Constructs a `ChartAxis` with an empty-string label entry (asserting success, proving a blank +category label is legitimate) and with a `null` label entry (asserting `ArgumentException`). + +##### CanvasNetCharts-ChartModel-ChartDocument-AxisRangeValidation: ChartAxis Validates Minimum/Maximum/TickInterval + +**Tests**: `ChartAxisTests.Constructor_NonFiniteMinimum_ThrowsArgumentOutOfRangeException`, +`ChartAxisTests.Constructor_NonFiniteMaximum_ThrowsArgumentOutOfRangeException`, +`ChartAxisTests.Constructor_MinimumEqualToMaximum_ThrowsArgumentOutOfRangeException`, +`ChartAxisTests.Constructor_MinimumGreaterThanMaximum_ThrowsArgumentOutOfRangeException`, +`ChartAxisTests.Constructor_InvalidTickInterval_ThrowsArgumentOutOfRangeException` + +Constructs a `ChartAxis` with a non-finite minimum, a non-finite maximum (both via `[Theory]` +over `NaN`/`PositiveInfinity`/`NegativeInfinity`), a minimum equal to the maximum, a minimum +greater than the maximum, and an invalid tick interval (`[Theory]` over `0`, a negative value, +and `NaN`/`PositiveInfinity`). Asserts `ArgumentOutOfRangeException` for every case. + +##### CanvasNetCharts-ChartModel-ChartDocument-AxisTitleValidation: ChartAxis Rejects an Invalid Title + +**Tests**: `ChartAxisTests.Constructor_WhitespaceTitle_ThrowsArgumentException`, +`ChartAxisTests.Constructor_EmptyTitle_ThrowsArgumentException` + +Constructs a `ChartAxis` with a whitespace-only title and an empty title. Asserts +`ArgumentException` for both cases. + +##### CanvasNetCharts-ChartModel-ChartDocument-LegendValidation: ChartLegend Applies Defaults, Rejects Undefined State + +**Tests**: `ChartLegendTests.Constructor_Defaults_IsVisibleAndRightPositioned`, +`ChartLegendTests.Constructor_EachDefinedPosition_IsAccepted`, +`ChartLegendTests.Constructor_IsVisibleFalse_IsStored`, +`ChartLegendTests.Constructor_UndefinedPosition_ThrowsArgumentOutOfRangeException` + +Constructs a `ChartLegend` with no arguments (asserting `Position == Right` and +`IsVisible == true`), with each defined `ChartLegendPosition` value (`[Theory]`, asserting +success), with `isVisible: false` (asserting it is stored), and with an out-of-range +`(ChartLegendPosition)99` cast value (asserting `ArgumentOutOfRangeException`). + +##### CanvasNetCharts-ChartModel-ChartDocument-TitleValidation: ChartTitle Constructs From Text and an Optional Font Size + +**Tests**: `ChartTitleTests.Constructor_TextOnly_SetsTextAndLeavesFontSizeUnset`, +`ChartTitleTests.Constructor_TextAndFontSize_SetsBothMembers`, +`ChartTitleTests.Constructor_NullText_ThrowsArgumentNullException`, +`ChartTitleTests.Constructor_EmptyText_ThrowsArgumentException`, +`ChartTitleTests.Constructor_WhitespaceText_ThrowsArgumentException`, +`ChartTitleTests.Constructor_InvalidFontSize_ThrowsArgumentOutOfRangeException` + +Constructs a `ChartTitle` with only text, with text and a font size, with a `null` text argument, +with an empty text argument, with a whitespace-only text argument, and with an invalid font size +(`[Theory]` over `0`, a negative value, and `NaN`/`PositiveInfinity`/`NegativeInfinity`). Asserts +the success cases store the expected properties, `ArgumentNullException` for the `null` case, +`ArgumentException` for the empty/whitespace cases, and `ArgumentOutOfRangeException` for the +invalid font size cases. + +##### CanvasNetCharts-ChartModel-ChartDocument-DefensiveCopies: Every Collection Argument Is Defensively Copied + +**Tests**: `ChartTests.Constructor_MutatingOriginalSeriesList_DoesNotAffectStoredSnapshot`, +`ChartTests.Constructor_MutatingOriginalColorPaletteList_DoesNotAffectStoredSnapshot`, +`ChartSeriesTests.Constructor_MutatingOriginalValuesList_DoesNotAffectStoredSnapshot`, +`ChartSeriesTests.Constructor_MutatingOriginalPointColorsList_DoesNotAffectStoredSnapshot`, +`ChartSeriesTests.Constructor_MutatingOriginalPointLabelsList_DoesNotAffectStoredSnapshot`, +`ChartAxisTests.Constructor_MutatingOriginalLabelsList_DoesNotAffectStoredSnapshot` + +Constructs each type from a mutable `List`, then mutates (adds to and/or overwrites an +element of) the original list after construction. Asserts the constructed instance's exposed +property still reflects the original, pre-mutation contents, proving the constructor copied the +collection rather than wrapping or referencing it directly. + +##### CanvasNetCharts-ChartModel-ChartDocument-BuilderFluentChaining: ChartBuilder Methods Chain and Preserve Series Order + +**Tests**: `ChartBuilderTests.FluentMethods_EachReturnSameBuilderInstance`, +`ChartBuilderTests.Build_WithMultipleSeriesAddedViaChartSeriesOverload_PreservesOrder` + +Calls every `ChartBuilder` fluent method and asserts (`Assert.Same`) each returns the same +builder instance; separately adds two series via the pre-built `ChartSeries` overload of +`AddSeries` and asserts `Build()`'s resulting `Chart.Series` preserves insertion order. + +##### CanvasNetCharts-ChartModel-ChartDocument-BuilderDelegatesValidation: ChartBuilder Forwards Constructor Exceptions + +**Tests**: `ChartBuilderTests.AddSeries_InvalidName_PropagatesChartSeriesConstructorException`, +`ChartBuilderTests.AddSeries_NullChartSeries_PropagatesArgumentNullException`, +`ChartBuilderTests.WithCategoryAxis_NullChartAxis_PropagatesArgumentNullException`, +`ChartBuilderTests.WithValueAxis_NullChartAxis_PropagatesArgumentNullException`, +`ChartBuilderTests.WithValueAxis_InvalidRange_PropagatesChartAxisConstructorException`, +`ChartBuilderTests.WithLegend_NullChartLegend_PropagatesArgumentNullException`, +`ChartBuilderTests.WithTitle_WhitespaceText_PropagatesChartTitleConstructorException`, +`ChartBuilderTests.WithColorPalette_Null_ThrowsArgumentNullException`, +`ChartBuilderTests.Build_SeriesLengthMismatchedWithCategoryAxis_PropagatesChartConstructorException` + +Calls each `ChartBuilder` method with an argument the corresponding model constructor would +itself reject (an invalid series name, a `null` pre-built `ChartSeries`/`ChartAxis`/ +`ChartLegend`, an invalid axis range, whitespace-only title text, a `null` color palette, and a +configuration that produces a series/category-axis length mismatch only once `Build()` calls +`Chart`'s own constructor). Asserts each surfaces the identical exception type the corresponding +model constructor documents. + +##### CanvasNetCharts-ChartModel-ChartDocument-BuilderIncompleteBuildFails: ChartBuilder.Build Fails on Incomplete Input + +**Tests**: `ChartBuilderTests.Build_TypeNeverSet_ThrowsInvalidOperationException`, +`ChartBuilderTests.Build_NoSeriesAdded_ThrowsInvalidOperationException` + +Calls `Build()` after adding a series but never calling `OfType`, and separately after calling +`OfType` but never adding a series. Asserts `InvalidOperationException` is thrown in both cases. + +##### CanvasNetCharts-ChartModel-ChartDocument-TypeEnumeration: ChartType Defines Exactly the Phase 1 Chart Kinds + +**Tests**: `ChartTypeTests.AllV1Members_AreDefined`, `ChartTypeTests.UndefinedValue_IsNotDefined`, +`ChartTypeTests.UndefinedValue_RejectedByChartConstructor` + +Asserts `Enum.IsDefined` is `true` for each of `Bar`, `Column`, `Line`, `Pie`, `Doughnut`, and +`Area` (via `[Theory]`); asserts `Enum.IsDefined` is `false` for an out-of-range cast value; and +asserts `Chart`'s constructor rejects that same out-of-range value with +`ArgumentOutOfRangeException`, cross-checking the enumeration test against the consuming type's +own validation. + +#### Acceptance Criteria + +A unit-level test run passes when every scenario above passes without error or exception beyond +those explicitly asserted. Any unexpected exception, wrong exception type, or wrong property +value constitutes a failure. diff --git a/docs/verification/canvas-net-charts/chart-model/chart-renderer.md b/docs/verification/canvas-net-charts/chart-model/chart-renderer.md new file mode 100644 index 00000000..54a63160 --- /dev/null +++ b/docs/verification/canvas-net-charts/chart-model/chart-renderer.md @@ -0,0 +1,216 @@ +### ChartRenderer Unit Verification Design + +This document describes the unit-level verification strategy for the `ChartRenderer`, +`ChartRenderOptions`, and `ChartColorPalette` types. + +#### Verification Approach + +The `ChartRenderer` unit is verified through tests that call `ChartRenderer.Render` (both +overloads) with a `Chart` built via `ChartBuilder` (or the model constructors directly), then +assert on the returned `Surface`: its pixel dimensions, specific-coordinate pixel sampling (an +exact color, or presence/absence of non-background content), and, where another always-present +painted feature (such as a value-axis gridline spanning the full plot width at every tick +y-position) would contaminate an absolute region scan, a differential pixel-count comparison +between the same chart definition rendered with and without the feature under test. Because +`ChartRenderer`'s dependencies are the core `CanvasNet` system's own public rendering primitives +(`Surface`, `PathBuilder`, `TrueTypeFont`/text layout, `Rgba32`), already-verified, in-house +units, no mocking or stubbing is required. + +Unit tests reside in `ChartRendererTests.cs`, `ChartRendererAxisLegendTitleTests.cs`, and +`ChartRendererEdgeCaseTests.cs` within the `DemaConsulting.CanvasNet.Charts.Tests` project. + +#### Test Environment + +- **Framework**: xUnit v3 running under the .NET SDK +- **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline +- **Mocking**: None required; `ChartRenderer`'s dependencies are the in-house `Surface`, + `PathBuilder`, `TrueTypeFont`, and `Rgba32` units +- **Isolation**: Each test method constructs its own `Chart` and renders its own `Surface`; no + shared state between tests + +#### Unit-Level Test Scenarios + +##### CanvasNetCharts-ChartModel-ChartRenderer-PixelRender: ChartRenderer Renders to a Caller-Specified Pixel Size + +**Tests**: `ChartRendererTests.Render_PixelOverloadNullChart_ThrowsArgumentNullException`, +`ChartRendererTests.Render_PixelOverloadInvalidSize_ThrowsArgumentOutOfRangeException` + +Calls `Render(Chart, int, int, ChartRenderOptions?)` with a `null` chart (asserting +`ArgumentNullException`) and with invalid width/height combinations (`[Theory]`, asserting +`ArgumentOutOfRangeException` propagated from `Surface`'s own constructor). + +##### CanvasNetCharts-ChartModel-ChartRenderer-DpiRender: ChartRenderer Renders From a Physical Width/Height/DPI + +**Tests**: `ChartRendererTests.Render_DpiOverloadNullChart_ThrowsArgumentNullException`, +`ChartRendererTests.Render_DpiOverloadInvalidInputs_ThrowsArgumentOutOfRangeException`, +`ChartRendererTests.Render_DpiOverloadExceedsMaxDimension_ThrowsArgumentOutOfRangeException`, +`ChartRendererTests.Render_DpiOverload_ComputesExpectedPixelDimensions` + +Calls `Render(Chart, float, float, float, ChartRenderOptions?)` with a `null` chart (asserting +`ArgumentNullException`), with non-finite/non-positive width/height/dpi combinations (`[Theory]`, +asserting `ArgumentOutOfRangeException`), with inputs whose computed pixel dimension exceeds +`Surface.MaxDimension` (asserting `ArgumentOutOfRangeException`), and with valid inputs (asserting +the returned `Surface`'s `Width`/`Height` equal `round(widthInches * dpi)`/ +`round(heightInches * dpi)`). + +##### CanvasNetCharts-ChartModel-ChartRenderer-ChartTypeDispatch: ChartRenderer Dispatches Every Defined ChartType + +**Tests**: `ChartRendererTests.Render_EveryChartType_ProducesCorrectlySizedSurface`, +`ChartRendererTests.Render_Bar_FullScaleBarPaintsInteriorPixel`, +`ChartRendererTests.Render_Column_FullScaleBarPaintsInteriorPixel`, +`ChartRendererTests.Render_Line_StrokesVisibleSegmentBetweenPoints`, +`ChartRendererTests.Render_Area_FillsRegionAndStrokesTopEdge` + +Renders a `Chart` of each defined `ChartType` (`[Theory]`), asserting the returned `Surface`'s +dimensions match the request; separately renders a representative `Bar`/`Column`/`Line`/`Area` +chart and samples a pixel known (by deterministic geometry) to fall inside the painted +bar/line/area content, asserting it is non-background. + +##### CanvasNetCharts-ChartModel-ChartRenderer-PieDoughnutWedgeLayout: ChartRenderer Lays Out Pie/Doughnut Wedges Proportionally + +**Tests**: `ChartRendererTests.Render_Pie_SingleCategory_FillsEntireCircleWithoutDegenerateArc`, +`ChartRendererTests.Render_Pie_TwoEqualCategories_SplitsIntoRightAndLeftWedges`, +`ChartRendererTests.Render_Doughnut_SingleCategory_FillsRingAndLeavesHoleUnpainted` + +Renders a single-category `Pie` chart and samples pixels at several angles around the circle, +asserting every sampled pixel is non-background (proving the single 100%-share wedge painted a +full circle rather than a degenerate zero-length arc); renders a two-equal-category `Pie` chart +and samples one pixel on each half, asserting each half resolved to its own series' color; +renders a single-category `Doughnut` chart and samples a pixel in the ring and a pixel in the +central hole, asserting the ring pixel is non-background and the hole pixel remains the +background color. + +##### CanvasNetCharts-ChartModel-ChartRenderer-DefaultStyling: ChartRenderer Applies Default Styling and Colors + +**Tests**: `ChartRendererTests.Render_CustomBackgroundColor_ClearsToThatColor`, +`ChartRendererTests.Render_TransparentBackgroundColor_ClearsToTransparent`, +`ChartRendererTests.Render_SeriesWithoutExplicitColor_ResolvesToDefaultPaletteInOrder`, +`ChartRendererTests.Render_ChartColorPaletteOverride_TakesPriorityOverDefaultPalette`, +`ChartRendererTests.Render_OptionsColorPaletteOverride_UsedWhenChartSuppliesNone` + +Renders with a custom `ChartRenderOptions.BackgroundColor` and samples a corner pixel, asserting +it equals that color; separately renders with a fully transparent background color and asserts +the sampled pixel's alpha is zero; renders a chart whose series supply no explicit color and +asserts each series' painted color matches `ChartColorPalette.Default`'s corresponding by-index +entry; renders a chart whose `Chart.ColorPalette` is supplied and asserts it takes priority over +`ChartColorPalette.Default`; renders a chart whose `ChartRenderOptions.ColorPalette` is supplied +(and `Chart.ColorPalette` is not) and asserts it is used in preference to +`ChartColorPalette.Default`. + +##### CanvasNetCharts-ChartModel-ChartRenderer-TitleRendering: ChartRenderer Paints an Optional Title Band + +**Tests**: `ChartRendererAxisLegendTitleTests.Render_WithTitle_PaintsContentInTitleBand`, +`ChartRendererAxisLegendTitleTests.Render_WithTitle_PushesPlotContentDownComparedToNoTitle` + +Renders a chart with `Chart.Title` set and counts non-background pixels within the reserved +title band, asserting a strictly greater count than the same chart rendered with no title +(a differential comparison, since the band itself is otherwise always background); separately +renders the same chart with and without a title and asserts the plot area's own content is +measurably shifted downward when a title band is reserved. + +##### CanvasNetCharts-ChartModel-ChartRenderer-LegendRendering: ChartRenderer Paints a Legend at Any Position + +**Tests**: `ChartRendererAxisLegendTitleTests.Render_LegendTop_PaintsSwatchesInTopBand`, +`ChartRendererAxisLegendTitleTests.Render_LegendBottom_PaintsSwatchesInBottomBand`, +`ChartRendererAxisLegendTitleTests.Render_LegendLeft_PaintsSwatchesInLeftBand`, +`ChartRendererAxisLegendTitleTests.Render_LegendRight_PaintsSwatchesInRightBand`, +`ChartRendererAxisLegendTitleTests.Render_LegendRight_NarrowsPlotAreaComparedToNoLegend`, +`ChartRendererAxisLegendTitleTests.Render_LegendPositionNone_RendersSuccessfullyWithoutLegendBand`, +`ChartRendererAxisLegendTitleTests.Render_LegendNotVisible_RendersSuccessfullyWithoutLegendBand` + +Renders a chart with `ChartLegend.Position` set to each of `Top`/`Bottom`/`Left`/`Right` and +counts non-background pixels within the corresponding reserved band, asserting (by differential +comparison against the same chart with no legend) that swatches were painted there; separately +renders with a `Right` legend and asserts the plot area is measurably narrower than the same +chart with no legend; renders with `Position: None` and with `IsVisible: false`, asserting +successful rendering with no legend band reserved in either case. + +##### CanvasNetCharts-ChartModel-ChartRenderer-LegendOverflowByOmission: ChartRenderer Omits Entries On Legend Overflow + +**Test**: `ChartRendererEdgeCaseTests.Render_ManySeries_LegendDegradesByOmissionWithoutError` + +Renders a chart with a series count far exceeding what its legend band can display at a modest +render-target size, asserting the render completes without exception and the surface is +returned at the requested dimensions, proving the excess entries were omitted rather than +overflowing or throwing. + +##### CanvasNetCharts-ChartModel-ChartRenderer-AxisRendering: ChartRenderer Paints Category/Value Axes + +**Test**: `ChartRendererAxisLegendTitleTests.Render_WithAxes_PaintsNonBackgroundContentInReservedMargins` + +Renders a category-based chart with both a category axis and a value axis configured, and +samples pixels within the reserved axis margins, asserting non-background content (gridlines, +tick marks, and/or tick labels) is present there. + +##### CanvasNetCharts-ChartModel-ChartRenderer-NegativeValueHandling: ChartRenderer Handles Values Spanning Zero + +**Tests**: +`ChartRendererEdgeCaseTests.Render_Column_NegativeAndPositiveValues_PaintsBothAboveAndBelowZeroBaseline`, +`ChartRendererEdgeCaseTests.Render_Bar_NegativeAndPositiveValues_PaintsBothLeftAndRightOfZeroBaseline`, +`ChartRendererEdgeCaseTests.Render_Line_NegativeAndPositiveValues_RendersSuccessfully`, +`ChartRendererEdgeCaseTests.Render_Area_NegativeAndPositiveValues_RendersSuccessfully` + +Renders a `Column` chart with a series spanning negative and positive values and samples pixels +both above and below the computed zero baseline, asserting non-background content on both sides; +renders the equivalent `Bar` chart and samples both left and right of the baseline; renders the +equivalent `Line`/`Area` charts and asserts successful rendering without exception. + +##### CanvasNetCharts-ChartModel-ChartRenderer-DataLabelRendering: ChartRenderer Paints Opt-In Data Labels + +**Tests**: `ChartRendererAxisLegendTitleTests.Render_PointWithDataLabel_PaintsVisibleTextNearPoint`, +`ChartRendererAxisLegendTitleTests.Render_PointWithoutDataLabel_RendersDeterministicallyWithNoLabelArtifacts` + +Renders a (sub-maximum-value, to leave genuine headroom above the bar) series with +`ChartSeries.PointLabels` supplied and counts non-background pixels near the data point, +asserting (by differential comparison against the same chart with `PointLabels` omitted) that +label text was painted; separately asserts the two renders are pixel-for-pixel deterministic +when `PointLabels` is omitted from both. + +##### CanvasNetCharts-ChartModel-ChartRenderer-SinglePointSeries: ChartRenderer Renders a Single-Point Series Successfully + +**Tests**: `ChartRendererEdgeCaseTests.Render_Bar_SingleDataPoint_RendersSuccessfully`, +`ChartRendererEdgeCaseTests.Render_Column_SingleDataPoint_RendersSuccessfully`, +`ChartRendererEdgeCaseTests.Render_Line_SingleDataPoint_RendersSuccessfully`, +`ChartRendererEdgeCaseTests.Render_Area_SingleDataPoint_RendersSuccessfully`, +`ChartRendererEdgeCaseTests.Render_Pie_SingleValue_RendersFullCircleWedge`, +`ChartRendererEdgeCaseTests.Render_Doughnut_SingleValue_RendersFullCircleWedge` + +Renders a `Bar`/`Column`/`Line`/`Area` chart whose single series contains exactly one data point, +and a `Pie`/`Doughnut` chart whose single series contains exactly one value, asserting each +renders successfully without exception (and, for `Pie`/`Doughnut`, that the full circle is +painted). + +##### CanvasNetCharts-ChartModel-ChartRenderer-LongLabelTruncation: ChartRenderer Truncates Over-Long Labels + +**Tests**: `ChartRendererEdgeCaseTests.Render_VeryLongCategoryLabel_RendersSuccessfullyWithoutOverflow`, +`ChartRendererEdgeCaseTests.Render_VeryLongLegendLabel_RendersSuccessfullyWithoutOverflow` + +Renders a chart with an unusually long category-axis label, and separately with an unusually +long series/legend name, at a modest render-target size, asserting both render successfully +without exception. + +##### CanvasNetCharts-ChartModel-ChartRenderer-RenderTargetSizeExtremes: ChartRenderer Handles Tiny and Huge Render Targets + +**Tests**: +`ChartRendererEdgeCaseTests.Render_OnePixelByOnePixelTarget_RendersSuccessfullyForEveryChartType`, +`ChartRendererEdgeCaseTests.Render_VerySmallRenderTargets_RenderSuccessfully`, +`ChartRendererEdgeCaseTests.Render_MaximumDimensionRenderTarget_RendersSuccessfully` + +Renders every `ChartType` at a 1x1 pixel target (`[Theory]`), asserting success; renders at +several other minimal sizes up to roughly 10x10 pixels, asserting success; renders at the +largest `Surface`-supported dimension (8192x8192), asserting success. All assert the absence of +any thrown exception. + +##### CanvasNetCharts-ChartModel-ChartRenderer-ManySeriesGroupedLayout: ChartRenderer Narrows Bars for a Large Series Count + +**Test**: `ChartRendererEdgeCaseTests.Render_ManySeriesGroupedColumn_NarrowsBarsRatherThanOverlapping` + +Renders a grouped `Column` chart with a series count large enough that naive full-width bars +would overlap within their shared category band, asserting the render completes without +exception, proving each series' bar width was narrowed to fit. + +#### Acceptance Criteria + +A unit-level test run passes when every scenario above passes without error or exception beyond +those explicitly asserted. Any unexpected exception, wrong exception type, or wrong pixel/ +dimension value constitutes a failure. diff --git a/docs/verification/canvas-net-charts/open-xml-chart.md b/docs/verification/canvas-net-charts/open-xml-chart.md new file mode 100644 index 00000000..92ba9ecd --- /dev/null +++ b/docs/verification/canvas-net-charts/open-xml-chart.md @@ -0,0 +1,35 @@ +## OpenXmlChart Subsystem Verification Design + +This document describes the subsystem-level verification strategy for the `OpenXmlChart` +subsystem, as of this release containing one unit: `OpenXmlChartParser`. + +### Verification Approach + +The `OpenXmlChart` subsystem is verified entirely through its one unit's own unit tests (see +_OpenXmlChartParser Unit Verification Design_, `open-xml-chart/open-xml-chart-parser.md`). No +separate subsystem-level tests otherwise exist: with a single unit, there is no cross-unit +integration surface within this subsystem to additionally verify, mirroring the same rationale +already documented for the `ChartModel` subsystem (see _ChartModel Subsystem Verification +Design_, `chart-model.md`). Several of `OpenXmlChartParser`'s own tests additionally hand its +parsed output directly to `ChartModel`'s `ChartRenderer.Render`, which exercises the one +cross-subsystem call this subsystem's unit makes, within the same unit-level test suite. + +### Test Environment + +- **Framework**: xUnit v3 running under the .NET SDK +- **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline +- **Mocking**: None required; `OpenXmlChartParser`'s dependencies are `System.Xml.Linq`, + `System.Globalization`, and the `ChartModel` subsystem's own already-verified, in-house + `ChartDocument` unit + +### Acceptance Criteria + +The `OpenXmlChart` subsystem's verification passes when every unit test scenario described in +_OpenXmlChartParser Unit Verification Design_ (`open-xml-chart/open-xml-chart-parser.md`) passes +without error or unexpected exception. + +### Test Scenarios + +The `OpenXmlChart` subsystem's test scenarios are those named in +_OpenXmlChartParser Unit Verification Design_ (`open-xml-chart/open-xml-chart-parser.md`); see +that document for the complete list. diff --git a/docs/verification/canvas-net-charts/open-xml-chart/open-xml-chart-parser.md b/docs/verification/canvas-net-charts/open-xml-chart/open-xml-chart-parser.md new file mode 100644 index 00000000..8f40385b --- /dev/null +++ b/docs/verification/canvas-net-charts/open-xml-chart/open-xml-chart-parser.md @@ -0,0 +1,202 @@ +### OpenXmlChartParser Unit Verification Design + +This document describes the unit-level verification strategy for the `OpenXmlChartParser` static +class and its `ChartUnsupportedFeatureException`. + +#### Verification Approach + +The `OpenXmlChartParser` unit is verified through unit tests that call `Parse(XDocument)`/ +`Parse(XElement)` with hand-authored, schema-legal ECMA-376 `c:chartSpace`/`c:chart` fragments +(and, for one scenario, a trimmed, styling-stripped excerpt derived directly from a real +PowerPoint-authored `chart1.xml` part), asserting on the resulting `Chart`'s properties or on a +thrown exception's type and (for `ChartUnsupportedFeatureException`) its `Feature` token. Because +`OpenXmlChartParser`'s only in-house dependency is the already-verified `ChartModel` subsystem's +`ChartDocument` unit, no mocking or stubbing is required. Several scenarios additionally feed the +parsed `Chart` directly into `ChartRenderer.Render`, reusing the Phase 2 pixel-sampling test +convention to prove the parser's output is directly renderable with no further adaptation. + +Unit tests reside in `OpenXmlChartParserTests.cs` within the +`DemaConsulting.CanvasNet.Charts.Tests` project. Hand-authored XML fragments are built inline via +`System.Xml.Linq` object construction; the one real-fixture-derived fragment is documented, with +its provenance, in `test/DemaConsulting.CanvasNet.Charts.Tests/OpenXmlChartFixtures/README.md`. + +#### Test Environment + +- **Framework**: xUnit v3 running under the .NET SDK +- **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline +- **Mocking**: None required; `OpenXmlChartParser`'s dependencies are `System.Xml.Linq`, + `System.Globalization`, and the in-house `ChartDocument` unit +- **Isolation**: Each test method constructs its own `XElement`/`XDocument` fragment; no shared + state between tests + +#### Unit-Level Test Scenarios + +##### CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-ArgumentValidation: OpenXmlChartParser Validates Its Input Shape + +**Tests**: `OpenXmlChartParserTests.Parse_NullDocument_ThrowsArgumentNullException`, +`OpenXmlChartParserTests.Parse_NullElement_ThrowsArgumentNullException`, +`OpenXmlChartParserTests.Parse_DocumentWithNoRoot_ThrowsArgumentException`, +`OpenXmlChartParserTests.Parse_ElementWithNoChartChild_ThrowsArgumentException`, +`OpenXmlChartParserTests.Parse_ChartWithNoPlotArea_ThrowsArgumentException`, +`OpenXmlChartParserTests.Parse_ChartSpaceWrapper_ParsesSameAsBareChart`, +`OpenXmlChartParserTests.Parse_Document_ParsesSameAsElement` + +Calls each `Parse` overload with a `null` argument, a rootless `XDocument`, an element with no +`c:chart` descendant, and a `c:chart` with no `c:plotArea` child. Asserts `ArgumentNullException` +for the `null` cases and `ArgumentException` for the three structurally invalid cases. Separately +parses an identical chart both as a bare `c:chart` element and wrapped in a full `c:chartSpace`, +and both via `Parse(XElement)` and `Parse(XDocument)`, asserting both pairs produce an +equivalent `Chart`. + +##### CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-BarChartClassification: OpenXmlChartParser Classifies by c:barDir + +**Tests**: `OpenXmlChartParserTests.Parse_BarChartWithBarDirection_ClassifiesAsBar`, +`OpenXmlChartParserTests.Parse_BarChartWithColumnDirection_ClassifiesAsColumn`, +`OpenXmlChartParserTests.Parse_BarChartWithNoBarDirection_DefaultsToColumn` + +Parses a `c:barChart` with `c:barDir val="bar"`, with `c:barDir val="col"`, and with no +`c:barDir` child at all. Asserts the resulting `Chart.Type` is `ChartType.Bar` for the first case +and `ChartType.Column` for the latter two. + +##### CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-OtherChartTypeClassification: OpenXmlChartParser Classifies Line/Pie/Doughnut/Area + +**Tests**: `OpenXmlChartParserTests.Parse_LineChart_ClassifiesAsLine`, +`OpenXmlChartParserTests.Parse_AreaChart_ClassifiesAsArea`, +`OpenXmlChartParserTests.Parse_PieChart_ClassifiesAsPieWithPointLabels`, +`OpenXmlChartParserTests.Parse_DoughnutChart_ClassifiesAsDoughnut` + +Parses a `c:lineChart`, a `c:areaChart`, a `c:pieChart`, and a `c:doughnutChart`. Asserts the +resulting `Chart.Type` matches each element respectively, and - for the `Pie` case - that each +series' own `PointLabels` (rather than a shared `Chart.CategoryAxis`) carries the parsed +category text. + +##### CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-SeriesNameExtraction: OpenXmlChartParser Extracts a Series Name + +**Tests**: `OpenXmlChartParserTests.Parse_SeriesWithCachedName_ExtractsName`, +`OpenXmlChartParserTests.Parse_SeriesWithNoTx_DefaultsToSeriesIndexName`, +`OpenXmlChartParserTests.Parse_SeriesWithLiteralTxValue_ExtractsName` + +Parses a series with a cached `c:tx/c:strRef/c:strCache` name, a series with no `c:tx` element +at all, and a series with a literal `c:tx/c:v` value. Asserts the resulting `ChartSeries.Name` +is the cached text, a 1-based `"Series N"` default, and the literal text, respectively. + +##### CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-ValueExtraction: OpenXmlChartParser Extracts Cached Values + +**Tests**: `OpenXmlChartParserTests.Parse_SparseNumCache_MissingIndexDefaultsToZero`, +`OpenXmlChartParserTests.Parse_MultipleSeries_ParsesAllInOrder` + +Parses a `c:numCache` whose `c:pt` entries skip an index within its declared `c:ptCount`, and +separately parses a plot area with more than one `c:ser` element. Asserts the missing index +resolves to `0.0`, and that every series is parsed, in document order, into +`Chart.Series`. + +##### CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-CategoryExtraction: OpenXmlChartParser Extracts Categories + +**Tests**: `OpenXmlChartParserTests.Parse_NumericCategories_ConvertsToInvariantCultureStrings`, +`OpenXmlChartParserTests.Parse_CategoryBasedChart_ExtractsCategoryAxisLabels` + +Parses a `c:cat/c:numCache` (rather than a `c:strCache`), and separately parses a category-based +chart's first series' `c:cat/c:strCache`. Asserts the numeric cache's values are converted to +`CultureInfo.InvariantCulture`-formatted strings, and that the shared `Chart.CategoryAxis.Labels` +reflects the first series' categories. + +##### CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-TitleExtraction: OpenXmlChartParser Extracts a Chart Title + +**Tests**: `OpenXmlChartParserTests.Parse_TitleWithCachedText_ExtractsTitle`, +`OpenXmlChartParserTests.Parse_TitleWithRichText_ConcatenatesRuns`, +`OpenXmlChartParserTests.Parse_TitlePresentWithNoTx_MapsToNullTitle`, +`OpenXmlChartParserTests.Parse_NoTitleElement_MapsToNullTitle` + +Parses a `c:title/c:tx/c:strRef/c:strCache`, a `c:title/c:tx/c:rich` body with more than one +`a:t` run, a `c:title` with no `c:tx` child, and a chart with no `c:title` element at all. +Asserts the resulting `Chart.Title.Text` is the cached text, the concatenation of every run, and +`null` for the latter two cases. + +##### CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-LegendExtraction: Extracts Legend Presence/Position + +**Tests**: `OpenXmlChartParserTests.Parse_NoLegendElement_MapsToNullLegend`, +`OpenXmlChartParserTests.Parse_LegendPosition_MapsToExpectedChartLegendPosition`, +`OpenXmlChartParserTests.Parse_LegendWithNoPosition_DefaultsToRight` + +Parses a chart with no `c:legend` element, a `c:legend` with each of `c:legendPos` `"t"`/`"b"`/ +`"l"`/`"r"`/`"tr"` (via `[Theory]`), and a `c:legend` with no `c:legendPos` child. Asserts +`Chart.Legend` is `null` for the first case, the expected `ChartLegendPosition` for each defined +value (with `"tr"` approximated to `Right`), and `Right` for the missing-position case. + +##### CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-AxisExtraction: OpenXmlChartParser Extracts Axis Titles/Range + +**Tests**: `OpenXmlChartParserTests.Parse_CatAxTitle_ExtractsCategoryAxisTitle`, +`OpenXmlChartParserTests.Parse_ValAx_ExtractsRangeAndTitle`, +`OpenXmlChartParserTests.Parse_NoValAx_MapsToNullValueAxis` + +Parses a `c:catAx` with a `c:title`, a `c:valAx` with `c:scaling/c:min`, `c:scaling/c:max`, +`c:majorUnit`, and its own `c:title`, and a chart with no `c:valAx` element at all. Asserts the +category axis title, and the value axis's `Minimum`/`Maximum`/`TickInterval`/`Title`, are +extracted correctly, and that `Chart.ValueAxis` is `null` for the absent case. + +##### CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-UnsupportedChartTypeRejection: Rejects Unsupported Chart Kinds + +**Tests**: `OpenXmlChartParserTests.Parse_UnsupportedChartType_ThrowsWithExpectedFeatureToken`, +`OpenXmlChartParserTests.Parse_EmptyPlotArea_ThrowsWithNoChartTypeFeatureToken` + +Parses a plot area containing, in turn (via `[Theory]`), each of `c:radarChart`, +`c:scatterChart`, `c:bubbleChart`, `c:stockChart`, `c:surfaceChart`, `c:surface3DChart`, +`c:bar3DChart`, `c:line3DChart`, `c:pie3DChart`, `c:area3DChart`, and `c:ofPieChart`, and +separately a plot area with no chart-type element at all. Asserts +`ChartUnsupportedFeatureException` is thrown with the documented `Feature` token for each kind, +and `"charts-openxml-no-chart-type"` for the empty case. + +##### CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-ComboChartRejection: OpenXmlChartParser Rejects a Combo Chart + +**Test**: `OpenXmlChartParserTests.Parse_ComboChart_ThrowsWithComboChartFeatureToken` + +Parses a plot area containing both a `c:barChart` and a `c:lineChart`. Asserts +`ChartUnsupportedFeatureException` is thrown with `Feature` `"charts-openxml-combo-chart"`. + +##### CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-UncachedValuesRejection: Rejects a Series With No Cached Values + +**Test**: `OpenXmlChartParserTests.Parse_SeriesWithUncachedValues_ThrowsWithUncachedValuesFeatureToken` + +Parses a series whose `c:val/c:numRef` has no `c:numCache` child. Asserts +`ChartUnsupportedFeatureException` is thrown with `Feature` `"charts-openxml-uncached-values"`. + +##### CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-ChartUnsupportedFeatureException: Carries a Feature Token + +**Tests**: `OpenXmlChartParserTests.Parse_SeriesWithUncachedValues_ThrowsWithUncachedValuesFeatureToken`, +`OpenXmlChartParserTests.Parse_UnsupportedChartType_ThrowsWithExpectedFeatureToken`, +`OpenXmlChartParserTests.Parse_ComboChart_ThrowsWithComboChartFeatureToken`, +`OpenXmlChartParserTests.Parse_EmptyPlotArea_ThrowsWithNoChartTypeFeatureToken` + +Each test above additionally asserts the thrown `ChartUnsupportedFeatureException`'s `Feature` +property equals the exact documented token for its scenario, and that the exception is an +`IOException`, proving the exception's shape in the same pass as its triggering condition. + +##### CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-RealFixtureEndToEnd: OpenXmlChartParser Parses a Real chart1.xml Excerpt + +**Tests**: `OpenXmlChartParserTests.Parse_RealFixtureBarChartExcerpt_ParsesExpectedShape`, +`OpenXmlChartParserTests.Parse_ThenRender_RealFixtureBarChartExcerpt_ProducesCorrectlySizedSurface` + +Parses a trimmed, styling-stripped excerpt derived directly from the `aiden0z/pptx-renderer` +corpus's real `chart1.xml` part (a stacked column chart, three series, four categories, a +styled-but-textless title, title-less axes, and a bottom legend - see +`test/DemaConsulting.CanvasNet.Charts.Tests/OpenXmlChartFixtures/README.md` for full provenance). +Asserts the resulting `Chart`'s type, series count/names/values, category labels, `Title` +(`null`), and `Legend` (`Bottom`) all match the real part's actual content, and separately feeds +that same parsed `Chart` into `ChartRenderer.Render`, asserting the returned `Surface`'s +dimensions match the requested render size. + +##### CanvasNetCharts-OpenXmlChart-OpenXmlChartParser-RenderRoundTrip: Output Renders for Every Supported Type + +**Test**: `OpenXmlChartParserTests.Parse_ThenRender_EveryChartType_ProducesCorrectlySizedSurface` + +For each of `Bar`, `Column`, `Line`, `Pie`, `Doughnut`, and `Area` (via `[Theory]`), parses a +minimal but complete hand-authored chart fragment of that kind and feeds the resulting `Chart` +directly into `ChartRenderer.Render`. Asserts the returned `Surface`'s `Width`/`Height` match the +requested render size, proving the parser's output is directly consumable by the Phase 2 +renderer with no further adaptation, for every supported chart kind. + +#### Acceptance Criteria + +A unit-level test run passes when every scenario above passes without error or exception beyond +those explicitly asserted. Any unexpected exception, wrong exception type, wrong `Feature` token, +or wrong property value constitutes a failure. diff --git a/docs/verification/canvas-net-pdf/pdf-document.md b/docs/verification/canvas-net-pdf/pdf-document.md index afcdc9b3..54c14b38 100644 --- a/docs/verification/canvas-net-pdf/pdf-document.md +++ b/docs/verification/canvas-net-pdf/pdf-document.md @@ -64,6 +64,14 @@ end-to-end rather than only the empty-password shortcut. Unit tests reside in `PdfDocumentTests.cs` within the `DemaConsulting.CanvasNet.Pdf.Tests` project; encryption-specific tests reside in the sibling `PdfDocumentEncryptionTests.cs`. +The private `CompositeImageOntoSurface` image-alpha-compositing regression test resides in its +own sibling `PdfDocumentImageCompositingTests.cs`, which invokes that private method directly via +reflection against a `PdfDocument` instance created with +`System.Runtime.CompilerServices.RuntimeHelpers.GetUninitializedObject` (bypassing its private +constructor, which requires a real, parseable PDF byte buffer) since `CompositeImageOntoSurface` +only reads/writes the instance's own `_surface` field, which the test sets directly - the same +reflection-based internals-testing approach `PdfDocumentSymbolicEncodingTests.cs` already +established for a private static field. ### Test Environment @@ -656,7 +664,8 @@ Renders a bare `l` with no preceding `m`/`re`, asserting `InvalidDataException`. `PdfDocument_PathOps_FillEvenOdd_PaintsExpectedPixels`, `PdfDocument_PathOps_Stroke_PaintsExpectedPixels`, `PdfDocument_PathOps_CloseAndFillAndStroke_PaintsExpectedPixels`, `PdfDocument_PathOps_NoOp_DiscardsPathWithoutPainting`, -`PdfDocument_PathOps_PaintOperator_ClearsPathButPreservesGraphicsState` +`PdfDocument_PathOps_PaintOperator_ClearsPathButPreservesGraphicsState`, +`PdfDocument_PathOps_StrokeOnlyClosedBezierCircle_RendersThinRingNotSolidDisc` Renders a filled rectangle (`f`), asserting an interior pixel is opaque black and an exterior pixel remains transparent. Renders two nested, same-winding rectangles (`f*`), asserting the outer @@ -670,6 +679,18 @@ rectangles filled under the same scaled `cm`, asserting the second path's fill l own expected region - proving the first path was cleared after its own `f` rather than accumulating into the second, while the surrounding CTM was preserved across the clear. +`PdfDocument_PathOps_StrokeOnlyClosedBezierCircle_RendersThinRingNotSolidDisc` is a shared-library +regression guard (see `../canvas-net/drawing/path-stroker.md`'s "Inner-ring collapse" section and +`../../design/canvas-net-pdf/pdf-document.md`'s "Shared `StrokeOutliner` false-collapse +investigation" note): it strokes-only (no fill) a closed circular path built from four cubic +`c` Bezier curves, asserting the circle's own center pixel remains the (transparent) background - +proving the ring's own interior is not filled solid - while a pixel on the circle's own boundary is +opaque black, proving the ring itself still paints. This exercises the exact `PaintStroke` -> +`Drawing.PathStroker.Stroke` -> `Drawing.StrokeOutliner` code path shared with `PptxDocument`, +guarding PDF against the false inner-ring-collapse regression even though PDF's own +stroke-after-transform architecture made it unlikely to manifest for typical PDF device-space +coordinate magnitudes. + #### CanvasNetPdf-PdfDocument-Dispose: Dispose Is Idempotent **Test**: `PdfDocument_Dispose_CalledTwice_DoesNotThrow` @@ -948,7 +969,8 @@ bits before each row, rather than the implementation coincidentally tolerating z `PdfDocument_Images_UnsupportedColorSpace_ThrowsUnsupportedImageFeatureException`, `PdfDocument_Images_DoOperator_UndefinedXObjectName_ThrowsInvalidDataException`, `PdfDocument_Images_DoOperator_MalformedOperandCount_ThrowsInvalidDataException`, -`CanvasNetPdf_SystemIntegration_PdfRender_ImageXObjectPlacement_CompositesExpectedPixels` +`CanvasNetPdf_SystemIntegration_PdfRender_ImageXObjectPlacement_CompositesExpectedPixels`, +`CompositeImageOntoSurface_SourceImageWithAlphaChannel_AlphaBlendsOntoExistingBackground` Places a small, raw 8-bit `DeviceGray`/`DeviceRGB`/`DeviceCMYK` `FlateDecode` image XObject via `cm`/`Do`, asserting the four composited device pixels match the image's four known source @@ -965,7 +987,15 @@ renders `Do` with a malformed operand count/type, asserting `InvalidDataExceptio The end-to-end system-integration test independently proves the same `Do` compositing against a real, hand-authored fixture, asserting specific composited pixel colors at specific coordinates matching the fixture's known 2x2 source image, and a pixel outside the placed image's -device-space footprint remains transparent. +device-space footprint remains transparent. A dedicated regression test (regression guard for a +confirmed real-world raw-overwrite alpha-compositing bug, invoking the private +`CompositeImageOntoSurface` method directly via reflection, since every image XObject this +package currently decodes is always fully opaque and so cannot exercise non-opaque sampling +end-to-end) proves each sampled source pixel is alpha-blended "over" the existing destination +pixel - a fully transparent source pixel with a non-matching stored RGB leaves the background +completely unchanged, a fully opaque source pixel exactly replaces it, and a partially +transparent source pixel blends to the exact expected bytes per the documented Porter-Duff "over" +formula. #### CanvasNetPdf-PdfDocument-FormXObjects: Do Executes Nested Form XObject Content Streams @@ -1403,6 +1433,7 @@ silently ignored like the CMap's own PostScript resource-management wrapper keyw **Tests**: `PdfDocument_BuildResolvedFont_Standard14NoEmbeddedFont_ResolvesViaFallback`, `PdfDocument_BuildResolvedFont_NonStandard14FlagsOnlyUnmatchedFamily_ResolvesViaBundledFallback`, `PdfDocument_BuildResolvedFont_SymbolicFlagWithoutEmbeddedFont_ThrowsSymbolicNotEmbeddedException`, +`PdfDocument_BuildResolvedFont_PrimaryMatchMissesCodepoint_FallsBackToBundledLiberationSans`, `CanvasNetPdf_SystemIntegration_RenderStandard14HelveticaWithoutEmbeddedFont_PaintsVisibleGlyphInk`, `CanvasNetPdf_SystemIntegration_RenderOtherSymbolicFontWithoutEmbeddedFont_ThrowsUnsupportedImageFeatureException` @@ -1427,6 +1458,28 @@ operating system), and the other-symbolic-font case asserts the render throws a regression guard proving the new Symbol/ZapfDingbats substitution path did not loosen this fail-closed policy for any other symbolic font. +`PdfDocument_BuildResolvedFont_PrimaryMatchMissesCodepoint_FallsBackToBundledLiberationSans` +proves the ordinary (non-Symbol/non-ZapfDingbats) substitution path's extended 2-element candidate +list (primary match, then the bundled Liberation fallback) actually resolves a codepoint the +primary match itself does not cover, rather than painting `.notdef`. Renders two fixtures and +asserts pixel-identity between them - "any pixel was painted" is deliberately **not** sufficient +here, since a real/synthetic `.notdef` glyph commonly has its own non-empty (visible box) outline, +so that weaker assertion would pass even without this fix. The "actual" fixture uses an unmatched +fixed-pitch family name (deliberately diverting `Fonts.SystemFontCatalog.FindBestMatchCore`'s +tier-2 generic-family matching to whichever monospace font the host provides) with +`/Encoding/Differences [1 /uni0237]` mapping code `1` to U+0237 (LATIN SMALL LETTER DOTLESS J) - a +codepoint present in the bundled `LiberationSans-Regular.ttf` but absent from +`LiberationMono-Regular.ttf`/`LiberationSerif-Regular.ttf` (confirmed via `fontTools`), so a +fixed-pitch request's likely system substitute (for example Courier New, itself also missing +U+0237) must fall through to the bundled Liberation Sans fallback to resolve it at all. The +"reference" fixture sidesteps `Fonts.SystemFontCatalog.FindBestMatch`'s own host-dependent +behavior entirely by embedding the bundled `LiberationSans-Regular.ttf` bytes directly as its own +`/FontFile2` (read via reflection over `Fonts.SystemFontCatalog`'s embedded-resource assembly), +guaranteeing a deterministic, environment-independent rendering of that exact glyph to compare +against. This test was deliberately verified (via a temporary `git stash` of the production fix, +rebuild, and re-run) to fail without the fix and pass with it, confirming it is a meaningful +regression guard and not a vacuously-passing assertion. + #### CanvasNetPdf-PdfDocument-SymbolZapfDingbatsFallback: Symbol/ZapfDingbats Resolve via a Bundled Noto Union **Tests**: `SymbolEncodingTable_RepresentativeSample_MapsToKnownCorrectCodepoints`, diff --git a/docs/verification/canvas-net-pptx.md b/docs/verification/canvas-net-pptx.md new file mode 100644 index 00000000..eacece87 --- /dev/null +++ b/docs/verification/canvas-net-pptx.md @@ -0,0 +1,188 @@ +# System Verification Design + + + +This document describes the system-level verification strategy for CanvasNetPptx. + +## Verification Approach + +The CanvasNetPptx system is verified through system-level integration tests that exercise the +library as a whole from the perspective of a consumer. Most tests drive the library purely +through its public API (`Open`, `SlideCount`, `SlideSize`, and the `Render` overloads) and assert +on observable outputs, with no mocking or stubbing required at the system level. A subset of +tests additionally call internal primitives (`GetSlide`/`GetLayout`/`GetMaster`/`GetTheme`, +`ResolveShapeFrame`, `ResolveTextLayout`, `PaintTextLayout`, and an injected font resolver) that +are accessible only via the `InternalsVisibleTo` grant to the +`DemaConsulting.CanvasNet.Pptx.Tests` project (see the `PptxDocument` project file) - these calls +exercise a specific phase's resolution pipeline directly, from a real, package-resolved input, +rather than re-deriving it from hand-built fragments as the unit-level tests do, while still +proving the feature works end to end through the full package-load path. + +System tests reside in `PptxSystemIntegrationTests.cs` within the +`DemaConsulting.CanvasNet.Pptx.Tests` project. + +## Test Environment + +- **Framework**: xUnit v3 running under the .NET SDK +- **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline +- **Dependencies**: No external services, databases, or network access required +- **Isolation**: Each test method constructs its own test fixtures; no shared state between tests + +## External Interface Simulation + +The system has no external network or service interfaces requiring simulation - no HTTP calls, +databases, or remote services are involved. It does support local file-path-based I/O through its +public API (`Open(string path)` reads a `.pptx` package from a local file path), but system tests +exercise this entirely through hand-authored, in-memory `ZipArchive`-built packages passed via +`Open(Stream)` rather than a file-based fixture - no file-based fixture exists for this system +(see _PptxDocument Unit Verification Design_, `canvas-net-pptx/pptx-document.md`, for why). +System tests call the public API directly with controlled inputs and verify returned values and +thrown exceptions. + +## System-Level Test Scenarios + +### Integration: Pptx Open Succeeds On Well Formed Package + +**Test**: `CanvasNetPptx_SystemIntegration_PptxOpen_SucceedsOnWellFormedPackage` + +Exercises end-to-end system behavior for `Open`/package-layer resolution: opens a minimal, +well-formed, in-memory OPC package through the public API, then asserts `SlideCount`/`SlideSize` +resolve to the expected values declared by the package's `ppt/presentation.xml`. Confirms that +opening a well-formed `.pptx`-shaped package and navigating its content-type/relationship graph +integrate correctly through the system's own public entry point, and serves as the shared +platform-proof test referenced by every `CanvasNetPptx-Platform-*` requirement. + +### Integration: Pptx Open Validation Null Null Stream Throws Argument Null Exception + +**Test**: `CanvasNetPptx_SystemIntegration_PptxOpenValidationNull_NullStreamThrowsArgumentNullException` + +Exercises end-to-end system behavior for null-argument validation: calls the public `Open(Stream)` +API with a null stream. Asserts `ArgumentNullException` is thrown, confirming the documented +validation contract is honored at the system's own public entry point, before any parsing is +attempted. + +### Integration: Pptx Open Validation Empty Path Empty Path Throws Argument Exception + +**Test**: `CanvasNetPptx_SystemIntegration_PptxOpenValidationEmptyPath_EmptyPathThrowsArgumentException` + +Exercises end-to-end system behavior for empty-path-argument validation: calls the public +`Open(string)` API with `string.Empty`. Asserts `ArgumentException` is thrown, confirming the +documented validation contract is honored at the system's own public entry point, before any +file access is attempted. + +### Integration: Pptx Open Presentation Slide Count And Size Resolve End To End + +**Test**: `CanvasNetPptx_SystemIntegration_PptxOpenPresentation_SlideCountAndSizeResolveEndToEnd` + +Exercises end-to-end system behavior for Phase 1b's presentation parsing: opens a two-slide, +well-formed, in-memory `.pptx`-shaped package through the public API and asserts `SlideCount` +and `SlideSize` resolve to the package's declared ``/`` values, confirming +the presentation model integrates correctly through the system's own public entry point. + +### Integration: Pptx Open Validation Empty Slide List Throws Invalid Data Exception + +**Test**: `CanvasNetPptx_SystemIntegration_PptxOpenValidationEmptySlideList_ThrowsInvalidDataException` + +Exercises end-to-end system behavior for presentation validation: opens a package whose +`` is declared but empty, asserting `InvalidDataException` is thrown from the +public `Open` entry point, confirming the fail-closed validation contract for a non-navigable +presentation is honored end-to-end. + +### Integration: Geometry And Paint Freeform Shape Resolves End To End + +**Test**: `CanvasNetPptx_SystemIntegration_GeometryAndPaint_FreeformShapeResolvesEndToEnd` + +Exercises end-to-end system behavior for Phase 1c's shape geometry and paint resolution: opens a +full presentation package whose one slide contains a single freeform `` declaring a rotated +``, an ``, a theme-scheme-color ``, and an +`` stroke. Confirms the geometry transform, preset-geometry resolution, and fill/stroke +paint resolution all compose correctly when driven through the full package-load path (slide -> +layout -> master -> theme), not just from hand-built fragments. + +### Integration: Geometry And Paint Group Shape Child Transform Composes End To End + +**Test**: `CanvasNetPptx_SystemIntegration_GeometryAndPaint_GroupShapeChildTransformComposesEndToEnd` + +Exercises end-to-end system behavior for Phase 1c's group child-coordinate-space transform: opens +a full presentation package whose one slide contains a `` (with its own `` +declaring both the group's parent-space placement and its child coordinate space) wrapping a +single child ``. Confirms the composed group/child transform resolves correctly end-to-end. + +### Integration: Text Layout And Render Title Placeholder Resolves Master Title Style End To End + +**Test**: `CanvasNetPptx_SystemIntegration_TextLayoutAndRender_TitlePlaceholderResolvesMasterTitleStyleEndToEnd` + +Exercises end-to-end system behavior for Phase 1d's text property inheritance and layout: opens a +full presentation package whose one slide contains a `title` placeholder shape with a `` +run declaring no font size of its own. Confirms the run's effective font size resolves through the +real slide -> layout -> master placeholder-matching chain all the way to the master's own +``, and that the resulting text body lays out and paints without error. + +### Integration: Text Layout And Render Non Placeholder Shape Resolves Master Other Style End To End + +**Test**: `CanvasNetPptx_SystemIntegration_TextLayoutAndRender_NonPlaceholderShapeResolvesMasterOtherStyleEndToEnd` + +Exercises end-to-end system behavior for Phase 1d's text property inheritance: opens a full +presentation package whose one slide contains an ordinary, non-placeholder text box. Confirms the +`placeholderType: ""` sentinel convention correctly selects the master's `` bucket +(not ``) when driven through a real, non-placeholder shape resolved from the full +package-load path. + +### Integration: Images Picture Decodes And Renders End To End + +**Test**: `CanvasNetPptx_SystemIntegration_Images_PictureDecodesAndRendersEndToEnd` + +Exercises end-to-end system behavior for Phase 1e's picture-shape resolution: opens a full +presentation package whose one slide contains a single `` referencing an embedded, +single-pixel PNG. Confirms the shape is parsed into the slide's shape tree as a picture node and +that `Render` decodes and composites the embedded image onto the output surface at the expected +location, driven entirely through the public `PptxDocument` surface. + +### Integration: Tables Graphic Frame Parses And Renders Cell Fill End To End + +**Test**: `CanvasNetPptx_SystemIntegration_Tables_GraphicFrameParsesAndRendersCellFillEndToEnd` + +Exercises end-to-end system behavior for Phase 1e's table resolution: opens a full presentation +package whose one slide contains a single `` declaring an `` with one +solid-filled cell. Confirms the shape is parsed into the slide's shape tree with the expected row/ +cell structure and that `Render` paints the cell's resolved fill across its own cell rectangle. + +### Integration: Shape Tree Nested Group Enumerates And Composes Transform End To End + +**Test**: `CanvasNetPptx_SystemIntegration_ShapeTree_NestedGroupEnumeratesAndComposesTransformEndToEnd` + +Exercises end-to-end system behavior for Phase 1e's recursive shape-tree parsing: opens a full +presentation package whose one slide contains a `` nested two levels deep, wrapping a +single freeform shape. Confirms the full nested structure is recovered in the slide's shape tree +and that `Render` composes both levels' own child transforms correctly, distinct from the +single-level-only transform math proven by the Phase 1c group-transform scenario above. + +### Integration: Render Pixel And Dpi Overloads Produce Consistent Output End To End + +**Test**: `CanvasNetPptx_SystemIntegration_Render_PixelAndDpiOverloadsProduceConsistentOutputEndToEnd` + +Exercises end-to-end system behavior for Phase 1f's public rendering API: opens a full +presentation package whose one slide contains a single full-slide solid-filled shape, then renders +it through both the explicit pixel-dimension `Render` overload and the DPI-based `Render` +overload. Confirms both overloads dispatch to the same shape-tree walk and painter pipeline, and +that the DPI overload's own documented EMU-to-pixel conversion produces the expected surface size. + +## Acceptance Criteria + +A system-level test run passes when all scenarios above pass without error or exception beyond +those explicitly asserted. Any unexpected exception, wrong exception type, or wrong return value +constitutes a failure. Collectively, these scenarios cover the complete current (through Phase 1f, +plus its subsequent Phase 2 Follow-Ups) CanvasNetPptx feature set: opening a well-formed OOXML/ +`.pptx` package, resolving its content-type and relationship graph, resolving its declared slide +count/size (Phase 1b), resolving DrawingML shape geometry and paint - including group +child-transform composition (Phase 1c), resolving DrawingML text property inheritance, layout, and +rendering (Phase 1d), resolving pictures/tables and recursively enumerating a slide's full shape +tree (Phase 1e), and rendering a full slide through the public `Render` API (Phase 1f) - plus +validating the documented argument- and structural-validation contracts. Pattern/picture background +fills, radial/path gradients, full text justification, `spAutoFit` shape-resize behavior, kerning, +text clipping on overflow, nested tables, and table auto-sizing/banding remain explicitly deferred; +a future, corpus-driven hardening pass will extend this document's scenarios as that content is +added. As of Phase 4, a `` declaring a chart renders successfully by delegating to +the sibling `CanvasNetCharts` system - see _PptxDocument Unit Verification Design_ +(`canvas-net-pptx/pptx-document.md`) for the unit-level chart-graphic-frame scenarios; SmartArt/ +diagram and OLE-object graphic frames remain unsupported. diff --git a/docs/verification/canvas-net-pptx/pptx-document.md b/docs/verification/canvas-net-pptx/pptx-document.md new file mode 100644 index 00000000..f724ff05 --- /dev/null +++ b/docs/verification/canvas-net-pptx/pptx-document.md @@ -0,0 +1,2052 @@ +## PptxDocument Unit Verification Design + + + + + + + + + +This document describes the unit-level verification strategy for the `PptxDocument` class. + +`PptxDocument` is distributed as the separate `DemaConsulting.CanvasNet.Pptx` NuGet package +(namespace `DemaConsulting.CanvasNet.Pptx`), which references the core `DemaConsulting.CanvasNet` +package; its unit tests live in the sibling `DemaConsulting.CanvasNet.Pptx.Tests` project. + +### Verification Approach + +The `PptxDocument` unit is verified through unit tests that exercise its OOXML package layer +(ZIP opening, `[Content_Types].xml` resolution, relationship resolution) and its current +presentation/theme/master/layout/slide model, placeholder-inheritance resolver, DrawingML shape +geometry/paint resolution, and text layout/rendering, in isolation, through the public API plus +`internal` members exposed to the test project via `InternalsVisibleTo` (`ResolvePart`/ +`ResolveRelationship` from Phase 1a; `GetTheme`/`GetMaster`/`GetLayout`/`GetSlide`/ +`ResolvePlaceholderProperties` and every other `internal` record type and resolver added through +Phase 1d). Every test builds its own minimal, in-memory `.pptx`-shaped ZIP package via a private +helper (`BuildPackage`, parameterized by an arbitrary set of entry name/content pairs) using the +BCL `System.IO.Compression.ZipArchive` writer over a `MemoryStream` - this is a new fixture +pattern for this repository (no existing precedent uses `ZipArchive` to build an in-memory test +fixture), chosen as a natural, low-risk extension of the already-established "hand-authored, +in-memory, byte-exact fixture" philosophy used throughout `PdfDocumentTests.cs` for this same +class of structural/malformed-input test, without introducing an undocumented binary blob for a +trivial package-layer scenario. No file-based fixture exists for this unit (unlike +`PdfFixtures/*.pdf`); every fixture is constructed entirely in-memory, at test-method scope. +Phase 1b's placeholder-inheritance tests additionally construct `PptxPlaceholder`/`PptxTheme` +records directly in C# (bypassing XML parsing entirely), using marker-attribute XElements to +unambiguously assert which level's property fragment "won" the fallback chain. Phase 1d's +text-layout tests additionally link the sibling `DemaConsulting.CanvasNet.Tests` project's +`TestSupport` folder (via an MSBuild `` glob, mirroring +`DemaConsulting.CanvasNet.Pdf.Tests`'s own established precedent) to reuse the shared +`SyntheticFontBuilder` helper, constructing a hand-built `TrueTypeFont` with deterministic +metrics so every word-wrap/alignment/anchor/autofit expectation can be hand-computed exactly in +EMU, and Phase 1d's rendering tests use a synthetic filled-square glyph font for deterministic, +installed-font-independent painted-pixel assertions, mirroring `PdfDocumentTests.cs`'s own +painted-pixel assertion style. + +Because `PptxDocument`'s dependencies (`System.IO.Compression.ZipArchive`, `System.Xml.Linq`, +and, as of Phase 1b, `DemaConsulting.CanvasNet.Canvas.Rgba32`) are BCL types or an already-tested +sibling-package type, not external services, no mocking or stubbing is required. Tests assert on +resolved content-type strings, resolved relationship target paths, resolved slide size/count, +resolved theme colors/fonts, parsed placeholder type/idx values, resolved effective property +fragments, and thrown exception types - never on "no exception thrown" alone, so every test can +actually fail if the implementation is wrong. + +### Test Environment + +- **Framework**: xUnit v3 running under the .NET SDK +- **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline +- **Dependencies**: No external services, databases, or network access required; every fixture is + built entirely in-memory +- **Isolation**: Each test method constructs its own in-memory package; no shared state between + tests + +### Unit-Level Test Scenarios + +#### CanvasNetPptx-PptxDocument-OpenStream: Open(Stream) Buffers Input and Validates Null Argument + +**Tests**: `PptxDocument_Open_WellFormedMinimalPackage_Succeeds`, +`PptxDocument_Open_NullStream_ThrowsArgumentNullException`, +`PptxDocument_Open_NonTerminatingOversizedStream_ThrowsInvalidDataException` + +Proves `Open(Stream)` succeeds against a minimal, well-formed, in-memory package (constructed via +`BuildMinimalValidPackage`, which declares only `[Content_Types].xml` and `_rels/.rels`), proves a +null stream throws `ArgumentNullException` before any parsing is attempted, and proves a +non-terminating, oversized stream (a synthetic `InfiniteZeroStream` that never reaches end-of-stream +and would otherwise be copied without bound) throws `InvalidDataException` once more than the +documented `MaxPackageBytes` (256 MiB) bound has been read, rather than exhausting memory or +blocking indefinitely. + +#### CanvasNetPptx-PptxDocument-OpenPath: Open(string) Validates Null and Empty/Whitespace Path Arguments + +**Tests**: `PptxDocument_Open_NullPath_ThrowsArgumentNullException`, +`PptxDocument_Open_WhitespacePath_ThrowsArgumentException` + +Proves `Open(string)` throws `ArgumentNullException` for a null path and `ArgumentException` for +a whitespace-only path, mirroring `PdfDocument.Open(string, ...)`'s own validation order and +exception types. + +#### CanvasNetPptx-PptxDocument-ZipValidation: Corrupt/Non-ZIP Stream Throws InvalidDataException + +**Test**: `PptxDocument_Open_CorruptNonZipStream_ThrowsInvalidDataException` + +Proves a stream of arbitrary bytes that is not a valid ZIP local-file-header signature throws +`InvalidDataException` from `Open`, rather than an unrelated `ZipArchive`-internal exception type +leaking through uncaught. + +#### CanvasNetPptx-PptxDocument-ContentTypesValidation: Missing [Content_Types].xml Throws InvalidDataException + +**Tests**: `PptxDocument_Open_MissingContentTypes_ThrowsInvalidDataException`, +`PptxDocument_Open_OversizedContentTypesPart_ThrowsInvalidDataException`, +`PptxDocument_Open_ContentTypesWrongRoot_ThrowsInvalidDataException`, +`PptxDocument_Open_ContentTypesDefaultMissingRequiredAttribute_ThrowsInvalidDataException`, +`PptxDocument_Open_ContentTypesOverrideMissingRequiredAttribute_ThrowsInvalidDataException` + +Proves a package containing only `_rels/.rels` (no `[Content_Types].xml` entry at all) throws +`InvalidDataException` from `Open`. Also proves `Open` fails closed for an attacker-controlled +`[Content_Types].xml` part: one padded past the bounded character budget (an "XML bomb" pattern) +throws `InvalidDataException` rather than risking unbounded memory use; a part whose root element +is not the OPC `Types` element throws `InvalidDataException` rather than being silently accepted +with no `Default`/`Override` entries found; and a `Default`/`Override` element missing one of its +required attributes (`Extension`/`ContentType` or `PartName`/`ContentType` respectively) throws +`InvalidDataException` rather than being silently discarded. + +#### CanvasNetPptx-PptxDocument-MediaPartSizeValidation: Oversized Decompressed Part Throws InvalidDataException + +**Tests**: `PptxDocument_GetPartBytes_OversizedDecompressedEntry_ThrowsInvalidDataException`, +`PptxDocument_GetPartBytes_WellFormedPart_ReturnsExactBytes` + +Proves `GetPartBytes` rejects a part whose actual decompressed content exceeds the documented +256 MiB bound with `InvalidDataException`, even when the part's compressed ZIP entry is tiny (a +"zip bomb" - a highly-compressible, all-zero payload), proving the bound is enforced from bytes +actually read during the copy rather than from any declared or compressed size. Also proves a +normal-sized binary part is still returned byte-for-byte unchanged, confirming the new bound does +not disturb ordinary media-part reads. + +#### CanvasNetPptx-PptxDocument-PackageRelationshipsValidation: Missing _rels/.rels Throws InvalidDataException + +**Tests**: `PptxDocument_Open_MissingPackageRelationships_ThrowsInvalidDataException`, +`PptxDocument_Open_PackageRelsWrongRoot_ThrowsInvalidDataException`, +`PptxDocument_Open_RelationshipMissingRequiredAttribute_ThrowsInvalidDataException` + +Proves a package containing only `[Content_Types].xml` (no package-level `_rels/.rels` entry at +all) throws `InvalidDataException` from `Open`. Also proves a relationships part whose root +element is not the OPC `Relationships` element throws `InvalidDataException` rather than being +silently accepted with no relationships found, and a `` element missing one of its +required attributes (`Id`, `Type`, or `Target`) throws `InvalidDataException` rather than being +silently skipped. + +#### CanvasNetPptx-PptxDocument-CaseSensitivePartNames: OPC Part Names Compare Ordinally (Case-Sensitively) + +**Tests**: `PptxDocument_ResolvePart_PartNamesDifferingOnlyByCase_ResolveAsDistinctParts`, +`PptxDocument_Open_WrongCaseContentTypesPartName_ThrowsInvalidDataException`, +`PptxDocument_Open_DuplicateEntryNames_ThrowsInvalidDataException` + +Declares a package with both `ppt/slides/Slide1.xml` and `ppt/slides/slide1.xml`, each given its +own distinct `Override` content type, and asserts `ResolvePart` resolves each to its own distinct +content type rather than the two names colliding under a case-insensitive comparison. Also proves +a package whose reserved content-types part is incorrectly cased (for example +`[content_types].xml` instead of `[Content_Types].xml`) is treated as if the part were entirely +absent, throwing `InvalidDataException`; and proves a package containing two ZIP entries with +exactly identical names throws `InvalidDataException` from `Open` rather than one silently +shadowing the other. + +#### CanvasNetPptx-PptxDocument-ContentTypeOverride: Override Content Type Takes Precedence Over Default Extension + +**Test**: `PptxDocument_ResolvePart_OverrideContentType_TakesPrecedenceOverDefaultExtension` + +Declares a `[Content_Types].xml` with both a `Default Extension="xml"` mapping to +`"application/xml"` and a part-specific `Override PartName="/ppt/presentation.xml"` mapping to +the real presentation content type. Asserts `ResolvePart("ppt/presentation.xml")` returns the +override value, not the default, proving the documented precedence rule. + +#### CanvasNetPptx-PptxDocument-ContentTypeDefault: Default Extension Resolves, Unknown Part Fails Closed + +**Tests**: `PptxDocument_ResolvePart_NoOverride_ResolvesDefaultExtensionContentType`, +`PptxDocument_ResolvePart_UnknownPart_ThrowsInvalidDataException` + +Proves a part with no `Override` entry resolves via the `Default` extension mapping, and proves +requesting a part path the package does not actually contain throws `InvalidDataException` +rather than silently falling through to a spurious default. + +#### CanvasNetPptx-PptxDocument-RelationshipResolution: Known Relationship Resolves, Unknown Relationship Fails Closed + +**Tests**: `PptxDocument_Open_WellFormedMinimalPackage_Succeeds` (also proves package-root +relationship resolution via `ResolveRelationship(string.Empty, "rId1")`), +`PptxDocument_ResolveRelationship_UnknownRelationshipId_ThrowsInvalidDataException`, +`PptxDocument_ResolveRelationship_ExternalTargetMode_ThrowsInvalidDataException` + +Proves a known relationship ID in the package-level `_rels/.rels` resolves to its declared +target part path, and proves requesting an unknown relationship ID throws +`InvalidDataException`. Also proves a relationship declaring `TargetMode="External"` throws +`InvalidDataException` rather than being resolved as if it were an in-package target. + +#### CanvasNetPptx-PptxDocument-RelativeTargetTraversal: Relative "../" Target Resolves, Root Escape Fails Closed + +**Tests**: `PptxDocument_ResolveRelationship_RelativeTargetWithParentTraversal_ResolvesCorrectTarget`, +`PptxDocument_ResolveRelationship_TargetEscapesPackageRoot_ThrowsInvalidDataException` + +Declares `ppt/slides/slide1.xml`'s own relationship to `"../slideLayouts/slideLayout1.xml"` and +asserts it resolves to `"ppt/slideLayouts/slideLayout1.xml"` (relative to the source part's own +directory, not the package root). Separately, declares a relationship whose target traverses more +`".."` segments than the source part has preceding directories, asserting +`InvalidDataException` is thrown rather than producing a nonsensical or out-of-package path. + +#### CanvasNetPptx-PptxDocument-Dispose: Dispose Is Idempotent + +**Test**: `PptxDocument_Dispose_CalledTwice_DoesNotThrow` + +Opens a minimal package, calls `Dispose()` twice, and asserts (via `Record.Exception`) that the +second call throws nothing. + +#### CanvasNetPptx-PptxDocument-PresentationParsing: SlideCount/SlideSize/GetSlideSizeInPixels Resolve Correctly + +**Tests**: `PptxDocument_Open_MultiSlidePresentation_SlideCountAndSlideSizeResolveCorrectly`, +`PptxDocument_GetSlideSizeInPixels_ValidDpi_ComputesExpectedPixelSize`, +`PptxDocument_GetSlideSizeInPixels_NonPositiveDpi_ThrowsArgumentOutOfRangeException`, +`CanvasNetPptx_SystemIntegration_PptxOpenPresentation_SlideCountAndSizeResolveEndToEnd` + +Opens a multi-slide presentation package and asserts `SlideCount` and `SlideSize` resolve to the +declared ``/`` values; asserts `GetSlideSizeInPixels(96f)` computes the +expected pixel dimensions for a known EMU size (9144000x6858000 EMU -> 960x720px at 96 DPI); and +asserts a non-positive/non-finite `dpi` throws `ArgumentOutOfRangeException`. + +#### CanvasNetPptx-PptxDocument-PresentationValidation: Malformed Presentation Parts Fail Closed + +**Tests**: `PptxDocument_Open_MissingOfficeDocumentRelationship_ThrowsInvalidDataException`, +`PptxDocument_Open_PresentationMissingSldSz_ThrowsInvalidDataException`, +`PptxDocument_Open_PresentationEmptySlideList_ThrowsInvalidDataException`, +`PptxDocument_Open_PresentationNonNumericSldSz_ThrowsInvalidDataException`, +`PptxDocument_Open_SlideIdRelationshipWrongType_ThrowsInvalidDataException`, +`PptxDocument_GetSlideSizeInPixels_OversizedDimension_ThrowsInvalidDataException`, +`CanvasNetPptx_SystemIntegration_PptxOpenValidationEmptySlideList_ThrowsInvalidDataException` + +Proves each of the following throws `InvalidDataException` from `Open`: a package with no +`/officeDocument` relationship; a presentation missing ``; a presentation with an empty +``; a presentation whose `` has a non-numeric `cx` attribute; and a +`` whose resolved relationship `Type` does not identify a slide part (for example one +pointed at a theme part instead), so a malformed package cannot silently report a non-slide part +as a valid slide. Also proves `GetSlideSizeInPixels` throws `InvalidDataException` (rather than +overflowing or returning a negative size) for a slide dimension whose pixel conversion would +exceed the representable `int` range. + +#### CanvasNetPptx-PptxDocument-SlideSizePixelConversion: EMU-to-Pixel Conversion Rounds Correctly and Validates DPI + +**Tests**: `PptxDocument_GetSlideSizeInPixels_ValidDpi_ComputesExpectedPixelSize`, +`PptxDocument_GetSlideSizeInPixels_NonPositiveDpi_ThrowsArgumentOutOfRangeException` + +Proves `GetSlideSizeInPixels` computes the exact expected pixel dimensions for a known EMU slide +size at a known DPI, and rejects a non-positive or non-finite `dpi` with +`ArgumentOutOfRangeException`. + +#### CanvasNetPptx-PptxDocument-ThemeColorScheme: srgbClr and sysClr Color Scheme Slots Resolve Correctly + +**Tests**: `PptxDocument_GetTheme_SrgbClrColorScheme_ResolvesRgba32Values`, +`PptxDocument_GetTheme_SysClrColorScheme_ResolvesLastClrValue`, +`PptxDocument_GetTheme_SrgbClrEightDigitValue_ThrowsInvalidDataException` + +Proves all 12 `` slots resolve to the expected `Rgba32` value when each is an +``, and separately proves an `` +slot resolves to its cached `lastClr` RGB equivalent. Also proves an eight-digit `val` +(`AARRGGBB`) throws `InvalidDataException` rather than being silently accepted with its leading +byte misinterpreted as alpha, since OOXML color values are always exactly six hex digits. + +#### CanvasNetPptx-PptxDocument-ThemeFontScheme: Major/Minor Font Scheme Typefaces Resolve Correctly + +**Test**: `PptxDocument_GetTheme_FontScheme_ResolvesMajorMinorTypefaces` + +Proves a theme's `` resolves both the major and minor font collections' Latin, +East Asian, and complex-script typeface names correctly. + +#### CanvasNetPptx-PptxDocument-MasterPlaceholderParsing: Master Placeholder Shapes and Theme Relationship Resolve + +**Test**: `PptxDocument_GetMaster_PlaceholderShapes_ParsedWithTypeAndIdx` + +Proves a slide master's immediate placeholder shapes parse with their explicit `type`/`idx` +attribute values, and that the master's `/theme` relationship resolves to the expected theme +part path. + +#### CanvasNetPptx-PptxDocument-LayoutPlaceholderParsing: Layout Placeholders Default Correctly, Master Relationship Resolves + +**Test**: `PptxDocument_GetLayout_PlaceholderShapes_ParsedWithDefaultTypeAndIdx` + +Proves a slide layout's `` element with no `type`/`idx` attributes defaults to `"obj"`/`0` +per the OOXML schema default, and that the layout's `/slideMaster` relationship resolves to the +expected master part path. + +#### CanvasNetPptx-PptxDocument-SlidePlaceholderParsing: Slide Placeholders Parse, Non-Placeholders Excluded, Index Checked + +**Tests**: `PptxDocument_GetSlide_ImmediatePlaceholderShapes_ParsedStructurally`, +`PptxDocument_GetSlide_IndexOutOfRange_ThrowsArgumentOutOfRangeException` + +Proves a slide's immediate placeholder shapes parse structurally while a sibling non-placeholder +`` (no `` descendant) is excluded, and that an out-of-range slide index (negative or +`>= SlideCount`) throws `ArgumentOutOfRangeException`. + +#### CanvasNetPptx-PptxDocument-PlaceholderInheritanceIdxMatch: Slide-to-Layout Match Is Idx-Only, Miss Short-Circuits + +**Tests**: `PptxDocumentInheritance_ResolvePlaceholderProperties_IdxMatchAtLayout_UsesLayoutSpPr`, +`PptxDocumentInheritance_ResolvePlaceholderProperties_NoIdxMatchAtLayout_FallsThroughToMasterOnly` + +Proves a slide placeholder matches a layout placeholder sharing the same `idx` (regardless of +differing `type`), resolving the layout placeholder's ``/``. Separately, +proves that when no layout placeholder shares the slide placeholder's `idx`, the chain +short-circuits entirely - a master placeholder whose `type` matches the slide's own `type` is +deliberately *not* consulted, proving there is no type-based retry at this hop. + +#### CanvasNetPptx-PptxDocument-PlaceholderInheritanceTypeMatch: Layout-to-Master Matching Is Type-Only via Remap Table + +**Tests**: `PptxDocumentInheritance_ResolvePlaceholderProperties_TypeMatchAtMaster_IgnoresMismatchedIdx`, +`PptxDocumentInheritance_ResolvePlaceholderProperties_RemappedBodyLikeType_MatchesMasterBodyPlaceholder` + +Proves a matched layout placeholder resolves a master placeholder by `type` alone (a deliberately +mismatched `idx` between the layout and master placeholders does not prevent the match), and +proves a layout placeholder whose type remaps to `"body"` (for example `"subTitle"`) matches a +master placeholder whose own `type` is literally `"body"`. + +#### CanvasNetPptx-PptxDocument-EffectivePlaceholderTypeForTextStyle: Omitted-Type Resolves to Idx-Matched Layout Type + +**Tests**: `ResolveEffectiveRunProperties_SectionHeaderTitlePlaceholderWithOmittedType_ResolvesMasterTitleStyleNotBodyStyle`, +`Render_SectionHeaderTitleWithOmittedType_PaintsIdenticallyToExplicitTitleType` + +Proves a "Section Header" layout's title placeholder whose slide-level `` omits +`type` resolves `EffectivePlaceholderType` to the idx-matched layout placeholder's own +`type="title"`, and that `ResolveEffectiveRunProperties` consequently resolves the run's font +size from the master's `` (90pt in the test's fixture), not `` (28pt) - +the fix for a confirmed, real-world regression (a Section Header title rendering at roughly 1/3 +PowerPoint's own ground-truth size). The fix was verified to be both necessary and sufficient by +temporarily reverting it and confirming the exact same test failed, resolving to `bodyStyle`'s +28pt instead of `titleStyle`'s 90pt, before re-applying it. The second, end-to-end test proves the +same omitted-type placeholder paints pixel-for-pixel identical glyph ink to an otherwise-identical +placeholder that declares `type="title"` explicitly, through the public `Render` API rather than +only the internal resolver. + +#### CanvasNetPptx-PptxDocument-PlaceholderInheritancePerCategory: Categories Independent, Theme Is Unchanged Context + +**Tests**: +`PptxDocumentInheritance_ResolvePlaceholderProperties_PerCategoryIndependentFallthrough_SlideOverridesFillButNotText`, +`PptxDocumentInheritance_ResolvePlaceholderProperties_NoMatchAtAnyLevel_ReturnsNullForBothCategories` + +Proves `` and `/` resolve independently - a slide-level `` +with no slide-level `` resolves the slide's own fill while still falling through to +the layout for text style. Separately, proves that when no placeholder matches at any level, both +effective property categories resolve to `null` while the resolved theme is still returned +unchanged as context. For `` (fill/line resolution), an empty element still counts as +present, stopping the fallback for that category; ``/geometry +(`PlaceholderXfrmGeometryInheritance`, below) and `` level overrides +(`TxBodyListStyleLevelInheritance`, below) are the two named exceptions, each verified separately +via its own independent, non-whole-element walk. + +#### CanvasNetPptx-PptxDocument-PlaceholderXfrmGeometryInheritance: ``/Geometry Inherit Past an Empty `` + +**Tests**: `Render_PlaceholderShapeWithEmptySpPr_InheritsXfrmAndGeometryFromLayout`, +`PptxDocument_Render_SamplelibSamplePresentationFixture_Slide4ThrowsUnsupportedFeatureOthersPaintContent` + +Proves a placeholder shape whose own `` is present but empty (declaring neither +`` nor ``/``, deliberately relying on its matched layout +placeholder for both) still renders and paints visible content, rather than being silently +skipped (for a missing ``) or crashing with `InvalidDataException` (for missing geometry) - +`EffectiveXfrmElement`/`EffectiveGeometrySpPr` each independently walk slide -> matched layout -> +matched master looking specifically for an `` child, or a `` that itself declares +a geometry child, continuing past a tier's own empty-but-present `` that declares +neither - unlike `PlaceholderInheritancePerCategory`'s own whole-element fallback, which stops at +the first present `` regardless of its own contents. The second test additionally proves +this against a real-world fixture (`samplelib-sample-presentation.pptx` slides 0-2, each a +placeholder with exactly this empty-`` shape), confirming the synthetic regression test's +finding generalizes to genuine third-party-generated files, not merely a hand-authored edge case. + +#### CanvasNetPptx-PptxDocument-TxBodyListStyleLevelInheritance: `` Level Overrides Inherit Past an Empty `` + +**Tests**: +`PptxDocumentInheritance_ResolvePlaceholderProperties_SlideLstStyleEmptyWithNoLevelOverride_FallsThroughToLayoutLevelOverride`, +`PptxDocumentInheritance_ResolvePlaceholderProperties_SlideLstStyleHasLevelOverride_SlideWinsOverLayout`, +`ResolveEffectiveRunProperties_CtrTitlePlaceholderWithEmptySlideLstStyle_ResolvesLayoutDefRPrSizeNotMasterTitleStyleFallback`, +`Render_CtrTitlePlaceholderWithEmptySlideLstStyle_PaintsIdenticallyToAbsentLstStyle` + +The first resolver-level test proves a slide's own empty, self-closing `` (no +``..`` level-override child at all - the exact real-world shape reported +from "ERF IWF Breadboard Peer Review.pptx") does not "win" `PlaceholderInheritancePerCategory`'s +own whole-element fallback: the idx-matched layout placeholder's own, level-bearing +`` is selected instead. The second resolver-level test is a regression guard proving +the pre-existing, correct behavior is preserved - when the slide-level `` DOES declare +a real level override, it still wins over the layout's. The third test proves the resolved +`EffectiveTxBodyListStyle` is actually consumed correctly downstream: a `ctrTitle` placeholder +with this exact empty-`` shape resolves its run's effective font size from the +layout's ``/`` (60pt), not the master's `` fallback +(28pt) - the exact pre-fix symptom magnitude described in the bug report. The fourth, end-to-end +test proves (through the public `Render` API, not just the internal resolver) that the same +placeholder with an empty-but-present `` paints pixel-for-pixel identical glyph ink +to the same placeholder with `` omitted entirely, confirming the layout's 60pt +override is actually painted, not merely resolved, in both cases. + +#### CanvasNetPptx-PptxDocument-ShapeFrameTransform: Position/Rotation/Flip Compose Into the Correct Local-to-Parent Transform + +**Tests**: `ResolveShapeFrame_PlainOffsetAndSize_MapsLocalOriginToOffset`, +`ResolveShapeFrame_MissingOff_ThrowsInvalidDataException`, +`ResolveShapeFrame_MissingExt_ThrowsInvalidDataException`, +`ResolveShapeFrame_Rotation90Degrees_RotatesAboutOwnCenterClockwise`, +`ResolveShapeFrame_MissingRot_DefaultsToZeroRotation`, +`ResolveShapeFrame_FlipHorizontal_MirrorsAboutOwnCenterX`, +`ResolveShapeFrame_FlipVertical_MirrorsAboutOwnCenterY`, +`ResolveShapeFrame_InvalidRotAttribute_ThrowsInvalidDataException`, +`ResolveShapeFrame_NonNumericOffAttribute_ThrowsInvalidDataException`, +`ResolveShapeFrame_NonNumericExtAttribute_ThrowsInvalidDataException`, +`CanvasNetPptx_SystemIntegration_GeometryAndPaint_FreeformShapeResolvesEndToEnd` + +Proves a plain ``/`` (no rotation/flip) maps a shape's local origin to the declared +offset; proves a `90`-degree `rot` rotates a known local point about the shape's own center to +the expected clockwise-on-screen location; proves an absent `rot` defaults to no rotation; proves +`flipH`/`flipV` each mirror a known local point about the shape's own center on the expected +axis; and proves a missing ``, missing ``, non-numeric `rot` attribute, or +non-numeric ``/`` `x`/`y`/`cx`/`cy` attribute each throw `InvalidDataException` +rather than an unrelated raw `FormatException`. + +#### CanvasNetPptx-PptxDocument-GroupChildTransform: Group chOff/chExt Child Coordinate Space Composes Correctly + +**Tests**: `ResolveGroupChildTransform_IdentityChildSpace_MatchesGroupFrameDirectly`, +`ResolveGroupChildTransform_ScaledChildSpace_ScalesChildCoordinatesIntoGroupBox`, +`ResolveGroupChildTransform_OffsetChildSpace_TranslatesChildOriginBeforeScaling`, +`ResolveGroupChildTransform_ChildOfChild_ComposesThroughBothTransforms`, +`ResolveGroupChildTransform_MissingChOffChExt_DefaultsToIdentityChildSpace`, +`ResolveGroupChildTransform_ChOffPresentWithMissingAttribute_ThrowsInvalidDataException`, +`ResolveGroupChildTransform_ChExtPresentWithMissingAttribute_ThrowsInvalidDataException`, +`CanvasNetPptx_SystemIntegration_GeometryAndPaint_GroupShapeChildTransformComposesEndToEnd` + +Proves a group whose `chOff`/`chExt` exactly matches its own `off`/`ext` produces the same +transform as the group's own resolved frame; proves a `chExt` smaller than the group's own `ext` +scales a child coordinate up proportionally into the group's box; proves a non-zero `chOff` +translates a child coordinate's origin before that scaling is applied; proves a group nested +inside another group composes both levels' child transforms together with a descendant shape's +own frame correctly; proves an absent `chOff`/`chExt` defaults to an identity child coordinate +space; proves a `chOff`/`chExt` that IS present but omits one of its required attributes throws +`InvalidDataException` rather than silently substituting a default (a present-but-incomplete +element is malformed input, not the "absent element" case the default is meant to cover); and the +end-to-end system test proves this composition holds through the real package-load path (a shape +nested in a group with non-trivial `chOff`/`chExt` resolves to the expected slide-space point). + +#### CanvasNetPptx-PptxDocument-PresetGeometry: Supported Presets Build Correctly, Unsupported Presets Fail Closed + +**Tests**: `ResolveShapeGeometry_PrstGeomRect_ReturnsRectanglePath`, +`ResolveShapeGeometry_NeitherPrstGeomNorCustGeom_ThrowsInvalidDataException`, +`ResolveShapeGeometry_UnsupportedPreset_ThrowsPptxUnsupportedFeatureException`, +`ResolveShapeGeometry_PrstGeomMissingPrstAttribute_ThrowsInvalidDataException`, +`PptxPresetGeometry_Build_EachSupportedPreset_ProducesPathWithinDeclaredBounds` (a `[Theory]` +covering all 28 supported preset names), `PptxPresetGeometry_Build_UpArrow_PointsUpward`, +`PptxPresetGeometry_Build_DownArrow_PointsDownward`, +`PptxPresetGeometry_Build_UnsupportedPreset_ThrowsWithFeatureToken`, +`PptxPresetGeometry_Build_NonPositiveSize_ReturnsEmptyPath`, +`PptxPresetGeometry_Build_CurvedUpArrow_PointsUpwardWithoutThrowing`, +`PptxPresetGeometry_Build_CurvedArrowSiblings_EachPointsTowardItsOwnDeclaredEdge`, +`Render_CurvedUpArrowShape_RendersWithoutThrowingAndPaintsInk` + +Proves `ResolveShapeGeometry` dispatches `` to a rectangle path sized to +the shape's declared extent; proves a `` with neither `` nor ``, +and a `` missing its required `prst` attribute, each throw `InvalidDataException`; and +proves an unrecognized preset name throws `PptxUnsupportedFeatureException`. Proves every one of +the 28 supported preset names (via a `[Theory]` enumerating all of them) builds a path whose +points all lie within `(0,0)`-`(w,h)` bounds; proves `upArrow`/`downArrow` each orient their arrow +point toward the expected direction (a directional sanity check distinguishing a correctly +oriented preset from a trivially-passing bounds check alone); proves an unsupported preset throws +`PptxUnsupportedFeatureException` carrying the expected feature token; and proves a non-positive +width or height resolves to `Path.Empty` rather than throwing or producing a degenerate path. +`PptxPresetGeometry_Build_CurvedUpArrow_PointsUpwardWithoutThrowing` proves the exact reported +crash scenario (`Build("curvedUpArrow", ...)`) no longer throws and that the resulting path's +apex lies near the shape's top edge; `PptxPresetGeometry_Build_CurvedArrowSiblings_EachPointsTowardItsOwnDeclaredEdge` +proves each of `curvedDownArrow`/`curvedLeftArrow`/`curvedRightArrow` similarly points toward its +own named edge; `Render_CurvedUpArrowShape_RendersWithoutThrowingAndPaintsInk` proves the full +`curvedUpArrow` scenario renders end-to-end through the public `Render` API and paints visible +ink, not merely that the geometry builder itself does not throw. + +#### CanvasNetPptx-PptxDocument-CustomGeometry: pathLst Commands Parse Into the Expected Path, Coordinate Space Scales Correctly + +**Tests**: `ResolveCustomGeometry_MoveLineClose_ProducesClosedTriangle`, +`ResolveCustomGeometry_CubicBezTo_ProducesCubicBezierCommand`, +`ResolveCustomGeometry_QuadBezTo_ProducesQuadraticBezierCommand`, +`ResolveCustomGeometry_PathCoordinateSpaceScaledToShapeExtent`, +`ResolveCustomGeometry_NoPathLst_ReturnsEmptyPath` + +Proves a `` of `moveTo`/`lnTo`/`lnTo`/`close` commands produces a closed triangular +subpath with the expected start point and command count; proves `cubicBezTo` produces a path +command whose type and control/end points match the two control points plus endpoint declared in +XML; proves `quadBezTo` produces a path command whose single control point and endpoint match; +proves a `` declaring a coordinate space different from the shape's own +`widthEmu`/`heightEmu` scales every point correctly (a point at the path's own declared far corner +resolves to exactly `(widthEmu, heightEmu)`, not the path's own raw `w`/`h` value); and proves a +`` with no `` resolves to `Path.Empty` rather than throwing. + +#### CanvasNetPptx-PptxDocument-FillResolution: noFill/solidFill/gradFill/pattFill/blipFill Resolve or Fail Closed + +**Tests**: `ResolveFill_NullParent_ReturnsNoFill`, `ResolveFill_ExplicitNoFill_ReturnsNoFill`, +`ResolveFill_NoRecognizedFillChild_ReturnsNoFill`, +`ResolveFill_SolidFillSrgbClr_ReturnsResolvedSolidFill`, +`ResolveFill_SolidFillSchemeClr_ResolvesThroughTheme`, +`ResolveFill_GradFillLinear_ReturnsResolvedGradientFill`, +`ResolveFill_PatternFillUnsupportedPreset_ThrowsPptxUnsupportedFeatureException`, +`ResolveFill_PictureFill_ThrowsPptxUnsupportedFeatureException`, +`ResolveFill_BarePatternFillWithNoPrstAttribute_ReturnsNoFill`, +`CanvasNetPptx_SystemIntegration_GeometryAndPaint_FreeformShapeResolvesEndToEnd` + +Proves a `null` fill parent, an explicit ``, and a `` with no recognized fill +child, all three resolve to `PptxNoFill.Instance`; proves `` and +`` both resolve to the expected `PptxSolidFill` (the latter +resolving its color through the supplied `PptxTheme`, not merely parsing the slot name); proves +`` with a linear direction resolves to a `PptxGradientFill` wrapping the expected +gradient stops; proves `` naming an uncovered preset throws +`PptxUnsupportedFeatureException` rather than silently resolving to an incorrect fill (a covered +preset's own resolution is proven by `CanvasNetPptx-PptxDocument-PatternFillResolution` below); +proves a non-conformant, attribute-less `` with no `prst` at all resolves to +`PptxNoFill` instead of either resolving a preset or throwing; and proves `` still +throws `PptxUnsupportedFeatureException` (feature token `"pptx-picture-fill"`) when no blip-image +resolver is supplied, its pre-existing fallback behavior, now narrowed to document that specific +"no resolver supplied" case rather than picture fill being unsupported outright - see +`CanvasNetPptx-PptxDocument-PictureFillResolution` below for the resolver-supplied, fully +supported case. The end-to-end system test additionally proves a real shape's fill resolves +correctly when reached through the full slide/layout/master/theme package-load chain, not merely +from a hand-built `XElement` fragment. + +#### CanvasNetPptx-PptxDocument-PictureFillResolution: blipFill Resolves to a Tiled/Stretched PptxImageFill + +**Tests**: `ResolveFill_BlipFillWithResolver_ReturnsPptxImageFill`, +`ResolveImageFillTransform_TileWithNonTrivialOffsetAndScale_AppliesBothToEachAxis`, +`ResolveImageFillTransform_StretchWithFillRectInsets_CropsBeforeStretching`, +`ResolveImageFillTransform_DegenerateFillRect_FallsBackToFullStretch`, +`PptxDocument_Render_DmlFillFixture_Slide0RendersPictureAndPatternFillShapesWithoutThrowing`, +`CanvasNetPptx_SystemIntegration_Images_TileBlipFillShapeDecodesAndRendersEndToEnd` + +Proves a minimal ``, given a fake +`resolveBlipImage` delegate, resolves to a `PptxImageFill` wrapping that delegate's own returned +`Surface` (rather than throwing, the regression this feature fixes); pins down +`ResolveImageFillTransform`'s own `` derivation with a non-trivial, non-default `tx`/ +`ty`/`sx`/`sy` combination (independent of the real fixture's own trivial `100%`/`0`-offset +values); pins down ``'s own `` inset-cropping math, and its degenerate-crop +(edges summing to `100%` or more) fallback-to-full-stretch behavior, guarding against a +non-finite or divide-by-zero transform. The real-fixture test proves `pythonpptx-dml-fill.pptx`'s +slide 0 - which places a real, embedded ``/`` picture-filled shape alongside a +`` pattern-filled shape, now also covered (see +`CanvasNetPptx-PptxDocument-PatternFillResolution` below) - renders completely end-to-end, with +neither shape throwing; the system-integration test proves a genuine, full end-to-end render of a +minimal, purpose-built single-shape package (an ordinary ``, not a ``, whose own +``/`` shape fill is the slide's only content) completes without exception, +with a pixel-level sanity check that the filled shape's own region painted the embedded image's +content rather than being left as untouched background. + +#### CanvasNetPptx-PptxDocument-PatternFillResolution: pattFill Resolves a Covered Preset to a Synthesized Tile + +**Tests**: `ResolveFill_PatternFillCoveredPreset_ReturnsPptxPatternFill`, +`ResolveFill_PatternFillMissingFgBgClr_DefaultsToBlackOnWhite`, +`ResolveFill_PatternFillUnsupportedPreset_ThrowsPptxUnsupportedFeatureException`, +`ResolveFill_BarePatternFillWithNoPrstAttribute_ReturnsNoFill`, +`ResolveLineStyle_PatternFillCoveredPreset_ReturnsLineStyleWithPptxPatternFill`, +`RenderTile_Horz_AlternatesFullRows`, `RenderTile_Vert_AlternatesFullColumns`, +`RenderTile_LtHorz_SparseThinRows`, `RenderTile_DkHorz_DominantRows`, +`RenderTile_LtVert_SparseThinColumns`, `RenderTile_DkVert_DominantColumns`, +`RenderTile_DnDiag_FollowsXPlusYModuloRule`, `RenderTile_UpDiag_FollowsXMinusYModuloRule`, +`RenderTile_WdDnDiag_ProducesWideBands`, `RenderTile_Cross_FgAtHorizontalOrVerticalLines`, +`RenderTile_DiagCross_FgAtEitherDiagonal`, `RenderTile_PercentageFamily_BothColorsPresent` (a +`[Theory]` covering the `pct5`...`pct90` family), `RenderTile_PercentageFamily_FgPixelCountIncreasesWithPercent`, +`RenderTile_Divot_BothColorsPresent`, `RenderTile_Wave_BothColorsPresent`, +`RenderTile_EveryCoveredPreset_ProducesTileSizeSquareSurface` (a `[Theory]` covering all 30 +covered presets), `PptxDocument_Render_DmlFillFixture_Slide0RendersPictureAndPatternFillShapesWithoutThrowing`, +`PptxDocument_Render_DmlFillFixture_Slide1PatternFillShapesRenderAndBareFillIsTransparent`, +`Render_ShapeWithPatternFillStroke_PaintsCorrectlyTransformedStripesNotFlatColor`, +`Render_ConnectorWithPatternFillStroke_PaintsCorrectlyTransformedStripesNotFlatColor`, +`Render_SlideLevelPatternFillBackground_PaintsCorrectlyTransformedStripesNotFlatColor` + +Proves `` (a representative covered preset), with explicit ``/ +`` children, resolves to a `PptxPatternFill` wrapping the expected preset/foreground/ +background; proves an absent ``/`` defaults to black-on-white; proves an +uncovered preset name (for example `zigZag`) still throws `PptxUnsupportedFeatureException` +(feature token `"pptx-pattern-fill"`); proves a non-conformant, attribute-less `` +resolves to `PptxNoFill` rather than a named preset; proves `ResolveLineStyle` resolves an +`...` (a line/stroke fill) identically to a shape fill, +since both share the same `ResolveFill` resolver. Each `RenderTile_*` test directly exercises +`PptxPatternTileRenderer.RenderTile` (bypassing `ResolveFill` entirely) and asserts its +synthesized tile's own per-pixel rule: horizontal/vertical-stripe presets alternate whole +rows/columns at the documented density; diagonal presets follow an `(x +/- y) mod period` rule; +the percentage family's foreground pixel count strictly increases as the named percentage +increases (proving genuine density scaling, not a fixed ratio); `divot`/`wave` each paint both +colors; and every covered preset produces a tile exactly `PptxPatternTileRenderer`'s own declared +tile size, square. The two real-fixture tests prove `pythonpptx-dml-fill.pptx` renders completely, +end-to-end, without throwing on either slide: slide 0's `prst="divot"` shape (alongside the +picture-fill shape covered above) and slide 1's three pattern-fill shapes (a non-conformant, +attribute-less `` - proven to remain an unfilled, un-overridden white rather than +falling back to its own `/` - plus a `prst="divot"` and a `prst="wave"` shape, +each proven to paint a genuine mix of its own resolved foreground and background colors by +sampling pixels inside its own bounds, not a flat single color). Proving real pixel variation +(rather than merely "did not throw") required a companion fix to a real, pre-existing +transform-composition gap - see `PptxDocument.Tables.cs`'s own `FillPaintComposingLocalTransform` +XmlDoc remarks, and the design doc's own *Pattern Fill (``) (Phase 2 Follow-Up)* +section, for the full detail: without it, every pattern-filled shape/connector fill, stroke +outline, or arrowhead not positioned at the surface's own origin would have rendered as a single +flat color instead of a repeating tile. The two `Render_*PatternFillStroke_*` tests prove this +same fix for ``-based line/stroke pattern fills specifically: an off-origin shape's and +connector's own pattern-filled stroke each paint a genuine alternating foreground/background +stripe band, not a single flat color - each test is proven to fail against the pre-fix code +(only the foreground color would ever appear in the sampled band). +`Render_SlideLevelPatternFillBackground_PaintsCorrectlyTransformedStripesNotFlatColor` proves the +identical fix for the slide/layout/master background-fill call site itself: a pattern-filled +`` slide background now composes the slide's own EMU-to-pixel +`baseTransform` into the synthesized tile's transform via `FillPaintComposingLocalTransform`, +so the background genuinely tiles (both resolved colors appear across the sampled region) rather +than collapsing to a single flat color - this test is likewise proven to fail against the pre-fix +code, where the background-fill call site still passed the identity transform. + +#### CanvasNetPptx-PptxDocument-ColorResolution: srgbClr/sysClr/schemeClr/prstClr Resolve to the Expected Rgba32 Value + +**Tests**: `ResolveColor_SrgbClr_ParsesHexDirectly`, `ResolveColor_SysClr_UsesLastClrAttribute`, +`ResolveColor_SchemeClrOrdinarySlots_ResolveToMatchingThemeSlot` (a `[Theory]` covering all 12 +ordinary scheme slot names), `ResolveColor_SchemeClrBackgroundTextAliases_MapToExpectedSlot` (a +`[Theory]` covering `bg1`/`tx1`/`bg2`/`tx2`), `ResolveColor_SchemeClrUnrecognizedSlot_ThrowsInvalidDataException`, +`ResolveColor_UnsupportedColorKind_ThrowsPptxUnsupportedFeatureException`, +`ResolveColor_SrgbClrEightDigitValue_ThrowsInvalidDataException`, +`ResolveColor_SysClrEightDigitLastClrValue_ThrowsInvalidDataException`, +`ResolveColor_PrstClrWhite_ReturnsWhite`, `ResolveColor_PrstClrBlack_ReturnsBlack`, +`ResolveColor_PrstClrUnsupportedName_ThrowsPptxUnsupportedFeatureException`, +`ResolveColor_PrstClrMissingValAttribute_ThrowsInvalidDataException` + +Proves `` parses its hex value directly; proves `` +resolves via its `lastClr` attribute; proves, for every one of the 12 ordinary +`` slot names, the resolved color matches that exact slot on a +distinctly-colored `PptxColorScheme` (not merely "some" color); proves each of the four +background/text aliases (`bg1`/`tx1`/`bg2`/`tx2`) resolves to its documented aliased slot +(`lt1`/`dk1`/`lt2`/`dk2` respectively); proves an unrecognized `` slot name +throws `InvalidDataException`; proves an unsupported color-definition element kind (for +example ``) throws `PptxUnsupportedFeatureException`; and proves an 8-digit +`#AARRGGBB`-style value on either `` or `` throws +`InvalidDataException` rather than being passed to the underlying `Rgba32` hex parser, which would +otherwise accept the 8-digit form and silently treat its leading byte as alpha, producing a wrong, +unintended-alpha color instead of failing closed. Proves `` and +`` - the two ECMA-376 `ST_PresetColorVal` names this package resolves, a +narrowly-scoped companion fix needed by pattern fill's own `bgClr` (see +`CanvasNetPptx-PptxDocument-PatternFillResolution` above) - resolve to their standard RGB values; +proves any other preset-color name (for example `red`) still throws +`PptxUnsupportedFeatureException` (feature token `"pptx-color-kind"`), narrowed rather than +removed; and proves a `` missing its own required `val` attribute throws +`InvalidDataException`. + +#### CanvasNetPptx-PptxDocument-ColorTransforms: lumMod/lumOff/shade/tint/alpha Apply in the Documented Fixed Order + +**Tests**: `ResolveColor_Alpha_SetsAlphaChannel`, `ResolveColor_Shade_DarkensProportionally`, +`ResolveColor_Tint_LightensProportionally`, `ResolveColor_LumMod_DecreasesLuminance`, +`ResolveColor_LumOff_IncreasesLuminance`, +`ResolveColor_CombinedLumModLumOffShadeTintAlpha_AppliesFixedPipelineOrder` + +Proves each of `alpha`/`shade`/`tint`/`lumMod`/`lumOff` individually transforms a known base +color to the exact expected result (per this phase's documented truncating-cast arithmetic, not +rounding - for example a `50%` `alpha` resolves to alpha `127`, not `128`); and proves all five +applied together on the same color-definition element produce the result of the documented fixed +pipeline order (`lumMod`/`lumOff`, then `shade`, then `tint`, then `alpha`) rather than the +source-XML element order, which the test deliberately orders differently from the pipeline order +to prove the implementation does not depend on it. + +#### CanvasNetPptx-PptxDocument-GradientFill: Linear Gradient Direction/Stops Resolve, Path/Missing Stops Fail Closed + +**Tests**: `ResolveGradientFill_LinearZeroDegrees_PointsAlongPositiveX`, +`ResolveGradientFill_MissingLin_ThrowsPptxUnsupportedFeatureException`, +`ResolveGradientFill_MissingGsLst_ThrowsInvalidDataException`, +`ResolveGradientFill_NonNumericGsPos_ThrowsInvalidDataException` + +Proves a `` (zero-degree angle) resolves a `LinearGradient` whose start/end points +lie along the positive X axis, centered on the shape's own bounding box, with its `` +stops resolved to the expected offsets and colors; proves a `` with no `` child +(a path/radial gradient) throws `PptxUnsupportedFeatureException` rather than silently +approximating a linear direction; proves a `` missing its required `` +throws `InvalidDataException`; and proves a `` element with a non-numeric `pos` attribute +throws `InvalidDataException` rather than an unrelated raw `FormatException`. + +#### CanvasNetPptx-PptxDocument-LineStyleResolution: Stroke Width/Fill/Dash Resolve, Outline Realizes via Core PathStroker + +**Tests**: `ResolveLineStyle_NullElement_ReturnsNull`, `ResolveLineStyle_ZeroWidth_ReturnsNull`, +`ResolveLineStyle_NoFillLine_ReturnsNull`, `ResolveLineStyle_WidthAndSolidFill_ResolvesWidthAndColor`, +`ResolveLineStyle_DashPresets_ProduceNonNullDashArray` (a `[Theory]` covering all seven supported +dash preset names), `ResolveLineStyle_SolidPreset_ProducesNullDashArray`, +`ResolveLineStyle_ExplicitZeroWidth_ReturnsNull`, `ResolveLineStyle_ExplicitNegativeWidth_ReturnsNull`, +`ResolveLineStyle_MissingWidthAttribute_ResolvesDefaultWidth`, +`Render_EllipseWithNoFillAndLnMissingWidthButRecognizedColor_PaintsDefaultWidthStroke`, +`ResolveStrokeOutline_RectangleWithSolidLine_ProducesNonEmptyOutline`, +`Render_FullSlideScaleEllipseWithNoFillAndLnMissingWidth_RendersThinRingNotSolidDisc`, +`Render_FullSlideScaleRoundRectWithNoFillAndLnMissingWidth_RendersThinOutlineNotSolidFill` + +Proves a `null` ``, an explicitly-declared zero/non-positive width, and an explicit or +implicit no-fill line, each resolve to `null` (no stroke drawn); proves a positive width with a +`` resolves a `PptxLineStyle` carrying the expected width and color; proves each of +the seven supported `` preset names resolves a non-null, width-proportional +dash array; proves the common `solid` preset (and, by extension, any unrecognized name) resolves +`null` (a solid line); and proves `ResolveStrokeOutline` produces a non-empty outline path for a +rectangle with a solid line, via the core `PathStroker`/`StrokeStyle` machinery reused from the +PDF renderer's own call pattern. + +**Default-width regression scenarios** (fixing the bug where a genuinely-absent `w` attribute +collapsed to the same "no stroke" outcome as an explicit `w="0"`): + +- `ResolveLineStyle_ExplicitZeroWidth_ReturnsNull` and + `ResolveLineStyle_ExplicitNegativeWidth_ReturnsNull` prove an explicitly-present `w="0"` or + negative `w` (e.g. `w="-100"`) still resolve to `null` (no stroke) - a regression guard proving + OOXML's own "explicit zero/negative width means no stroke" idiom is unaffected by this fix. +- `ResolveLineStyle_MissingWidthAttribute_ResolvesDefaultWidth` proves an `` with a + recognized `` but no `w` attribute at all resolves a non-`null` `PptxLineStyle` + whose `WidthEmu` is the documented default (`9525` EMU / `0.75`pt) and whose color matches the + declared fill. +- `Render_EllipseWithNoFillAndLnMissingWidthButRecognizedColor_PaintsDefaultWidthStroke` is a + render-level regression test reproducing the exact reported real-world pattern (an ellipse shape + with `` and an `...` declaring no `w` + attribute): it renders the shape against a small 1"x1" slide (so the sub-pixel-at-normal-scale + 9525 EMU default width survives as several solid, easily-sampled pixels) and asserts a red stroke + pixel appears directly on the ellipse's own boundary, while both its interior and the area well + outside its bounding box remain the default opaque-white clear - proving the stroke is now + visible, proving `` still means no interior fill, and proving the stroke does not + spill unexpectedly far beyond its own narrow band. + +**Full-slide-scale "solid disc" regression scenarios** (fixing a second, deeper bug: the same +small-slide test above does not catch a regression that only manifests at a realistic, full-size +slide's own native EMU coordinate magnitude, where a curved shape's stroke outline falsely +collapsed to a solid fill - see `../../design/canvas-net/drawing/path-stroker.md`'s "Inner-ring +collapse" section and `../../design/canvas-net-pptx/pptx-document.md`'s "Realizing a stroke +outline" section for the full root-cause/fix explanation): + +- `Render_FullSlideScaleEllipseWithNoFillAndLnMissingWidth_RendersThinRingNotSolidDisc` renders an + `ellipse`, ``, color-only (default-width) `` shape against a standard full-size + slide (`9144000x6858000` EMU rendered at `960x720` - exactly `9525` EMU/pixel, matching + PowerPoint's own default stroke width) and asserts the ellipse's own exact center pixel remains + the background color (proving the interior is **not** filled solid), a pixel on the ellipse's + own top boundary is red-dominant (proving the ring itself still paints), and a pixel outside the + ellipse's bounding box remains the background color. This test was confirmed, by direct + before/after comparison against the unfixed code, to fail (center rendered the stroke color) in + the pre-fix state and pass post-fix. +- `Render_FullSlideScaleRoundRectWithNoFillAndLnMissingWidth_RendersThinOutlineNotSolidFill` is the + equivalent test for a `roundRect` preset geometry at the same full-slide scale, proving the fix + is general to curved/Bezier-flattened closed geometry and not an ellipse-only special case (a + plain `rect`, with no curved segments, was confirmed unaffected and does not need an equivalent + test). + +### Text Layout and Rendering (Phase 1d) Test Scenarios + +#### CanvasNetPptx-PptxDocument-TextBodyParsing: Text Body/Paragraph/Run Structure Parses Correctly + +**Tests**: `ParseBodyProperties_Absent_ResolvesSchemaDefaults`, +`ParseBodyProperties_AnchorCtr_ResolvesMiddle`, `ParseBodyProperties_AnchorB_ResolvesBottom`, +`ParseBodyProperties_AnchorT_ResolvesTop`, `ParseBodyProperties_WrapNone_ResolvesWrapNone`, +`ParseBodyProperties_CustomInsets_ResolvesDeclaredValues`, +`ParseBodyProperties_AutofitElement_IsRetainedUnparsed` (a `[Theory]` covering +`noAutofit`/`normAutofit`/`spAutoFit`), `ParseTextBody_NoParagraphs_ResolvesEmptyParagraphList`, +`ParseTextBody_SingleParagraphSingleRun_ParsesBodyAndParagraph`, +`ParseParagraph_NoChildren_ResolvesEmptyRunList` + +Proves `ParseBodyProperties` resolves the OOXML schema's documented defaults when `` or +an individual attribute is absent, resolves each of the three vertical anchors and the `none` wrap +mode explicitly, resolves custom inset values when declared, and retains the raw autofit child +element unparsed; proves `ParseTextBody` resolves an empty paragraph list for a text body with no +`` children and resolves a single paragraph/run structure correctly; and proves an empty +`` with no recognized child resolves an empty run list rather than throwing. + +#### CanvasNetPptx-PptxDocument-ParagraphRunParsing: Paragraph/Run Raw Property Extraction and Level Clamping + +**Tests**: `ParseParagraphProperties_Absent_ResolvesLevelZeroAndNullOptionals`, +`ParseParagraphProperties_LevelAttribute_ClampsToZeroToEight` (a `[Theory]` covering below-range, +in-range, and above-range `lvl` values), `ParseParagraphProperties_FullyPopulated_ResolvesEveryField`, +`ParseRun_NoText_ResolvesEmptyString`, `ParseRun_WithTextAndRPr_ResolvesBoth`, +`ParseParagraph_RunBreakRun_PreservesBreakInDocumentOrder` + +Proves `ParseParagraphProperties` resolves level `0` and every optional field `null` when +`` is absent, clamps an out-of-range `lvl` attribute into the documented `[0,8]` range, +resolves every field when fully populated; proves `ParseRun` resolves an empty string when +`` is absent and resolves both the raw `` element and text when both are present; and +proves `ParseParagraph`, given a `` containing a run, then a ``, then a second run, +preserves all three as an ordered item list in document order (run, break, run) rather than +selecting only the `` children and silently discarding the ``. + +#### CanvasNetPptx-PptxDocument-TextPropertyInheritance: Attribute-Level Run/Paragraph Property Resolution + +**Tests**: `ResolveEffectiveRunProperties_RunOverride_WinsOverEveryOtherTier`, +`ResolveEffectiveRunProperties_ParagraphDefRPrOnly_WinsWhenRunDeclaresNothing`, +`ResolveEffectiveRunProperties_PlaceholderLevelStyleOnly_WinsWhenRunAndParagraphDeclareNothing`, +`ResolveEffectiveRunProperties_NoneDeclared_ResolvesHardCodedDefaults`, +`ResolveEffectiveRunProperties_UnderlineAttribute_ResolvesExpectedBoolean` (a `[Theory]` covering +`"1"`/`"true"`/`"0"`/`"false"` underline values), `ResolveEffectiveRunProperties_RunSolidFillSrgbClr_ResolvesExplicitColor`, +`ResolveEffectiveParagraphProperties_NoneDeclared_ResolvesHardCodedDefaults`, +`ResolveEffectiveParagraphProperties_Alignment_NormalizesJustificationToLeft` (a `[Theory]` +covering `just`/`justLow`), `ResolveEffectiveParagraphProperties_FixedLineSpacing_ResolvesPointsToEmu`, +`ResolveEffectiveParagraphProperties_PlaceholderLevelStyleOnly_WinsWhenParagraphDeclaresNothing`, +`ResolveEffectiveRunProperties_ThemeFontToken_ResolvesThroughFontScheme` (a `[Theory]` covering +all six reserved theme-font tokens: `+mj-lt`/`+mn-lt`, `+mj-ea`/`+mn-ea`, `+mj-cs`/`+mn-cs`), +`ResolveEffectiveRunProperties_LiteralTypeface_PassesThroughUnchanged` + +Proves each inheritance tier (run override, paragraph `defRPr`, placeholder level-indexed +`lstStyle`, hard-coded default) wins in isolation when every higher-priority tier declares +nothing, for both run properties (typeface/size/bold/italic/underline/color) and paragraph +properties (alignment/margin/indent/line-spacing); proves `algn="just"`/`"justLow"` normalizes to +`"l"`; proves fixed-point line spacing (``) resolves to the expected EMU value; proves +each of the six reserved DrawingML theme-font tokens in `` resolves +through the supplied `PptxTheme`'s `FontScheme` (major/minor Latin/EastAsian/ComplexScript font) +to that scheme's own declared family name rather than the literal token string; and proves an +ordinary, non-token literal typeface name passes through unresolved/unchanged. + +#### CanvasNetPptx-PptxDocument-TextStylesInheritance: Master `` Parsing and Bucket Selection + +**Tests**: `GetMaster_TxStylesSiblingOfCSld_ParsesAllThreeStyles`, +`ResolveEffectiveRunProperties_MasterTxStylesOnly_WinsWhenEverythingAboveIsAbsent`, +`ResolveEffectiveRunProperties_MasterTitleStyle_SelectedForTitlePlaceholderType`, +`ResolveEffectiveRunProperties_MasterOtherStyle_SelectedForNonPlaceholderShape`, +`ResolveEffectiveParagraphProperties_MasterTxStylesOnly_WinsWhenEverythingAboveIsAbsent`, +`CanvasNetPptx_SystemIntegration_TextLayoutAndRender_TitlePlaceholderResolvesMasterTitleStyleEndToEnd`, +`CanvasNetPptx_SystemIntegration_TextLayoutAndRender_NonPlaceholderShapeResolvesMasterOtherStyleEndToEnd` + +Proves `GetMaster` parses a slide master's own `` element (a direct sibling of +``) into its three schema-optional style buckets; proves the master's own `txStyles` +bucket wins when every higher-priority tier is absent; proves `title`/`ctrTitle` placeholder types +select `TitleStyle` and a non-placeholder shape (the empty-string sentinel) selects `OtherStyle`; +and the two system-integration tests prove, end-to-end through a full in-memory package opened +via `PptxDocument.Open`, that a `title` placeholder resolves its effective run size from the +master's own `` and that an ordinary (non-placeholder) text box resolves its +effective run size from the master's own `` rather than ``. + +#### CanvasNetPptx-PptxDocument-TextLayoutWordWrap: Word-Wrap and Horizontal Alignment + +**Tests**: `ResolveTextLayout_WordWrap_NarrowWidth_WrapsAtTokenBoundary`, +`ResolveTextLayout_WordWrap_SingleTokenWiderThanAvailableWidth_PlacedAloneOnOwnLine`, +`ResolveTextLayout_WordWrap_WordSplitAcrossRuns_DoesNotWrapAtRunBoundary`, +`ResolveTextLayout_AlignCenter_CentersLineWithinAvailableWidth`, +`ResolveTextLayout_AlignRight_RightAlignsLineAgainstAvailableWidth`, +`ResolveTextLayout_RunBreakRun_ForcesSecondLine`, +`ResolveTextLayout_TallestFontComparison_ScalesByUnitsPerEmAndSizeEmu_NotRawFontUnits` + +Proves a long run wraps onto multiple lines at the expected token boundary for a narrow +`widthEmu`; proves a single token that alone exceeds the available width is placed alone on its +own (overflowing) line rather than looping indefinitely; proves a word spelled across two +adjacent formatting runs with no whitespace between them (for example a bold "Hel" run +immediately followed by a plain "lo" run) is never wrapped at that internal run boundary - +`PackTokensIntoLines` packs consecutive non-whitespace tokens as a single wrap-atomic word group +regardless of which run each token's text came from, so an available width that fits either run's +own text alone but not their combined word still keeps both runs' glyphs on the same (overflowing) +line rather than incorrectly wrapping where no whitespace exists; proves center/right alignment +position a line's glyphs at the expected hand-computed X offset within the available width; +proves a paragraph containing a run, a preserved `` line-break item, and a second run lays +out as two separate lines (the break forces a line boundary independent of word-wrap's own +token-packing decisions); and proves the per-line "tallest run" selection compares two runs using +different fonts/sizes by each run's own font metrics scaled by that font's `UnitsPerEm` and the +run's own resolved `SizeEmu`, so a visually smaller run on a font reporting disproportionately +large raw font-design-unit metrics is not incorrectly selected over a visually larger run on a +differently-scaled font. + +#### CanvasNetPptx-PptxDocument-TextAlignmentWidthUnderWrapNone: Alignment Width Independent of Line-Breaking Width + +**Tests**: `ResolveTextLayout_AlignCenter_WrapNone_StillCentersAgainstShapesDeclaredWidth`, +`PptxDocument_Render_TxtFontPropsFixture_RendersEverySlideWithVisibleContent` + +Proves a `wrap="none"` text body's centered paragraph is positioned against the same finite, +real width (`alignmentWidth`, the shape's own declared width less its horizontal insets) a +`wrap="square"` body's own alignment uses - identical X offsets for the same `"AA"` line in both +wrap modes - rather than against the effectively-infinite width `ResolveTextLayout` uses only to +suppress `wrap="none"`'s own word-wrap line-breaking decision. The second test additionally +proves this against a real-world fixture (`pythonpptx-txt-font-props.pptx` slide index 3, a +centered, `wrap="none"` underline-demo slide), confirming the synthetic regression test's finding +generalizes to a genuine third-party-generated file, not merely a hand-authored edge case. + +#### CanvasNetPptx-PptxDocument-TextVerticalAnchor: Vertical Anchor Positioning + +**Tests**: `ResolveTextLayout_AnchorTop_PositionsFirstBaselineAtAscentFromTop`, +`ResolveTextLayout_AnchorMiddle_CentersBlockVerticallyWithinAvailableHeight`, +`ResolveTextLayout_AnchorBottom_PositionsBlockAtBottomOfAvailableHeight` + +Proves each of the three vertical anchors (`t`/`ctr`/`b`) positions the resolved text block's +first/last glyph `OriginYEmu` at the expected hand-computed offset within the available height. + +#### CanvasNetPptx-PptxDocument-TextAutofit: Three-Tier Autofit Policy + +**Tests**: `ResolveTextLayout_NoAutofit_AppliesNoScalingEvenWhenOverflowing`, +`ResolveTextLayout_SpAutoFit_AppliesNoScaling`, +`ResolveTextLayout_NormAutofitWithExplicitAttributes_AppliesStoredFactorsVerbatim`, +`ResolveTextLayout_NormAutofitAttributeLess_ShrinkLoopConvergesOnFirstFittingScale`, +`ResolveTextLayout_NormAutofitFontScaleOnly_AppliesStoredFontScaleVerbatim`, +`ResolveTextLayout_NormAutofitLnSpcReductionOnly_AppliesStoredReductionWithNeutralFontScale`, +`ResolveTextLayout_SubtitlePlaceholderWithAttributeLessNormAutofit_DoesNotOverShrinkContentThatFits` + +Proves ``/absent-autofit/`` all apply no scaling even when the resolved +text overflows the available height; proves an explicit `` applies both stored factors verbatim; proves an attribute-less +`` on an intentionally overflowing fixture converges, via the bounded 10%-step +shrink loop, to the first hand-computed scale whose naturally laid-out height fits; proves a +`` with no `lnSpcReduction` attribute applies the stored +`fontScale` verbatim while leaving line spacing at its neutral (no-reduction) default rather than +falling through to the attribute-less shrink loop; and proves a `` with no `fontScale` attribute applies the stored line-spacing reduction +(verified via the resulting glyph Y positions) while leaving `fontScale` at its neutral 100% +default. The final test was added during a real-world-corpus investigation of a reported subtitle +"invisible sliver" symptom: it proves a subtitle placeholder's attribute-less `` +does not over-shrink content that genuinely fits a realistically-sized box (`AppliedFontScale` +stays at `1.0`), refuting the hypothesis that the bounded shrink loop itself over-shrinks given +correct inputs - the reported symptom's precise real-world trigger could not be isolated further +from a minimal synthetic fixture in this pass (see the companion design document's "Genuine bug 3" +entry for the full investigation). + +#### CanvasNetPptx-PptxDocument-TextRendering: Glyph Painting and Font Resolution + +**Tests**: `PaintTextLayout_IdentityTransform_PaintsGlyphAtExpectedLocationWithResolvedColor`, +`PaintTextLayout_TranslatedShapeTransform_ShiftsPaintedLocation`, +`PaintTextLayout_EmptyOutlineGlyph_PaintsNothing`, +`PaintTextLayout_AsymmetricGlyph_PaintsInkAboveBaselineNotBelow`, +`ResolveTextFont_AnyFamilyHint_ResolvesNonNullFont`, +`ResolveTextLayout_PlusMinusFollowedByDegree_BothNeitherInPrimary_BothIndependentlyResolveFallback`, +`ResolveTextLayout_PlusMinusAlone_ResolvesFallbackGlyph`, +`ResolveTextLayout_PlusMinusFollowedByDegree_PrimaryCoversOnlyDegree_DegreeStaysOnPrimaryFont`, +`CanvasNetPptx_SystemIntegration_TextLayoutAndRender_TitlePlaceholderResolvesMasterTitleStyleEndToEnd`, +`CanvasNetPptx_SystemIntegration_TextLayoutAndRender_NonPlaceholderShapeResolvesMasterOtherStyleEndToEnd` + +Proves `PaintTextLayout` paints a synthetic filled-square glyph's known ink pixel(s) in the +resolved color at the expected location under an identity shape-to-surface transform, proves a +translated shape transform shifts the painted location by the expected offset, proves a glyph +with an empty outline (no subpaths) paints nothing, proves a synthetic, deliberately asymmetric +glyph outline (ink confined to font-space y toward the ascender only, with none at all toward the +descender - a shape a symmetric square glyph cannot substitute for, since flipping a symmetric +shape about its own center is visually/structurally identical either way) is painted above the +baseline and not below it, catching a regression where a positive (rather than negated) Y scale on +the glyph-outline transform would vertically invert every glyph, and proves `ResolveTextFont` +resolves a non-null `TrueTypeFont` for an arbitrary family-name hint (falling back to the bundled +font); the two system-integration tests additionally prove `PaintTextLayout` paints at least one +non-background pixel end-to-end, from a full in-memory package opened via `PptxDocument.Open` +through `ResolveTextLayout` to a test `Surface`. + +The three `ResolveTextLayout_PlusMinus...` tests reproduce the real-world "tofu box" (missing- +glyph) defect report - a degree sign (U+00B0) immediately following a plus-minus sign (U+00B1) +rendering as a missing-glyph box - and prove the fix is genuine per-character glyph-coverage +fallback, not any form of cross-character state leakage. Each injects two synthetic fonts via +`ResolveTextLayout`'s `fallbackFontResolver` test seam: a **primary** font built with a known, +deliberately incomplete cmap, and a **fallback** font built to cover whichever codepoint(s) the +primary font deliberately omits, each codepoint given its own distinct, real (non-empty) glyph +outline so a resolved glyph index can be asserted exactly, not merely "non-zero by accident." + +- `ResolveTextLayout_PlusMinusFollowedByDegree_BothNeitherInPrimary_BothIndependentlyResolveFallback`: + primary font covers neither U+00B1 nor U+00B0; both characters independently resolve to the + fallback font with their own distinct, correct (non-`.notdef`) glyph indices - proving the + second character's resolution is its own independent decision, not aided or corrupted by the + first character's own fallback substitution. +- `ResolveTextLayout_PlusMinusAlone_ResolvesFallbackGlyph`: a baseline/control case isolating + single-character fallback behavior (a run containing only the fallback-triggering character, + with no following character to interact with). +- `ResolveTextLayout_PlusMinusFollowedByDegree_PrimaryCoversOnlyDegree_DegreeStaysOnPrimaryFont`: + the literal reported-symptom reproduction - the primary font covers U+00B0 but deliberately + **not** U+00B1; asserts U+00B1 resolves via the fallback font while the immediately following + U+00B0 stays on the **primary** font (its own correct, non-`.notdef` glyph index), proving the + preceding character's fallback substitution does not "stick" onto, or otherwise corrupt, the + next character's own independent resolution - directly refuting the "sticky fallback"/state- + corruption hypothesis the original bug report raised, in favor of the actual, confirmed root + cause (no per-character coverage fallback existed at all prior to this change). This test also + includes a baseline check: with no `fallbackFontResolver` configured at all (the historical, + pre-feature default), U+00B0 alone still resolves via the primary font exactly the same way, + confirming the primary font's own coverage - not an accidental fallback-font side effect - is + what resolves it. + +A one-off, non-permanent visual repro generator, +`GeneratePlusMinusDegreeTofuReproPng`, additionally renders this same "±°" two-character fixture +to an inspectable PNG file (`.agent-logs/pptx-degree-tofu-glyph-fallback-repro.png`) for manual +visual confirmation: its top half paints the pre-fix "before" behavior (a fallback resolver that +returns the primary font itself, i.e. no wider-coverage font is ever consulted - both characters +paint as invisible `.notdef` tofu, since the synthetic `.notdef` glyph used throughout this file +has zero contours), and its bottom half paints the fixed "after" behavior (a real fallback font +covering both characters - both paint their distinct, non-empty glyph outlines). This generator is +not part of the permanent regression-test guarantees (the three `[Fact]` tests above already +provide those); it exists solely to produce an artifact a human reviewer can open and inspect. + +#### CanvasNetPptx-PptxDocument-TabStopExpansion: Default Tab-Stop Expansion + +**Tests**: `ResolveTextLayout_TabCharacter_ExpandsToNextDefaultTabStop`, +`ResolveTextLayout_TabCharacter_WrapDecisionUsesExpandedWidthNotGlyphWidth`, +`ResolveTextLayout_MultipleTabCharacters_EachAdvancesToItsOwnNextTabStop` + +Proves a literal U+0009 TAB run character expands the rendered cursor to the exact next +914400-EMU (1 inch) default tab stop - not the near-zero position a `.notdef`-glyph font advance +would otherwise produce for a character most fonts' own `cmap` does not cover - using a run +containing `"A\tB"` and asserting the glyph following the tab lands at the exact hand-computed +`914400` EMU X coordinate. Proves `PackTokensIntoLines`'s wrap/fit decision uses this same +dynamically-computed, tab-stop-expanded width (not the tab token's static, near-zero +glyph-measured width) when deciding where word-wrap breaks occur: a run containing two words +separated by an early tab, sized so the glyph-measured near-zero tab width would (incorrectly) +keep both words on one line (combined width 50800 EMU, comfortably under the chosen 920000 EMU +available width) while the true, tab-stop-expanded width (bringing the running width to 939400 +EMU) correctly wraps the second word onto its own line - asserting the correct (wrapped) 2-line +outcome with hand-computed glyph X/Y coordinates for both lines. Proves two adjacent tab +characters each independently advance to their own next tab stop, computed from the cursor +position after the previous tab's own expansion (not both from the pre-first-tab position) - a +run containing `"A\t\tB"` where the second tab starts exactly on an already-aligned tab-stop +boundary (`914400`) still advances by a full, non-zero interval (landing at `1828800`, never +collapsing to a zero-width advance), confirming the "always advance by at least a minimal amount" +tab semantics `GetNextTabStopEmu` implements. + +#### CanvasNetPptx-PptxDocument-PictureDecodeAndCrop: Picture Decoding, Linking, and Crop-Rectangle Resolution + +**Tests**: `ResolveSrcRect_NoSrcRectElement_ReturnsNull`, +`ResolveSrcRect_AllEdgesPresent_ParsesScaledFractions`, +`ResolveSrcRect_SomeEdgesAbsent_DefaultsToZero`, +`ResolveSrcRect_NonNumericEdgeAttribute_ThrowsInvalidDataException`, +`ResolveSrcRect_NullBlipFillElement_ThrowsArgumentNullException`, +`ResolvePictureSurface_EmbeddedPng_DecodesSurfaceWithExpectedPixel`, +`ResolvePictureSurface_NoBlipElement_ThrowsInvalidDataException`, +`ResolvePictureSurface_BlipMissingEmbedAndLink_ThrowsInvalidDataException`, +`ResolvePictureSurface_LinkedBlipOnly_ThrowsPptxUnsupportedFeatureExceptionWithImageLinkToken`, +`ResolvePictureSurface_SvgOnlyBlipExtension_ThrowsPptxUnsupportedFeatureExceptionWithSvgOnlyToken`, +`ResolvePictureSurface_UnsupportedContentType_ThrowsPptxUnsupportedFeatureExceptionWithImageFormatToken`, +`ResolvePictureSurface_NullArguments_ThrowsArgumentNullException` + +Proves `ResolveSrcRect` returns `null` when `` is absent, proves it parses all four +`l`/`t`/`r`/`b` scaled-fraction edges into a normalized `[0,1]` `PptxSrcRect` when present, proves +each omitted edge defaults to `0`, proves a non-numeric edge attribute throws +`InvalidDataException`, and proves a `null` `` element throws +`ArgumentNullException`. Proves `ResolvePictureSurface` decodes a synthetic 1x1 embedded PNG media +part (constructed via an in-memory `.pptx`-shaped ZIP package with slide/layout/master/theme/media +parts, `BuildMinimalImagePackage`) into a `Surface` whose single pixel matches the source PNG's +known color, proves a `` with no `` element at all throws +`InvalidDataException`, proves a `` with neither `r:embed` nor `r:link` nor any recognized +extension fallback throws `InvalidDataException` (a genuinely malformed blip - this is an explicit +non-regression check: the fix below only reclassifies the one recognized SVG-extension-fallback +case, not every embed/link-less blip), proves a `` with only `r:link` (no embedded bytes) +throws `PptxUnsupportedFeatureException` carrying the `"pptx-image-link"` feature token, proves a +`` with neither `r:embed` nor `r:link` but carrying a recognized Microsoft SVG extension +fallback (`/` wrapping an +``, the "Insert Icon" SVG-with-no-raster-fallback pattern real PowerPoint produces) +throws `PptxUnsupportedFeatureException` carrying the `"pptx-image-svg-only"` feature token rather +than `InvalidDataException` - a regression fix, since this well-formed, valid OOXML construct was +previously misclassified as malformed data, proves a media part whose resolved content type is not +a recognized raster format throws `PptxUnsupportedFeatureException` carrying the +`"pptx-image-format"` feature token, and proves null argument validation for both +`picElement`/`document`. + +#### CanvasNetPptx-PptxDocument-PicturePainting: Picture Compositing Without PDF's Y-Flip + +**Tests**: `PaintPicture_IdentityTransformNoCrop_PaintsImagePixelsUnflipped`, +`PaintPicture_TopBottomAsymmetricImage_PaintsWithoutVerticalFlip`, +`PaintPicture_WithSrcRectCroppingLeftHalf_SamplesOnlyRightHalf`, +`PaintPicture_TranslatedTransform_ShiftsPaintedFootprint`, +`PaintPicture_DegenerateTransform_PaintsNothing`, +`PaintPicture_NullSurfaceOrImage_ThrowsArgumentNullException`, +`PaintPicture_SourceImageWithAlphaChannel_AlphaBlendsOntoExistingBackground`, +`PaintPicture_EllipseClipPath_CornerUnpaintedCenterPainted`, +`PaintPicture_NullClipPath_PaintsFullRectangleUnclipped` + +Proves `PaintPicture` paints a decoded image's pixels onto a destination `Surface` unchanged +(pixel-for-pixel) under an identity shape transform with no crop, proves a deliberately +top/bottom-asymmetric synthetic source image (a shape a symmetric image cannot substitute for, +mirroring Phase 1d's own asymmetric-glyph regression-detection rationale) paints with its rows in +the same top-down order as the source - **not** vertically flipped, the deliberate divergence from +`DemaConsulting.CanvasNet.Pdf`'s own y-up image-painting convention - proves an `` +cropping away the left half samples only the image's own right half, proves a translated shape +transform shifts the painted footprint by the expected offset, proves a singular (non-invertible, +for example zero-scale) shape transform paints no pixels at all rather than throwing or dividing +by zero, proves `null` `destination`/`image` arguments throw `ArgumentNullException`, and proves +(regression guard for a confirmed real-world raw-overwrite alpha-compositing bug) each sampled +source pixel is alpha-blended "over" the existing destination pixel - a fully transparent source +pixel with a non-matching stored RGB leaves the background completely unchanged, a fully opaque +source pixel exactly replaces it, and a partially transparent source pixel blends to the exact +expected bytes per the documented Porter-Duff "over" formula. Proves `PaintPicture`'s own new +optional `clipPath` parameter: an elliptical clip path leaves a bounding-box corner unpainted +while the shape's own center still paints the image's color, and a `null` clip path (the +default) continues painting the full rectangle unclipped - a direct regression guard for every +pre-existing call site above. See also +"CanvasNetPptx-PptxDocument-PictureGeometryClipping" below for the end-to-end render-level +clip-resolution coverage. + +#### CanvasNetPptx-PptxDocument-TableParsing: Table Structure, Cell Attributes, and Verbatim Fill/Border/Text Reuse + +**Tests**: `ParseTable_WellFormedTable_ParsesColumnsAndRows`, +`ParseTable_MissingGraphicData_ThrowsInvalidDataException`, +`ParseTable_NonTableGraphicFrameKind_ThrowsPptxUnsupportedFeatureExceptionWithGraphicFrameKindToken`, +`ParseTable_MissingTblGrid_ThrowsInvalidDataException`, +`ParseTable_GridColNonNumericWidth_ThrowsInvalidDataException`, +`ParseTable_TrMissingHeight_ThrowsInvalidDataException`, +`ParseTableCell_NoAttributes_DefaultsToSpanOneNoMerge`, +`ParseTableCell_AttributesPresent_ParsesSpanAndMergeFlags`, +`ParseTableCell_NonNumericGridSpan_ThrowsInvalidDataException`, +`ParseTableCell_TcPrWithFillAndBorders_ResolvesFillAndBorders`, +`ParseTableCell_NoTxBody_TextBodyIsNull`, +`ParseTableCell_WithTxBody_ParsesTextBody`, +`ParseTable_GridSpanCellWithGradientFill_UsesMergedWidth`, +`ParseTable_RowSpanCellWithGradientFill_UsesMergedHeight` + +Proves `ParseTable` parses a well-formed ``'s column widths, row heights, and cell +structure into a `PptxTable`; proves a missing `` throws +`InvalidDataException`; proves a `` whose `uri` does not end in `"/table"` throws +`PptxUnsupportedFeatureException` carrying the `"pptx-graphic-frame-kind"` feature token; proves a +missing ``, a non-numeric `` `w` attribute, and a missing `` `h` +attribute each throw `InvalidDataException`. Proves `ParseTableCell` defaults `gridSpan`/`rowSpan` +to `1` and `hMerge`/`vMerge` to `false` when their attributes are absent, proves it parses each +attribute correctly when present, proves a non-numeric `gridSpan` throws `InvalidDataException`, +proves a cell's `` fill/border elements resolve via the already-verified Phase 1c +`ResolveFill`/`ResolveLineStyle` resolvers, and proves a cell's `` presence/absence +resolves its `PptxTableCell.TextBody` field correctly (`null` when absent, a parsed +`PptxTextBody` via the already-verified Phase 1d `ParseTextBody` when present). Proves (Review +Follow-Up) that `ParseTable` sizes a `gridSpan="2"` cell's gradient fill against its true summed +two-column width - not only its first column's own width, as an un-merge-aware sizing would +produce - by comparing the merged cell's own resolved `LinearGradient`'s `End.X - Start.X` extent +against an equivalent `gridSpan="1"` cell's own extent; proves the same for a `rowSpan="2"` +cell's gradient height, comparing against the row-heights list `ParseTable` now precomputes up +front (`rowHeightsEmu`) via the new `SumConsecutive` helper. + +#### CanvasNetPptx-PptxDocument-TableCellRectResolution: Merge-Aware Cell-Rect Computation + +**Tests**: `ResolveCellRects_SimpleGrid_ComputesCumulativeOffsets`, +`ResolveCellRects_MergeContinuationCells_AreSkipped`, +`ResolveCellRects_HorizontalMerge_ComputesSpannedWidth`, +`ResolveCellRects_VerticalMerge_ComputesSpannedHeight`, +`ResolveCellRects_GridSpanMissingHMergePlaceholder_CompensatesColumnAdvance` + +Proves `ResolveCellRects` computes each cell's own cumulative `X`/`Y` offset correctly across a +simple, unmerged grid; proves a merge-continuation cell (`hMerge`/`vMerge` set) contributes no +`PptxResolvedTableCell` of its own (it is skipped entirely); proves a horizontally-merged cell's +resolved rectangle width equals the sum of its spanned columns' own widths, while the column +offset for cells *after* the merge still advances correctly (this test originally failed during +implementation - the running column offset was incorrectly advancing by the merged cell's own +full spanned width instead of by each physical `` entry's own single-column width, a defect +diagnosed and fixed in `ResolveCellRects` before this test was declared passing - see the design +doc's own *Cell-Rect Resolution* subsection for the full root-cause explanation); and proves a +vertically-merged cell's resolved rectangle height equals the sum of its spanned rows' own +heights. Proves (Review Follow-Up) that a malformed row - a `gridSpan="2"` governing cell +immediately followed by a different, independent cell with no `hMerge` continuation placeholder +at all (a structure the schema's own `minOccurs="0"` on `` permits) - no longer corrupts +the following cell's computed column offset: `ResolveCellRects` now counts the actually-present +`hMerge` continuations following a governing cell and defensively advances both `xEmu` and +`columnIndex` past any shortfall, so the governing cell's own merged rectangle is unaffected and +the following, unrelated cell lands at its correct, non-overlapping column offset. + +#### CanvasNetPptx-PptxDocument-TablePainting: Cell Fill, Border, and Text Painting + +**Tests**: `PaintTable_SolidFilledCell_PaintsFillColorAcrossCellRectangle`, +`PaintTable_CellWithTopBorder_PaintsStrokedLineAtTopEdge`, +`PaintTable_CellWithText_PaintsGlyphInkInsideCellRectangle`, +`PaintTable_EmptyCell_PaintsNothing`, +`PaintTable_GradientFilledCellUnderNonIdentityTransform_PositionsGradientInSurfaceSpace` + +Proves `PaintTable` paints a solid-filled cell's resolved color across its own rectangle, proves a +cell with a resolved top border paints a stroked line at the cell's own top edge, proves a cell's +text content paints glyph ink somewhere inside the cell's own rectangle (via a cell sized/scaled +and inset-zeroed so a default-size glyph reliably lands within the test surface - mirroring Phase +1d's own `ResolveTextLayout`/`PaintTextLayout` test conventions), and proves an empty cell (no +fill, no border, no text) paints no non-background pixels at all. Proves (Review Follow-Up) that +`PaintTable`/`PaintCellBorder` compose a non-identity `shapeToSurfaceTransform` into a +gradient-filled cell's own `Gradient` before filling (via `FillPaint`'s new four-parameter +overload): a gradient-filled cell painted through a 2x-scale `shapeToSurfaceTransform` samples the +first stop's color near the surface-space-mapped start of the ramp and the last stop's color near +its mapped end, proving the ramp follows the shape into surface space rather than staying fixed in +untransformed local-EMU coordinates. + +#### CanvasNetPptx-PptxDocument-TableStyleResolution: `` Table-Style/Banding Resolution + +**Tests**: `ParseTable_FirstRowTblPrWithMatchingTableStyle_HeaderRowCellUsesFirstRowStyleFill`, +`ParseTable_BandRowTblPrWithMatchingTableStyle_AlternatesBand1HAndBand2HFillStartingAfterHeaderRow`, +`ParseTable_TblPrWithNoTableStyleId_FallsBackToPlainCellOnlyBorderAndNoFill`, +`ParseTable_TblPrWithUnresolvableTableStyleId_FallsBackToPlainCellOnlyBorderAndNoFillWithoutThrowing`, +`ParseTableCell_TcPrExplicitFillAndBorder_OverridesTableStyleFillAndBorder`, +`ParseTableCell_InteriorColumnBorder_UsesInsideVNotLeftRightTcBdrEdge` + +Proves a table whose `` declares a `` that resolves to a matched +``, with `firstRow="1"`, renders its header row (row index `0`) cell using that +style's own `` tier fill, while a non-header row falls back to the style's +`` base-tier fill. Proves a five-row table with both `firstRow="1"` and `bandRow="1"` +renders its header row via `` and alternates ``/`` fills across +the remaining rows starting immediately after the header row (confirming the row-parity +convention - a styled header row excluded from band-row counting entirely), and proves a band +tier declaring no `` at all (here, ``) falls through to the `` base +fill rather than rendering "no fill". Proves a table whose `` declares no +`` at all never even invokes the supplied table-style resolver delegate (asserted +via a resolver that throws if invoked), and proves a table whose `` does not +resolve to any known `` (the resolver returns `null`) likewise falls back to today's +pre-existing plain, style-less cell-only fill/border resolution, in both cases without throwing. +Proves a cell's own explicit `` fill and a single explicit border edge (``) take +final precedence over a matched table style's own fill/borders (mirroring +`ResolveShapeLineStyle`'s established "explicit always wins" precedence pattern), while the +cell's remaining, non-overridden border edges still fall back to the matched style's own +`` tier. Proves a three-column table's interior column boundaries resolve against the +matched style's own `/` edge rather than its `left`/`right` edges, while the +table's two true outer-boundary edges (the first cell's own left edge, the last cell's own right +edge) still resolve against `left`/`right` - confirming the structural outer-vs-interior edge-name +mapping independently of the `firstRow`/`bandRow` *styling* flags exercised by the preceding tests. + +#### CanvasNetPptx-PptxDocument-ChartGraphicFrames: Chart Graphic Frame Dispatch, Resolution, Exception Wrapping, and Rendering + +**Tests**: `ParseShapeTree_GraphicFrameWithSupportedChart_DispatchesToChartNode`, +`ParseShapeTree_GraphicFrameWithTable_StillDispatchesToTableNode`, +`ParseShapeTree_GraphicFrameWithRadarChart_ThrowsPptxUnsupportedFeatureExceptionWithWrappedFeatureToken`, +`ParseChart_ChartElementWithNoRelationshipId_ThrowsInvalidDataException`, +`ParseChart_GraphicDataWithNoChartChild_ThrowsInvalidDataException`, +`Render_ChartOnlySlide_PaintsVisibleContent`, +`PptxDocument_Render_Aiden0zChartAndComplexFixture_Slide0AndSlide1PaintContent`, +`PptxDocument_Render_SamplelibSamplePresentationFixture_EverySlidePaintsContent`, +`PptxDocument_Render_PythonPptxChartLineFixture_RendersSuccessfully`, +`PptxDocument_Render_PythonPptxChartUnsupportedRadarFixture_ThrowsPptxUnsupportedFeatureException`, +`PptxDocument_Render_ShpShapesFixture_Chart2NodeHasNonNullChart` + +Proves `ParseShapeTree` dispatches a `` declaring a supported chart kind (bar, +line, pie, and area, each via its own `[InlineData]` case) to a `PptxGraphicFrameShapeNode` whose +`Chart` is non-null and whose `Table` is `null`, via the new `ParseChart`/`OpenXmlChartParser` +integration; proves a `` declaring a table still dispatches to `ParseTable` +exactly as before (`Table` non-null, `Chart` null) - a sanity check that chart dispatch does not +regress the pre-existing table path. Proves a chart declaring a recognized-but-unsupported kind +(radar) throws `PptxUnsupportedFeatureException` whose `Feature` is the wrapped +`ChartUnsupportedFeatureException`'s own `Feature` value prefixed with `"pptx-chart-"` (here, +`"pptx-chart-charts-openxml-radar-chart"`). Proves `ParseChart` itself throws +`InvalidDataException` for an `` with no `` child, and separately for a +`` element with no `r:id` attribute - each a malformed, not merely unsupported, chart +graphic frame. Proves a synthetic, fully in-memory chart-only slide package renders through the +public `Render` API and paints at least one non-transparent pixel, proving the chart-compositing +branch (`ChartRenderer.Render` + the existing `PaintPicture` primitive) works end-to-end, not just +at the dispatch/parse level. Proves three real-world corpus fixtures now render their own +chart-bearing slides successfully where they previously threw +(`aiden0z-1-chart-and-complex.pptx` slide index 1, `samplelib-sample-presentation.pptx` slide +index 4 - now folded into every slide of that fixture rendering - and the new, locally-generated +`pythonpptx-chart-line.pptx`), and that the new, locally-generated +`pythonpptx-chart-unsupported-radar.pptx` throws `PptxUnsupportedFeatureException` with the +expected wrapped radar feature token. Proves, via a dedicated isolation test that parses +`pythonpptx-shp-shapes.pptx` slide index 0's own shape tree directly with +`containUnsupportedGraphicFrames: true` (isolating "Chart 2" from its unsupported sibling +"Diagram 7" SmartArt graphic frame, and supplying this slide's own real layout/master/theme chain +as its `themeResolver` since the same shape tree also contains a real "Table 1" table graphic +frame whose own `ParseTable` call needs a real theme), that "Chart 2" alone resolves to a +non-null `Chart`/null `Table` node - confirming directly, not assuming, that this slide's +whole-`Render` call still throws `PptxUnsupportedFeatureException` solely because of "Diagram 7", +not because of "Chart 2" (see the corpus-level scenario below for the containing, uncontained +slide-level assertion). + +**Note on the originally-anticipated "master/layout-owned chart gracefully skipped" scenario**: +this phase's planning anticipated documenting a `containUnsupportedGraphicFrames: true` scenario +specifically for a master/layout-owned *chart* with an unsupported feature. Since charts are now +a supported graphic-frame kind in general (not a deferred one), that scenario no longer applies as +originally envisioned; the pre-existing `Render_MasterGraphicFrameWithUnsupportedKind_ +SkipsThatShapeAndStillRendersSlideContent` test (in `PptxRenderTests.cs`), which proves a master- +owned graphic frame of *any* unsupported kind is gracefully skipped (not propagated) while the +rest of the slide still renders, was adapted this phase to use a diagram-kind (SmartArt) stand-in +graphic frame in place of its original chart-kind one, since a `.../chart`-suffixed URI now +dispatches to the real `ParseChart` rather than remaining a generic "any unsupported kind" +stand-in - preserving this test's original intent (a master-owned, genuinely-unsupported-kind +graphic frame is gracefully skipped under `containUnsupportedGraphicFrames: true`) without +colliding with the new chart dispatch. + +#### CanvasNetPptx-PptxDocument-ShapeTree: Recursive Shape-Tree Parsing and Deferred Theme Resolution + +**Tests**: `ParseShapeTree_FreeformShape_ProducesSpShapeNodeWithNullPlaceholder`, +`ParseShapeTree_PlaceholderShape_ProducesSpShapeNodeWithResolvedPlaceholder`, +`ParseShapeTree_Picture_ProducesPictureShapeNode`, +`ParseShapeTree_GraphicFrameTable_ProducesGraphicFrameShapeNodeWithParsedTable`, +`ParseShapeTree_NoGraphicFrame_NeverInvokesThemeResolver`, +`ParseShapeTree_UnrecognizedElements_SilentlySkipped`, +`ParseShapeTree_Connector_ProducesConnectorShapeNode`, +`ParseShapeTree_Group_ProducesGroupShapeNodeWithDirectSiblingChildren`, +`ParseShapeTree_NestedGroups_EachResolveOwnChildTransformAndChildren`, +`ParseShapeTree_GroupMissingXfrm_ThrowsInvalidDataException`, +`ParseShapeTree_GraphicFrameInsideGroup_ParsesTableUsingPropagatedThemeResolver`, +`TryParsePlaceholder_FreeformShape_ReturnsNull`, +`TryParsePlaceholder_NoTypeOrIdxAttributes_DefaultsToObjAndZero`, +`TryParsePlaceholder_NonNumericIdx_ThrowsInvalidDataException` + +Proves `ParseShapeTree` dispatches a freeform `` (no ``) to a `PptxSpShapeNode` with a +`null` `Placeholder`, proves a placeholder `` resolves a non-null `Placeholder` with the +expected type/idx, proves a `` dispatches to a `PptxPictureShapeNode` wrapping the raw +element, proves a `` declaring a table dispatches to a `PptxGraphicFrameShapeNode` +with its table eagerly parsed, proves a shape tree with no `` at all never invokes +the supplied `Func` theme resolver (confirming the deferred, lazy theme-resolution +design - see the design doc's own *Shape Tree* subsection), proves unrecognized element kinds +(``) are silently skipped rather than rejected, proves a `` dispatches to +a `PptxConnectorShapeNode` wrapping the raw element (see the *Connector Shape Rendering* +subsection below), proves a `` +dispatches to a `PptxGroupShapeNode` whose children are its own direct siblings (not wrapped in a +nested ``) with its child transform resolved from its own ``, proves nested +`` elements each resolve their own child transform and recurse into their own direct +children independently, proves a `` with no `/` element throws +`InvalidDataException`, and proves a `` nested inside a group still resolves its +table correctly via the propagated theme resolver. Proves `PptxPlaceholderParser.TryParsePlaceholder` +(the Phase 1b placeholder-detection logic, extracted this phase into a standalone, independently +callable helper with no behavior change - confirmed by re-running the full pre-existing Phase +1a-1d test suite immediately after the extraction) returns `null` for a freeform shape, applies +the `"obj"`/`0` schema defaults when `type`/`idx` attributes are omitted, and rejects a +non-numeric `idx` attribute with `InvalidDataException`. + +### Full Slide Rendering (Phase 1f) Test Scenarios + +#### CanvasNetPptx-PptxDocument-SlideRendering: Shape-Tree Walk and Per-Node-Kind Dispatch + +**Tests**: `Render_SingleFullSlideShape_PaintsFillColorAcrossSurface`, +`Render_EmptyShapeTree_RendersOnlyBackground`, +`Render_SmallPixelDimensionsAgainstLargeEmuSlideSize_DoesNotTruncateTransformToZero`, +`Render_TwoOverlappingShapes_LaterShapePaintsOnTopInDocumentOrder`, +`Render_ShapeWithNoXfrm_SkippedSilently`, +`Render_NestedGroup_ComposesChildTransformIntoExpectedSurfaceLocation`, +`Render_DoublyNestedGroups_EachComposeOwnChildTransform`, +`Render_Picture_PaintsEmbeddedImageAtExpectedLocation`, +`Render_PictureMissingBlipFill_SkippedSilently`, +`Render_Table_PaintsCellFillAcrossCellRectangle`, +`Render_GraphicFrameMissingXfrm_SkippedSilently`, +`Render_NonPlaceholderShapeWithText_PaintsGlyphInkInsideShapeRectangle`, +`Render_PlaceholderShape_ReadsTextFromSlideLevelShapeNotLayout`, +`Render_PlaceholderShapeWithEmptySpPr_InheritsXfrmAndGeometryFromLayout`, +`PptxDocument_Render_BlankSlideFixtures_RendersWithoutError`, +`PptxDocument_Render_ShpAutoshapePropsFixture_PaintsVisibleContent`, +`PptxDocument_Render_ShpGroupShapeFixture_RendersWithoutError`, +`PptxDocument_Render_ShpPictureFixture_RendersEverySlideWithVisibleContent`, +`PptxDocument_Render_TblCellFixture_RendersEverySlideWithVisibleContent`, +`PptxDocument_Render_TxtFontPropsFixture_RendersEverySlideWithVisibleContent`, +`PptxDocument_Render_TxtTextFrameFixture_RendersEverySlideWithVisibleContent`, +`PptxDocument_Render_ShpShapesFixture_Slide0ThrowsUnsupportedFeatureSlide1PaintsContent`, +`PptxDocument_Render_SamplelibSamplePresentationFixture_Slide4ThrowsUnsupportedFeatureOthersPaintContent`, +`PptxDocument_Render_SldBackgroundFixture_RendersWithoutErrorBackgroundNotPainted`, +`PptxDocument_Render_PhInheritPropsFixture_Slide0PaintsSlide1RendersWithoutError`, +`PptxDocument_Render_PhUnpopulatedPlaceholdersFixture_RendersEverySlideWithoutError`, +`PptxDocument_Render_TxtFitTextFixture_PaintsVisibleContent`, +`PptxDocument_Render_ShpConnectorPropsFixture_ConnectorsPaintVisibleLines`, +`PptxDocument_Render_DmlFillFixture_Slide0PictureFillNoLongerBlocksRenderingOnlyPatternFillThrows`, +`PptxDocument_Render_DmlFillFixture_Slide1PatternFillThrowsUnsupportedFillFeature`, +`PptxDocument_Render_DmlLineFixture_RendersEverySlideWithVisibleContent`, +`PptxDocument_Render_Aiden0zChartAndComplexFixture_Slide0PaintsSlide1ThrowsUnsupportedFeature`, +`PptxDocument_Render_Aiden0zImageCropCssResetFixture_PaintsVisibleContentBackgroundNotPainted`, +`PptxDocument_Render_Aiden0zTableStaleFrameFixture_PaintsVisibleContentDespiteFrameSizeMismatch` + +Proves a single full-slide shape's resolved fill paints across the destination surface, proves a +slide with no shapes at all renders only the cleared background, proves rendering at a pixel size +far smaller than the slide's own EMU magnitude does not truncate the EMU-to-pixel transform to a +zero scale factor via integer division, proves two overlapping shapes composite in document order +(the later shape paints on top of the earlier one), proves a shape with no resolvable `` +is skipped silently rather than throwing, proves a shape nested inside a `` is painted at +the expected surface location after composing the group's own child transform with the base +transform, proves doubly nested groups each compose their own child transform independently +through two full levels of +recursion, proves a `` paints its embedded image at the expected surface location, proves +a `` missing its own `` is skipped silently, proves a ``'s +table paints a cell's own resolved fill across its cell rectangle, proves a `` +missing its own direct `` child is skipped silently, proves a non-placeholder shape's own +text body paints glyph ink somewhere inside the shape's own rectangle, proves a placeholder +shape's text is read from the slide-level shape element's own `` rather than from its +matched layout placeholder (which supplies styling only), and proves a placeholder shape whose +own `` is empty still inherits its ``/geometry from its matched layout placeholder +rather than being silently skipped. + +The remaining twenty `PptxDocument_Render_*Fixture_*` tests are the real-world corpus-conformance +tier added in the Phase 2 hardening pass (see `pptx-document.md`'s own Phase 2 design section and +`PptxFixtures/README.md` for full provenance): each opens one of twenty genuine, independently- +sourced `.pptx` files across three independent sources and renders every one of its slides, +proving real-file conformance - not merely synthetic-package conformance - for the shape-tree walk +and per-node-kind dispatch this requirement specifies. These tests additionally prove, against +real files rather than merely hand-authored packages, that a chart/SmartArt-bearing +`` slide throws `PptxUnsupportedFeatureException` cleanly +(`pythonpptx-shp-shapes.pptx` slide 0, `samplelib-sample-presentation.pptx` slide 4, +`aiden0z-1-chart-and-complex.pptx` slide 1), that a group shape whose every child shape +relies solely on an unresolved `` shape-style-matrix reference for its fill (an +out-of-scope construct, see `pptx-document.md`'s Phase 1c deferred-items list) renders without +error despite painting no visible ink (`pythonpptx-shp-groupshape.pptx`), and that +`pythonpptx-dml-fill.pptx`'s slide 0 and slide 1 - which between them place a real, embedded +``/`` picture-filled shape, a non-conformant attribute-less ``, +and three named-preset `` pattern-filled shapes (`divot` x2, `wave`) - +all now render completely, end-to-end, without throwing at all: picture fill is fully supported +(see `CanvasNetPptx-PptxDocument-PictureFillResolution` above) and pattern fill is supported for +a documented covered-preset subset, including every preset this fixture itself declares (see +`CanvasNetPptx-PptxDocument-PatternFillResolution` above) - this fixture no longer exercises the +`"pptx-pattern-fill"` exception token at all; that token is still proven directly, against an +uncovered preset, by `ResolveFill_PatternFillUnsupportedPreset_ThrowsPptxUnsupportedFeatureException` +above instead. A slide's own +`` background fill is covered by +`CanvasNetPptx-PptxDocument-SlideBackgroundFill` below instead, including both of these same real +fixtures' own now-painted backgrounds. + +#### CanvasNetPptx-PptxDocument-RenderPublicApi: Public API Argument Validation and DPI Convenience Overload + +**Tests**: `Render_DefaultOptions_ClearsUnpaintedAreaToOpaqueWhite`, +`Render_CustomBackgroundColor_ClearsToThatColor`, +`Render_FullyTransparentBackgroundColor_ClearsToTransparent`, +`Render_NegativeSlideIndex_ThrowsArgumentOutOfRangeException`, +`Render_SlideIndexAtSlideCount_ThrowsArgumentOutOfRangeException`, +`Render_NonPositiveWidth_ThrowsArgumentOutOfRangeException`, +`Render_NonPositiveHeight_ThrowsArgumentOutOfRangeException`, +`Render_DisposedDocument_ThrowsObjectDisposedException`, +`Render_Dpi_NonPositiveDpi_ThrowsArgumentOutOfRangeException`, +`Render_Dpi_NonFiniteDpi_ThrowsArgumentOutOfRangeException`, +`Render_Dpi_NegativeSlideIndex_ThrowsArgumentOutOfRangeException`, +`Render_Dpi_SlideIndexAtSlideCount_ThrowsArgumentOutOfRangeException`, +`Render_DpiOverload_ComputesExpectedPixelDimensionsFromSlideSize` + +Proves `Render` clears its destination surface to `PptxRenderOptions.Default`'s opaque white when +no options are supplied, proves a caller-supplied `BackgroundColor` (including a fully transparent +one) clears the surface to that exact color instead, proves both overloads reject a negative or +out-of-range `slideIndex`, a non-positive `width`/`height`, a disposed document, and - for the DPI +overload only - a non-positive or non-finite `dpi`, each with `ArgumentOutOfRangeException` or +`ObjectDisposedException` as appropriate, and proves the DPI convenience overload computes the +expected pixel width/height from the slide's own EMU `SlideSize` (a 10in by 7.5in slide at 96 DPI +renders a 960 by 720 pixel surface). + +#### CanvasNetPptx-PptxDocument-SlideBackgroundFill: Slide/Layout/Master Background Fill (``) + +**Tests**: `Render_SlideLevelSolidBackground_PaintsAcrossFullSlide`, +`Render_SlideLevelBackgroundWithShape_ShapePaintsOnTopOfBackground`, +`Render_LayoutLevelSolidBackground_PaintsWhenSlideDeclaresNone`, +`Render_MasterLevelSolidBackground_PaintsWhenNeitherSlideNorLayoutDeclaresOne`, +`Render_SlideLevelBackground_TakesPriorityOverLayoutAndMasterBackgrounds`, +`Render_LayoutLevelBackground_TakesPriorityOverMasterBackgroundWhenSlideDeclaresNone`, +`Render_NoBackgroundAnywhere_FallsBackToOptionsBackgroundColor`, +`Render_SlideLevelThemeIndexedBackgroundReference_ResolvesBgFillStyleListEntryWithPhClrSubstitution`, +`Render_SlideLevelThemeIndexedBackgroundReferenceIdxZero_PaintsNoBackground`, +`ResolveSlideBackgroundFill_AllThreeTiersNull_ReturnsNull`, +`ResolveSlideBackgroundFill_SlideDeclaresOne_ResolvesSlideOwnBackground`, +`ResolveSlideBackgroundFill_SlideDeclaresNoneLayoutDoes_ResolvesLayoutOwnBackground`, +`ResolveSlideBackgroundFill_SlideAndLayoutDeclareNoneMasterDoes_ResolvesMasterOwnBackground`, +`ResolveSlideBackgroundFill_EmptySlideBackgroundStillWinsOverLayout_MatchesPlaceholderInheritancePrecedent`, +`ResolveSlideBackgroundFill_BgElementWithNeitherBgPrNorBgRef_ThrowsInvalidDataException`, +`ResolveSlideBackgroundFill_BgRefIdxZero_ReturnsNoFill`, +`ResolveSlideBackgroundFill_BgRefIdxOneThousand_ReturnsNoFill`, +`ResolveSlideBackgroundFill_BgRefIdxInFillStyleListRange_ThrowsPptxUnsupportedFeatureException`, +`ResolveSlideBackgroundFill_BgRefIdxResolvesBgFillStyleListEntryWithPhClrSubstitution`, +`ResolveSlideBackgroundFill_BgRefIdxSecondEntry_ResolvesSecondBgFillStyleListEntry`, +`ResolveSlideBackgroundFill_BgRefResolution_DoesNotMutateThemeBgFillStyleList`, +`ResolveSlideBackgroundFill_BgRefIdxPastEndOfBgFillStyleList_ThrowsInvalidDataException`, +`ResolveSlideBackgroundFill_BgRefWithNoIdxAttribute_ThrowsInvalidDataException`, +`ResolveSlideBackgroundFill_BgRefWithNonNumericIdxAttribute_ThrowsInvalidDataException`, +`ResolveSlideBackgroundFill_BgRefWithNoColorChild_ResolvesEntryWithDefaultPhClr`, +`GetTheme_NoFmtScheme_BgFillStyleListIsEmpty`, +`GetTheme_FmtSchemeWithNoBgFillStyleLst_BgFillStyleListIsEmpty`, +`GetTheme_FmtSchemeWithBgFillStyleLst_ParsesEntriesInDocumentOrder`, +`PptxDocument_Render_SldBackgroundFixture_RendersAndPaintsSlideBackgroundFill`, +`PptxDocument_Render_Aiden0zImageCropCssResetFixture_PaintsVisibleContentAndBackgroundFill` + +Proves the slide -> layout -> master `` precedence chain resolves exactly like the +established placeholder-property "first element present at all wins" inheritance rule, at both +the resolver level (`ResolveSlideBackgroundFill` called directly against hand-built `` +fragments) and the full, pixel-level `Render` integration level (a slide-level background paints +across the full surface; a layout-level background paints only when the slide itself declares +none; a master-level background paints only when neither the slide nor its layout declares one; a +higher tier's own background takes priority over a lower tier's even when both are present; an +empty-but-present `` at a higher tier still wins, and - having neither `` nor +`` - throws `InvalidDataException` rather than silently falling through to the next +tier). Proves the resolved background paints *before* the shape-tree walk, so a shape's own fill +still paints on top of it unchanged, and proves that when none of slide/layout/master declare a +`` at all, rendering falls back unchanged to `PptxRenderOptions.BackgroundColor` exactly as +before this feature existed. Proves `` theme-indexed resolution across its full +ECMA-376 `CT_StyleMatrixReference` boundary: `idx` 0 and 1000 both resolve to no background +(`PptxNoFill`); `idx` 1-999 (the theme's `` half of the style matrix) throws +`PptxUnsupportedFeatureException` with feature token `pptx-bg-fill-style-ref`; `idx` 1001 and +above resolves the theme's own `/`, 0-based from that offset +(including its second, not just first, entry), substituting ``'s own color child for the +matched entry's `phClr` token (or, when `` itself declares no color child, falling back +to `ResolveColor`'s own pre-existing default); a missing/non-numeric `idx` and an out-of-range +`idx` (including against an empty ``) both throw `InvalidDataException`. Proves +resolving a `` never mutates the theme's own cached, shared `BgFillStyleList` tree (a +regression test for the "wrap the matched entry in a synthetic parent, which clones rather than +reparents it" approach this resolver relies on - the theme is parsed once and reused across every +slide in a document). Proves `GetTheme` parses `/` into +`PptxTheme.BgFillStyleList` in document order, tolerating an absent `` or an absent +`` as an empty list rather than an error (the overwhelming majority of real +themes declare neither). Proves, against two independent real-world fixture files +(`pythonpptx-sld-background.pptx`, added specifically for this feature, and +`aiden0z-image-crop-css-reset.pptx`), that each file's own real, solid-color `` fill now +paints the exact declared color at the pixel level (`FF0000` and `FAFAF9` respectively) - a +pixel-level, not merely XML-resolution-level, regression guard against this feature's own +single highest-visual-impact motivation. A pattern or picture background fill is not covered by +this requirement - it is rejected with `PptxUnsupportedFeatureException`, inherited unchanged +from `ResolveFill`'s own pre-existing Phase 1c boundary (see +`CanvasNetPptx-PptxDocument-FillResolution`'s own verification above) - and a linear gradient +background fill is covered only indirectly, by the same `ResolveGradientFill` tests +`CanvasNetPptx-PptxDocument-GradientFill` already verifies, since `` resolution reuses that +pipeline verbatim rather than introducing a background-specific gradient path. + +#### CanvasNetPptx-PptxDocument-MasterLayoutShapeRendering: Master/Layout Decorative Shape Rendering + +**Tests**: `Render_MasterNonPlaceholderPicture_PaintsOnEverySlideUsingThatMasterResolvingOwnRels`, +`Render_LayoutNonPlaceholderAutoshape_PaintsOnlyOnSlidesUsingThatLayout`, +`Render_MasterAndSlideShapesOverlap_SlideShapePaintsOnTopOfMasterShape`, +`Render_MasterAndLayoutShapesOverlap_LayoutShapePaintsOnTopOfMasterShape`, +`Render_MasterPlaceholderShape_DoesNotRenderItsOwnPromptContent`, +`Render_LayoutPlaceholderShape_DoesNotRenderItsOwnPromptContent`, +`Render_MasterNonPlaceholderPictureWithUnsupportedFormat_SkipsThatShapeAndStillRendersSlideContent`, +`Render_SlideOwnPictureWithUnsupportedFormat_StillThrowsPptxUnsupportedFeatureException`, +`PptxDocument_Render_Aiden0zChartAndComplexFixture_Slide0PaintsSlide1ThrowsUnsupportedFeature` + +Proves, at the pixel level with deliberately contrasting colors so a wrong z-order or a missed +shape fails visibly rather than silently, that a slide master's own non-placeholder picture paints +on every slide that uses that master regardless of which layout the slide itself uses, and that +the picture resolves its own image relationship against its own owning part's own `.rels` file - +not the rendering slide's - by giving the slide's own `.rels` a deliberately conflicting, +wrong-colored `rId2` and confirming the master's own correct color still paints (a regression +guard for the `ownerPartPath`-threading fix this feature required). Proves a slide layout's own +non-placeholder autoshape paints only on a slide that uses that specific layout, not on a sibling +slide using a different layout backed by the same master. Proves the documented back-to-front +z-order at both boundaries: a slide's own shape paints on top of an overlapping master shape, and +a layout's own shape paints on top of an overlapping master shape. Proves a master's and a +layout's own placeholder shape is never rendered directly - its own "Click to edit…" prompt +content stays invisible - while its own non-placeholder sibling shape in the same shape tree still +renders, confirming the `skipPlaceholderShapes` flag discriminates correctly between a +`PptxSpShapeNode` with and without a non-null `Placeholder`. Proves, as a regression guard for a +graceful-skip containment fix, that a master's own non-placeholder picture shape referencing an +unsupported raster format (an EMF vector picture) is caught and skipped - rendering continues and +the slide's own content still paints normally over the full slide footprint - rather than +aborting the whole render; and, as the matching counter-proof that this containment is scoped +only to master/layout-owned shapes, that a **slide's own** picture shape referencing the same +unsupported raster format still hard-fails `Render` with `PptxUnsupportedFeatureException`, +exactly as before this containment was added. Proves, against a real-world fixture +(`aiden0z-1-chart-and-complex.pptx`, whose own `slideLayout1.xml` declares a real, non-placeholder, +`userDrawn="1"` `` freeform shape named "Freeform 5"), that the custGeom-freeform-on- +layout dispatch path executes without throwing and resolves its own documented `bg1`-scheme fill +to a near-white pixel within its own bounding box (a tolerance, not an exact pixel match, because +the real custGeom path is a thin decorative flourish rather than a solid-filled rectangle, so most +of its own bounding box is unfilled background with anti-aliased edges) - this fixture cannot show +a dramatic visual contrast on its own, since its own `bg1` theme color and its own slide master +background both already resolve to the same pure white this freeform shape itself is filled, so +the synthetic tests above carry the primary, deliberately-contrasting-color proof instead. + +#### CanvasNetPptx-PptxDocument-BulletProperties: Bullet/Numbering Property Parsing and Choice-Group Inheritance + +**Tests**: `ParseParagraphProperties_Absent_ResolvesEmptyBulletProperties`, +`ParseBulletProperties_BuNone_CapturesTypeElement`, +`ParseBulletProperties_BuChar_CapturesTypeElement`, +`ParseBulletProperties_BuAutoNum_CapturesTypeElement`, +`ParseBulletProperties_ColorFontSizeModifiers_CapturesEachIndependently`, +`ParseBulletProperties_FollowTextModifiers_CapturesEachIndependently`, +`ResolveEffectiveParagraphProperties_NoBulletMarkupAnywhere_ResolvesNone`, +`ResolveEffectiveParagraphProperties_OwnBuChar_WinsOverEveryOtherTier`, +`ResolveEffectiveParagraphProperties_OwnBuNone_SuppressesInheritedBullet`, +`ResolveEffectiveParagraphProperties_PlaceholderLevelBuAutoNum_WinsWhenParagraphDeclaresNothing`, +`ResolveEffectiveParagraphProperties_MasterBodyStyleBuChar_WinsWhenEverythingAboveIsAbsent`, +`ResolveEffectiveParagraphProperties_BuAutoNumNoAttributes_ResolvesSchemaDefaults`, +`ResolveEffectiveParagraphProperties_OwnTypeOnly_InheritsColorFontSizeIndependently`, +`ResolveEffectiveParagraphProperties_BuClrTxOrAbsent_FollowsFirstRunColor`, +`ResolveEffectiveParagraphProperties_BuClrTxRunLessParagraph_FallsBackToDark1`, +`ResolveEffectiveParagraphProperties_BuFontTxOrAbsent_FollowsFirstRunFont`, +`ResolveEffectiveParagraphProperties_BuSzPct_ResolvesFractionOfFirstRunSize`, +`ResolveEffectiveParagraphProperties_BuSzPts_ResolvesAbsoluteSizeIndependentOfRunSize`, +`ResolveEffectiveParagraphProperties_SldNumDtFtrPlaceholderType_SuppressesMasterBodyStyleBullet`, +`ResolveEffectiveParagraphProperties_SldNumDtFtrPlaceholderTypeWithOwnBuChar_StillResolvesOwnBullet` + +Proves raw parsing correctly captures each of the four OOXML bullet choice-groups (type: `buNone`/ +`buAutoNum`/`buChar`; color: `buClrTx`/`buClr`; font: `buFontTx`/`buFont`; size: `buSzTx`/ +`buSzPct`/`buSzPts`) as its own independent raw element, and that an absent `` resolves the +shared `PptxRawBulletProperties.Empty` singleton. Proves the resolver's own four independent +inheritance chains: the paragraph's own type/color/font/size markup each wins over a placeholder- +or master-level equivalent; an explicit `` at the paragraph's own tier suppresses an +inherited bullet entirely rather than merely failing to add one; a placeholder-level-only and a +master-`bodyStyle`-level-only bullet each resolve correctly when nothing above declares one; the +complete absence of bullet markup anywhere resolves the conservative `None` default; and, the +central inheritance-fidelity proof, that a paragraph may declare only its own bullet *type* while +independently inheriting color/font/size from the placeholder level style, proving the four +choice-groups are genuinely resolved independently rather than as one tier-wins-everything object. +Proves ``'s own schema defaults (`type="arabicPeriod"`, `startAt="1"`) when both +attributes are omitted. Proves the three "follow text" sentinels (`buClrTx`/`buFontTx`/`buSzTx`, +and their own absent-markup equivalent) resolve to the paragraph's own first run's effective +color/typeface/size (with a run-less paragraph's color falling back to the theme's `Dark1`), and +that `buSzPct`/`buSzPts` each correctly compute a relative-fraction-of-run-size versus an +absolute, run-size-independent size respectively. Proves `"sldNum"`/`"dt"`/`"ftr"` field +placeholder types resolve no bullet when the same master `bodyStyle` fixture that wins a bullet +for a `"body"`-typed placeholder is reused unchanged *and the paragraph itself declares no bullet +markup* - the regression guard for a stray-bullet defect found during visual QA +(`SelectMasterTextStyle` routes these three field placeholder types to `bodyStyle`, which +commonly declares a bullet, rather than the bullet-free `otherStyle` these types should consult in +genuine PowerPoint output; `ResolveEffectiveBulletProperties` now excludes only the master tier's +contribution to the TYPE choice-group for these three types). Proves, as the companion regression +guard for a narrower defect introduced and caught across two quality-retry cycles, that the same +three field placeholder types still resolve their own, explicitly-declared `` override +correctly even when that same master `bodyStyle` bullet is present for the same level - an +own-paragraph explicit bullet choice always wins over any style-bucket default, regardless of +placeholder type, confirming the master-tier exclusion above is scoped to only the master tier +and does not disturb the paragraph's own markup. + +#### CanvasNetPptx-PptxDocument-BulletRendering: Auto-Number Formatting, Gutter Positioning, and Minimum Gap + +**Tests**: `FormatAutoNumber_SupportedTypes_FormatsExpectedString`, +`FormatAutoNumber_UnsupportedType_ReturnsNull`, +`ResolveTextLayout_NonBulletedParagraph_FirstLineIndentStillAppliesToTextX`, +`ResolveTextLayout_BulletedParagraph_TextStartsAtMarLGutterHoldsBullet`, +`ResolveTextLayout_BuNone_PaintsNoGlyphBeyondRunText`, +`ResolveTextLayout_ConsecutiveAutoNumParagraphs_SequencesCounterAcrossParagraphs`, +`ResolveTextLayout_NestedThenReturnToShallowerLevel_ResumesShallowerCounterRestartsDeeperLevel`, +`ResolveTextLayout_UnsupportedAutoNumType_SkipsOnlyThatBulletGracefully`, +`PaintTextLayout_BulletedParagraph_PaintsBulletGlyphToSurface`, +`BuildLines_ParagraphWithBulletPropertiesAndNoRuns_PaintsNoBulletGlyph`, +`BuildLines_OtherwiseIdenticalParagraphWithOneRun_StillPaintsBulletGlyph`, +`BuildLines_RunlessAutoNumberParagraph_CounterStillAdvancesForSubsequentParagraph`, +`PptxDocument_Render_Aiden0zChartAndComplexFixture_Slide0PaintsSlide1ThrowsUnsupportedFeature`, +`ResolveTextLayout_BulletedParagraphWithZeroIndent_TextClearsBulletWidth`, +`PaintTextLayout_BulletedParagraphWithZeroIndent_BulletAndTextInkDoNotOverlap`, +`ResolveTextLayout_MasterBuCharDefault_OwnBuAutoNumOverride_PaintsOnlyAutoNumberMarker`, +`ResolveTextLayout_BulletedParagraphWithZeroIndent_AutoNumTwoDigitMarker_TextClearsBulletWidthPlusGap`, +`PaintTextLayout_BulletedParagraphWithZeroIndent_AutoNumMarker_BulletAndTextHaveVisibleGap` + +Proves `FormatAutoNumber` formats all eleven supported `ST_TextAutonumberScheme` values at +representative values (including alphabetic base-26 rollover at value 27 and several Roman-numeral +edge cases: 4, 9, and 1994), and gracefully returns `null` (not an exception) for an unrecognized +scheme. Proves, by contrast with its own regression case, the hanging-indent gutter fix: a +non-bulleted paragraph's first-line text still renders at the pre-existing `marL+indent` position +exactly as before this feature, while a bulleted paragraph's own first-line text instead renders +flush at `marL`, with its bullet glyph painted separately at the `marL+indent` gutter - two +glyphs, at two distinct, independently-verified X positions. Proves an explicit `` +paragraph paints no bullet glyph at all (only its own run text), and that the bullet-less +hanging-indent fix does not apply to it, unlike a resolved bullet. Proves consecutive same-level +`` paragraphs sequence their own rendered digit glyph in order (by asserting each +paragraph's own bullet glyph index, not merely its count), and that returning to a shallower +indent level after a nested, deeper auto-numbered sub-list resumes the shallower level's own +counter undisturbed while the (now-closed) deeper level restarts from its own `startAt` the next +time it is used - the full per-level reset/resume state machine. Proves an unrecognized +`` scheme skips painting only that one paragraph's own bullet glyph, with its own run +text, and the render as a whole, otherwise unaffected - the graceful per-bullet degradation policy. +Proves a resolved bullet glyph is actually painted to a pixel `Surface`, not merely resolved and +positioned, via `PaintTextLayout`. Finally, against the real-world `aiden0z-1-chart-and-complex.pptx` +fixture's own "Rectangle 5" shape (two consecutive ``-bulleted paragraphs whose +own `marL+indent` geometry collapses exactly to the shape's own left inset), proves visible ink +paints in that shape's own bullet-gutter pixel column, strictly left of where any paragraph text +itself can start, and meaningfully darker than a column sampled just inside that same inset - the +real-file, end-to-end visual-fidelity proof this feature's acceptance bar requires. A further +assertion against this same fixture proves no stray bullet ink appears anywhere within this +fixture's own slide-number placeholder's resolved geometry (inherited by type-match from the +slide master's own "Slide Number Placeholder 5": `off x="11669529" y="6400800"`, +`ext cx="438912" cy="155448"`), comparing the darkest pixel found there against a same-column +background baseline row just above it rather than an absolute threshold - the exact real-world +location and failure mode the stray-bullet defect above was found and closed at. Finally, proves +the run-less/spacer-paragraph bullet-suppression fix: a paragraph with resolved `` +bullet properties but no `` run children (only a synthetic ``) paints no glyphs +at all - neither run text nor a bullet glyph; the otherwise-identical paragraph with one run still +paints both its own run glyph and its bullet glyph (the differential counterpart); and a run-less +auto-numbered paragraph followed by a same-level paragraph with a run proves the counter still +advanced past the suppressed paragraph (the second paragraph's bullet renders "2", not "1"), +confirming only the glyph is suppressed, never the counter state. Proves the bullet-gutter/ +text-start-X collision fix: `ResolveTextLayout_BulletedParagraphWithZeroIndent_TextClearsBulletWidth` +reproduces a real-world slide's exact attribute set (`marL="320040"`, `lvl="1"`, no `indent` +attribute, ``, no placeholder/master tier contributing an +indent) and asserts, with exact numeric values, that the bullet glyph's own X is unchanged (still +the unclamped gutter) while the paragraph's own first run-text glyph's X is now clamped to clear +the bullet string's own measured width - directly disproving the pre-fix collision +(`bulletGlyphX == textGlyphX`) this defect was confirmed to produce. +`PaintTextLayout_BulletedParagraphWithZeroIndent_BulletAndTextInkDoNotOverlap` proves this at the +pixel level: rendered to a `Surface`, the bullet's own ink and the paragraph's own (now correctly +offset) text ink occupy disjoint X-pixel columns, with no shared range. Finally, +`ResolveTextLayout_MasterBuCharDefault_OwnBuAutoNumOverride_PaintsOnlyAutoNumberMarker` is the +permanent regression guard for the duplicate-paint/type-precedence-merge hypothesis this defect's +investigation refuted: with a master `` level declaring a default `` and +the slide paragraph's own `` declaring ``, the resolved bullet is exactly +`AutoNum` with a `null` `Character` (the master's `buChar` fully superseded, not merged), and the +full `ResolveTextLayout` pipeline emits exactly one run-glyph set plus exactly one bullet-glyph +set - never two markers. The pre-existing +`ResolveTextLayout_BulletedParagraph_TextStartsAtMarLGutterHoldsBullet` and +`ResolveTextLayout_BuNone_PaintsNoGlyphBeyondRunText` tests continue to pass unchanged, confirming +the fix's clamp is a no-op for the already-correct, sufficiently-negative-indent case. + +A follow-up real-world report found that same clamp, while correct for `buChar`, still +insufficient for `buAutoNum`: CanvasNet rendered an auto-numbered marker immediately touching the +paragraph's own first word (e.g. `"1.3 custom tip geometries"`), whereas real PowerPoint shows a +clear, word-space-sized gap after the marker. The updated +`ResolveTextLayout_BulletedParagraphWithZeroIndent_TextClearsBulletWidth` now asserts the clamped +text-start-X includes both the bullet string's own measured width and a `buAutoNum`-only minimum +trailing gap (one space character's own advance width). The new +`ResolveTextLayout_BulletedParagraphWithZeroIndent_AutoNumTwoDigitMarker_TextClearsBulletWidthPlusGap` +proves this gap is additive to a genuinely multi-character marker's own full measured width (using +`startAt="12"` so the marker renders as `"12."`, three glyphs wide) rather than a fixed constant +that would understate the clamp for longer markers, and uses a digit-first run text (`"3"`) +reproducing the exact real-world ambiguous "marker digit immediately followed by the paragraph's +own digit" pattern. The new +`PaintTextLayout_BulletedParagraphWithZeroIndent_AutoNumMarker_BulletAndTextHaveVisibleGap` proves +this at the pixel level: rendered to a `Surface`, there is no ink anywhere at or before the +pre-fix "touching" boundary, no ink anywhere within the new gap region itself, and real ink only +once comfortably past the clamped text-start-X - a genuinely blank gap, not merely "no overlap". +This gap is gated to `PptxBulletKind.AutoNum` only, so the pre-existing +`ResolveTextLayout_BulletedParagraph_TextStartsAtMarLGutterHoldsBullet`, +`ResolveTextLayout_BuNone_PaintsNoGlyphBeyondRunText`, and +`PaintTextLayout_BulletedParagraphWithZeroIndent_BulletAndTextInkDoNotOverlap` (a `buChar` test) +all continue to pass unmodified, confirming the fix introduces zero regression to the +already-correct, already-verified `buChar` "touching, zero extra gap" behavior. + +#### CanvasNetPptx-PptxDocument-ColorMapResolution: ``/`` Indirection + +**Tests**: `ResolveSchemeColor_DefaultColorMap_Tx1ResolvesToDark1`, +`ResolveSchemeColor_NonIdentityColorMap_Tx1ResolvesToOverriddenSlot`, +`ResolveColor_SchemeClrWithNonIdentityColorMap_AppliesIndirectionBeforeTransforms`, +`GetMaster_ParsesRequiredClrMap`, `GetMaster_MissingClrMap_ThrowsInvalidDataException`, +`GetLayout_ParsesOptionalClrMapOvr_DefaultsToNullWhenAbsent`, +`GetLayout_ParsesOptionalClrMapOvr_RetainsElementWhenPresent`, +`GetSlide_ParsesOptionalClrMapOvr_DefaultsToNullWhenAbsent`, +`ResolveEffectiveColorMap_NoOverridesAnywhere_ReturnsMasterColorMap`, +`ResolveEffectiveColorMap_LayoutOverrideClrMapping_ReturnsLayoutOverride`, +`ResolveEffectiveColorMap_SlideOverrideClrMapping_ReturnsSlideOverrideEvenWhenLayoutAlsoOverrides`, +`ResolveEffectiveColorMap_SlideMasterClrMapping_FallsThroughToLayoutOverride`, +`ResolveEffectiveColorMap_SlideAndLayoutMasterClrMapping_FallsThroughToMasterColorMap`, +`ResolveEffectiveColorMap_OverrideClrMappingMissingRequiredAttribute_ThrowsInvalidDataException`, +`Render_SlideClrMapOvrInvertsBg1_SchemeClrFillResolvesToOverriddenSlotNotHardcodedAlias`, +`Render_NoClrMapOvr_SchemeClrFillResolvesToMasterColorMapDefault`, +`GetSlide_TableCellSchemeColorFillWithSlideClrMapOvr_UsesEffectiveColorMapNotDefault` + +Proves `bg1`/`tx1`/`bg2`/`tx2` scheme-color resolution consults the slide master's own +`` (and any layout/slide ``) rather than a hard-coded `Light1`/`Dark1`/ +`Light2`/`Dark2` mapping: the default, identity color map still resolves `tx1` to `Dark1` +(preserving this unit's pre-existing baseline behavior), while a non-identity color map +redirects `tx1` to whatever slot it names instead, with the indirection applied before any +``/``-style color transform is layered on top. Proves `GetMaster` parses a +slide master's own required `` into `PptxMaster.ColorMap`, throwing +`InvalidDataException` when the element (or any of its four required attributes) is missing - a +genuine ECMA-376 schema violation, not a tolerable omission. Proves `GetLayout`/`GetSlide` each +parse an optional `` into `PptxLayout.ClrMapOvr`/`PptxSlide.ClrMapOvr` as a raw, +unparsed element when present, and `null` when absent. Proves `ResolveEffectiveColorMap`'s full +slide -> layout -> master fallback chain: with no override anywhere, the master's own color map +applies; a layout-level `` wins over the master; a slide-level +`` wins over both the layout and the master even when the layout also +declares an override; an `` marker at the slide tier falls through to the +layout's own override; and the same marker at both the slide and layout tiers falls all the way +through to the master's own color map. Proves a malformed `` missing a +required attribute throws `InvalidDataException` rather than silently substituting a default. +Finally, proves the fix end-to-end at the render/pixel level: a slide-level `/ +` makes a shape filled with `` +actually paint the theme's `Dark1` pixel color, not the previously hard-coded `Light1`, while a +companion test confirms the pre-existing, no-override baseline still renders `Light1` unchanged - +the real-world, visual-fidelity proof this fix's acceptance bar requires. Proves (Review +Follow-Up), at the `GetSlide` integration level, that a table cell's own fill color is no longer +always resolved against the default color map for a slide-owned table: a slide declaring a +`/` and a table cell filled with +`` resolves that cell's fill to the theme's `Dark1` color via `GetSlide` +itself (not a direct `ParseTable` call bypassing the real inheritance chain), proving `GetSlide`'s +new lazy `colorMapResolver` is correctly threaded through `ParseShapeTree` into `ParseTable`. A +narrower, documented, accepted residual limitation remains for a **master- or layout-owned** +table only (not separately tested as a defect, since `GetMaster`/`GetLayout`'s own part caches are +shared across every consuming slide and cannot soundly bake in any one slide's effective color +map): such a table cell's own fill/border color is still resolved once, eagerly, at parse time, +and therefore still always effectively uses the default color map regardless of any real override +in effect for a particular consuming slide, while a table cell's own text color is resolved at +paint time and correctly reflects the real, per-render effective color map like any other text +run, for tables owned by any tier. + +#### CanvasNetPptx-PptxDocument-ShapeStyleReference: `` `fillRef`/`lnRef` Shape-Style Resolution + +**Tests**: `ResolveShapeStyleFill_NullStyleElement_ReturnsNoFill`, +`ResolveShapeStyleFill_NoFillRef_ReturnsNoFill`, `ResolveShapeStyleFill_IdxZero_ReturnsNoFill`, +`ResolveShapeStyleFill_FillRefIdxOne_ResolvesFillStyleListEntryZeroNoOffset`, +`ResolveShapeStyleFill_FillRefWithSchemeClrPhClr_SubstitutesFillRefOwnColor`, +`ResolveShapeStyleFill_IdxOutOfRange_ThrowsInvalidDataException`, +`ResolveShapeStyleFill_IdxPastEndOfEmptyFillStyleList_ThrowsInvalidDataException`, +`ResolveShapeStyleFill_FillRefWithNoIdxAttribute_ThrowsInvalidDataException`, +`ResolveShapeStyleLineStyle_NullStyleElement_ReturnsNull`, +`ResolveShapeStyleLineStyle_NoLnRef_ReturnsNull`, `ResolveShapeStyleLineStyle_IdxZero_ReturnsNull`, +`ResolveShapeStyleLineStyle_LnRefIdxOne_ResolvesLnStyleListEntryZeroNoOffset`, +`ResolveShapeStyleLineStyle_LnRefWithSchemeClrPhClr_SubstitutesLnRefOwnColor`, +`ResolveShapeStyleLineStyle_IdxOutOfRange_ThrowsInvalidDataException`, +`ResolveShapeStyleLineStyle_LnRefWithNoIdxAttribute_ThrowsInvalidDataException`, +`GetTheme_NoFmtScheme_FillStyleListAndLnStyleListDefaultToEmpty`, +`GetTheme_FmtSchemeWithNoFillStyleLstOrLnStyleLst_BothDefaultToEmpty`, +`GetTheme_FmtSchemeWithFillStyleLstAndLnStyleLst_ParsesEntriesInDocumentOrder`, +`Render_ShapeWithOnlyStyleFillRefNoExplicitSpPrFill_RendersResolvedTintedAccentColor`, +`Render_ShapeWithBothExplicitSpPrFillAndStyleFillRef_ExplicitFillWins`, +`Render_ShapeWithOnlyStyleLnRefNoExplicitLn_RendersResolvedStrokeColor`, +`Render_ShapeWithExplicitLnNoFillAndStyleLnRef_ExplicitLnWins`, +`ResolveShapeLineStyle_LnElementNull_DefersToStyleLineStyle`, +`ResolveShapeLineStyle_LnHasExplicitSolidFill_OwnFillWinsOverStyle`, +`ResolveShapeLineStyle_LnHasExplicitNoFill_ResolvesNullRegardlessOfStyle`, +`ResolveShapeLineStyle_LnHasWidthButNoFillChild_KeepsOwnWidthUsesStyleColor`, +`ResolveShapeLineStyle_LnHasWidthAndOwnDashButNoFillChild_KeepsOwnDashArray`, +`ResolveShapeLineStyle_LnHasWidthButNoFillChildAndNoStyleElement_ResolvesNullDefault`, +`ResolveShapeLineStyle_LnHasWidthButNoFillChildAndStyleLnRefIdxZero_ResolvesNullDefault`, +`ResolveShapeLineStyle_LnHasNoWidthAndNoFillChild_ResolvesNullRegardlessOfStyle`, +`Render_ShapeWithLnWidthOnlyNoFillChildAndStyleLnRef_UsesOwnWidthAndStyleColor` + +Proves a shape built from PowerPoint's "Shape Styles" gallery - which declares its fill/line +purely via ``'s sibling `/`/``, indexing the +theme's own `/`/`` - resolves its fill/line correctly, +where it previously rendered with neither at all. Proves `GetTheme` parses +`/`/`` into `PptxTheme.FillStyleList`/`LnStyleList`, +defaulting to an empty list when either (or the whole ``) is absent, and preserving +document order when present. Proves `ResolveShapeStyleFill`/`ResolveShapeStyleLineStyle` return +"no fill"/`null` (respectively) for a `null` style element, an absent `fillRef`/`lnRef`, or an +explicit `idx="0"` (the schema-defined "none" sentinel). Proves the no-offset indexing directly, +pinning `idx="1"` to `FillStyleList`/`LnStyleList`'s entry `0` - not entry `1`, and not a +``-style `+1000`-offset entry. Proves the ref's own color-definition child substitutes +for the matched style-list entry's own `` token. Proves an `idx` outside +`[1,3]`, an `idx` indexing past the end of an empty list, or a missing/non-numeric `idx` +attribute, each throw `InvalidDataException` rather than being silently clamped or defaulted. +Proves the fill side end-to-end at the render/pixel level: a shape with only a +`/` (no explicit `` fill at all) paints the resolved, +phClr-substituted style-list fill color; an explicit `` fill on the same shape wins over +a simultaneously-present ``. + +For the line side specifically, proves the corrected 4-case precedence implemented by +`ResolveShapeLineStyle`: (1) an explicit fill-definition child on the shape's own `` (for +example ``) wins outright over a visible style ``, resolving to the +``'s own color/width; (2) an explicit `` resolves to "no stroke" +regardless of a visible style ``, remaining distinguishable from case 3; (3) a present +`` declaring a width but no recognized fill-definition child of its own (the exact reported +construct, ``) keeps its own width and dash verbatim while adopting the style +``'s own resolved color - and resolves to "no stroke" (not an invented default color) +when no `` element is present at all, and separately when its own `` +resolves to "no line"; and (4) a fully absent `` defers entirely to the style `` +for both width and color (`ResolveShapeLineStyle_LnElementNull_DefersToStyleLineStyle` proves this +resolves identically to calling `ResolveShapeStyleLineStyle` directly). A further edge-case test +proves a fill-less, width-less `` (no `w` attribute at all) still resolves to "no stroke" +regardless of a visible style `` - width is never pulled from style for an ordinary +shape's own line, unlike `ResolveConnectorLineStyle`'s broader per-attribute merge for connectors. +Finally, `Render_ShapeWithLnWidthOnlyNoFillChildAndStyleLnRef_UsesOwnWidthAndStyleColor` proves +case 3 end-to-end at the render/pixel level, reproducing the exact reported construct alongside a +style `` resolving to a deliberately distinct, much wider line-style-list entry: the +painted stroke is the style's own color, sampled directly on the shape's own edge, and a second +sample point - outside the shape's own (narrow) stroke band but well inside where the style's own +(much wider) stroke band would have reached - remains unpainted, proving width is the shape's own +and never merged in from the style. Together these are the real-world, visual-fidelity proof this +fix's acceptance bar requires. Documented, accepted limitations (not separately tested as +defects): `` and `` are out of scope (different resolution +mechanisms/unimplemented features entirely, not an extension of this fix's pattern), and a +placeholder shape's own `` is never inherited from its matched layout/master placeholder - +only a shape's own, directly-declared `` is consulted. + +#### CanvasNetPptx-PptxDocument-ConnectorRendering: `` Connector Shape Rendering + +**Tests**: `ParseShapeTree_Connector_ProducesConnectorShapeNode`, +`PptxPresetGeometry_Build_StraightConnectorPresets_ProducesOpenDiagonal`, +`PptxPresetGeometry_Build_StraightConnectorWithZeroWidthOrHeight_StillProducesLine`, +`PptxPresetGeometry_Build_OrdinaryPresetWithZeroHeight_StillDegradesToEmpty`, +`PptxPresetGeometry_Build_BentConnectorPresets_ReachesEndpointAsOpenPolyline`, +`PptxPresetGeometry_Build_CurvedConnectorPresets_ReachesEndpoint`, +`ResolveShapeFrame_StraightConnectorWithFlipCombination_MapsEndpointsCorrectly`, +`ResolveConnectorLineStyle_OwnLnExplicitNoFill_AlwaysWinsOverStyle`, +`ResolveConnectorLineStyle_OwnLnHasNoWidth_FallsBackToStyleWidth`, +`ResolveConnectorLineStyle_OwnLnHasWidthButNoFill_FallsBackToStylePaint`, +`ResolveConnectorLineStyle_OwnLnHasExplicitWidthAndFill_OwnValuesWin`, +`ResolveConnectorLineStyle_NoOwnWidthAndNoStyle_ResolvesToNull`, +`ResolveConnectorLineStyle_OwnLnHasExplicitDash_UsesOwnDashArray`, +`ResolveArrowhead_RecognizedTypeValue_ResolvesExpectedKind`, +`ResolveArrowhead_NoLnElement_ReturnsNull`, `ResolveArrowhead_NoMatchingEndElement_ReturnsNull`, +`ResolveArrowhead_NoneOrUnrecognizedType_ReturnsNull`, +`ResolveArrowhead_NoWOrLenAttributes_DefaultsToMed`, +`PptxArrowheadGeometry_Build_FilledKinds_TipAtOriginBodyExtendsBackward`, +`PptxArrowheadGeometry_Build_Oval_IsClosedAndCenteredNearOrigin`, +`PptxArrowheadGeometry_Build_Arrow_IsOpenChevron`, +`PptxArrowheadGeometry_Build_NonPositiveLineWidth_ReturnsEmpty`, +`PptxArrowheadGeometry_Build_SizeKeys_LargeIsBiggerThanSmall`, +`ComputeEndpointsAndTangents_HorizontalStraightConnector_TangentsPointAlongPositiveX`, +`ComputeEndpointsAndTangents_VerticalStraightConnector_TangentsPointAlongPositiveY`, +`ComputeEndpointsAndTangents_DiagonalStraightConnector_TangentsPointAtFortyFiveDegrees`, +`ComputeEndpointsAndTangents_BentConnector_FinalSegmentTangentPointsAlongLastDirection`, +`Render_StraightConnector_PaintsDiagonalLineAtExpectedPixels`, +`Render_ZeroHeightHorizontalConnector_PaintsLineWithoutDegradingToEmpty`, +`Render_ConnectorWithTailArrowhead_PaintsArrowheadNearEndpoint`, +`PptxDocument_Render_ShpConnectorPropsFixture_ConnectorsPaintVisibleLines` + +Proves `ParseShapeTree` dispatches a `` to its own `PptxConnectorShapeNode` in the same +document-order tree walk as every other shape kind (so z-order among siblings, and group-nested +transform composition, apply identically) - closing the gap where a connector was previously +never represented in the parsed shape tree at all. Proves `PptxPresetGeometry.Build` resolves +`line`/`straightConnector1` as a plain open two-point diagonal path across `flipH`/`flipV`/both/ +neither transform combinations, proves a connector preset with a zero width and/or zero height +still builds its line (the real-world bug fix, confirmed necessary by the +`pythonpptx-shp-connector-props.pptx` fixture's own zero-height connector) while an ordinary, +non-connector preset given the same zero-size input still correctly degrades to `Path.Empty` +(proving the bypass is scoped to connector presets only, not a general relaxation), and proves +`bentConnectorN`/`curvedConnectorN` elbow geometry reaches both of its own declared endpoints. +Proves `ResolveConnectorLineStyle`'s per-attribute merge: a connector's own fully-specified +`` wins outright; an `` declaring neither width nor fill falls back to its sibling +`/` for both; an explicit `` on the connector's own `` always +wins over the style reference regardless; a connector's own dash style wins over any style +fallback; and a connector with neither its own `` nor a `/` resolves to no +line style at all. Proves `ResolveArrowhead` resolves each recognized `type` value +(`triangle`/`stealth`/`diamond`/`oval`/`arrow`) to its matching `PptxArrowheadKind` carrying +through its `w`/`len` size keys, returns no arrowhead for an absent ``, an absent matching +``/`` child, or an explicit `none`/unrecognized `type` value, and defaults +absent `w`/`len` attributes to `"med"`. Proves `PptxArrowheadGeometry.Build` places every filled +kind's tip at the local origin with its body extending backward, builds `oval` as a closed +ellipse and `arrow` as an open (not filled) chevron, returns `Path.Empty` for a non-positive line +width, and scales strictly larger for the `"lg"` size key than for `"sm"`. Proves +`ComputeEndpointsAndTangents` resolves the correct start/end points and tangent directions for +horizontal, vertical, diagonal, and bent connector geometries. Finally, proves the fix end-to-end +at the render/pixel level: a straight connector paints its diagonal line at the expected pixel +positions; a zero-height connector still paints its horizontal line (the bug-fix proof, now at +the render level); a connector with a `tailEnd type="triangle"` arrowhead paints ink well outside +the plain line's own stroke width, flared out just above its tip at the connector's own endpoint; +and the real `pythonpptx-shp-connector-props.pptx` fixture - whose one connector relies entirely +on its `/` for its own visible color, with no fill/width declared on +its own bare `` - now paints visible content on both of its slides, where previously only +slide 0's dimensions were asserted and no pixel content was checked at all. Documented, accepted +limitations (not separately tested as defects): only `line`/`straightConnector1` and the four +`bentConnectorN`/four `curvedConnectorN` presets are implemented (any other connector preset name +degrades to "this one connector paints nothing", not a crash); the arrowhead size-keyword-to-EMU +mapping is a documented visual approximation, not derived from a published PowerPoint-internal +constant table; and connection-site (``/``) auto-routing is out of scope - a +connector always renders at its own last-saved, static `` position. + +#### CanvasNetPptx-PptxDocument-UnderlineRendering: `` Underline Style/Color Resolution and Rendering + +**Tests**: `ResolveEffectiveRunProperties_NoneDeclared_ResolvesHardCodedDefaults`, +`ResolveEffectiveRunProperties_UnderlineAttribute_ResolvesExpectedStyle`, +`ResolveEffectiveRunProperties_ExplicitUFill_OverridesTextColor`, +`ResolveEffectiveRunProperties_NoUnderlineFillMarkup_DefaultsUnderlineColorToTextColor`, +`ResolveEffectiveRunProperties_ExplicitUFillTx_DefaultsUnderlineColorToTextColor`, +`ResolveTextLayout_UnderlinedRun_EmitsOneSegmentSpanningRunWidth`, +`ResolveTextLayout_NonUnderlinedRun_EmitsNoSegment`, +`ResolveTextLayout_TwoRunsOnlyFirstUnderlined_EmitsExactlyOneSegment`, +`ResolveTextLayout_UnderlinedRunWithInteriorSpace_SpanCoversSpace`, +`PaintTextLayout_SingleUnderlineSegment_PaintsStrokeBelowBaselineAtExpectedPositionAndColor`, +`PaintTextLayout_NoUnderlineSegments_PaintsNoExtraInk`, +`PaintTextLayout_DoubleUnderlineSegment_DoesNotThrowAndPaintsInk`, +`PaintTextLayout_OtherUnderlineSegment_DoesNotThrowAndPaintsInk` + +Proves `ResolveEffectiveRunProperties` resolves a run with no underline markup declared anywhere +in its own inheritance chain to the hard-coded default `PptxUnderlineStyle.None` (closing the +regression where an underlined run previously rendered with no underline at all), and proves its +own `u` attribute resolution: `"sng"` resolves to `Single`, `"none"` resolves to `None`, `"dbl"` +resolves to `Double`, and both a recognized-but-less-common variant (`"wavy"`) and an entirely +unrecognized value (`"heavy"`) both resolve to `Other` - proving every ECMA-376 variant this phase +does not render distinctly still resolves to a defined, intentional single-solid-line rendering +rather than crashing or silently dropping the underline again. Proves the underline's own color +resolves independently of the run's own text-fill color: an explicit `/` +overrides the text color; no `` child at all defaults the underline color to the run's +own already-resolved text color; and an explicit `` (the "inherit text color" marker) +also defaults to the text color, proving it is not misinterpreted as "no color". Proves +`ResolveTextLayout` emits exactly one `PptxUnderlineSegment` spanning an underlined run's own +measured glyph width, with `SizeEmu`/`Color` matching that run's own resolved properties; proves +a non-underlined run emits no segment at all; proves that when only the first of two runs on one +line is underlined, exactly one segment is emitted, spanning only that first run's own width (the +span correctly flushes at the run boundary rather than bridging into the second, non-underlined +run); and proves a single underlined run containing an interior space emits one segment spanning +the full run width including that interior space (the span does not stop at the first word). +Finally, proves `PaintTextLayout` paints a visible, solid-filled stroke below the baseline at the +segment's own expected position and color for a `Single`-style segment (with no ink painted above +the baseline or outside the segment's own horizontal range), proves a layout with no underline +segments at all paints no extra ink anywhere (a regression guard against a null/default +`Underlines` list throwing or painting unexpectedly), and proves both `Double`- and `Other`-style +segments paint without throwing. Documented, accepted limitation (not separately tested as a +defect): underline thickness/offset are computed as a pragmatic, `SizeEmu`-proportional +approximation, not derived from the target font's own OpenType `post` table +`underlinePosition`/`underlineThickness` fields - true font-metric-derived values are deferred to +a later phase. + +#### CanvasNetPptx-PptxDocument-PictureGeometryClipping: `` Preset/Custom-Geometry Clipping + +**Tests**: `ResolvePictureClipPath_RectPreset_ReturnsNull`, +`ResolvePictureClipPath_NoGeometryElement_ReturnsNull`, +`ResolvePictureClipPath_EllipsePreset_ReturnsNonNullPath`, +`ResolvePictureClipPath_RoundRectPreset_ReturnsNonNullPath`, +`ResolvePictureClipPath_CustGeom_ReturnsNonNullPath`, +`ResolvePictureClipPath_PrstGeomMissingPrstAttribute_ThrowsInvalidDataException`, +`ResolvePictureClipPath_UnsupportedPreset_ThrowsPptxUnsupportedFeatureException`, +`ResolvePictureClipPath_NullSpPrElement_ThrowsArgumentNullException`, +`Render_PictureEllipseGeometry_ClipsImageToEllipticalRegion`, +`Render_PictureRectGeometry_PaintsFullBoundingBoxUnclipped`, +`Render_PictureNoPrstGeom_PaintsFullBoundingBoxUnclipped`, +`Render_PictureRoundRectGeometry_ClipsImageCorners`, +`Render_PictureEllipseGeometry_NearSquareRealWorldAspect_ClipsAllFourBoundingBoxCorners` + +Proves `ResolvePictureClipPath` returns `null` (meaning "no clip - paint the original unclipped +full bounding-box rectangle") for a `` that explicitly declares `` +and, separately, for a `` that declares no ``/`` at all - +deliberately more tolerant than `ResolveShapeGeometry`'s own all-or-nothing auto-shape contract, +since a geometry-less `` is schema-valid and historically unclipped, not malformed. +Proves an `ellipse` preset, a `roundRect` preset, and an explicit `` each resolve to a +non-null `Path`, dispatching to the exact same `PptxPresetGeometry.Build`/`ResolveCustomGeometry` +resolvers an auto-shape's own `ResolveShapeGeometry` already uses - not a second, divergent +geometry resolver. Proves a `` missing its own `prst` attribute throws +`InvalidDataException`, an unsupported preset name propagates `PptxUnsupportedFeatureException` +unchanged, and a `null` `spPrElement` argument throws `ArgumentNullException` - the identical +fail-closed contract `ResolveShapeGeometry` already carries, applied consistently to pictures. + +End-to-end, render-level, proves a `` whose `` declares `` +clips the painted image to the elliptical region described by its own bounding box (a bounding-box +corner remains unpainted/background while the shape's own center paints the image's content) - +the confirmed real-world defect this fix closes. Proves, as explicit no-regression guards, that a +`` with an explicit `` and a `` with no ``/ +`` at all both continue painting the full bounding-box rectangle unclipped, including +its own corners - the identical behavior this package always produced before this fix. Proves a +`` with `` clips its own bounding-box corners (background +visible at the rounded-corner arc) while its own center remains fully painted, confirming the fix +generalizes correctly beyond the single `ellipse` preset to any non-`rect` preset. A +non-permanent visual-verification generator (`GeneratePictureEllipseClipReproPng`) renders the +same ellipse-clipped fixture end-to-end through the public `PptxDocument.Render` API and saves it +to `.agent-logs/pptx-picture-ellipse-clip-repro.png`, allowing a human reviewer to directly confirm +the circular photo-crop effect by eye. Documented, accepted limitation (not separately tested as a +defect): the clip mask's own anti-aliased edge inherits `PathFiller.Fill`'s default flatten +tolerance and `PptxPresetGeometry`'s own fixed-segment-count curve approximation - the same +characteristic every other preset-geometry shape fill in this package already carries. +`Render_PictureEllipseGeometry_NearSquareRealWorldAspect_ClipsAllFourBoundingBoxCorners` is a +hardening regression guard, added in response to a bug report claiming a near-square +(`4678591 x 5053859` EMU), real-world-corpus ellipse rendered with an unclipped rectangular top +while its bottom clipped correctly: it reproduces the report's own exact shape geometry and 16:9 +slide size, and asserts all four bounding-box corners (not just one diagonal pair) remain +background, including a point in the bounding box's upper quarter that lies outside the ellipse's +own curve. Direct investigation (a mask-boundary-trace overlay against the real rendered output) +found the clip mask is already pixel-correct and fully symmetric on all four sides - the +image-content clip concern itself has no production code defect; this test closes the +test-coverage gap for that concern. **A prior investigation pass incorrectly concluded from this +same evidence that the bug report's entire underlying complaint was "a visual illusion" with no +code defect anywhere.** That broader conclusion was wrong: the report's own fixture also declared +a red `` on the same ``, and a from-scratch, independently re-verified investigation +(see `.agent-logs/planning-picture-ln-stroke-fix-7f2a4d.md`) proved `RenderPicture` never read or +painted a ``'s own `` stroke at all - a real, 100%-of-perimeter-absent defect, fixed +and verified below under `CanvasNetPptx-PptxDocument-PictureStrokeOutline`. Only the narrower +image-content-clip conclusion above (no defect in `ResolvePictureClipPath`/`PaintPicture`) remains +correct. + +#### CanvasNetPptx-PptxDocument-PictureStrokeOutline: `` Own `` Stroke Outline Rendering + +**Tests**: `ResolvePictureGeometryPath_RectPreset_ReturnsNonNullRectanglePath`, +`ResolvePictureGeometryPath_NoGeometryElement_ReturnsImplicitRectanglePath`, +`ResolvePictureGeometryPath_EllipsePreset_ReturnsNonNullPath`, +`ResolvePictureGeometryPath_CustGeom_ReturnsNonNullPath`, +`ResolvePictureGeometryPath_PrstGeomMissingPrstAttribute_ThrowsInvalidDataException`, +`ResolvePictureGeometryPath_UnsupportedPreset_ThrowsPptxUnsupportedFeatureException`, +`ResolvePictureGeometryPath_NullSpPrElement_ThrowsArgumentNullException`, +`Render_PictureEllipseGeometry_WithRedLnStroke_RendersCompleteClosedEllipseOutline`, +`Render_PictureNoPrstGeom_WithLnStroke_StrokesRectangleOutline` + +Proves `ResolvePictureGeometryPath` always resolves a concrete, non-null `Path` - unlike +`ResolvePictureClipPath`'s own "rect"/"no geometry" collapse to `null` - for an explicit +``, for a `` with neither `` nor `` at all +(the implicit full-rectangle fallback), for a non-`rect` preset (`ellipse`), and for an explicit +``, dispatching to the exact same `PptxPresetGeometry.Build`/`ResolveCustomGeometry` +resolvers `ResolvePictureClipPath`/an auto-shape's own `ResolveShapeGeometry` already use. Proves +a `` missing its own `prst` attribute throws `InvalidDataException`, an unsupported +preset name propagates `PptxUnsupportedFeatureException` unchanged, and a `null` `spPrElement` +argument throws `ArgumentNullException` - the identical fail-closed contract +`ResolvePictureClipPath`/`ResolveShapeGeometry` already carry. + +End-to-end, render-level, proves `RenderPicture` now strokes a ``'s own `` as a +COMPLETE, correctly-closed outline: `Render_PictureEllipseGeometry_WithRedLnStroke_RendersCompleteClosedEllipseOutline` +reproduces the original bug report's own exact ``/``/red +`` geometry and 16:9 slide size, and samples all four of the ellipse's own perimeter +midpoints (top/bottom/left/right-center, not just the corners the sibling clip test samples) for +the stroke's own red-dominant color - a defect covering 0% of the perimeter (the confirmed, now- +fixed pre-fix state) would fail at all four simultaneously. +`Render_PictureNoPrstGeom_WithLnStroke_StrokesRectangleOutline` proves the new +`ResolvePictureGeometryPath` "neither `` nor ``" fallback is actually used +for stroking: a geometry-less picture with a present `` now strokes a complete rectangle +around its own bounding box, sampling both its top and left edges, rather than silently dropping +the stroke as it did before this fix (when `RenderPicture` never read `` at all, regardless +of geometry). A before/after visual repro (the bug report's own exact geometry, rendered end-to- +end and visually inspected) confirmed the fix directly: before, a plain pale ellipse with +absolutely no red anywhere on its perimeter; after, a complete, correctly-closed red ellipse +outline fully framing the same pale ellipse - matching real-world PowerPoint's own expected visual +stacking (image content painted first, stroke framing it on top). + +## Acceptance Criteria + +A unit-level test run passes when all scenarios above pass without error or exception beyond +those explicitly asserted. Any unexpected exception, wrong exception type, or wrong return value +constitutes a failure. Collectively, these scenarios cover the complete current `PptxDocument` +package layer (Phase 1a: ZIP opening, content-type resolution, relationship resolution including +relative-target traversal and its root-escape guard, and the public API's argument validation and +disposal contract); the presentation/theme/master/layout/slide model and placeholder +property-inheritance resolver (Phase 1b: slide size/count, EMU-to-pixel conversion, theme +color/font scheme resolution, master/layout/slide structural placeholder parsing, and the +verified ECMA-376 placeholder-matching and per-category property-resolution algorithm); DrawingML +shape geometry and paint resolution (Phase 1c: `` position/rotation/flip transform +resolution, `` child-coordinate-space transform composition, 24 supported `` +preset shapes plus `` custom path-command parsing, ``/``/ +`` resolution including scheme-color lookup through the resolved theme and the +`lumMod`/`lumOff`/`shade`/`tint`/`alpha` color-transform pipeline, and `` stroke +width/dash/fill resolution realized as a stroked outline path via the core `PathStroker`); and +DrawingML text body parsing, attribute-level property inheritance (including a slide master's own +`` buckets), word-wrap/alignment/vertical-anchor/autofit text layout, and glyph-ink +rendering onto a core `Surface` (Phase 1d: see the *Text Layout and Rendering (Phase 1d) Test +Scenarios* section above); and dedicated `` picture-shape decoding/cropping/painting, +`` table structure parsing (reusing the Phase 1c/1d fill/border/text-body resolvers +verbatim), merge-aware table cell-rect resolution, table cell fill/border/text painting, and +recursive, full shape-tree parsing/dispatch across ``/``/``/ +`` with lazy, invoke-on-demand theme resolution (Phase 1e: see the *Picture Decoding, +Linking, and Crop-Rectangle Resolution*, *Picture Compositing Without PDF's Y-Flip*, *Table +Structure, Cell Attributes, and Verbatim Fill/Border/Text Reuse*, *Merge-Aware Cell-Rect +Computation*, *Cell Fill, Border, and Text Painting*, and *Recursive Shape-Tree Parsing and +Deferred Theme Resolution* Test Scenarios sections above); and the public, slide-level rendering +API that walks a slide's full shape tree in document order and paints every supported node kind +onto a destination `Surface`, with full public-API argument validation and a DPI convenience +overload (Phase 1f: see the *Full Slide Rendering (Phase 1f) Test Scenarios* section above). With +Phase 1f, the planned PPTX 1.0 feature set is complete. A slide's own `` background fill is +covered by *CanvasNetPptx-PptxDocument-SlideBackgroundFill* above instead, and master/layout +decorative shape rendering is covered by *CanvasNetPptx-PptxDocument-MasterLayoutShapeRendering* +above instead. Not yet covered: picture effects/shadows, nested tables, table auto-sizing to +fit overflowing cell content (each row's resolved height is taken verbatim from its declared +`` value, with no growth to accommodate overflowing cell content), table +style/banding (``), group-level style cascading +beyond transform composition, radial/path gradients, `` preset adjustment-value parsing, +full text justification, `` shape-resize autofit, kerning, and text +clipping on overflow. (Bullets/numbering are now covered by *CanvasNetPptx-PptxDocument- +BulletProperties*/*CanvasNetPptx-PptxDocument-BulletRendering* above instead, and `` +connector shapes are now covered by *CanvasNetPptx-PptxDocument-ConnectorRendering* below +instead.) None of these is a currently planned phase; any of them remaining important is a +candidate for a future, corpus-driven hardening pass (`pptx-phase-2`), not a scheduled increment. diff --git a/docs/verification/canvas-net/canvas/rgba32.md b/docs/verification/canvas-net/canvas/rgba32.md index 32a83815..14788751 100644 --- a/docs/verification/canvas-net/canvas/rgba32.md +++ b/docs/verification/canvas-net/canvas/rgba32.md @@ -1,7 +1,7 @@ ### Rgba32 Unit Verification Design -The `Rgba32` `Parse` and `TryParse` methods are verified by -`test/DemaConsulting.CanvasNet.Tests/Canvas/Rgba32Tests.cs`. +The `Rgba32` `Parse`/`TryParse` methods and the internal single-pixel `CompositeOver` helper are +verified by `test/DemaConsulting.CanvasNet.Tests/Canvas/Rgba32Tests.cs`. ### Verification Approach @@ -20,6 +20,12 @@ The `Rgba32` `Parse` and `TryParse` methods are verified by non-hex-character message includes the actual offending character. - **TryParse**: valid, invalid, and null inputs all behave per contract, with `result` set to `default` on failure. +- **CompositeOver** (internal, shared single-pixel Porter-Duff "over" helper): a fully + transparent foreground leaves the background unchanged; a fully opaque foreground exactly + replaces it; a partially transparent foreground over an opaque background, and both background + and foreground partially transparent, each blend to exact expected bytes computed + independently per the documented formula; both fully transparent composite to the zeroed + degenerate result rather than a `NaN`. ### Test Environment @@ -78,6 +84,25 @@ actual offending character. Verifies valid, invalid, and `null` inputs all behave per contract, with `result` set to `default` on failure. +#### CanvasNet-Canvas-Rgba32-CompositeOver: Shared Single-Pixel Porter-Duff "Over" Alpha Compositing + +**Tests**: `Rgba32_CompositeOver_FullyTransparentForeground_ReturnsBackgroundUnchanged`, +`Rgba32_CompositeOver_FullyOpaqueForeground_ReturnsForegroundExactly`, +`Rgba32_CompositeOver_PartiallyTransparentForegroundOverOpaqueBackground_BlendsExactly`, +`Rgba32_CompositeOver_BothBackgroundAndForegroundPartiallyTransparent_BlendsExactly`, +`Rgba32_CompositeOver_BothBackgroundAndForegroundFullyTransparent_ReturnsZeroedResult` + +Proves the internal single-pixel `CompositeOver(Rgba32, Rgba32)` helper - reused cross-assembly +by `DemaConsulting.CanvasNet.Pptx`'s `PaintPicture` and `DemaConsulting.CanvasNet.Pdf`'s +`CompositeImageOntoSurface` - implements the documented Porter-Duff "over" formula: a fully +transparent foreground (`alpha == 0`) leaves the background pixel completely unchanged, +regardless of the (irrelevant) RGB stored alongside that zero alpha; a fully opaque foreground +(`alpha == 255`) exactly replaces the background pixel; a partially transparent foreground +composited over a fully opaque background, and two partially transparent pixels composited +together, each blend to exact expected bytes computed independently via the documented formula +with round-half-away-from-zero; and two fully transparent pixels composite to the fully +transparent, zeroed-color degenerate result rather than a division-by-zero `NaN`. + ### Traceability Every requirement in `docs/reqstream/canvas-net/canvas/rgba32.yaml` links to one or more of diff --git a/docs/verification/canvas-net/drawing/path-stroker.md b/docs/verification/canvas-net/drawing/path-stroker.md index 9131aace..d0bfe425 100644 --- a/docs/verification/canvas-net/drawing/path-stroker.md +++ b/docs/verification/canvas-net/drawing/path-stroker.md @@ -176,6 +176,9 @@ public styling values are preserved exactly. - `StrokeOutliner_Outline_MiterJoinExceedingLimit_FallsBackToBevelVertex` - `StrokeOutliner_Outline_ClosedSquareHalfWidthExceedsInradius_ProducesNoInvalidHole` - `StrokeOutliner_Outline_ClosedSquareHalfWidthNearButBelowInradius_ProducesValidHole` +- `StrokeOutliner_Outline_LargeCoordinateFinelyTessellatedClosedContour_ProducesValidThinRing` +- `StrokeOutliner_Outline_LargeCoordinateFinelyTessellatedSmallCircleThickStroke_CollapsesToSolid` +- `StrokeOutliner_Outline_SmallCircleRealisticTessellation_HalfWidthJustOverInradius_CollapsesToSolid` These tests verify the intermediate geometry contracts that feed the public API: preserving open/closed state, applying SVG-style dash semantics, stitching seam-wrapping visible runs, @@ -183,7 +186,20 @@ normalizing every independently-emitted outer outline (open-line outlines, point closed-contour outer rings) to a single consistent winding direction regardless of outline kind or source authoring order, tolerating segments spanning near-extreme float32 coordinates without overflowing to a degenerate outline, and producing shell rings with opposite winding for -`FillRule.NonZero`. +`FillRule.NonZero`. The middle two of these cover a confirmed regression fix: a closed, curved +contour tessellated finely relative to its own large coordinate magnitude (for example, a +PPTX shape's native EMU-space ellipse geometry, or an equivalent plain-coordinate repro at the +same magnitude/tessellation density) must still produce a valid, two-ring thin outline rather than +false-positively collapsing to a solid fill (`...ProducesValidThinRing`), while a genuine +thick-stroke-exceeds-inradius case at the same large coordinate magnitude and tessellation density +must still correctly collapse to a solid fill (`...CollapsesToSolid`) - proving the fix is scale- +relative, not merely a loosened absolute threshold. The last of these covers a second, related +regression fix: the collapse detector's tolerance must be derived from each edge's own local +flattened source-segment length, not from the whole contour's bounding-box span, because the raw +backwards-offset-edge signal it is compared against shrinks proportionally to tessellation density +(`1/N`) independent of how far the stroke half-width exceeds the inradius - a small shape at a +realistic (not unrealistically fine) tessellation density, stroked only 1% over its own inradius, +must still correctly collapse to a solid fill (`...SmallCircleRealisticTessellation_...`). #### Complexity Verification Policy diff --git a/docs/verification/introduction.md b/docs/verification/introduction.md index 958a79f7..43c7be42 100644 --- a/docs/verification/introduction.md +++ b/docs/verification/introduction.md @@ -42,6 +42,14 @@ constituent software items, specifically: - **CanvasNetPdf (System)** — A separate, independently-distributed software system providing PDF page-rendering support, containing a single unit, `PdfDocument` — see _CanvasNetPdf System Verification_ (`canvas-net-pdf.md`) +- **CanvasNetCharts (System)** — A separate, independently-distributed software system providing + chart support, being delivered incrementally. The current release ships two subsystems: + `ChartModel`, containing the `ChartDocument` and `ChartRenderer` units, and `OpenXmlChart`, + containing the `OpenXmlChartParser` unit — see _CanvasNetCharts System Verification_ + (`canvas-net-charts.md`) +- **CanvasNetPptx (System)** — A separate, independently-distributed software system providing + PowerPoint (`.pptx`) presentation-rendering support, containing a single unit, + `PptxDocument` — see _CanvasNetPptx System Verification_ (`canvas-net-pptx.md`) The following OTS items are also covered: diff --git a/pip-requirements.txt b/pip-requirements.txt index 8fb8a08e..8ba1d059 100644 --- a/pip-requirements.txt +++ b/pip-requirements.txt @@ -1,2 +1,3 @@ yamllint==1.38.0 yamlfix==1.19.1 +python-pptx==1.0.2 diff --git a/requirements.yaml b/requirements.yaml index ad5ae6a0..f93d5fee 100644 --- a/requirements.yaml +++ b/requirements.yaml @@ -43,6 +43,15 @@ includes: - docs/reqstream/canvas-net-pdf.yaml - docs/reqstream/canvas-net-pdf/pdf-document.yaml - docs/reqstream/canvas-net-pdf/platform-requirements.yaml + - docs/reqstream/canvas-net-pptx.yaml + - docs/reqstream/canvas-net-pptx/pptx-document.yaml + - docs/reqstream/canvas-net-pptx/platform-requirements.yaml + - docs/reqstream/canvas-net-charts.yaml + - docs/reqstream/canvas-net-charts/chart-model.yaml + - docs/reqstream/canvas-net-charts/chart-model/chart-document.yaml + - docs/reqstream/canvas-net-charts/chart-model/chart-renderer.yaml + - docs/reqstream/canvas-net-charts/open-xml-chart.yaml + - docs/reqstream/canvas-net-charts/open-xml-chart/open-xml-chart-parser.yaml - docs/reqstream/ots/xunit.yaml - docs/reqstream/ots/reqstream.yaml - docs/reqstream/ots/buildmark.yaml diff --git a/src/DemaConsulting.CanvasNet.Charts/Chart.cs b/src/DemaConsulting.CanvasNet.Charts/Chart.cs new file mode 100644 index 00000000..8e187f88 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Charts/Chart.cs @@ -0,0 +1,186 @@ +using System.Collections.ObjectModel; +using DemaConsulting.CanvasNet.Canvas; + +namespace DemaConsulting.CanvasNet.Charts; + +/// +/// Represents an immutable, fully validated description of a chart: its kind, data series, +/// optional category/value axes, optional legend, optional title, and an optional default +/// color palette. +/// +/// +/// +/// is the root aggregate of this library's data model. It is immutable +/// and thread-safe after construction: every caller-supplied list argument is defensively +/// copied into an immutable snapshot, so mutating the caller's original list after +/// construction has no effect on this instance. +/// +/// +/// Rather than constructing a directly, most callers will find +/// 's fluent API more ergonomic; +/// ultimately calls this constructor, so the two approaches enforce identical invariants. +/// +/// +public sealed class Chart +{ + /// + /// Initializes a new, validated . + /// + /// + /// The kind of chart this instance describes. Must be a defined + /// value. + /// + /// + /// The chart's data series. Must contain at least one entry, and no entry may be + /// . For , , + /// , and , when + /// is supplied with non-null + /// , every series' count + /// must exactly equal that label count. + /// + /// + /// An optional category axis, or for none. + /// + /// + /// An optional value axis, or for none. + /// + /// + /// An optional legend, or for none. + /// + /// + /// An optional title, or for none. + /// + /// + /// An optional default categorical color palette used by a renderer for any series or + /// point that does not specify its own color, or to let a renderer + /// choose a default. Must not be empty when supplied. + /// + /// + /// Thrown when is empty, contains a + /// entry, or contains a series/category-axis length mismatch for a category-based chart + /// type; or when is empty. + /// + /// Thrown when is . + /// + /// Thrown when is not a defined value. + /// + public Chart( + ChartType type, + IReadOnlyList series, + ChartAxis? categoryAxis = null, + ChartAxis? valueAxis = null, + ChartLegend? legend = null, + ChartTitle? title = null, + IReadOnlyList? colorPalette = null) + { + ArgumentNullException.ThrowIfNull(series); + + if (!Enum.IsDefined(type)) + { + throw new ArgumentOutOfRangeException(nameof(type), type, "Type must be a defined ChartType value."); + } + + Series = CopySeries(series); + + if (IsCategoryBased(type) && categoryAxis?.Labels is { } labels) + { + foreach (var entry in Series) + { + if (entry.Values.Count != labels.Count) + { + throw new ArgumentException( + "Every series' values count must exactly equal the category axis label count.", + nameof(series)); + } + } + } + + if (colorPalette is not null && colorPalette.Count == 0) + { + throw new ArgumentException("Color palette must not be empty when supplied.", nameof(colorPalette)); + } + + Type = type; + CategoryAxis = categoryAxis; + ValueAxis = valueAxis; + Legend = legend; + Title = title; + ColorPalette = colorPalette is null ? null : new ReadOnlyCollection([.. colorPalette]); + } + + /// + /// Gets the kind of chart this instance describes. + /// + public ChartType Type { get; } + + /// + /// Gets the chart's data series. + /// + public IReadOnlyList Series { get; } + + /// + /// Gets the category axis, or when none was supplied. + /// + public ChartAxis? CategoryAxis { get; } + + /// + /// Gets the value axis, or when none was supplied. + /// + public ChartAxis? ValueAxis { get; } + + /// + /// Gets the legend, or when none was supplied. + /// + public ChartLegend? Legend { get; } + + /// + /// Gets the title, or when none was supplied. + /// + public ChartTitle? Title { get; } + + /// + /// Gets the default categorical color palette, or when none was + /// supplied. + /// + public IReadOnlyList? ColorPalette { get; } + + /// + /// Determines whether associates each series' values + /// one-for-one with a category axis label (as opposed to, for example, + /// /, whose single series' + /// values instead represent proportional wedge shares with no category-axis concept). + /// + private static bool IsCategoryBased(ChartType type) => + type is ChartType.Bar or ChartType.Column or ChartType.Line or ChartType.Area; + + /// + /// Copies and validates , producing an immutable snapshot. + /// + /// The caller-supplied series list. + /// The copied, read-only series snapshot. + /// + /// Thrown when is empty or contains a + /// entry. + /// + private static IReadOnlyList CopySeries(IReadOnlyList series) + { + if (series.Count == 0) + { + throw new ArgumentException("Series must contain at least one entry.", nameof(series)); + } + + var copy = new ChartSeries[series.Count]; + for (var i = 0; i < series.Count; i++) + { + var entry = series[i]; + if (entry is null) + { + throw new ArgumentException("Series must not contain a null entry.", nameof(series)); + } + + copy[i] = entry; + } + + return new ReadOnlyCollection(copy); + } +} diff --git a/src/DemaConsulting.CanvasNet.Charts/ChartAxis.cs b/src/DemaConsulting.CanvasNet.Charts/ChartAxis.cs new file mode 100644 index 00000000..d7884032 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Charts/ChartAxis.cs @@ -0,0 +1,160 @@ +using System.Collections.ObjectModel; + +namespace DemaConsulting.CanvasNet.Charts; + +/// +/// Represents an immutable axis description for a : an optional ordered +/// list of category labels (used for a category axis), an optional value-axis range (used +/// for a value axis), and an optional title. +/// +/// +/// +/// A single type is used for both a chart's category axis and its +/// value axis; a caller constructs one with only the category +/// (category axis use) or only the value-range members (value axis use), or both, since +/// neither use is required to be exclusive of the other at the type level. +/// +/// +/// is immutable and thread-safe after construction: a caller-supplied +/// labels argument is defensively copied into an immutable snapshot, so mutating the +/// caller's original list after construction has no effect on this instance. +/// +/// +public sealed class ChartAxis +{ + /// + /// Initializes a new, validated . + /// + /// + /// The ordered category labels for this axis, or for an axis with + /// no category labels (for example, a pure value axis). An empty string entry is a + /// legitimate "blank category label" and is accepted; a entry is + /// not. + /// + /// + /// The value-axis minimum, or to leave the minimum unspecified. + /// Must be finite when supplied. + /// + /// + /// The value-axis maximum, or to leave the maximum unspecified. + /// Must be finite when supplied, and greater than when both + /// are supplied. + /// + /// + /// The spacing between value-axis tick marks, or to leave the tick + /// interval unspecified. Must be finite and strictly greater than zero when supplied. + /// + /// + /// An optional axis title, or for no title. Must not be empty or + /// consist only of whitespace when supplied. + /// + /// + /// Thrown when contains a entry, or when + /// is empty or consists only of whitespace. + /// + /// + /// Thrown when or is not finite; + /// when is not finite or is less than or equal to zero; + /// or when both and are supplied and + /// is not strictly less than . + /// + public ChartAxis( + IReadOnlyList? labels = null, + float? minimum = null, + float? maximum = null, + float? tickInterval = null, + string? title = null) + { + Labels = CopyLabels(labels); + + if (minimum.HasValue && !float.IsFinite(minimum.Value)) + { + throw new ArgumentOutOfRangeException(nameof(minimum), minimum, "Minimum must be a finite value."); + } + + if (maximum.HasValue && !float.IsFinite(maximum.Value)) + { + throw new ArgumentOutOfRangeException(nameof(maximum), maximum, "Maximum must be a finite value."); + } + + if (minimum.HasValue && maximum.HasValue && minimum.Value >= maximum.Value) + { + throw new ArgumentOutOfRangeException( + nameof(minimum), minimum, "Minimum must be strictly less than maximum when both are supplied."); + } + + if (tickInterval.HasValue && (!float.IsFinite(tickInterval.Value) || tickInterval.Value <= 0f)) + { + throw new ArgumentOutOfRangeException( + nameof(tickInterval), tickInterval, "Tick interval must be a finite value greater than zero."); + } + + if (title is not null && string.IsNullOrWhiteSpace(title)) + { + throw new ArgumentException("Title must not be empty or consist only of whitespace.", nameof(title)); + } + + Minimum = minimum; + Maximum = maximum; + TickInterval = tickInterval; + Title = title; + } + + /// + /// Gets the ordered category labels for this axis, or when this + /// axis has no category labels. + /// + public IReadOnlyList? Labels { get; } + + /// + /// Gets the value-axis minimum, or when unspecified. + /// + public float? Minimum { get; } + + /// + /// Gets the value-axis maximum, or when unspecified. + /// + public float? Maximum { get; } + + /// + /// Gets the spacing between value-axis tick marks, or when + /// unspecified. + /// + public float? TickInterval { get; } + + /// + /// Gets the axis title, or when this axis has no title. + /// + public string? Title { get; } + + /// + /// Copies and validates , producing an immutable snapshot or + /// when no labels were supplied. + /// + /// The caller-supplied labels list. + /// The copied, read-only labels snapshot, or . + /// + /// Thrown when contains a entry. + /// + private static IReadOnlyList? CopyLabels(IReadOnlyList? labels) + { + if (labels is null) + { + return null; + } + + var values = new string[labels.Count]; + for (var i = 0; i < labels.Count; i++) + { + var label = labels[i]; + if (label is null) + { + throw new ArgumentException("Labels must not contain a null entry.", nameof(labels)); + } + + values[i] = label; + } + + return new ReadOnlyCollection(values); + } +} diff --git a/src/DemaConsulting.CanvasNet.Charts/ChartBuilder.cs b/src/DemaConsulting.CanvasNet.Charts/ChartBuilder.cs new file mode 100644 index 00000000..3b733afc --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Charts/ChartBuilder.cs @@ -0,0 +1,207 @@ +using DemaConsulting.CanvasNet.Canvas; + +namespace DemaConsulting.CanvasNet.Charts; + +/// +/// Provides an ergonomic, fluent, mutable-until- API for constructing an +/// immutable . +/// +/// +/// +/// performs no data-shape validation of its own beyond reporting a +/// clear "nothing configured yet" error from : every other invariant +/// (non-empty series, finite values, matching category/value counts, and so on) is enforced +/// exactly once, by 's and its constituent types' own validating +/// constructors, which ultimately calls. An invalid argument passed to +/// any of this type's methods therefore surfaces the same exception the corresponding model +/// constructor would throw. +/// +/// +/// Each fluent method returns the same instance, so calls may be +/// chained. A single instance is not thread-safe for concurrent +/// use; build one chart per instance. +/// +/// +public sealed class ChartBuilder +{ + private readonly List _series = []; + private ChartType? _type; + private ChartAxis? _categoryAxis; + private ChartAxis? _valueAxis; + private ChartLegend? _legend; + private ChartTitle? _title; + private IReadOnlyList? _colorPalette; + + /// + /// Sets the kind of chart being built. + /// + /// The kind of chart to build. Must be a defined value. + /// This instance, for chaining. + public ChartBuilder OfType(ChartType type) + { + _type = type; + return this; + } + + /// + /// Adds an already-constructed series to the chart being built. + /// + /// The series to add. Must not be . + /// This instance, for chaining. + /// Thrown when is . + public ChartBuilder AddSeries(ChartSeries series) + { + ArgumentNullException.ThrowIfNull(series); + _series.Add(series); + return this; + } + + /// + /// Constructs a from the supplied values and adds it to the + /// chart being built. + /// + /// The series name. See 's constructor for validation. + /// The series' data points. See 's constructor for validation. + /// An optional per-point color override. See 's constructor for validation. + /// An optional per-point label override. See 's constructor for validation. + /// An optional series-level color. + /// This instance, for chaining. + public ChartBuilder AddSeries( + string name, + IReadOnlyList values, + IReadOnlyList? pointColors = null, + IReadOnlyList? pointLabels = null, + Rgba32? color = null) => + AddSeries(new ChartSeries(name, values, pointColors, pointLabels, color)); + + /// + /// Sets an already-constructed category axis on the chart being built. + /// + /// The category axis to set. Must not be . + /// This instance, for chaining. + /// Thrown when is . + public ChartBuilder WithCategoryAxis(ChartAxis axis) + { + ArgumentNullException.ThrowIfNull(axis); + _categoryAxis = axis; + return this; + } + + /// + /// Constructs a category axis from the supplied category labels and an optional title, + /// and sets it on the chart being built. + /// + /// The ordered category labels. See 's constructor for validation. + /// An optional axis title. See 's constructor for validation. + /// This instance, for chaining. + public ChartBuilder WithCategoryAxis(IReadOnlyList labels, string? title = null) => + WithCategoryAxis(new ChartAxis(labels, title: title)); + + /// + /// Sets an already-constructed value axis on the chart being built. + /// + /// The value axis to set. Must not be . + /// This instance, for chaining. + /// Thrown when is . + public ChartBuilder WithValueAxis(ChartAxis axis) + { + ArgumentNullException.ThrowIfNull(axis); + _valueAxis = axis; + return this; + } + + /// + /// Constructs a value axis from the supplied range/tick/title and sets it on the chart + /// being built. + /// + /// The value-axis minimum. See 's constructor for validation. + /// The value-axis maximum. See 's constructor for validation. + /// The tick-mark spacing. See 's constructor for validation. + /// An optional axis title. See 's constructor for validation. + /// This instance, for chaining. + public ChartBuilder WithValueAxis( + float? minimum = null, + float? maximum = null, + float? tickInterval = null, + string? title = null) => + WithValueAxis(new ChartAxis(minimum: minimum, maximum: maximum, tickInterval: tickInterval, title: title)); + + /// + /// Sets an already-constructed legend on the chart being built. + /// + /// The legend to set. Must not be . + /// This instance, for chaining. + /// Thrown when is . + public ChartBuilder WithLegend(ChartLegend legend) + { + ArgumentNullException.ThrowIfNull(legend); + _legend = legend; + return this; + } + + /// + /// Constructs a legend from the supplied position/visibility and sets it on the chart + /// being built. + /// + /// The legend's placement. See 's constructor for validation. + /// Whether the legend is shown. + /// This instance, for chaining. + public ChartBuilder WithLegend(ChartLegendPosition position = ChartLegendPosition.Right, bool isVisible = true) => + WithLegend(new ChartLegend(position, isVisible)); + + /// + /// Constructs a title from the supplied text/font size and sets it on the chart being + /// built. + /// + /// The title text. See 's constructor for validation. + /// An optional font-size hint. See 's constructor for validation. + /// This instance, for chaining. + public ChartBuilder WithTitle(string text, float? fontSize = null) + { + _title = new ChartTitle(text, fontSize); + return this; + } + + /// + /// Sets the default categorical color palette on the chart being built. + /// + /// The color palette. See 's constructor for validation. + /// This instance, for chaining. + /// Thrown when is . + public ChartBuilder WithColorPalette(IReadOnlyList colorPalette) + { + ArgumentNullException.ThrowIfNull(colorPalette); + _colorPalette = colorPalette; + return this; + } + + /// + /// Constructs the immutable described by this builder's accumulated + /// configuration. + /// + /// The constructed, validated . + /// + /// Thrown when was never called, or when no series was added via + /// /. + /// + /// + /// Thrown when the accumulated configuration is rejected by 's own + /// constructor (for example a series/category-axis length mismatch). + /// + public Chart Build() + { + if (_type is null) + { + throw new InvalidOperationException( + "A chart type must be set via OfType before calling Build()."); + } + + if (_series.Count == 0) + { + throw new InvalidOperationException( + "At least one series must be added via AddSeries before calling Build()."); + } + + return new Chart(_type.Value, _series, _categoryAxis, _valueAxis, _legend, _title, _colorPalette); + } +} diff --git a/src/DemaConsulting.CanvasNet.Charts/ChartColorPalette.cs b/src/DemaConsulting.CanvasNet.Charts/ChartColorPalette.cs new file mode 100644 index 00000000..2f01493b --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Charts/ChartColorPalette.cs @@ -0,0 +1,53 @@ +using DemaConsulting.CanvasNet.Canvas; + +namespace DemaConsulting.CanvasNet.Charts; + +/// +/// Supplies 's built-in default categorical color sequence, used +/// whenever a chart/series/point does not supply its own color. +/// +/// +/// +/// This mirrors the categorical-palette convention common to mainstream charting libraries: +/// ten colors at approximately evenly spaced hues, a consistent saturation/lightness, and a +/// fixed, documented alpha of 255 (fully opaque) - chosen so that adjacent series/wedges +/// remain visually distinguishable from one another without relying on color alone for every +/// comparison. The ten entries, in order, are: blue, orange, green, red, purple, brown, pink, +/// gray, olive, and cyan - the same ordering convention (and approximate hues) widely +/// recognized from common charting-library "tab10"-style categorical palettes. +/// +/// +/// resolves a series/point's effective color by first checking +/// that series/point's own explicit color (ChartSeries.Color/ +/// ChartSeries.PointColors), then Chart.ColorPalette, then +/// ChartRenderOptions.ColorPalette, and only falls back to when +/// all of those are absent. Whichever palette is actually in effect, a series/point index +/// beyond the palette's own length wraps around via index % palette.Count rather than +/// throwing or running out of colors - so a chart with more series/points than palette +/// entries simply repeats colors from the start, documented explicitly here since it is the +/// one piece of resolution behavior every palette (not just this default one) shares. +/// +/// +public static class ChartColorPalette +{ + /// + /// The built-in, fixed 10-entry categorical color palette + /// falls back to when no caller-supplied palette (Chart.ColorPalette or + /// ChartRenderOptions.ColorPalette) and no series/point-level explicit color is + /// available for a given series/point index. See this type's own remarks for the + /// resolution order and index-wrapping behavior. + /// + public static readonly IReadOnlyList Default = Array.AsReadOnly( + [ + new Rgba32(0x1F, 0x77, 0xB4, 255), // blue + new Rgba32(0xFF, 0x7F, 0x0E, 255), // orange + new Rgba32(0x2C, 0xA0, 0x2C, 255), // green + new Rgba32(0xD6, 0x27, 0x28, 255), // red + new Rgba32(0x94, 0x67, 0xBD, 255), // purple + new Rgba32(0x8C, 0x56, 0x4B, 255), // brown + new Rgba32(0xE3, 0x77, 0xC2, 255), // pink + new Rgba32(0x7F, 0x7F, 0x7F, 255), // gray + new Rgba32(0xBC, 0xBD, 0x22, 255), // olive + new Rgba32(0x17, 0xBE, 0xCF, 255), // cyan + ]); +} diff --git a/src/DemaConsulting.CanvasNet.Charts/ChartLegend.cs b/src/DemaConsulting.CanvasNet.Charts/ChartLegend.cs new file mode 100644 index 00000000..1ea0c05f --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Charts/ChartLegend.cs @@ -0,0 +1,78 @@ +namespace DemaConsulting.CanvasNet.Charts; + +/// +/// Identifies where a 's legend is placed relative to the plot area, or +/// that it is not shown at all. +/// +public enum ChartLegendPosition +{ + /// + /// The legend is placed above the plot area. + /// + Top, + + /// + /// The legend is placed below the plot area. + /// + Bottom, + + /// + /// The legend is placed to the left of the plot area. + /// + Left, + + /// + /// The legend is placed to the right of the plot area. + /// + Right, + + /// + /// The legend is not shown. + /// + None +} + +/// +/// Represents an immutable legend description for a : a placement position +/// and an overall visibility flag. +/// +/// +/// is immutable and thread-safe after construction. +/// +public sealed class ChartLegend +{ + /// + /// Initializes a new, validated . + /// + /// + /// The legend's placement relative to the plot area. Must be a defined + /// value. Defaults to . + /// + /// + /// Whether the legend is shown at all. Defaults to . + /// + /// + /// Thrown when is not a defined value. + /// + public ChartLegend(ChartLegendPosition position = ChartLegendPosition.Right, bool isVisible = true) + { + if (!Enum.IsDefined(position)) + { + throw new ArgumentOutOfRangeException( + nameof(position), position, "Position must be a defined ChartLegendPosition value."); + } + + Position = position; + IsVisible = isVisible; + } + + /// + /// Gets the legend's placement relative to the plot area. + /// + public ChartLegendPosition Position { get; } + + /// + /// Gets whether the legend is shown at all. + /// + public bool IsVisible { get; } +} diff --git a/src/DemaConsulting.CanvasNet.Charts/ChartRenderOptions.cs b/src/DemaConsulting.CanvasNet.Charts/ChartRenderOptions.cs new file mode 100644 index 00000000..eb132858 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Charts/ChartRenderOptions.cs @@ -0,0 +1,107 @@ +using System.Collections.ObjectModel; +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Fonts; + +namespace DemaConsulting.CanvasNet.Charts; + +/// +/// Groups chart-rendering configuration accepted by +/// and +/// . +/// +/// +/// This type exists as a single, extensible options parameter rather than a growing list of +/// individual Render arguments, so future rendering options can be added as new +/// -only properties without another breaking change to +/// 's Render signatures - mirroring +/// DemaConsulting.CanvasNet.Pptx.PptxRenderOptions's own rationale exactly. It is a +/// class with -only properties (rather than a +/// positional record) so that object-initializer call sites such as +/// new ChartRenderOptions { BackgroundColor = ... } keep compiling unchanged whenever a +/// new option is added later. +/// +public sealed class ChartRenderOptions +{ + /// + /// The default instance, used by Render whenever a + /// caller passes (or omits the options parameter entirely). + /// + public static readonly ChartRenderOptions Default = new(); + + /// + /// The color the rendered is cleared to before any chart content is + /// painted. Defaults to opaque white (R=255, G=255, B=255, A=255). + /// + /// + /// Set this to a fully transparent color (new Rgba32(0, 0, 0, 0)) to reproduce a + /// fully transparent background, matching PptxRenderOptions.BackgroundColor's own + /// documented convention. + /// + public Rgba32 BackgroundColor { get; init; } = new(255, 255, 255, 255); + + /// + /// An explicit font used for every piece of text paints + /// (title, axis labels, legend labels, data labels), or (the + /// default) to use the bundled Liberation Sans Regular fallback font + /// () - the same zero-OS-dependency, + /// process-lifetime-cached font family core CanvasNet document renderers already + /// fall back to when no more specific font is available, chosen here as the deliberate + /// default (rather than requiring every caller to supply a font) because a chart has no + /// document-level font resource to inherit from the way a PPTX/PDF page does. + /// + public TrueTypeFont? Font { get; init; } + + /// + /// The font size, in pixels, used to paint 's text when + /// is . Defaults to 18. + /// + public float TitleFontSize { get; init; } = 18f; + + /// + /// The font size, in pixels, used to paint category-axis and value-axis tick labels. + /// Defaults to 11. + /// + public float AxisFontSize { get; init; } = 11f; + + /// + /// The font size, in pixels, used to paint legend entry labels. Defaults to 11. + /// + public float LegendFontSize { get; init; } = 11f; + + /// + /// The font size, in pixels, used to paint per-point data labels + /// (). Defaults to 10. + /// + public float DataLabelFontSize { get; init; } = 10f; + + /// + /// An explicit categorical color sequence used in place of + /// whenever a series/point has no more specific + /// color of its own and is , or + /// (the default) to defer entirely to + /// /. See + /// 's own remarks for the full color-resolution order and + /// index-wrapping behavior. Must not be empty when non-null - an empty override is + /// treated the same as (falls through to + /// ), since an empty sequence can resolve no + /// color at all. + /// + public IReadOnlyList? ColorPalette + { + get => _colorPalette; + init => _colorPalette = value is null ? null : new ReadOnlyCollection([.. value]); + } + + /// + /// The backing field for . A defensive copy is made and wrapped + /// in a in the accessor above, + /// so neither mutating the caller's original list/array after construction (or reusing a + /// single mutable list across several instances), nor + /// casting back to Rgba32[]/IList<Rgba32> + /// and mutating it that way, can retroactively change palette resolution for an + /// already-constructed, supposedly-immutable options instance - matching the same + /// defensive-copy-plus-read-only-wrapper pattern already used by + /// and . + /// + private readonly IReadOnlyList? _colorPalette; +} diff --git a/src/DemaConsulting.CanvasNet.Charts/ChartRenderer.CategoryChart.cs b/src/DemaConsulting.CanvasNet.Charts/ChartRenderer.CategoryChart.cs new file mode 100644 index 00000000..788b4b76 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Charts/ChartRenderer.CategoryChart.cs @@ -0,0 +1,796 @@ +using System.Globalization; +using System.Numerics; +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Drawing; +using DemaConsulting.CanvasNet.Fonts; +using DemaConsulting.CanvasNet.Geometry; +using DemaConsulting.CanvasNet.Rendering; +using Path = DemaConsulting.CanvasNet.Geometry.Path; +using RenderCanvas = DemaConsulting.CanvasNet.Rendering.Canvas; + +namespace DemaConsulting.CanvasNet.Charts; + +public static partial class ChartRenderer +{ + /// The length, in pixels, a value/category axis tick mark extends beyond the plot frame. + private const float TickLength = 4f; + + /// The gap, in pixels, between a tick mark and its text label. + private const float AxisLabelGap = 4f; + + /// The radius, in pixels, of a point marker. + private const float LineMarkerRadius = 3f; + + /// The stroke width, in pixels, of a polyline segment and an top edge. + private const float LineStrokeWidth = 2f; + + /// The gap, in pixels, between a data point and its data label text. + private const float DataLabelGap = 4f; + + /// The alpha channel value used for an series' fill, so overlapping series/the plot grid remain visible underneath. + private const byte AreaFillAlpha = 170; + + /// The fraction of the available plot width a vertical category-axis label column (Bar's left-side labels) may claim at most. + private const float MaxCategoryAxisWidthFraction = 0.4f; + + /// The gridline color used for value-axis gridlines - a light gray that recedes behind the plotted data. + private static readonly Rgba32 GridlineColor = new(224, 224, 224, 255); + + /// The color used for axis lines, tick marks, and axis tick labels - a dark, legible gray. + private static readonly Rgba32 AxisTextColor = new(64, 64, 64, 255); + + /// The color used for data-label text. + private static readonly Rgba32 DataLabelColor = new(0, 0, 0, 255); + + /// + /// Paints a Bar ( = ) or Column + /// ( = ) chart: grouped bars per + /// category, one value/category axis pass, and per-point data labels. + /// + /// The target canvas. + /// The chart being rendered. + /// The plot-area rectangle reserved by . + /// The effective render options. + /// The resolved text font. + /// for Bar (horizontal bars); for Column (vertical bars). + private static void PaintBarOrColumn(RenderCanvas canvas, Chart chart, Rect plotRect, ChartRenderOptions options, TrueTypeFont font, bool isHorizontal) + { + if (plotRect.Width <= 0f || plotRect.Height <= 0f) + { + return; + } + + var (min, max) = ComputeValueRange(chart); + var ticks = ComputeValueTicks(chart, min, max); + var inner = isHorizontal + ? ComputeHorizontalFrame(chart, plotRect, options, font, ticks, out var hasCategoryAxis, out var hasValueAxis) + : ComputeVerticalFrame(chart, plotRect, options, font, ticks, out hasCategoryAxis, out hasValueAxis); + + if (inner.Width <= 0f || inner.Height <= 0f) + { + return; + } + + if (hasValueAxis) + { + PaintValueAxis(canvas, inner, min, max, ticks, options, font, isHorizontal); + } + + if (hasCategoryAxis) + { + PaintCategoryAxis(canvas, chart, plotRect, inner, options, font, isHorizontal); + } + + var categoryCount = GetCategoryCount(chart); + if (categoryCount <= 0) + { + return; + } + + var seriesCount = chart.Series.Count; + var zero = Math.Clamp(0d, min, max); + + for (var c = 0; c < categoryCount; c++) + { + for (var s = 0; s < seriesCount; s++) + { + var series = chart.Series[s]; + if (c >= series.Values.Count) + { + continue; + } + + var value = series.Values[c]; + var color = ResolveSeriesColor(chart, options, series, s); + + if (isHorizontal) + { + var bandHeight = inner.Height / categoryCount; + var groupTop = inner.Top + c * bandHeight + bandHeight * 0.1f; + var barHeight = bandHeight * 0.8f / seriesCount; + var barTop = groupTop + s * barHeight; + var zeroX = ValueToPixel(zero, min, max, inner.Left, inner.Width); + var valueX = ValueToPixel(value, min, max, inner.Left, inner.Width); + var left = Math.Min(zeroX, valueX); + var width = Math.Abs(valueX - zeroX); + canvas.FillRect(left, barTop, width, barHeight, color); + + var labelY = barTop + barHeight * 0.65f; + var labelAlign = value >= zero ? TextAlign.Left : TextAlign.Right; + var labelX = value >= zero ? valueX + DataLabelGap : valueX - DataLabelGap; + PaintPointDataLabel(canvas, series, c, labelX, labelY, labelAlign, options, font); + } + else + { + var bandWidth = inner.Width / categoryCount; + var groupLeft = inner.Left + c * bandWidth + bandWidth * 0.1f; + var barWidth = bandWidth * 0.8f / seriesCount; + var barLeft = groupLeft + s * barWidth; + var zeroY = inner.Bottom - ValueToPixel(zero, min, max, 0f, inner.Height); + var valueY = inner.Bottom - ValueToPixel(value, min, max, 0f, inner.Height); + var top = Math.Min(zeroY, valueY); + var height = Math.Abs(valueY - zeroY); + canvas.FillRect(barLeft, top, barWidth, height, color); + + var labelX = barLeft + barWidth / 2f; + var labelY = value >= zero ? valueY - DataLabelGap : valueY + DataLabelGap + options.DataLabelFontSize * 0.8f; + PaintPointDataLabel(canvas, series, c, labelX, labelY, TextAlign.Center, options, font); + } + } + } + } + + /// + /// Paints a Line chart: one polyline + point markers per series, a vertical value axis, + /// a horizontal category axis, and per-point data labels. + /// + /// The target canvas. + /// The chart being rendered. + /// The plot-area rectangle reserved by . + /// The effective render options. + /// The resolved text font. + private static void PaintLine(RenderCanvas canvas, Chart chart, Rect plotRect, ChartRenderOptions options, TrueTypeFont font) + { + if (plotRect.Width <= 0f || plotRect.Height <= 0f) + { + return; + } + + var (min, max) = ComputeValueRange(chart); + var ticks = ComputeValueTicks(chart, min, max); + var inner = ComputeVerticalFrame(chart, plotRect, options, font, ticks, out var hasCategoryAxis, out var hasValueAxis); + if (inner.Width <= 0f || inner.Height <= 0f) + { + return; + } + + if (hasValueAxis) + { + PaintValueAxis(canvas, inner, min, max, ticks, options, font, isHorizontal: false); + } + + if (hasCategoryAxis) + { + PaintCategoryAxis(canvas, chart, plotRect, inner, options, font, isHorizontal: false); + } + + var categoryCount = GetCategoryCount(chart); + if (categoryCount <= 0) + { + return; + } + + var bandWidth = inner.Width / categoryCount; + for (var s = 0; s < chart.Series.Count; s++) + { + var series = chart.Series[s]; + var color = ResolveSeriesColor(chart, options, series, s); + Vector2? previous = null; + for (var c = 0; c < series.Values.Count; c++) + { + var x = inner.Left + (c + 0.5f) * bandWidth; + var y = inner.Bottom - ValueToPixel(series.Values[c], min, max, 0f, inner.Height); + if (previous is { } prevPoint) + { + StrokeLineSegment(canvas, prevPoint, new Vector2(x, y), color); + } + + canvas.FillCircle(x, y, LineMarkerRadius, color); + PaintPointDataLabel(canvas, series, c, x, y - LineMarkerRadius - DataLabelGap, TextAlign.Center, options, font); + previous = new Vector2(x, y); + } + } + } + + /// + /// Paints an Area chart: one filled region (between each series' line and the zero + /// baseline) + top-edge stroke per series, a vertical value axis, a horizontal category + /// axis, and per-point data labels. + /// + /// The target canvas. + /// The chart being rendered. + /// The plot-area rectangle reserved by . + /// The effective render options. + /// The resolved text font. + private static void PaintArea(RenderCanvas canvas, Chart chart, Rect plotRect, ChartRenderOptions options, TrueTypeFont font) + { + if (plotRect.Width <= 0f || plotRect.Height <= 0f) + { + return; + } + + var (min, max) = ComputeValueRange(chart); + var ticks = ComputeValueTicks(chart, min, max); + var inner = ComputeVerticalFrame(chart, plotRect, options, font, ticks, out var hasCategoryAxis, out var hasValueAxis); + if (inner.Width <= 0f || inner.Height <= 0f) + { + return; + } + + if (hasValueAxis) + { + PaintValueAxis(canvas, inner, min, max, ticks, options, font, isHorizontal: false); + } + + if (hasCategoryAxis) + { + PaintCategoryAxis(canvas, chart, plotRect, inner, options, font, isHorizontal: false); + } + + var categoryCount = GetCategoryCount(chart); + if (categoryCount <= 0) + { + return; + } + + var bandWidth = inner.Width / categoryCount; + var zeroY = inner.Bottom - ValueToPixel(Math.Clamp(0d, min, max), min, max, 0f, inner.Height); + + for (var s = 0; s < chart.Series.Count; s++) + { + var series = chart.Series[s]; + if (series.Values.Count == 0) + { + continue; + } + + var color = ResolveSeriesColor(chart, options, series, s); + var fillColor = new Rgba32(color.R, color.G, color.B, AreaFillAlpha); + + var points = new Vector2[series.Values.Count]; + for (var c = 0; c < series.Values.Count; c++) + { + var x = inner.Left + (c + 0.5f) * bandWidth; + var y = inner.Bottom - ValueToPixel(series.Values[c], min, max, 0f, inner.Height); + points[c] = new Vector2(x, y); + } + + var builder = new PathBuilder().MoveTo(new Vector2(points[0].X, zeroY)); + foreach (var point in points) + { + builder.LineTo(point); + } + + builder.LineTo(new Vector2(points[^1].X, zeroY)).Close(); + canvas.FillPath(builder.Build(), fillColor); + + for (var c = 1; c < points.Length; c++) + { + StrokeLineSegment(canvas, points[c - 1], points[c], color); + } + + for (var c = 0; c < points.Length; c++) + { + PaintPointDataLabel(canvas, series, c, points[c].X, points[c].Y - DataLabelGap, TextAlign.Center, options, font); + } + } + } + + /// + /// Draws ' data label at + /// (, ) when that series supplies + /// for that point - the one, documented opt-in + /// mechanism for data labels (there is no separate "show data labels" flag on the model). + /// + /// The target canvas. + /// The series the point belongs to. + /// The point's zero-based index. + /// The label's anchor x-coordinate, interpreted per . + /// The label's baseline y-coordinate. + /// Horizontal text alignment relative to . + /// The effective render options (supplies ). + /// The resolved text font. + private static void PaintPointDataLabel(RenderCanvas canvas, ChartSeries series, int pointIndex, float x, float y, TextAlign align, ChartRenderOptions options, TrueTypeFont font) + { + var label = series.PointLabels?[pointIndex]; + if (label is null) + { + return; + } + + canvas.DrawText(label, x, y, align, font, options.DataLabelFontSize, DataLabelColor); + } + + /// + /// Strokes a single straight segment from to . + /// + /// The target canvas. + /// The segment's start point. + /// The segment's end point. + /// The stroke color. + private static void StrokeLineSegment(RenderCanvas canvas, Vector2 start, Vector2 end, Rgba32 color) + { + var path = new PathBuilder().MoveTo(start).LineTo(end).Build(); + canvas.StrokePath(path, new StrokeStyle(LineStrokeWidth), color); + } + + /// + /// Linearly maps (clamped to [, + /// ]) onto [, + + /// ]. + /// + /// The data value to map. + /// The value-axis minimum. + /// The value-axis maximum. Must be strictly greater than . + /// The pixel coordinate corresponding to . + /// The pixel distance spanning the full [, ] range. + /// The mapped pixel coordinate. + private static float ValueToPixel(double value, double min, double max, float origin, float extent) + { + var clamped = Math.Clamp(value, min, max); + return origin + (float)SafeRatio(clamped, min, max) * extent; + } + + /// + /// Computes (value - min) / (max - min), guarding against the case where + /// / are individually finite but extreme + /// enough (for example [-double.MaxValue, double.MaxValue]) that max - min + /// - and potentially value - min too - overflows to , + /// which would otherwise produce Infinity / Infinity = NaN instead of a meaningful + /// fraction. + /// + /// The already-clamped data value (min <= value <= max). + /// The value-axis minimum. + /// The value-axis maximum. Must be strictly greater than . + /// The fraction of the way lies between and . + private static double SafeRatio(double value, double min, double max) + { + var range = max - min; + if (double.IsFinite(range) && range != 0d) + { + return (value - min) / range; + } + + // range overflowed: halve each operand before subtracting, so the intermediate + // differences stay within double's finite range (max/2 - min/2 can never overflow when + // max and min are themselves finite), trading a little precision for a finite, + // meaningful fraction instead of NaN. + var halfRange = max / 2d - min / 2d; + return halfRange == 0d ? 0d : (value / 2d - min / 2d) / halfRange; + } + + /// + /// Computes the inclusive value-axis [min, max] range for : the + /// axis' own explicit / when + /// supplied, otherwise derived from the data with a forced zero baseline (so negative + /// values are always visible relative to zero) and a small symmetric padding on whichever + /// end(s) were auto-derived. + /// + /// The chart to compute a value range for. + /// The resolved (min, max) pair, always satisfying max > min. + private static (double Min, double Max) ComputeValueRange(Chart chart) + { + var hasExplicitMin = chart.ValueAxis?.Minimum is not null; + var hasExplicitMax = chart.ValueAxis?.Maximum is not null; + + double dataMin = 0d; + double dataMax = 0d; + foreach (var series in chart.Series) + { + foreach (var value in series.Values) + { + dataMin = Math.Min(dataMin, value); + dataMax = Math.Max(dataMax, value); + } + } + + var min = hasExplicitMin ? chart.ValueAxis!.Minimum!.Value : dataMin; + var max = hasExplicitMax ? chart.ValueAxis!.Maximum!.Value : dataMax; + + // Force a zero baseline into any auto-derived bound, so a mix of positive and negative + // values is always visually anchored to zero rather than an arbitrary data-only range. + if (!hasExplicitMin) + { + min = Math.Min(0d, min); + } + + if (!hasExplicitMax) + { + max = Math.Max(0d, max); + } + + // Degenerate guard: every value (and the forced zero baseline) resolved to the same + // number - give an auto-derived side a unit span rather than dividing by zero later. + // ChartAxis's own constructor already rejects an explicit min >= max, so both-explicit + // is unreachable here in practice; the final branch is defensive only. + if (max <= min) + { + if (!hasExplicitMax) + { + max = min + 1d; + } + else if (!hasExplicitMin) + { + min = max - 1d; + } + else + { + max = min + 1d; + } + } + + // Guard against overflow for extreme-but-finite bounds (e.g. [-double.MaxValue, + // double.MaxValue]): both the range itself and min-padding/max+padding can overflow to + // +/-Infinity, which would otherwise propagate into ValueToPixel as NaN. Fall back to a + // zero padding, and/or clamp to the nearest finite double, whenever that would occur. + var range = max - min; + var padding = double.IsFinite(range) ? range * 0.05d : 0d; + if (!hasExplicitMin) + { + var paddedMin = min - padding; + min = double.IsFinite(paddedMin) ? paddedMin : double.MinValue; + } + + if (!hasExplicitMax) + { + var paddedMax = max + padding; + max = double.IsFinite(paddedMax) ? paddedMax : double.MaxValue; + } + + return (min, max); + } + + /// + /// Computes a bounded, auto-"nice" tick interval candidate (1/2/5 x a power of ten) for a + /// [, ] range, targeting approximately five + /// gridlines. + /// + /// The value-axis minimum. + /// The value-axis maximum. + /// A positive, finite interval, never producing more than 100 gridlines for the given range. + /// + /// Capped defensively (range / interval never exceeds 100) regardless of the 1/2/5 + /// candidate chosen, so a pathologically large or tiny range cannot drive the gridline + /// loop in into an unbounded iteration count. + /// + private static double ComputeNiceInterval(double min, double max) + { + const int targetTicks = 5; + const int maxTicks = 100; + + var range = max - min; + if (!double.IsFinite(range) || range <= 0d) + { + return 1d; + } + + var rawStep = range / targetTicks; + var magnitude = Math.Pow(10d, Math.Floor(Math.Log10(rawStep))); + var normalized = rawStep / magnitude; + var niceFraction = normalized switch + { + <= 1d => 1d, + <= 2d => 2d, + <= 5d => 5d, + _ => 10d, + }; + + var interval = niceFraction * magnitude; + if (!double.IsFinite(interval) || interval <= 0d || range / interval > maxTicks) + { + interval = range / maxTicks; + } + + return !double.IsFinite(interval) || interval <= 0d ? range : interval; + } + + /// + /// Computes the list of value-axis tick positions between and + /// , using when the chart's + /// value axis supplies one, otherwise . + /// + /// The chart whose value axis may supply an explicit tick interval. + /// The value-axis minimum. + /// The value-axis maximum. + /// An ascending list of tick values, starting at , capped at 100 entries. + private static List ComputeValueTicks(Chart chart, double min, double max) + { + const int maxTicks = 100; + + var interval = chart.ValueAxis?.TickInterval ?? ComputeNiceInterval(min, max); + if (!double.IsFinite(interval) || interval <= 0d) + { + interval = ComputeNiceInterval(min, max); + } + + // Defensive hard cap applied regardless of where the interval came from (an explicit, + // caller-supplied ChartAxis.TickInterval included) - see the companion planning report's + // "pathological gridline loop" risk mitigation. + if ((max - min) / interval > maxTicks) + { + interval = (max - min) / maxTicks; + } + + var ticks = new List(); + var value = min; + var count = 0; + while (value <= max + interval * 0.001d && count < maxTicks) + { + ticks.Add(value); + value += interval; + count++; + } + + if (ticks.Count == 0) + { + ticks.Add(min); + } + + return ticks; + } + + /// + /// Formats a value-axis tick value for display, rounding to at most two decimal places. + /// + /// The tick value to format. + /// The formatted tick label. + private static string FormatTickValue(double value) => value.ToString("0.##", CultureInfo.InvariantCulture); + + /// + /// Determines the number of category slots a category-based chart has: the category + /// axis' own label count when is supplied, otherwise the + /// longest series' count. + /// + /// The chart to determine a category count for. + /// The category count (at least 1, since every series has at least one value). + private static int GetCategoryCount(Chart chart) + { + if (chart.CategoryAxis?.Labels is { } labels) + { + return labels.Count; + } + + var max = 0; + foreach (var series in chart.Series) + { + max = Math.Max(max, series.Values.Count); + } + + return max; + } + + /// + /// Resolves the display label for category : the category axis' + /// own entry when available, otherwise a 1-based ordinal + /// fallback ("1", "2", ...). + /// + /// The chart to resolve a category label from. + /// The category's zero-based index. + /// The resolved category label. + private static string GetCategoryLabel(Chart chart, int index) + { + var labels = chart.CategoryAxis?.Labels; + return labels is not null && index < labels.Count + ? labels[index] + : (index + 1).ToString(CultureInfo.InvariantCulture); + } + + /// + /// Truncates with a trailing "..." ellipsis so it measures + /// no wider than , the documented behavior for the "very long + /// label text" edge case (rather than painting outside its allotted band or throwing). + /// + /// The candidate text. + /// The font the text will be measured/drawn with. + /// The font size, in pixels. + /// The maximum allowed measured width, in pixels. + /// + /// unchanged when it already fits; otherwise a truncated, + /// ellipsis-suffixed prefix that fits; or when + /// is non-positive or too small for even the ellipsis alone. + /// + private static string TruncateToFit(string text, TrueTypeFont font, float fontSize, float maxWidth) + { + if (maxWidth <= 0f) + { + return string.Empty; + } + + if (TextRenderer.MeasureText(text, font, fontSize).Width <= maxWidth) + { + return text; + } + + for (var length = text.Length - 1; length >= 0; length--) + { + var candidate = text[..length] + "..."; + if (TextRenderer.MeasureText(candidate, font, fontSize).Width <= maxWidth) + { + return candidate; + } + } + + return string.Empty; + } + + /// + /// Computes the Column/Line/Area plot frame: a left margin reserved for the vertical + /// value axis (sized from the widest tick label) and a bottom margin reserved for the + /// horizontal category axis (a fixed text-line height) - each skipped (and its own + /// flag left ) when reserving it would leave + /// a non-positive remaining dimension, per the small-render-target graceful-degradation + /// behavior documented on 's own ChartLayout. + /// + /// The chart being laid out. + /// The full plot-area rectangle. + /// The effective render options. + /// The resolved text font, used to measure tick label widths. + /// The value-axis tick values (see ). + /// Receives whether the bottom category-axis band was actually reserved. + /// Receives whether the left value-axis band was actually reserved. + /// The inner data rectangle, after reserving whichever bands fit. + private static Rect ComputeVerticalFrame( + Chart chart, Rect plotRect, ChartRenderOptions options, TrueTypeFont font, List ticks, + out bool hasCategoryAxis, out bool hasValueAxis) + { + var maxTickLabelWidth = 0f; + foreach (var tick in ticks) + { + var width = TextRenderer.MeasureText(FormatTickValue(tick), font, options.AxisFontSize).Width; + maxTickLabelWidth = MathF.Max(maxTickLabelWidth, width); + } + + var leftMargin = maxTickLabelWidth + TickLength + AxisLabelGap * 2f; + hasValueAxis = leftMargin > 0f && plotRect.Width - leftMargin > 0f; + if (!hasValueAxis) + { + leftMargin = 0f; + } + + var bottomMargin = options.AxisFontSize * 1.4f + TickLength + AxisLabelGap; + var categoryCount = GetCategoryCount(chart); + hasCategoryAxis = categoryCount > 0 && bottomMargin > 0f && plotRect.Height - bottomMargin > 0f; + if (!hasCategoryAxis) + { + bottomMargin = 0f; + } + + return new Rect(plotRect.X + leftMargin, plotRect.Y, plotRect.Width - leftMargin, plotRect.Height - bottomMargin); + } + + /// + /// Computes the Bar plot frame: a bottom margin reserved for the horizontal value axis + /// (a fixed text-line height) and a left margin reserved for the vertical category axis + /// (sized from the widest category label, capped to + /// of the plot width) - each skipped (and its + /// own flag left ) when reserving it would + /// leave a non-positive remaining dimension. See 's own + /// remarks for the shared small-render-target rationale. + /// + /// The chart being laid out. + /// The full plot-area rectangle. + /// The effective render options. + /// The resolved text font, used to measure tick/category label widths. + /// The value-axis tick values (see ). + /// Receives whether the left category-axis band was actually reserved. + /// Receives whether the bottom value-axis band was actually reserved. + /// The inner data rectangle, after reserving whichever bands fit. + private static Rect ComputeHorizontalFrame( + Chart chart, Rect plotRect, ChartRenderOptions options, TrueTypeFont font, List ticks, + out bool hasCategoryAxis, out bool hasValueAxis) + { + _ = ticks; + var bottomMargin = options.AxisFontSize * 1.4f + TickLength + AxisLabelGap; + hasValueAxis = bottomMargin > 0f && plotRect.Height - bottomMargin > 0f; + if (!hasValueAxis) + { + bottomMargin = 0f; + } + + var categoryCount = GetCategoryCount(chart); + var maxCategoryLabelWidth = 0f; + for (var c = 0; c < categoryCount; c++) + { + var width = TextRenderer.MeasureText(GetCategoryLabel(chart, c), font, options.AxisFontSize).Width; + maxCategoryLabelWidth = MathF.Max(maxCategoryLabelWidth, width); + } + + var leftMargin = MathF.Min( + maxCategoryLabelWidth + TickLength + AxisLabelGap * 2f, + plotRect.Width * MaxCategoryAxisWidthFraction); + hasCategoryAxis = categoryCount > 0 && leftMargin > 0f && plotRect.Width - leftMargin > 0f; + if (!hasCategoryAxis) + { + leftMargin = 0f; + } + + return new Rect(plotRect.X + leftMargin, plotRect.Y, plotRect.Width - leftMargin, plotRect.Height - bottomMargin); + } + + /// + /// Paints value-axis gridlines, tick marks, and tick labels across . + /// + /// The target canvas. + /// The inner data rectangle (see /). + /// The value-axis minimum. + /// The value-axis maximum. + /// The tick values to paint, from . + /// The effective render options. + /// The resolved text font. + /// for Bar's horizontal (bottom) value axis; for a vertical (left) value axis. + private static void PaintValueAxis(RenderCanvas canvas, Rect inner, double min, double max, List ticks, ChartRenderOptions options, TrueTypeFont font, bool isHorizontal) + { + foreach (var tick in ticks) + { + var label = FormatTickValue(tick); + if (isHorizontal) + { + var x = ValueToPixel(tick, min, max, inner.Left, inner.Width); + StrokeLineSegment(canvas, new Vector2(x, inner.Top), new Vector2(x, inner.Bottom), GridlineColor); + StrokeLineSegment(canvas, new Vector2(x, inner.Bottom), new Vector2(x, inner.Bottom + TickLength), AxisTextColor); + canvas.DrawText(label, x, inner.Bottom + TickLength + AxisLabelGap + options.AxisFontSize * 0.8f, TextAlign.Center, font, options.AxisFontSize, AxisTextColor); + } + else + { + var y = inner.Bottom - ValueToPixel(tick, min, max, 0f, inner.Height); + StrokeLineSegment(canvas, new Vector2(inner.Left, y), new Vector2(inner.Right, y), GridlineColor); + StrokeLineSegment(canvas, new Vector2(inner.Left - TickLength, y), new Vector2(inner.Left, y), AxisTextColor); + canvas.DrawText(label, inner.Left - TickLength - AxisLabelGap, y + options.AxisFontSize * 0.3f, TextAlign.Right, font, options.AxisFontSize, AxisTextColor); + } + } + } + + /// + /// Paints category-axis tick marks and (truncated-to-fit) tick labels across + /// . + /// + /// The target canvas. + /// The chart supplying category labels (see ). + /// The full plot-area rectangle, used to recover the reserved category-axis band width/height for truncation. + /// The inner data rectangle. + /// The effective render options. + /// The resolved text font. + /// for Bar's vertical (left) category axis; for a horizontal (bottom) category axis. + private static void PaintCategoryAxis(RenderCanvas canvas, Chart chart, Rect plotRect, Rect inner, ChartRenderOptions options, TrueTypeFont font, bool isHorizontal) + { + var categoryCount = GetCategoryCount(chart); + if (categoryCount <= 0) + { + return; + } + + if (isHorizontal) + { + var bandHeight = inner.Height / categoryCount; + var labelBudget = inner.Left - plotRect.X - TickLength - AxisLabelGap * 2f; + for (var c = 0; c < categoryCount; c++) + { + var y = inner.Top + (c + 0.5f) * bandHeight; + var label = TruncateToFit(GetCategoryLabel(chart, c), font, options.AxisFontSize, labelBudget); + StrokeLineSegment(canvas, new Vector2(inner.Left - TickLength, y), new Vector2(inner.Left, y), AxisTextColor); + canvas.DrawText(label, inner.Left - TickLength - AxisLabelGap, y + options.AxisFontSize * 0.3f, TextAlign.Right, font, options.AxisFontSize, AxisTextColor); + } + } + else + { + var bandWidth = inner.Width / categoryCount; + for (var c = 0; c < categoryCount; c++) + { + var x = inner.Left + (c + 0.5f) * bandWidth; + var label = TruncateToFit(GetCategoryLabel(chart, c), font, options.AxisFontSize, bandWidth - AxisLabelGap); + StrokeLineSegment(canvas, new Vector2(x, inner.Bottom), new Vector2(x, inner.Bottom + TickLength), AxisTextColor); + canvas.DrawText(label, x, inner.Bottom + TickLength + AxisLabelGap + options.AxisFontSize * 0.8f, TextAlign.Center, font, options.AxisFontSize, AxisTextColor); + } + } + } +} diff --git a/src/DemaConsulting.CanvasNet.Charts/ChartRenderer.Layout.cs b/src/DemaConsulting.CanvasNet.Charts/ChartRenderer.Layout.cs new file mode 100644 index 00000000..2df4466a --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Charts/ChartRenderer.Layout.cs @@ -0,0 +1,199 @@ +using DemaConsulting.CanvasNet.Fonts; +using DemaConsulting.CanvasNet.Geometry; +using DemaConsulting.CanvasNet.Rendering; + +namespace DemaConsulting.CanvasNet.Charts; + +public static partial class ChartRenderer +{ + /// + /// Vertical padding, in pixels, added above/below a title's text when sizing its band. + /// + private const float TitleBandPadding = 8f; + + /// + /// Padding, in pixels, added around a legend band's content (both the swatch/label rows + /// for Top/Bottom and the stacked column for Left/Right). + /// + private const float LegendBandPadding = 8f; + + /// + /// The side length, in pixels, of a legend entry's color swatch square. + /// + private const float LegendSwatchSize = 12f; + + /// + /// Horizontal gap, in pixels, between a legend entry's swatch and its label text, and + /// between consecutive entries flowing on the same row (Top/Bottom legends). + /// + private const float LegendEntryGap = 6f; + + /// + /// Vertical gap, in pixels, between consecutive legend entries stacked in one column + /// (Left/Right legends). + /// + private const float LegendRowGap = 4f; + + /// + /// The maximum fraction of the full render width a Left/Right legend band may claim, + /// regardless of how wide its longest label measures - a defensive cap so one + /// pathologically long legend label cannot collapse the plot area to nothing (see the + /// "very long label text" edge case). + /// + private const float MaxLegendWidthFraction = 0.4f; + + /// + /// Describes the three rectangular regions a + /// call paints into, computed once up front and shared by every per- + /// painter and the title/legend passes. + /// + /// + /// Bands are reserved in a fixed order - title first, then legend - so that, on a very + /// small render target, the plot area is the band that shrinks (and is ultimately omitted) + /// before either decorative band is; this matches the documented small-render-target + /// graceful-degradation behavior (see the companion design doc). A band whose reservation + /// would leave a non-positive width or height for the remaining area is skipped entirely + /// (its own rectangle becomes ) rather than reserving a + /// degenerate sliver - the corresponding Has* flag reports whether the band was + /// actually reserved. + /// + private readonly struct ChartLayout + { + /// + /// The title band's rectangle, or when + /// is . + /// + public Rect TitleRect { get; } + + /// + /// The legend band's rectangle, or when + /// is . + /// + public Rect LegendRect { get; } + + /// + /// The remaining plot-area rectangle, after any title/legend bands were reserved. May + /// itself have a non-positive width or height on a sufficiently small render target; + /// every painter must tolerate that (relying on + /// / + /// 's own documented + /// no-op-on-non-positive-size behavior) rather than assuming a usable plot area. + /// + public Rect PlotRect { get; } + + /// if a title band was actually reserved in . + public bool HasTitle { get; } + + /// if a legend band was actually reserved in . + public bool HasLegend { get; } + + /// + /// Initializes a new, fully computed . + /// + /// See . + /// See . + /// See . + /// See . + /// See . + public ChartLayout(Rect titleRect, Rect legendRect, Rect plotRect, bool hasTitle, bool hasLegend) + { + TitleRect = titleRect; + LegendRect = legendRect; + PlotRect = plotRect; + HasTitle = hasTitle; + HasLegend = hasLegend; + } + } + + /// + /// Computes the title/legend/plot-area band layout for a x + /// render target, defensively skipping any band that would + /// leave a non-positive remaining area. + /// + /// The chart being rendered. Must not be . + /// The full render target width, in pixels. Must be positive. + /// The full render target height, in pixels. Must be positive. + /// The effective render options (never by the time this is called). + /// The resolved font used to measure title/legend text extents. + /// The computed . + private static ChartLayout ComputeLayout(Chart chart, int width, int height, ChartRenderOptions options, TrueTypeFont font) + { + var remaining = new Rect(0f, 0f, width, height); + + // Reserve the title band first (top of the remaining area) - only when a title is + // present and doing so leaves a positive-height remainder. + var hasTitle = false; + var titleRect = Rect.Empty; + if (chart.Title is not null) + { + var titleFontSize = chart.Title.FontSize ?? options.TitleFontSize; + var titleHeight = titleFontSize * 1.6f + TitleBandPadding * 2f; + if (titleHeight > 0f && remaining.Height - titleHeight > 0f) + { + titleRect = new Rect(remaining.X, remaining.Y, remaining.Width, titleHeight); + remaining = new Rect(remaining.X, remaining.Y + titleHeight, remaining.Width, remaining.Height - titleHeight); + hasTitle = true; + } + } + + // Reserve the legend band second, from whichever edge of the remaining area its + // ChartLegendPosition indicates - only when the legend is visible, has a real position, + // has at least one entry to show, and doing so leaves a positive plot area. + var hasLegend = false; + var legendRect = Rect.Empty; + if (chart.Legend is { IsVisible: true, Position: not ChartLegendPosition.None }) + { + var entries = GetLegendEntries(chart, options); + if (entries.Count > 0) + { + var isHorizontal = chart.Legend.Position is ChartLegendPosition.Top or ChartLegendPosition.Bottom; + if (isHorizontal) + { + var bandHeight = options.LegendFontSize * 1.4f + LegendBandPadding * 2f; + if (bandHeight > 0f && remaining.Height - bandHeight > 0f) + { + hasLegend = true; + if (chart.Legend.Position == ChartLegendPosition.Top) + { + legendRect = new Rect(remaining.X, remaining.Y, remaining.Width, bandHeight); + remaining = new Rect(remaining.X, remaining.Y + bandHeight, remaining.Width, remaining.Height - bandHeight); + } + else + { + legendRect = new Rect(remaining.X, remaining.Bottom - bandHeight, remaining.Width, bandHeight); + remaining = new Rect(remaining.X, remaining.Y, remaining.Width, remaining.Height - bandHeight); + } + } + } + else + { + var maxLabelWidth = 0f; + foreach (var entry in entries) + { + var measured = TextRenderer.MeasureText(entry.Label, font, options.LegendFontSize).Width; + maxLabelWidth = MathF.Max(maxLabelWidth, measured); + } + + var desiredWidth = LegendSwatchSize + LegendEntryGap + maxLabelWidth + LegendBandPadding * 2f; + var bandWidth = MathF.Min(desiredWidth, remaining.Width * MaxLegendWidthFraction); + if (bandWidth > 0f && remaining.Width - bandWidth > 0f) + { + hasLegend = true; + if (chart.Legend.Position == ChartLegendPosition.Left) + { + legendRect = new Rect(remaining.X, remaining.Y, bandWidth, remaining.Height); + remaining = new Rect(remaining.X + bandWidth, remaining.Y, remaining.Width - bandWidth, remaining.Height); + } + else + { + legendRect = new Rect(remaining.Right - bandWidth, remaining.Y, bandWidth, remaining.Height); + remaining = new Rect(remaining.X, remaining.Y, remaining.Width - bandWidth, remaining.Height); + } + } + } + } + } + + return new ChartLayout(titleRect, legendRect, remaining, hasTitle, hasLegend); + } +} diff --git a/src/DemaConsulting.CanvasNet.Charts/ChartRenderer.Legend.cs b/src/DemaConsulting.CanvasNet.Charts/ChartRenderer.Legend.cs new file mode 100644 index 00000000..2afde64a --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Charts/ChartRenderer.Legend.cs @@ -0,0 +1,198 @@ +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Fonts; +using DemaConsulting.CanvasNet.Geometry; +using DemaConsulting.CanvasNet.Rendering; +using RenderCanvas = DemaConsulting.CanvasNet.Rendering.Canvas; + +namespace DemaConsulting.CanvasNet.Charts; + +public static partial class ChartRenderer +{ + /// + /// Describes one legend entry: the label text painted beside a color swatch, and the + /// swatch's own fill color. + /// + /// The entry's label text. + /// The entry's swatch color. + private readonly record struct LegendEntry(string Label, Rgba32 Color); + + /// + /// Builds the ordered list of legend entries for : one entry per + /// series for category-based types (), or one entry per data + /// point in chart.Series[0] for Pie/Doughnut. + /// + /// The chart being rendered. + /// The effective render options. + /// + /// The legend entries, in the same order / + /// will lay them out. Empty when has no series. + /// + /// + /// For Pie/Doughnut, a point's label is its series' PointLabels[i] entry when + /// present, otherwise the literal fallback "Series {i + 1}" (1-based) - this exact, + /// slightly unusual fallback wording is specified by the companion planning report and is + /// intentional, not a naming oversight: a Pie/Doughnut's "series" in the legend sense is + /// really each wedge/point, not itself. + /// + private static IReadOnlyList GetLegendEntries(Chart chart, ChartRenderOptions options) + { + if (chart.Series.Count == 0) + { + return []; + } + + var entries = new List(); + if (IsCategoryBased(chart.Type)) + { + for (var s = 0; s < chart.Series.Count; s++) + { + var series = chart.Series[s]; + entries.Add(new LegendEntry(series.Name, ResolveSeriesColor(chart, options, series, s))); + } + } + else + { + var series = chart.Series[0]; + for (var i = 0; i < series.Values.Count; i++) + { + var label = series.PointLabels?[i] ?? $"Series {i + 1}"; + entries.Add(new LegendEntry(label, ResolvePointColor(chart, options, series, i))); + } + } + + return entries; + } + + /// + /// Paints the legend band computed by : a swatch followed by + /// its label, for each entry returned by . + /// + /// The target canvas. + /// The chart being rendered. + /// The legend band's rectangle, as computed by . + /// The effective render options. + /// The resolved text font. + /// + /// Top/Bottom legends flow entries left-to-right on a single centered row; Left/Right + /// legends stack entries in a single left-aligned column. Neither layout wraps onto + /// additional rows/columns: once an entry would overflow the band's bounds, it (and every + /// entry after it) is simply omitted - the documented "many series" graceful-overflow + /// behavior (no entry is ever drawn clipped or outside the band). + /// + private static void PaintLegend(RenderCanvas canvas, Chart chart, Rect legendRect, ChartRenderOptions options, TrueTypeFont font) + { + if (legendRect.Width <= 0f || legendRect.Height <= 0f) + { + return; + } + + var entries = GetLegendEntries(chart, options); + if (entries.Count == 0) + { + return; + } + + var isHorizontal = chart.Legend!.Position is ChartLegendPosition.Top or ChartLegendPosition.Bottom; + if (isHorizontal) + { + PaintHorizontalLegend(canvas, entries, legendRect, options, font); + } + else + { + PaintVerticalLegend(canvas, entries, legendRect, options, font); + } + } + + /// + /// Paints a single centered row of swatch+label entries flowing left-to-right, for a + /// Top/Bottom legend band. See 's remarks for the + /// overflow-by-omission behavior. + /// + /// The target canvas. + /// The legend entries to paint. + /// The legend band's rectangle. + /// The effective render options. + /// The resolved text font. + private static void PaintHorizontalLegend(RenderCanvas canvas, IReadOnlyList entries, Rect legendRect, ChartRenderOptions options, TrueTypeFont font) + { + // Measure every entry's full width (swatch + gap + label) up front so the whole row of + // entries that actually fit can be centered as a group, matching how a real chart legend + // visually balances within its band. + var widths = new float[entries.Count]; + var totalWidth = 0f; + var fittingCount = 0; + for (var i = 0; i < entries.Count; i++) + { + var labelWidth = TextRenderer.MeasureText(entries[i].Label, font, options.LegendFontSize).Width; + widths[i] = LegendSwatchSize + LegendEntryGap + labelWidth; + var candidateWidth = totalWidth + widths[i] + (fittingCount > 0 ? LegendEntryGap : 0f); + if (candidateWidth > legendRect.Width - LegendBandPadding * 2f) + { + break; + } + + totalWidth = candidateWidth; + fittingCount++; + } + + if (fittingCount == 0) + { + return; + } + + var x = legendRect.X + (legendRect.Width - totalWidth) / 2f; + var centerY = legendRect.Y + legendRect.Height / 2f; + for (var i = 0; i < fittingCount; i++) + { + PaintLegendEntry(canvas, entries[i], x, centerY - LegendSwatchSize / 2f, options, font); + x += widths[i] + LegendEntryGap; + } + } + + /// + /// Paints a single left-aligned column of swatch+label entries stacked top-to-bottom, for + /// a Left/Right legend band. See 's remarks for the + /// overflow-by-omission behavior. + /// + /// The target canvas. + /// The legend entries to paint. + /// The legend band's rectangle. + /// The effective render options. + /// The resolved text font. + private static void PaintVerticalLegend(RenderCanvas canvas, IReadOnlyList entries, Rect legendRect, ChartRenderOptions options, TrueTypeFont font) + { + var rowHeight = MathF.Max(LegendSwatchSize, options.LegendFontSize) + LegendRowGap; + var totalHeight = entries.Count * rowHeight - LegendRowGap; + var x = legendRect.X + LegendBandPadding; + var y = legendRect.Y + MathF.Max(LegendBandPadding, (legendRect.Height - totalHeight) / 2f); + + foreach (var entry in entries) + { + if (y + LegendSwatchSize > legendRect.Bottom - LegendBandPadding) + { + break; + } + + PaintLegendEntry(canvas, entry, x, y, options, font); + y += rowHeight; + } + } + + /// + /// Paints one legend entry's swatch square followed by its label, with the swatch's + /// top-left corner at (, ). + /// + /// The target canvas. + /// The entry to paint. + /// The swatch's left x-coordinate. + /// The swatch's top y-coordinate. + /// The effective render options. + /// The resolved text font. + private static void PaintLegendEntry(RenderCanvas canvas, LegendEntry entry, float x, float y, ChartRenderOptions options, TrueTypeFont font) + { + canvas.FillRect(x, y, LegendSwatchSize, LegendSwatchSize, entry.Color); + var textX = x + LegendSwatchSize + LegendEntryGap; + var textY = y + LegendSwatchSize / 2f + options.LegendFontSize * 0.35f; + canvas.DrawText(entry.Label, textX, textY, TextAlign.Left, font, options.LegendFontSize, AxisTextColor); + } +} diff --git a/src/DemaConsulting.CanvasNet.Charts/ChartRenderer.PieChart.cs b/src/DemaConsulting.CanvasNet.Charts/ChartRenderer.PieChart.cs new file mode 100644 index 00000000..7e555fbf --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Charts/ChartRenderer.PieChart.cs @@ -0,0 +1,206 @@ +using System.Numerics; +using DemaConsulting.CanvasNet.Fonts; +using DemaConsulting.CanvasNet.Geometry; +using DemaConsulting.CanvasNet.Rendering; +using Path = DemaConsulting.CanvasNet.Geometry.Path; +using RenderCanvas = DemaConsulting.CanvasNet.Rendering.Canvas; + +namespace DemaConsulting.CanvasNet.Charts; + +public static partial class ChartRenderer +{ + /// The padding, in pixels, left between the plot area's edge and a Pie/Doughnut wedge's outer radius. + private const float PieOuterPadding = 8f; + + /// The fraction of 's outer radius used as its inner (hole) radius. + private const float DoughnutInnerRadiusFraction = 0.5f; + + /// + /// Paints a Pie ( = ) or Doughnut + /// ( = ) chart: one wedge per data + /// point in chart.Series[0], proportional to that point's share of the series' + /// total, plus per-point data labels. + /// + /// The target canvas. + /// + /// The chart being rendered. Only chart.Series[0] is painted - matching the + /// companion planning report's "single series" Pie/Doughnut model, any further series are + /// silently ignored (a does not itself restrict Pie/Doughnut to + /// exactly one series, so this is a deliberate, documented renderer-level convention, not + /// a model constraint). + /// + /// The plot-area rectangle reserved by . + /// The effective render options. + /// The resolved text font. + /// to cut a central hole (Doughnut); for a solid Pie. + private static void PaintPieOrDoughnut(RenderCanvas canvas, Chart chart, Rect plotRect, ChartRenderOptions options, TrueTypeFont font, bool isDoughnut) + { + if (plotRect.Width <= 0f || plotRect.Height <= 0f) + { + return; + } + + var series = chart.Series[0]; + var values = series.Values; + + // Only positive values contribute angular share; a non-positive value draws no wedge + // (rather than throwing) - the documented "negative/zero pie value" edge case. Summing + // raw values directly can overflow to +Infinity when two or more large-but-finite values + // are present (double.MaxValue + double.MaxValue), which would then make every share + // 0/Infinity = 0 and paint no wedges at all. Avoid this by first normalizing every value + // against the largest positive value before accumulating: each normalized term is in + // [0, 1], so neither the running total nor any later share computation can overflow, + // regardless of how extreme the input magnitudes are. + var maxPositive = values.Count > 0 ? Math.Max(0d, values.Max()) : 0d; + + var normalizedTotal = 0d; + if (maxPositive > 0d) + { + foreach (var value in values) + { + normalizedTotal += Math.Max(0d, value) / maxPositive; + } + } + + var centerX = plotRect.X + plotRect.Width / 2f; + var centerY = plotRect.Y + plotRect.Height / 2f; + var outerRadius = MathF.Min(plotRect.Width, plotRect.Height) / 2f - PieOuterPadding; + if (outerRadius <= 0f) + { + return; + } + + var innerRadius = isDoughnut ? outerRadius * DoughnutInnerRadiusFraction : 0f; + + // Wedges start at 12 o'clock (-90 degrees in standard math-angle convention, where 0 is + // 3 o'clock) and sweep clockwise - the near-universal pie-chart convention - since this + // canvas' y-axis already points down, an increasing angle via cos/sin moves clockwise on + // screen with no extra sign flip needed. + var angle = -MathF.PI / 2f; + for (var i = 0; i < values.Count; i++) + { + var share = normalizedTotal > 0d ? Math.Max(0d, values[i]) / maxPositive / normalizedTotal : 0d; + var sweepAngle = (float)(share * (2d * Math.PI)); + var color = ResolvePointColor(chart, options, series, i); + + if (sweepAngle > 0f) + { + var endAngle = angle + sweepAngle; + var wedge = BuildWedgePath(centerX, centerY, innerRadius, outerRadius, angle, endAngle); + canvas.FillPath(wedge, color); + + var midAngle = angle + sweepAngle / 2f; + var labelRadius = (innerRadius + outerRadius) / 2f; + var labelX = centerX + MathF.Cos(midAngle) * labelRadius; + var labelY = centerY + MathF.Sin(midAngle) * labelRadius; + PaintPointDataLabel(canvas, series, i, labelX, labelY + options.DataLabelFontSize * 0.35f, TextAlign.Center, options, font); + + angle = endAngle; + } + } + } + + /// + /// Builds a single wedge : a Pie wedge ( + /// = 0) is center -> outer arc start -> outer arc end -> close; a Doughnut + /// wedge is the ring segment between and + /// . + /// + /// The pie/doughnut's center x-coordinate. + /// The pie/doughnut's center y-coordinate. + /// The ring's inner radius, or 0 for a solid Pie wedge. + /// The ring's outer radius. + /// The wedge's start angle, in radians (0 = 3 o'clock, increasing clockwise on screen). + /// The wedge's end angle, in radians. Must be greater than . + /// The built, immutable wedge . + private static Path BuildWedgePath(float centerX, float centerY, float innerRadius, float outerRadius, float startAngle, float endAngle) + { + var outerStart = new Vector2(centerX + MathF.Cos(startAngle) * outerRadius, centerY + MathF.Sin(startAngle) * outerRadius); + var builder = new PathBuilder(); + + if (innerRadius <= 0f) + { + builder.MoveTo(new Vector2(centerX, centerY)).LineTo(outerStart); + AppendForwardArc(builder, centerX, centerY, outerRadius, startAngle, endAngle); + builder.Close(); + } + else + { + var innerEnd = new Vector2(centerX + MathF.Cos(endAngle) * innerRadius, centerY + MathF.Sin(endAngle) * innerRadius); + builder.MoveTo(outerStart); + AppendForwardArc(builder, centerX, centerY, outerRadius, startAngle, endAngle); + builder.LineTo(innerEnd); + AppendBackwardArc(builder, centerX, centerY, innerRadius, endAngle, startAngle); + builder.Close(); + } + + return builder.Build(); + } + + /// + /// Appends one or more commands tracing a circular arc of + /// from angle to + /// (increasing-angle, clockwise-on-screen direction), onto the + /// builder's current point (which must already be positioned at the arc's start). + /// + /// The path builder, positioned at the arc's start point. + /// The arc's circle center x-coordinate. + /// The arc's circle center y-coordinate. + /// The arc's radius. + /// The arc's start angle, in radians. + /// The arc's end angle, in radians. Must be greater than or equal to . + /// + /// A sweep greater than half a circle (pi radians) is split at its midpoint into two + /// (recursively, if still greater than pi) smaller arcs, each emitted as a non-"large-arc" + /// SVG arc command. This is not a cosmetic choice: a single ArcTo spanning exactly + /// a full circle would have numerically identical start/end points, and + /// treats a coincident start/end arc as a zero-length no-op + /// (per the SVG spec) - silently discarding the entire wedge outline for a 100%-share + /// single-point Pie/Doughnut. Splitting first guarantees every emitted arc's start/end + /// points are genuinely distinct. + /// + private static void AppendForwardArc(PathBuilder builder, float centerX, float centerY, float radius, float startAngle, float endAngle) + { + var sweep = endAngle - startAngle; + if (sweep <= MathF.PI) + { + var end = new Vector2(centerX + MathF.Cos(endAngle) * radius, centerY + MathF.Sin(endAngle) * radius); + builder.ArcTo(new Vector2(radius, radius), 0f, largeArc: false, sweep: true, end); + } + else + { + var mid = (startAngle + endAngle) / 2f; + AppendForwardArc(builder, centerX, centerY, radius, startAngle, mid); + AppendForwardArc(builder, centerX, centerY, radius, mid, endAngle); + } + } + + /// + /// Appends one or more commands tracing a circular arc of + /// from angle down to + /// (decreasing-angle, counter-clockwise-on-screen direction) - + /// the Doughnut ring's inner-radius return edge. See 's own + /// remarks for why large sweeps are split. + /// + /// The path builder, positioned at the arc's start point (angle ). + /// The arc's circle center x-coordinate. + /// The arc's circle center y-coordinate. + /// The arc's radius. + /// The arc's start angle, in radians. Must be greater than or equal to . + /// The arc's end angle, in radians. + private static void AppendBackwardArc(PathBuilder builder, float centerX, float centerY, float radius, float fromAngle, float toAngle) + { + var sweep = fromAngle - toAngle; + if (sweep <= MathF.PI) + { + var end = new Vector2(centerX + MathF.Cos(toAngle) * radius, centerY + MathF.Sin(toAngle) * radius); + builder.ArcTo(new Vector2(radius, radius), 0f, largeArc: false, sweep: false, end); + } + else + { + var mid = (fromAngle + toAngle) / 2f; + AppendBackwardArc(builder, centerX, centerY, radius, fromAngle, mid); + AppendBackwardArc(builder, centerX, centerY, radius, mid, toAngle); + } + } +} diff --git a/src/DemaConsulting.CanvasNet.Charts/ChartRenderer.Title.cs b/src/DemaConsulting.CanvasNet.Charts/ChartRenderer.Title.cs new file mode 100644 index 00000000..2efc8d44 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Charts/ChartRenderer.Title.cs @@ -0,0 +1,39 @@ +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Fonts; +using DemaConsulting.CanvasNet.Geometry; +using DemaConsulting.CanvasNet.Rendering; +using RenderCanvas = DemaConsulting.CanvasNet.Rendering.Canvas; + +namespace DemaConsulting.CanvasNet.Charts; + +public static partial class ChartRenderer +{ + /// + /// Paints chart.Title's text, horizontally and vertically centered within + /// . + /// + /// The target canvas. + /// + /// The chart being rendered. must not be + /// (callers only invoke this once is ). + /// + /// The title band's rectangle, as computed by . + /// The effective render options. + /// The resolved text font. + private static void PaintTitle(RenderCanvas canvas, Chart chart, Rect titleRect, ChartRenderOptions options, TrueTypeFont font) + { + if (titleRect.Width <= 0f || titleRect.Height <= 0f) + { + return; + } + + var title = chart.Title!; + var fontSize = title.FontSize ?? options.TitleFontSize; + var x = titleRect.X + titleRect.Width / 2f; + var y = titleRect.Y + titleRect.Height / 2f + fontSize * 0.35f; + canvas.DrawText(title.Text, x, y, TextAlign.Center, font, fontSize, TitleTextColor); + } + + /// The color used to paint a chart's title text. + private static readonly Rgba32 TitleTextColor = new(32, 32, 32, 255); +} diff --git a/src/DemaConsulting.CanvasNet.Charts/ChartRenderer.cs b/src/DemaConsulting.CanvasNet.Charts/ChartRenderer.cs new file mode 100644 index 00000000..61a5a3fb --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Charts/ChartRenderer.cs @@ -0,0 +1,260 @@ +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Fonts; +using RenderCanvas = DemaConsulting.CanvasNet.Rendering.Canvas; + +namespace DemaConsulting.CanvasNet.Charts; + +/// +/// Renders an immutable description onto a pixel . +/// +/// +/// +/// is this library's second public unit (alongside the Phase 1 +/// data model/): a entry point, mirroring +/// DemaConsulting.CanvasNet.Pptx.PptxDocument's own Render shape, that paints a +/// - built entirely in memory via or the model +/// constructors directly, with no document-format parsing involved - onto a new +/// the caller owns and must . +/// +/// +/// Rendering is dispatched by : / +/// // +/// share one value-range/axis/data-label implementation (see +/// ChartRenderer.CategoryChart.cs); / +/// share one wedge-layout implementation (see +/// ChartRenderer.PieChart.cs). Every chart type shares one title pass +/// (ChartRenderer.Title.cs) and one legend pass (ChartRenderer.Legend.cs), and +/// all share the band layout computed once up front (ChartRenderer.Layout.cs). +/// +/// +public static partial class ChartRenderer +{ + /// + /// Renders onto a new x + /// pixel . + /// + /// The chart to render. Must not be . + /// The render target width, in pixels. Must be a valid size (1-8192). + /// The render target height, in pixels. Must be a valid size (1-8192). + /// + /// Rendering configuration, or (the default) to use + /// . + /// + /// + /// A new , owned by the caller, cleared to + /// ' and then + /// painted with 's content. + /// + /// Thrown when is . + /// + /// Thrown when / is not a valid + /// size (propagated directly from 's own + /// constructor - this method performs no separate re-validation of its own). + /// + public static Surface Render(Chart chart, int width, int height, ChartRenderOptions? options = null) + { + ArgumentNullException.ThrowIfNull(chart); + var effectiveOptions = options ?? ChartRenderOptions.Default; + + // Surface's own constructor validates the requested width/height range and throws when + // invalid; propagate that exception directly rather than duplicating the same check here. + var surface = new Surface(width, height); + try + { + surface.Clear(effectiveOptions.BackgroundColor); + var canvas = new RenderCanvas(surface); + var font = effectiveOptions.Font ?? SystemFontCatalog.LoadBundledFallback(serif: false, fixedPitch: false, bold: false, italic: false); + + var layout = ComputeLayout(chart, width, height, effectiveOptions, font); + + // Dispatch to the per-ChartType painter first, so axes/wedges sit beneath the title + // and legend passes that follow - matching normal chart-drawing (and PptxDocument + // slide-background-then-shapes) document-order-of-paint conventions. + switch (chart.Type) + { + case ChartType.Bar: + PaintBarOrColumn(canvas, chart, layout.PlotRect, effectiveOptions, font, isHorizontal: true); + break; + case ChartType.Column: + PaintBarOrColumn(canvas, chart, layout.PlotRect, effectiveOptions, font, isHorizontal: false); + break; + case ChartType.Line: + PaintLine(canvas, chart, layout.PlotRect, effectiveOptions, font); + break; + case ChartType.Area: + PaintArea(canvas, chart, layout.PlotRect, effectiveOptions, font); + break; + case ChartType.Pie: + PaintPieOrDoughnut(canvas, chart, layout.PlotRect, effectiveOptions, font, isDoughnut: false); + break; + case ChartType.Doughnut: + PaintPieOrDoughnut(canvas, chart, layout.PlotRect, effectiveOptions, font, isDoughnut: true); + break; + default: + throw new ArgumentOutOfRangeException(nameof(chart), chart.Type, "Chart.Type must be a defined ChartType value."); + } + + if (layout.HasTitle) + { + PaintTitle(canvas, chart, layout.TitleRect, effectiveOptions, font); + } + + if (layout.HasLegend) + { + PaintLegend(canvas, chart, layout.LegendRect, effectiveOptions, font); + } + } + catch + { + surface.Dispose(); + throw; + } + + return surface; + } + + /// + /// Convenience overload of for + /// the common "render at a given physical size and resolution" case. + /// + /// The chart to render. Must not be . + /// The desired render width, in inches. Must be finite and positive. + /// The desired render height, in inches. Must be finite and positive. + /// The resolution, in pixels per inch. Must be finite and positive. + /// + /// Rendering configuration, or (the default) to use + /// . + /// + /// + /// A new of size round(widthInches * dpi) x + /// round(heightInches * dpi) pixels, painted exactly as + /// documents. + /// + /// + /// Unlike PptxDocument.Render(int, float, PptxRenderOptions?) or + /// PdfDocument.Render(int, float, PdfRenderOptions?), a carries + /// no intrinsic physical page/slide size of its own to scale a pixel size from - it is a + /// pure in-memory data description, not a document page. This overload therefore takes + /// explicit / parameters in + /// addition to , a deliberate, documented reinterpretation of the + /// "DPI overload mirrors existing document renderers" convention for a data model that has + /// no physical size to mirror it from. + /// + /// Thrown when is . + /// + /// Thrown when , , or + /// is not finite or is not positive, or when the computed pixel + /// width/height is not a valid size. + /// + public static Surface Render(Chart chart, float widthInches, float heightInches, float dpi, ChartRenderOptions? options = null) + { + ArgumentNullException.ThrowIfNull(chart); + if (!float.IsFinite(widthInches) || widthInches <= 0f) + { + throw new ArgumentOutOfRangeException(nameof(widthInches), widthInches, "Width in inches must be a finite, positive value."); + } + + if (!float.IsFinite(heightInches) || heightInches <= 0f) + { + throw new ArgumentOutOfRangeException(nameof(heightInches), heightInches, "Height in inches must be a finite, positive value."); + } + + if (!float.IsFinite(dpi) || dpi <= 0f) + { + throw new ArgumentOutOfRangeException(nameof(dpi), dpi, "DPI must be a finite, positive value."); + } + + var widthPixels = widthInches * dpi; + var heightPixels = heightInches * dpi; + if (!float.IsFinite(widthPixels) || !float.IsFinite(heightPixels) || + widthPixels > Surface.MaxDimension || heightPixels > Surface.MaxDimension) + { + throw new ArgumentOutOfRangeException( + nameof(dpi), dpi, + $"The computed pixel dimensions must not exceed {Surface.MaxDimension}x{Surface.MaxDimension}."); + } + + var width = (int)Math.Round(widthPixels, MidpointRounding.AwayFromZero); + var height = (int)Math.Round(heightPixels, MidpointRounding.AwayFromZero); + return Render(chart, width, height, options); + } + + /// + /// Determines whether associates each series' values one-for-one + /// with a category axis position (Bar/Column/Line/Area), as opposed to a proportional + /// wedge share (Pie/Doughnut). Mirrors Chart's own private identically-named + /// check, since needs the same classification for dispatch + /// and legend-entry construction but cannot reuse Chart's private helper directly. + /// + /// The chart type to classify. + /// for a category-based type; otherwise, . + private static bool IsCategoryBased(ChartType type) => + type is ChartType.Bar or ChartType.Column or ChartType.Line or ChartType.Area; + + /// + /// Resolves a series' effective paint color: its own explicit + /// when set, otherwise a palette color for + /// (see ). + /// + /// The chart belongs to (supplies ). + /// The effective render options (supplies ). + /// The series to resolve a color for. + /// The series' zero-based index within . + /// The resolved, fully opaque-or-as-specified color. + private static Rgba32 ResolveSeriesColor(Chart chart, ChartRenderOptions options, ChartSeries series, int seriesIndex) => + series.Color ?? ResolvePaletteColor(chart, options, seriesIndex); + + /// + /// Resolves a single data point's effective paint color: its series' own explicit + /// entry when set, otherwise a palette color for + /// (see ). + /// + /// The chart belongs to (supplies ). + /// The effective render options (supplies ). + /// The series the point belongs to. + /// The point's zero-based index within . + /// The resolved, fully opaque-or-as-specified color. + private static Rgba32 ResolvePointColor(Chart chart, ChartRenderOptions options, ChartSeries series, int pointIndex) => + series.PointColors?[pointIndex] ?? ResolvePaletteColor(chart, options, pointIndex); + + /// + /// Resolves the palette color for , the single shared helper every + /// per-series/per-point color resolution and legend-swatch painting goes through - so the + /// "which palette, and how does the index wrap" decision can never drift between painters + /// (see the companion planning report's color-resolution risk mitigation). + /// + /// The chart being rendered (checked first for its own ). + /// + /// The effective render options (checked second for its own + /// ). + /// + /// The series/point index to resolve a color for. + /// + /// chart.ColorPalette[index % chart.ColorPalette.Count] when + /// is non-null and non-empty; otherwise + /// options.ColorPalette[index % options.ColorPalette.Count] when that is non-null + /// and non-empty; otherwise ChartColorPalette.Default[index % ChartColorPalette.Default.Count]. + /// + private static Rgba32 ResolvePaletteColor(Chart chart, ChartRenderOptions options, int index) + { + IReadOnlyList palette; + if (chart.ColorPalette is { Count: > 0 } chartPalette) + { + palette = chartPalette; + } + else if (options.ColorPalette is { Count: > 0 } optionsPalette) + { + palette = optionsPalette; + } + else + { + palette = ChartColorPalette.Default; + } + + // index is wrapped via modulo (never clamped or out-of-range) so a chart with more + // series/points than palette entries simply repeats colors from the start, matching + // ChartColorPalette's own documented wrapping convention. + var wrapped = index % palette.Count; + return palette[wrapped]; + } +} diff --git a/src/DemaConsulting.CanvasNet.Charts/ChartSeries.cs b/src/DemaConsulting.CanvasNet.Charts/ChartSeries.cs new file mode 100644 index 00000000..9de4ecad --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Charts/ChartSeries.cs @@ -0,0 +1,174 @@ +using System.Collections.ObjectModel; +using DemaConsulting.CanvasNet.Canvas; + +namespace DemaConsulting.CanvasNet.Charts; + +/// +/// Represents an immutable named series of numeric values for a , with +/// optional per-point colors, per-point labels, and a series-level color. +/// +/// +/// is immutable and thread-safe after construction: every +/// caller-supplied list argument is defensively copied into an immutable snapshot, so +/// mutating the caller's original list after construction has no effect on this instance. +/// +public sealed class ChartSeries +{ + /// + /// Initializes a new, validated . + /// + /// + /// The series name. Must not be , empty, or consist only of + /// whitespace. + /// + /// + /// The series' data points. Must contain at least one value, and every value must be + /// finite. + /// + /// + /// An optional per-point color override, or for none. When + /// supplied, its must exactly equal + /// 's count. + /// + /// + /// An optional per-point label override, or for none. When + /// supplied, its must exactly equal + /// 's count. A entry is not permitted. + /// + /// + /// An optional series-level color, or to let a renderer choose a + /// default (for example from a 's color palette). + /// + /// + /// Thrown when is empty or consists only of whitespace; when + /// is empty or contains a non-finite entry; when + /// 's count does not exactly equal + /// 's count; when 's count does + /// not exactly equal 's count; or when + /// contains a entry. + /// + /// + /// Thrown when or is + /// . + /// + public ChartSeries( + string name, + IReadOnlyList values, + IReadOnlyList? pointColors = null, + IReadOnlyList? pointLabels = null, + Rgba32? color = null) + { + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(values); + + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Name must not be empty or consist only of whitespace.", nameof(name)); + } + + Values = CopyValues(values); + + if (pointColors is not null && pointColors.Count != Values.Count) + { + throw new ArgumentException( + "Point colors count must exactly equal the values count.", nameof(pointColors)); + } + + if (pointLabels is not null && pointLabels.Count != Values.Count) + { + throw new ArgumentException( + "Point labels count must exactly equal the values count.", nameof(pointLabels)); + } + + Name = name; + PointColors = pointColors is null ? null : new ReadOnlyCollection([.. pointColors]); + PointLabels = CopyPointLabels(pointLabels); + Color = color; + } + + /// + /// Gets the series name. + /// + public string Name { get; } + + /// + /// Gets the series' data points. + /// + public IReadOnlyList Values { get; } + + /// + /// Gets the per-point color override, or when none was supplied. + /// + public IReadOnlyList? PointColors { get; } + + /// + /// Gets the per-point label override, or when none was supplied. + /// + public IReadOnlyList? PointLabels { get; } + + /// + /// Gets the series-level color, or when unspecified. + /// + public Rgba32? Color { get; } + + /// + /// Copies and validates , producing an immutable snapshot. + /// + /// The caller-supplied values list. + /// The copied, read-only values snapshot. + /// + /// Thrown when is empty or contains a non-finite entry. + /// + private static IReadOnlyList CopyValues(IReadOnlyList values) + { + if (values.Count == 0) + { + throw new ArgumentException("Values must contain at least one entry.", nameof(values)); + } + + var copy = new double[values.Count]; + for (var i = 0; i < values.Count; i++) + { + var value = values[i]; + if (!double.IsFinite(value)) + { + throw new ArgumentException("Values entries must all be finite.", nameof(values)); + } + + copy[i] = value; + } + + return new ReadOnlyCollection(copy); + } + + /// + /// Copies and validates , producing an immutable snapshot + /// or when no labels were supplied. + /// + /// The caller-supplied point labels list. + /// The copied, read-only point labels snapshot, or . + /// + /// Thrown when contains a entry. + /// + private static IReadOnlyList? CopyPointLabels(IReadOnlyList? pointLabels) + { + if (pointLabels is null) + { + return null; + } + + var copy = new string[pointLabels.Count]; + for (var i = 0; i < pointLabels.Count; i++) + { + var label = pointLabels[i]; + if (label is null) + { + throw new ArgumentException("Point labels must not contain a null entry.", nameof(pointLabels)); + } + + copy[i] = label; + } + + return new ReadOnlyCollection(copy); + } +} diff --git a/src/DemaConsulting.CanvasNet.Charts/ChartTitle.cs b/src/DemaConsulting.CanvasNet.Charts/ChartTitle.cs new file mode 100644 index 00000000..f97a6f38 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Charts/ChartTitle.cs @@ -0,0 +1,58 @@ +namespace DemaConsulting.CanvasNet.Charts; + +/// +/// Represents an immutable title description for a : the title text and +/// an optional font-size hint. +/// +/// +/// is immutable and thread-safe after construction. +/// +public sealed class ChartTitle +{ + /// + /// Initializes a new, validated . + /// + /// + /// The title text. Must not be , empty, or consist only of + /// whitespace. + /// + /// + /// An optional font-size hint, in the same units a renderer otherwise uses for text size, + /// or to let a renderer choose a default. Must be finite and + /// strictly greater than zero when supplied. + /// + /// + /// Thrown when is empty or consists only of whitespace. + /// + /// Thrown when is . + /// + /// Thrown when is not finite or is less than or equal to zero. + /// + public ChartTitle(string text, float? fontSize = null) + { + ArgumentNullException.ThrowIfNull(text); + if (string.IsNullOrWhiteSpace(text)) + { + throw new ArgumentException("Text must not be empty or consist only of whitespace.", nameof(text)); + } + + if (fontSize.HasValue && (!float.IsFinite(fontSize.Value) || fontSize.Value <= 0f)) + { + throw new ArgumentOutOfRangeException( + nameof(fontSize), fontSize, "Font size must be a finite value greater than zero."); + } + + Text = text; + FontSize = fontSize; + } + + /// + /// Gets the title text. + /// + public string Text { get; } + + /// + /// Gets the font-size hint, or when unspecified. + /// + public float? FontSize { get; } +} diff --git a/src/DemaConsulting.CanvasNet.Charts/ChartType.cs b/src/DemaConsulting.CanvasNet.Charts/ChartType.cs new file mode 100644 index 00000000..2b106a8c --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Charts/ChartType.cs @@ -0,0 +1,50 @@ +namespace DemaConsulting.CanvasNet.Charts; + +/// +/// Identifies the kind of chart a describes. +/// +/// +/// This enum enumerates only the chart kinds supported as of this release. A future release +/// may add further members (for example Scatter or Radar); existing members' +/// numeric values are stable across releases. +/// +public enum ChartType +{ + /// + /// A horizontal bar chart: each data point is drawn as a horizontal bar whose length is + /// proportional to its value, with categories arranged along the vertical axis. + /// + Bar, + + /// + /// A vertical bar chart (also known as a column chart): each data point is drawn as a + /// vertical bar whose height is proportional to its value, with categories arranged along + /// the horizontal axis. This is the orientation-flipped counterpart of . + /// + Column, + + /// + /// A line chart: each series is drawn as a connected sequence of line segments joining + /// one point per category, with categories arranged along the horizontal axis. + /// + Line, + + /// + /// A pie chart: a single series' values are drawn as proportionally sized wedges of a + /// single circle, each wedge's angular extent proportional to its share of the series' + /// total. + /// + Pie, + + /// + /// A doughnut chart: the same proportional-wedge layout as , but with a + /// circular hole removed from the center of the wedge ring. + /// + Doughnut, + + /// + /// An area chart: the same connected-line-segment layout as , but with + /// the region between each series' line and the category axis filled. + /// + Area +} diff --git a/src/DemaConsulting.CanvasNet.Charts/DemaConsulting.CanvasNet.Charts.csproj b/src/DemaConsulting.CanvasNet.Charts/DemaConsulting.CanvasNet.Charts.csproj new file mode 100644 index 00000000..79cbbb8e --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Charts/DemaConsulting.CanvasNet.Charts.csproj @@ -0,0 +1,82 @@ + + + + net8.0;net9.0;net10.0 + latest + enable + enable + + + DemaConsulting.CanvasNet.Charts + 0.0.0 + DEMA Consulting + DEMA Consulting + DemaConsulting.CanvasNet.Charts provides a format-agnostic charting data model, builder, and renderer for the CanvasNet canvas-based drawing and rendering API. + MIT + https://github.com/demaconsulting/CanvasNet + https://github.com/demaconsulting/CanvasNet + README.md + Icon.png + canvas;dotnet-library;charts + Copyright DEMA Consulting + CanvasNet.Charts + DemaConsulting.CanvasNet.Charts + + + true + snupkg + true + true + true + + + true + true + true + true + latest + + + true + $(PackageId) + $(Version) + Organization: $(Company) + + + true + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + diff --git a/src/DemaConsulting.CanvasNet.Charts/NamespaceDoc.cs b/src/DemaConsulting.CanvasNet.Charts/NamespaceDoc.cs new file mode 100644 index 00000000..b2ba05b4 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Charts/NamespaceDoc.cs @@ -0,0 +1,84 @@ +namespace DemaConsulting.CanvasNet.Charts; + +/// +/// The namespace provides a public, +/// format-agnostic charting data model (, , +/// , , , and +/// ), an ergonomic, fluent construction API +/// (), and a pixel-rendering engine +/// (, , and +/// ) that paints a onto a core +/// . This namespace is distributed as the +/// separate DemaConsulting.CanvasNet.Charts NuGet package, which references only the +/// core DemaConsulting.CanvasNet package - specifically its +/// namespace (for the +/// color type and the +/// render target), and, for rendering +/// specifically, its Drawing (path building/filling/stroking), Geometry +/// (transforms/rectangles), Fonts (TrueType text layout/metrics), and Rendering +/// (the bundled fallback font) namespaces. +/// +/// +/// +/// This library's immutable data-model types (, , +/// , , and ) are each a +/// class with a single validating constructor and read-only +/// properties, matching the convention already established by the core +/// DemaConsulting.CanvasNet package's own public immutable value types (for example +/// and +/// ): every constructor argument is +/// validated eagerly and rejected outright (never silently clamped or coerced), and every +/// caller-supplied collection argument is defensively copied into an immutable snapshot so the +/// constructed instance's state cannot be mutated by the caller after construction. +/// is the one concession to a fluent, mutable-until-built API: it +/// performs no data-shape validation of its own, delegating every invariant to the model +/// types' own constructors, so no validation rule is ever duplicated or able to drift out of +/// sync between the builder and the model. +/// +/// +/// This library imposes a hard, structural constraint: it must never reference +/// DemaConsulting.CanvasNet.Pptx, DemaConsulting.CanvasNet.Pdf, +/// DemaConsulting.CanvasNet.Svg, or any future DemaConsulting.CanvasNet.Vsdx +/// package. Keeping the dependency direction one-way (those packages may depend on +/// Charts, but Charts may never depend on any of them) is what keeps this +/// chart data model and its renderer and eventual OOXML adapter genuinely format-agnostic and +/// reusable by any future document-format library that needs to render a chart, rather than +/// coupled to one specific host document format. +/// +/// +/// As of this release, this namespace provides the data model, builder, and renderer +/// described above, plus a format-agnostic OOXML parser +/// (, in the nested +/// namespace) that produces a +/// directly from a raw c:chartSpace/c:chart +/// /. Chart +/// parsing/rendering is already integrated into a host document format: +/// DemaConsulting.CanvasNet.Pptx locates and opens the relevant chart1.xml +/// OPC part for a slide's <p:graphicFrame>, hands its content to +/// , and renders the +/// resulting via - see that package's +/// PptxDocument.Charts.cs (chart-part location/parsing) and its +/// RenderGraphicFrame rendering dispatch. +/// +/// +/// +/// Building an immutable describing a simple two-category bar chart with +/// a title, using 's fluent API, then rendering it onto a new +/// 400x300 using +/// : +/// +/// using DemaConsulting.CanvasNet.Charts; +/// +/// var chart = new ChartBuilder() +/// .OfType(ChartType.Bar) +/// .WithCategoryAxis(["Q1", "Q2"]) +/// .AddSeries("Revenue", [120.0, 150.0]) +/// .WithTitle("Quarterly Revenue") +/// .Build(); +/// +/// using var surface = ChartRenderer.Render(chart, 400, 300); +/// +/// +internal static class NamespaceDoc +{ +} diff --git a/src/DemaConsulting.CanvasNet.Charts/OpenXml/ChartUnsupportedFeatureException.cs b/src/DemaConsulting.CanvasNet.Charts/OpenXml/ChartUnsupportedFeatureException.cs new file mode 100644 index 00000000..7ee0001b --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Charts/OpenXml/ChartUnsupportedFeatureException.cs @@ -0,0 +1,100 @@ +namespace DemaConsulting.CanvasNet.Charts.OpenXml; + +/// +/// The exception thrown when refuses to parse an otherwise +/// well-formed DrawingML c:chartSpace/c:chart element because it declares a +/// chart kind or data shape this phase does not implement (for example a radar, bubble, +/// scatter, stock, surface, 3-D, or "of pie" chart type; a combo chart combining more than +/// one chart-type element in a single plot area; or a series whose value cache is absent - +/// see open-xml-chart-parser.md's "Supported and Deferred Chart Types" design section +/// for the exact supported/deferred boundary). +/// +/// +/// This type intentionally mirrors the shape of the equivalent "well-formed but unsupported" +/// exception already established elsewhere in the CanvasNet family of packages (a short, +/// stable, machine-matchable token distinct from the free-text, +/// human-readable , five parallel constructors, and a base +/// type of rather than the sealed +/// - a sibling of , not its base type, since +/// derives directly from , +/// not ). This type is deliberately re-declared here, local to +/// DemaConsulting.CanvasNet.Charts, rather than reused from another CanvasNet package: +/// DemaConsulting.CanvasNet.Charts must never take a project or package reference on +/// any host-document-format package (see the Charts namespace's own documented +/// dependency-direction constraint), so it cannot reference an exception type declared in one +/// of those packages even though the two types' shape is intentionally identical. A caller +/// that wants to handle both the malformed-XML case and the well-formed-but-unsupported case +/// together cannot do so with a single catch (IOException) clause and must add two +/// explicit clauses. identifies which unsupported feature was +/// encountered (for example "charts-openxml-combo-chart", +/// "charts-openxml-radar-chart", "charts-openxml-uncached-values"), distinct +/// from the free-text . +/// +public sealed class ChartUnsupportedFeatureException : IOException +{ + /// + /// A short, stable, machine-matchable token identifying which unsupported feature caused + /// this exception to be thrown (for example "charts-openxml-combo-chart"), or + /// when this exception was constructed via one of the standard + /// parameterless/message-only/inner-exception constructors rather than the two + /// feature-carrying constructors below. + /// + public string Feature { get; } + + /// + /// Initializes a new instance of the class + /// with a default message and an empty . + /// + public ChartUnsupportedFeatureException() + : this(string.Empty, "An unsupported OOXML chart feature was encountered.") + { + } + + /// + /// Initializes a new instance of the class + /// with the specified message and an empty . + /// + /// The message that describes the error. + public ChartUnsupportedFeatureException(string message) + : this(string.Empty, message) + { + } + + /// + /// Initializes a new instance of the class + /// with the specified message, inner exception, and an empty . + /// + /// The message that describes the error. + /// The exception that is the cause of this exception. + public ChartUnsupportedFeatureException(string message, Exception innerException) + : this(string.Empty, message, innerException) + { + } + + /// + /// Initializes a new instance of the class + /// with the specified feature token and message. + /// + /// + /// A short, stable, machine-matchable token identifying the unsupported feature (for + /// example "charts-openxml-combo-chart"). + /// + /// The message that describes the error. + public ChartUnsupportedFeatureException(string feature, string message) + : base(message) + => Feature = feature; + + /// + /// Initializes a new instance of the class + /// with the specified feature token, message, and inner exception. + /// + /// + /// A short, stable, machine-matchable token identifying the unsupported feature (for + /// example "charts-openxml-combo-chart"). + /// + /// The message that describes the error. + /// The exception that is the cause of this exception. + public ChartUnsupportedFeatureException(string feature, string message, Exception innerException) + : base(message, innerException) + => Feature = feature; +} diff --git a/src/DemaConsulting.CanvasNet.Charts/OpenXml/NamespaceDoc.cs b/src/DemaConsulting.CanvasNet.Charts/OpenXml/NamespaceDoc.cs new file mode 100644 index 00000000..8688acee --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Charts/OpenXml/NamespaceDoc.cs @@ -0,0 +1,43 @@ +namespace DemaConsulting.CanvasNet.Charts.OpenXml; + +/// +/// The namespace provides a public, +/// format-agnostic parser () that produces a +/// from a raw ECMA-376 DrawingML-Charts c:chartSpace (or bare +/// c:chart) /, +/// and a dedicated exception type () for a +/// recognized-but-out-of-scope chart kind or data shape. +/// +/// +/// +/// This namespace is deliberately format-agnostic: it consumes only +/// / content and +/// has no knowledge whatsoever of OPC/ZIP packaging, part relationships, or any specific host +/// document format (PresentationML/.pptx, SpreadsheetML/.xlsx, or a future +/// .vsdx). A caller is responsible for first locating and opening the relevant +/// chart#.xml part from whatever package format it is embedded in (for example +/// DemaConsulting.CanvasNet.Pptx's own OPC-reading machinery, in a later phase) and +/// handing this namespace only the resulting XML content. This boundary is what keeps +/// reusable by any future document-format library that +/// embeds an OOXML chart part - not just DemaConsulting.CanvasNet.Pptx - mirroring the +/// same "this package must never reference a host document-format package" dependency- +/// direction constraint the parent namespace +/// already documents for its data model and renderer. +/// +/// +/// reads only cached values: a series' value cache +/// (c:numCache), category cache (c:strCache/c:numCache), and name cache +/// (c:strCache or a literal c:v) are read directly; the sibling formula element +/// (c:f, for example Sheet1!$B$2:$B$5) that a spreadsheet application would use +/// to recompute those cached values is always ignored. A chart#.xml part embedded in a +/// document is, by construction, already a point-in-time snapshot - the authoring +/// application wrote the cached values precisely so a consumer with no access to (or +/// understanding of) the original source worksheet can still render the chart correctly - +/// making live recalculation both unnecessary and, for this phase's purposes, entirely out of +/// scope (mirroring DemaConsulting.CanvasNet.Pptx's own documented +/// <a:fld> "paint the cached value, never recompute" convention). +/// +/// +internal static class NamespaceDoc +{ +} diff --git a/src/DemaConsulting.CanvasNet.Charts/OpenXml/OpenXmlChartParser.cs b/src/DemaConsulting.CanvasNet.Charts/OpenXml/OpenXmlChartParser.cs new file mode 100644 index 00000000..bc2d2473 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Charts/OpenXml/OpenXmlChartParser.cs @@ -0,0 +1,790 @@ +using System.Globalization; +using System.Xml.Linq; + +namespace DemaConsulting.CanvasNet.Charts.OpenXml; + +/// +/// Parses a raw ECMA-376 DrawingML-Charts c:chartSpace (or bare c:chart) +/// element into a validated , reading only cached values. +/// +/// +/// +/// is this namespace's one public entry point. It supports +/// the five chart kinds the namespace's +/// enumeration models - c:barChart (classified as or +/// by its c:barDir value), c:lineChart +/// (), c:pieChart (), +/// c:doughnutChart (), and c:areaChart +/// () - and throws +/// for every other schema-legal plot-area shape: a recognized-but-unimplemented chart kind +/// (radar/bubble/scatter/stock/surface/3-D/"of pie"), a combo chart (more than one chart-type +/// element sharing a single plot area), a plot area with no recognized chart-type element at +/// all, and a series whose value cache (c:numCache) is absent. +/// +/// +/// This parser performs no data-shape validation of its own beyond the cached-values-only and +/// chart-kind-classification checks described above: every other invariant (non-empty series, +/// finite values, matching category/value counts, and so on) is enforced exactly once, by +/// 's and its constituent types' own validating constructors, which this +/// parser ultimately calls - mirroring 's own "delegate every +/// validation rule to the model constructors" convention. An OOXML document whose cached +/// series/category counts disagree therefore surfaces the identical +/// the corresponding model constructor documents, not a +/// parser-specific exception. +/// +/// +public static class OpenXmlChartParser +{ + /// The DrawingML-Charts XML namespace (prefix c: in a real chart#.xml part). + private static readonly XNamespace ChartNs = "http://schemas.openxmlformats.org/drawingml/2006/chart"; + + /// The DrawingML-Main XML namespace (prefix a:), used only for a rich-text title's a:t runs. + private static readonly XNamespace DrawingNs = "http://schemas.openxmlformats.org/drawingml/2006/main"; + + /// + /// The maximum c:ptCount value / + /// will allocate a dense array for, chosen to match Excel's own maximum worksheet row + /// count (2^20 = 1,048,576) - the practical upper bound on how many cached points a + /// genuine chart backed by real worksheet data could ever declare. + /// + /// + /// c:ptCount is read directly from the untrusted chart part with no inherent upper + /// bound of its own; without this cap, a crafted/corrupted value (for example + /// 2000000000) would drive an attacker-controlled new double[ptCount]/ + /// new string[ptCount] allocation - up to ~16 GB per series - causing an + /// rather than a clean, fail-closed rejection. + /// + private const int MaxCachedPointCount = 1_048_576; + + /// + /// The maximum total number of cached points / + /// will allocate dense arrays for, summed across every + /// c:numCache/c:strCache parsed while parsing one chart (one + /// per call). + /// + /// + /// alone only bounds a single series' own cache, not the + /// chart as a whole: a chart with many series can each declare a cache at or near + /// while the chart XML itself stays small, so the + /// per-series cap does not bound total allocation. This cumulative budget - four times + /// , comfortably covering a real multi-series worksheet + /// while still rejecting a pathological many-series crafted chart - closes that gap. + /// + private const int MaxTotalCachedPointCount = 4 * MaxCachedPointCount; + + /// + /// A simple mutable counter tracking the cumulative cached-point allocation budget + /// remaining for one call, shared by reference across every + /// / call made while parsing that one + /// chart - see . + /// + private sealed class CachePointBudget + { + private int _remaining = MaxTotalCachedPointCount; + + /// + /// Deducts from the remaining budget. + /// + /// The number of points about to be allocated. + /// The cache element's name ("c:numCache" or "c:strCache"), used in the thrown message. + /// + /// Thrown when exceeds the remaining budget. + /// + public void Consume(int ptCount, string elementName) + { + if (ptCount > _remaining) + { + throw new ChartUnsupportedFeatureException( + "charts-openxml-point-count-too-large", + $"This chart's cumulative cached point count exceeds the maximum supported total of {MaxTotalCachedPointCount} (rejected while parsing a {elementName} declaring c:ptCount={ptCount})."); + } + + _remaining -= ptCount; + } + } + + /// + /// The local names of every ECMA-376-defined plot-area chart-type element this parser + /// supports, mapped to a function that classifies the element's own . + /// + /// + /// A single lookup table (rather than scattered checks) is the one + /// place a future chart-type addition needs to edit - see + /// for its unsupported-type counterpart. + /// + private static readonly IReadOnlyDictionary> SupportedChartTypeClassifiers = + new Dictionary> + { + ["barChart"] = ClassifyBarChart, + ["lineChart"] = static _ => ChartType.Line, + ["pieChart"] = static _ => ChartType.Pie, + ["doughnutChart"] = static _ => ChartType.Doughnut, + ["areaChart"] = static _ => ChartType.Area + }; + + /// + /// The local names of every ECMA-376-defined plot-area chart-type element this parser + /// recognizes but does not yet implement, mapped to the short, stable + /// token thrown for it. + /// + private static readonly IReadOnlyDictionary UnsupportedChartTypeFeatureTokens = + new Dictionary + { + ["radarChart"] = "charts-openxml-radar-chart", + ["scatterChart"] = "charts-openxml-scatter-chart", + ["bubbleChart"] = "charts-openxml-bubble-chart", + ["stockChart"] = "charts-openxml-stock-chart", + ["surfaceChart"] = "charts-openxml-surface-chart", + ["surface3DChart"] = "charts-openxml-surface-chart", + ["bar3DChart"] = "charts-openxml-3d-chart", + ["line3DChart"] = "charts-openxml-3d-chart", + ["pie3DChart"] = "charts-openxml-3d-chart", + ["area3DChart"] = "charts-openxml-3d-chart", + ["ofPieChart"] = "charts-openxml-of-pie-chart" + }; + + /// + /// Parses 's root element into a validated + /// . See for the full parsing contract. + /// + /// + /// The chart XML document to parse, typically loaded directly from a chart#.xml + /// part's content. Must not be , and must have a root element. + /// + /// The parsed, validated . + /// Thrown when is . + /// + /// Thrown when has no root element, or propagated + /// directly from a model constructor - see . + /// + /// See . + public static Chart Parse(XDocument chartDocument) + { + ArgumentNullException.ThrowIfNull(chartDocument); + + if (chartDocument.Root is null) + { + throw new ArgumentException("The chart XML document must have a root element.", nameof(chartDocument)); + } + + return Parse(chartDocument.Root); + } + + /// + /// Parses into a validated . + /// + /// + /// Either a full c:chartSpace root element (its own c:chart child is + /// located and parsed) or a bare c:chart element directly (supports hand-authored + /// fragments that omit the c:chartSpace wrapper, such as this type's own unit + /// tests). Must not be . + /// + /// + /// The parsed, validated : its is classified + /// from the plot area's single recognized chart-type element; its + /// are built from that element's c:ser children (name from c:tx, defaulting + /// to "Series N (1-based) when absent; values from c:val's cached + /// c:numCache, honoring c:ptCount/gaps as 0.0); its + /// is populated, for a category-based + /// , from the first series' c:cat cache (a value-based + /// type's categories are instead mapped to each series' , + /// since has no per-point-category concept for + /// /); its + /// is populated from c:title's cached text, or + /// when c:title is absent or present without cached text (a + /// styled-but-textless PowerPoint auto-title placeholder); its + /// is populated from c:legend/c:legendPos (mapped to the nearest of + /// 's five defined positions - see this type's remarks), or + /// when c:legend is absent; and its + /// / titles/range are + /// populated from c:catAx/c:valAx's c:title/c:scaling/ + /// c:majorUnit when present. + /// + /// Thrown when is . + /// + /// Thrown when has no c:chart child + /// (and is not itself a c:chart element), or when it has no c:plotArea + /// child; or propagated directly from a model constructor (for example a series/ + /// category-axis length mismatch, or an inverted value-axis range). + /// + /// + /// Thrown when the plot area contains no recognized chart-type element + /// ("charts-openxml-no-chart-type), more than one recognized chart-type element + /// ("charts-openxml-combo-chart), a single recognized-but-unimplemented chart-type + /// element (a dedicated token per kind - see ), + /// or a series whose c:val has no cached c:numCache + /// ("charts-openxml-uncached-values). + /// + public static Chart Parse(XElement chartSpaceOrChartElement) + { + ArgumentNullException.ThrowIfNull(chartSpaceOrChartElement); + + // Accept either a full c:chartSpace root (locate its c:chart child) or a bare c:chart + // element directly, so a hand-authored test fragment need not reproduce the wrapper. + var chartElement = chartSpaceOrChartElement.Name == ChartNs + "chart" + ? chartSpaceOrChartElement + : chartSpaceOrChartElement.Element(ChartNs + "chart"); + if (chartElement is null) + { + throw new ArgumentException( + "The supplied element must be a c:chart element, or a c:chartSpace element containing one.", + nameof(chartSpaceOrChartElement)); + } + + var plotArea = chartElement.Element(ChartNs + "plotArea") + ?? throw new ArgumentException("The c:chart element must contain a c:plotArea child.", nameof(chartSpaceOrChartElement)); + + // Classify the plot area's single recognized chart-type element up front - this also + // detects and rejects a combo chart or an unrecognized/absent chart type before any + // series parsing is attempted. + var (type, chartTypeElement) = ClassifyPlotArea(plotArea); + var isCategoryBased = IsCategoryBased(type); + + var budget = new CachePointBudget(); + var seriesElements = chartTypeElement.Elements(ChartNs + "ser").ToList(); + var series = new List(seriesElements.Count); + for (var i = 0; i < seriesElements.Count; i++) + { + series.Add(ParseSeries(seriesElements[i], i, isCategoryBased, budget)); + } + + var categoryAxis = isCategoryBased + ? BuildCategoryAxis(seriesElements, plotArea, budget) + : null; + var valueAxis = ParseValueAxis(plotArea); + var title = ParseTitle(chartElement.Element(ChartNs + "title")); + var legend = ParseLegend(chartElement.Element(ChartNs + "legend")); + + return new Chart(type, series, categoryAxis, valueAxis, legend, title); + } + + /// + /// Determines whether associates each series' values one-for-one + /// with a category axis label (as opposed to a proportional wedge share). Mirrors + /// Chart's own private identically-named check, since this parser needs the same + /// classification to decide whether parsed categories become a shared + /// or each series' own . + /// + /// The chart type to classify. + /// for a category-based type; otherwise, . + private static bool IsCategoryBased(ChartType type) => + type is ChartType.Bar or ChartType.Column or ChartType.Line or ChartType.Area; + + /// + /// Locates and classifies the plot area's single recognized chart-type element. + /// + /// The c:plotArea element to classify. + /// The classified and the chart-type element it was classified from. + /// + /// Thrown when zero, more than one, or a recognized-but-unimplemented chart-type element + /// is found - see 's own exception documentation. + /// + private static (ChartType Type, XElement Element) ClassifyPlotArea(XElement plotArea) + { + var candidates = plotArea.Elements() + .Where(e => e.Name.Namespace == ChartNs && + (SupportedChartTypeClassifiers.ContainsKey(e.Name.LocalName) || + UnsupportedChartTypeFeatureTokens.ContainsKey(e.Name.LocalName))) + .ToList(); + + switch (candidates.Count) + { + case 0: + throw new ChartUnsupportedFeatureException( + "charts-openxml-no-chart-type", + "The plot area contains no recognized chart-type element."); + case > 1: + throw new ChartUnsupportedFeatureException( + "charts-openxml-combo-chart", + "A combo chart (multiple chart-type elements sharing one plot area) is not supported."); + } + + var element = candidates[0]; + var localName = element.Name.LocalName; + if (UnsupportedChartTypeFeatureTokens.TryGetValue(localName, out var feature)) + { + throw new ChartUnsupportedFeatureException(feature, $"The '{localName}' chart type is not supported."); + } + + return (SupportedChartTypeClassifiers[localName](element), element); + } + + /// + /// Classifies a c:barChart element as when its + /// c:barDir value is "bar, or for + /// "col or any other/absent value - "col is the ECMA-376-documented + /// default when c:barDir is omitted, even though the schema in fact requires it. + /// + /// The c:barChart element. + /// or . + private static ChartType ClassifyBarChart(XElement barChart) + { + var barDir = (string?)barChart.Element(ChartNs + "barDir")?.Attribute("val"); + return barDir == "bar" ? ChartType.Bar : ChartType.Column; + } + + /// + /// Parses a single c:ser element into a validated . + /// + /// The c:ser element to parse. + /// The series' zero-based position within the chart-type element, used for its default name. + /// + /// when the owning chart is a category-based type (categories are + /// reported via the chart's shared category axis instead of per-series point labels); + /// for a value-based type (Pie/Doughnut), whose categories become + /// this series' own . + /// + /// The chart-wide cumulative cached-point budget - see . + /// The parsed, validated . + /// + /// Thrown when the series' c:val has no cached c:numCache values (feature + /// token "charts-openxml-uncached-values). + /// + private static ChartSeries ParseSeries(XElement ser, int index, bool isCategoryBased, CachePointBudget budget) + { + var name = GetTxText(ser.Element(ChartNs + "tx")) ?? $"Series {index + 1}"; + + var numCache = ser.Element(ChartNs + "val")?.Element(ChartNs + "numRef")?.Element(ChartNs + "numCache"); + if (numCache is null) + { + throw new ChartUnsupportedFeatureException( + "charts-openxml-uncached-values", + $"Series '{name}' has no cached c:numCache values; only cached values are supported."); + } + + var values = ParseNumCache(numCache, budget); + + string[]? pointLabels = null; + if (!isCategoryBased) + { + pointLabels = ParseCategoryLabels(ser.Element(ChartNs + "cat"), budget); + } + + return new ChartSeries(name, values, pointLabels: pointLabels); + } + + /// + /// Builds the chart's shared category axis from the first series' c:cat cache and + /// the plot area's c:catAx title, for a category-based . + /// + /// The chart-type element's c:ser children, in order. + /// The c:plotArea element (consulted for c:catAx's title). + /// The chart-wide cumulative cached-point budget - see . + /// + /// A populated from whichever of the first series' categories and + /// the category axis title are present, or when neither is present. + /// + /// + /// models a single, shared category axis for every series, while + /// OOXML attaches a c:cat cache to each individual c:ser; in valid OOXML + /// every series of one chart-type element shares identical categories, so only the first + /// series' c:cat is consulted. A series whose own category count disagrees with + /// another surfaces 's own documented + /// when its mismatched + /// count is checked against this axis's label count. + /// + private static ChartAxis? BuildCategoryAxis(IReadOnlyList seriesElements, XElement plotArea, CachePointBudget budget) + { + var labels = seriesElements.Count > 0 + ? ParseCategoryLabels(seriesElements[0].Element(ChartNs + "cat"), budget) + : null; + var title = GetAxisTitleText(plotArea.Element(ChartNs + "catAx")); + + return labels is null && title is null ? null : new ChartAxis(labels: labels, title: title); + } + + /// + /// Parses the plot area's c:valAx element, when present, into a + /// carrying its title and/or c:scaling/c:majorUnit range. + /// + /// The c:plotArea element. + /// + /// The parsed value axis, or when c:valAx is absent or + /// carries none of a title/minimum/maximum/tick interval. + /// + private static ChartAxis? ParseValueAxis(XElement plotArea) + { + var valAx = plotArea.Element(ChartNs + "valAx"); + if (valAx is null) + { + return null; + } + + var scaling = valAx.Element(ChartNs + "scaling"); + var minimum = ParseNullableFloat(scaling?.Element(ChartNs + "min")?.Attribute("val")); + var maximum = ParseNullableFloat(scaling?.Element(ChartNs + "max")?.Attribute("val")); + var tickInterval = ParseNullableFloat(valAx.Element(ChartNs + "majorUnit")?.Attribute("val")); + var title = GetAxisTitleText(valAx); + + return minimum is null && maximum is null && tickInterval is null && title is null + ? null + : new ChartAxis(minimum: minimum, maximum: maximum, tickInterval: tickInterval, title: title); + } + + /// + /// Parses a c:title (or c:catAx/c:valAx's own identically-shaped + /// c:title) element into its cached display text. + /// + /// The c:title element, or . + /// + /// A new wrapping the cached text, or + /// when is , has no c:tx + /// child, or its cached text is empty/whitespace-only (a styled-but-textless PowerPoint + /// auto-title placeholder - see 's own remarks). + /// + private static ChartTitle? ParseTitle(XElement? titleElement) + { + var text = GetTxText(titleElement?.Element(ChartNs + "tx")); + return text is null ? null : new ChartTitle(text); + } + + /// + /// Parses a c:catAx/c:valAx element's own c:title child into its + /// cached display text, for direct use as a (which, unlike + /// , is a plain rather than a + /// ). + /// + /// The c:catAx/c:valAx element, or . + /// The axis's cached title text, or per 's own rules. + private static string? GetAxisTitleText(XElement? axisElement) => + GetTxText(axisElement?.Element(ChartNs + "title")?.Element(ChartNs + "tx")); + + /// + /// Parses a c:tx element (used by both a series name and a title) into its cached + /// display text, honoring both of CT_Tx's schema-legal shapes: a c:strRef + /// reference with a cached c:strCache, a rich-text c:rich body (every + /// descendant a:t run concatenated), or a bare literal c:v. + /// + /// The c:tx element, or . + /// + /// The cached/literal text, or when is + /// , has none of the three recognized shapes, or its text is + /// empty/whitespace-only. + /// + /// + /// A missing cache here (a c:strRef with a c:f formula but no + /// c:strCache) is treated the same as "no text at all" rather than rejected with + /// : unlike a series' numeric values (whose + /// absence leaves nothing meaningful to plot), a missing name/title cache has a safe, + /// well-defined fallback (the caller-visible default series name, or no title at all). + /// + private static string? GetTxText(XElement? tx) + { + if (tx is null) + { + return null; + } + + var strCache = tx.Element(ChartNs + "strRef")?.Element(ChartNs + "strCache"); + if (strCache is not null) + { + return NormalizeText(GetFirstStrCacheValue(strCache)); + } + + var rich = tx.Element(ChartNs + "rich"); + if (rich is not null) + { + return NormalizeText(string.Concat(rich.Descendants(DrawingNs + "t").Select(e => e.Value))); + } + + return NormalizeText((string?)tx.Element(ChartNs + "v")); + } + + /// + /// Normalizes a candidate text value, converting an empty or whitespace-only string to + /// so callers can use a single null check for "no meaningful text". + /// + /// The candidate text, or . + /// unchanged, or when it is null/empty/whitespace-only. + private static string? NormalizeText(string? text) => string.IsNullOrWhiteSpace(text) ? null : text; + + /// + /// Parses a c:legend element into a . + /// + /// The c:legend element, or . + /// + /// The parsed legend (always visible - c:legend's presence itself is the only + /// visibility signal OOXML's schema carries), or when + /// is . + /// + /// + /// defines only / + /// // + /// /, while + /// OOXML's c:legendPos additionally defines "tr (top-right); "tr + /// is mapped to the nearest defined member, - a + /// documented, inherent limitation of the Phase 1 model's five-value enum, not a defect + /// this parser can losslessly round-trip. A c:legendPos value this parser does not + /// recognize, or an absent c:legendPos element (ECMA-376's own documented default), + /// is likewise mapped to . + /// + private static ChartLegend? ParseLegend(XElement? legendElement) + { + if (legendElement is null) + { + return null; + } + + var positionValue = (string?)legendElement.Element(ChartNs + "legendPos")?.Attribute("val"); + var position = positionValue switch + { + "t" => ChartLegendPosition.Top, + "b" => ChartLegendPosition.Bottom, + "l" => ChartLegendPosition.Left, + "r" => ChartLegendPosition.Right, + "tr" => ChartLegendPosition.Right, + _ => ChartLegendPosition.Right + }; + + return new ChartLegend(position); + } + + /// + /// Parses a c:cat element into its cached category labels, honoring both of + /// CT_AxDataSource's commonly used shapes: a string reference (c:strRef/ + /// c:strCache) or a numeric reference (c:numRef/c:numCache, + /// formatted via ). + /// + /// The c:cat element, or . + /// The chart-wide cumulative cached-point budget - see . + /// + /// The cached category labels, or when is + /// , or uses a schema-legal shape this parser does not recognize + /// (for example c:multiLvlStrRef's multi-level category labels) - this is a + /// deliberate, documented defensive fallback (no categories, rather than a thrown + /// exception) for a chart kind/data shape otherwise fully supported. + /// + private static string[]? ParseCategoryLabels(XElement? cat, CachePointBudget budget) + { + if (cat is null) + { + return null; + } + + var strCache = cat.Element(ChartNs + "strRef")?.Element(ChartNs + "strCache"); + if (strCache is not null) + { + return ParseStrCache(strCache, budget); + } + + var numCache = cat.Element(ChartNs + "numRef")?.Element(ChartNs + "numCache"); + return numCache is not null ? ParseNumCacheAsStrings(numCache, budget) : null; + } + + /// + /// Parses a c:numCache element into a dense array of cached numeric values, sized + /// by its own c:ptCount and honoring sparse c:pt gaps as 0.0. + /// + /// The c:numCache element. + /// The chart-wide cumulative cached-point budget - see . + /// The dense, c:ptCount-sized values array. + /// + /// Thrown when c:ptCount exceeds , or when it + /// would exceed 's remaining . + /// + private static double[] ParseNumCache(XElement numCache, CachePointBudget budget) + { + var ptCount = Math.Max(0, ParseNullableInt(numCache.Element(ChartNs + "ptCount")?.Attribute("val")) ?? 0); + if (ptCount > MaxCachedPointCount) + { + throw new ChartUnsupportedFeatureException( + "charts-openxml-point-count-too-large", + $"c:numCache declares c:ptCount={ptCount}, which exceeds the maximum supported cached point count of {MaxCachedPointCount}."); + } + + budget.Consume(ptCount, "c:numCache"); + + var values = new double[ptCount]; + foreach (var pt in numCache.Elements(ChartNs + "pt")) + { + var idx = ParseNullableInt(pt.Attribute("idx")) ?? -1; + if (idx < 0 || idx >= ptCount) + { + continue; + } + + values[idx] = ParseNullableDouble(pt.Element(ChartNs + "v")) ?? 0d; + } + + return values; + } + + /// + /// Parses a c:strCache element into a dense array of cached string values, sized + /// by its own c:ptCount and honoring sparse c:pt gaps as + /// (a legitimate blank category label - see + /// 's own documented "empty string is a legitimate blank label" + /// convention). + /// + /// The c:strCache element. + /// The chart-wide cumulative cached-point budget - see . + /// The dense, c:ptCount-sized labels array. + /// + /// Thrown when c:ptCount exceeds , or when it + /// would exceed 's remaining . + /// + private static string[] ParseStrCache(XElement strCache, CachePointBudget budget) + { + var ptCount = Math.Max(0, ParseNullableInt(strCache.Element(ChartNs + "ptCount")?.Attribute("val")) ?? 0); + if (ptCount > MaxCachedPointCount) + { + throw new ChartUnsupportedFeatureException( + "charts-openxml-point-count-too-large", + $"c:strCache declares c:ptCount={ptCount}, which exceeds the maximum supported cached point count of {MaxCachedPointCount}."); + } + + budget.Consume(ptCount, "c:strCache"); + + var values = new string[ptCount]; + Array.Fill(values, string.Empty); + foreach (var pt in strCache.Elements(ChartNs + "pt")) + { + var idx = ParseNullableInt(pt.Attribute("idx")) ?? -1; + if (idx < 0 || idx >= ptCount) + { + continue; + } + + values[idx] = (string?)pt.Element(ChartNs + "v") ?? string.Empty; + } + + return values; + } + + /// + /// Parses a c:numCache element (used for a numeric category axis) into a dense + /// array of cached values formatted as invariant-culture strings, for use as category + /// labels. + /// + /// The c:numCache element. + /// The chart-wide cumulative cached-point budget - see . + /// The dense, c:ptCount-sized labels array. + private static string[] ParseNumCacheAsStrings(XElement numCache, CachePointBudget budget) => + [.. ParseNumCache(numCache, budget).Select(static v => v.ToString(CultureInfo.InvariantCulture))]; + + /// + /// Gets a c:strCache element's cached text for index 0 (the conventional + /// single-entry position for a series name/title cache), falling back to the first + /// c:pt present when no idx="0 entry exists. + /// + /// The c:strCache element. + /// The cached text, or when no c:pt entry exists. + private static string? GetFirstStrCacheValue(XElement strCache) + { + var pt = strCache.Elements(ChartNs + "pt").FirstOrDefault(p => ParseNullableInt(p.Attribute("idx")) == 0) + ?? strCache.Elements(ChartNs + "pt").FirstOrDefault(); + return (string?)pt?.Element(ChartNs + "v"); + } + + /// + /// Parses 's value as an , the same way the + /// explicit (int?) cast operator does, but rejects a present-but-malformed + /// (non-numeric or out-of--range) value with + /// instead of letting the cast operator's + /// raw / propagate uncaught - + /// mirroring PptxDocument.Geometry.cs's ParseRequiredFloatAttribute + /// guarded-parse convention, adapted to this parser's own + /// fail-closed idiom (see this type's own + /// remarks for why: PptxDocument.Charts.cs's ParseChart only catches this + /// specific exception type around the parser call). + /// + /// The attribute to parse, or . + /// + /// The parsed value, or when is + /// . + /// + /// + /// Thrown when is present but its value is not a + /// well-formed, in-range . + /// + private static int? ParseNullableInt(XAttribute? attribute) + { + if (attribute is null) + { + return null; + } + + try + { + return (int)attribute; + } + catch (Exception ex) when (ex is FormatException or OverflowException) + { + throw new ChartUnsupportedFeatureException( + "charts-openxml-malformed-numeric-value", + $"Attribute '{attribute.Name}' has a malformed integer value '{attribute.Value}'."); + } + } + + /// + /// Parses 's value as a , the same way the + /// explicit (float?) cast operator does, but rejects a present-but-malformed + /// (non-numeric or out-of--range) value with + /// instead of letting the cast operator's + /// raw / propagate uncaught - + /// see 's own remarks for the full rationale. + /// + /// The attribute to parse, or . + /// + /// The parsed value, or when is + /// . + /// + /// + /// Thrown when is present but its value is not a + /// well-formed, in-range . + /// + private static float? ParseNullableFloat(XAttribute? attribute) + { + if (attribute is null) + { + return null; + } + + try + { + return (float)attribute; + } + catch (Exception ex) when (ex is FormatException or OverflowException) + { + throw new ChartUnsupportedFeatureException( + "charts-openxml-malformed-numeric-value", + $"Attribute '{attribute.Name}' has a malformed numeric value '{attribute.Value}'."); + } + } + + /// + /// Parses 's cached text as a , the same + /// way the explicit (double?) cast operator does, but rejects a present-but- + /// malformed (non-numeric or out-of--range) value with + /// instead of letting the cast operator's + /// raw / propagate uncaught - + /// see 's own remarks for the full rationale. + /// + /// The element to parse (for example a c:v cache entry), or . + /// + /// The parsed value, or when is + /// . + /// + /// + /// Thrown when is present but its cached text is not a + /// well-formed, in-range . + /// + private static double? ParseNullableDouble(XElement? element) + { + if (element is null) + { + return null; + } + + try + { + return (double)element; + } + catch (Exception ex) when (ex is FormatException or OverflowException) + { + throw new ChartUnsupportedFeatureException( + "charts-openxml-malformed-numeric-value", + $"Element '{element.Name}' has a malformed numeric value '{element.Value}'."); + } + } +} diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Color.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Color.cs index 2c5dbb12..995a59d9 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Color.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Color.cs @@ -531,8 +531,10 @@ private PdfColorSpace ResolveColorSpaceValue(PdfObject value) /// /// /// Thrown when does not have exactly 1 or 2 elements (feature - /// pdf-colorspace-Pattern), or propagated from - /// when the base color space is itself unsupported. + /// pdf-colorspace-Pattern); when the resolved base color space is itself a + /// /Pattern color space - nested [/Pattern /Pattern] is not a meaningful PDF + /// construct (feature pdf-colorspace-Pattern-nested); or propagated from + /// when the base color space is itself unsupported. /// private PdfColorSpace ResolvePatternColorSpace(PdfObject array) { @@ -558,6 +560,13 @@ private PdfColorSpace ResolvePatternColorSpace(PdfObject array) try { var baseSpace = ResolveColorSpaceValue(Resolve(array.Items[1])); + if (baseSpace.Kind == PdfColorSpace.Family.Pattern) + { + throw new UnsupportedImageFeatureException( + "pdf-colorspace-Pattern-nested", + "Color space '/Pattern' base must not itself be a '/Pattern' color space."); + } + return PdfColorSpace.Pattern(baseSpace); } finally diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.cs index fa0ed9f6..a9acb726 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.cs @@ -242,6 +242,12 @@ private byte[] ApplyPredictor(byte[] data, PdfObject parms) /// same component's earlier byte in the same row - no per-row filter-type byte is present /// (unlike the PNG predictor forms). /// + /// + /// Thrown when 's length is not an exact multiple of the row + /// stride (rowBytes) - a partial trailing row would otherwise be silently dropped + /// by integer-division truncation rather than rejected, mirroring the equivalent check in + /// . + /// /// /// Thrown when is not 8. /// @@ -256,6 +262,12 @@ private static byte[] ApplyTiffPredictor(byte[] data, int colors, int bitsPerCom var bytesPerPixel = Math.Max(1, colors * bitsPerComponent / 8); var rowBytes = (colors * bitsPerComponent * columns + 7) / 8; + if (rowBytes != 0 && data.Length % rowBytes != 0) + { + throw new InvalidDataException( + $"TIFF predictor data length ({data.Length}) is not a multiple of the row stride ({rowBytes})."); + } + var result = (byte[])data.Clone(); var rowCount = rowBytes == 0 ? 0 : result.Length / rowBytes; for (var row = 0; row < rowCount; row++) diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.FontFallback.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.FontFallback.cs index 3b1f8051..1a11e112 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.FontFallback.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.FontFallback.cs @@ -59,16 +59,24 @@ public sealed partial class PdfDocument /// no meaningful generic-family equivalent; otherwise classifies the font's serif/ /// fixed-pitch/bold/italic flavor (via the Standard-14 table, or else /// /FontDescriptor flags/weight/angle/name heuristics), searches the host - /// operating system's installed fonts via , - /// and falls back to a bundled Liberation Sans/Serif/Mono font - /// () when no system font matches. + /// operating system's installed fonts via + /// (falling back to a style-matched bundled Liberation Sans/Serif/Mono font via + /// when no system font matches), and + /// appends a second, always-present, plain (non-serif, non-fixed-pitch, non-bold, + /// non-italic) bundled Liberation Sans candidate - a per-character glyph-coverage + /// fallback, consulted by 's existing first- + /// non-zero-glyph-wins candidate search only when the primary match's own font actually + /// lacks a given codepoint, mirroring PptxDocument.TextLayout.cs's own + /// per-character fallback for the parallel PPTX text-rendering path. /// /// The font dictionary's /BaseFont name. /// The font dictionary's resolved /FontDescriptor. /// /// The ordered, non-empty list of candidate substitute - /// instances - every non-Symbol/ZapfDingbats case returns a single-element - /// list; see for the multi-element case. + /// instances - every non-Symbol/ZapfDingbats case now returns a two-element + /// list (primary match, then the generic bundled Liberation Sans coverage fallback); see + /// for the (three- or one-element) + /// Symbol/ZapfDingbats case. /// /// /// Thrown when 's /Flags declares Symbolic @@ -99,12 +107,23 @@ private IReadOnlyList ResolveFallbackFont(string baseFontName, Pdf var familyNameHint = StripFontNameDecoration(baseFontName); var match = SystemFontCatalog.FindBestMatch(familyNameHint, bold, italic, serif, fixedPitch); - if (match is { } found) - { - return [LoadFallbackFontFromDisk(found.FilePath, found.FaceIndex)]; - } + var primary = match is { } found + ? LoadFallbackFontFromDisk(found.FilePath, found.FaceIndex) + : SystemFontCatalog.LoadBundledFallback(serif, fixedPitch, bold, italic); - return [SystemFontCatalog.LoadBundledFallback(serif, fixedPitch, bold, italic)]; + // Per-character glyph-coverage fallback: the primary match above (a system font, or the + // style-matched bundled Liberation substitute) is still just one font, picked purely from + // the family/flavor classification above, before any character is inspected - it may lack + // a specific codepoint a wider-coverage bundled font does cover. A plain + // (serif: false, fixedPitch: false, bold: false, italic: false) bundled Liberation Sans is + // appended as a second, always-present candidate; ResolvedSimpleFont.Resolve (see + // PdfDocument.Fonts.cs) already implements the stateless, first-non-zero-glyph-wins search + // across a candidate list this returns to - the same mechanism that already backs the + // Symbol/ZapfDingbats branch above. Listing the primary match first preserves existing + // behavior for every font whose primary match already has full coverage: the bundled + // fallback is only ever consulted on an actual glyph-0 miss. + var genericFallback = SystemFontCatalog.LoadBundledFallback(serif: false, fixedPitch: false, bold: false, italic: false); + return [primary, genericFallback]; } /// diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Images.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Images.cs index 6e01e4eb..a7c262ff 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Images.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Images.cs @@ -452,7 +452,13 @@ private static Rgba32 SamplesToColor(PdfColorSpace colorSpace, byte[] decoded, i /// simplification. A non-invertible (degenerate) silently paints /// nothing, rather than throwing. Per the PDF specification's image-space convention, /// image sample row 0 is the top of the unit square (the opposite of - /// user-space's y-up convention): row = floor((1 - v) * image.Height). + /// user-space's y-up convention): row = floor((1 - v) * image.Height). Each sampled + /// source pixel is alpha-blended "over" the existing destination pixel via + /// (standard Porter-Duff "over" compositing) rather + /// than overwriting it outright, so a source pixel with a non-opaque (including fully + /// transparent) alpha channel lets the existing destination content show through + /// correctly instead of being replaced by whatever RGB value happens to be stored + /// alongside that transparent alpha. /// private void CompositeImageOntoSurface(Surface image, Matrix3x2 ctm) { @@ -491,7 +497,7 @@ private void CompositeImageOntoSurface(Surface image, Matrix3x2 ctm) var column = Math.Clamp((int)Math.Floor(u * image.Width), 0, image.Width - 1); var row = Math.Clamp((int)Math.Floor((1 - v) * image.Height), 0, image.Height - 1); - _surface[x, y] = image[column, row]; + _surface[x, y] = Rgba32.CompositeOver(_surface[x, y], image[column, row]); } } } diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Shading.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Shading.cs index fbec1fb3..966bd23e 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Shading.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Shading.cs @@ -27,8 +27,8 @@ public sealed partial class PdfDocument /// /// The already-resolved /PatternType 2 pattern dictionary. /// - /// Thrown when /Shading, /ColorSpace, /Function, or /Coords is - /// missing or malformed, or propagated from / + /// Thrown when /Shading, /ColorSpace, /Function, /Domain, or + /// /Coords is missing or malformed, or propagated from / /// for their own documented malformed-input /// cases. /// @@ -69,7 +69,16 @@ private ResolvedPattern BuildShadingPattern(PdfObject patternDict) var functionEntry = shading.Get("Function") ?? throw new InvalidDataException("/Shading is missing required /Function."); - var (domain, evaluate) = ResolveFunctionOrFunctionArray(functionEntry); + var (_, evaluate) = ResolveFunctionOrFunctionArray(functionEntry); + + // The shading's own /Domain (PDF 32000-1 §8.7.4.5.3, default [0, 1]) parametrizes + // position along the /Coords geometry; it is distinct from - and must not be confused + // with - the /Function's own /Domain (which only clips/validates the function's input). + var domain = ResolveOptionalNumberArray(shading, "Domain") ?? [0.0, 1.0]; + if (domain.Length != 2) + { + throw new InvalidDataException("/Shading /Domain must have exactly 2 elements."); + } var coords = RequireNumberArray(shading, "Coords"); var expectedCoordCount = shadingType == 2 ? 4 : 6; diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Tiling.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Tiling.cs index 9555e60e..7e79b4c5 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Tiling.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Tiling.cs @@ -107,7 +107,9 @@ private ResolvedPattern BuildTilingPattern(PdfObject patternStream) /// by the existing ), and calling /// against the pattern's own decoded content bytes - /// structurally identical to , restoring every swapped - /// field in a finally block. + /// field in a finally block. If cell execution or recoloring throws, the + /// already-allocated tileSurface is disposed before the exception propagates (no + /// is ever constructed on that path, so nothing else owns it). /// /// /// Thrown when the current nesting depth already equals , @@ -142,64 +144,72 @@ private TilePaint RenderTilingPatternCell(ResolvedPattern pattern, Matrix3x2 pat var tileSurface = new Surface(tileWidth, tileHeight); - // Maps pattern-space BBox-local coordinates (the same coordinates the pattern's own - // content stream draws in) onto the tile surface's own pixel space: translate the BBox's - // lower-left corner to the origin, then scale by the tile surface's pixels-per-pattern- - // space-unit ratio - independently in X/Y, matching the independent X/Y tile sizing - // above. Deliberately never flips Y (unlike the page's own baseCtm): TilePaintEvaluator's - // own sampling math (wrappedY -> ty) performs no flip either, so both sides of the - // pattern-space <-> tile-pixel-space mapping must agree on the same (unflipped) - // convention. - var cellTransform = - Matrix3x2.CreateTranslation(-(float)pattern.BBox![0], -(float)pattern.BBox[1]) * - Matrix3x2.CreateScale(tileWidth / xStepAbs, tileHeight / yStepAbs); - - var savedSurface = _surface; - var savedResources = _resources; - var savedGs = _gs; - var savedGsStack = _gsStack; - var savedPathBuilder = _pathBuilder; - var savedCurrentPoint = _currentPoint; - var savedSubpathStart = _subpathStart; - var savedHasOpenSubpath = _hasOpenSubpath; - _formNestingDepth++; try { - _surface = tileSurface; - _resources = pattern.Resources; - _gs = new GraphicsState { CurrentTransform = cellTransform }; - _gsStack = new Stack(); - - // The outer path-painting operator ('f'/'S'/etc.) that led here may already have - // called _pathBuilder.Build() without yet calling _pathBuilder.Clear() (that clear - // only happens after PaintCurrentPath's fill/stroke branches return) - so, unlike - // OpDrawFormXObject (which is only ever invoked via 'Do', never mid-path-paint), this - // nested ExecuteOperators call must not reuse the live outer path-builder state: a - // fresh one is substituted for the duration of the tile's own content stream, and the - // original is restored afterward, so the tile's own 're'/'m'/'l'/... operators cannot - // corrupt the host's own in-flight path. - _pathBuilder = new PathBuilder(); - _currentPoint = default; - _subpathStart = default; - _hasOpenSubpath = false; - ExecuteOperators(pattern.ContentBytes!); - } - finally - { - _formNestingDepth--; - _surface = savedSurface; - _resources = savedResources; - _gs = savedGs; - _gsStack = savedGsStack; - _pathBuilder = savedPathBuilder; - _currentPoint = savedCurrentPoint; - _subpathStart = savedSubpathStart; - _hasOpenSubpath = savedHasOpenSubpath; - } + // Maps pattern-space BBox-local coordinates (the same coordinates the pattern's own + // content stream draws in) onto the tile surface's own pixel space: translate the BBox's + // lower-left corner to the origin, then scale by the tile surface's pixels-per-pattern- + // space-unit ratio - independently in X/Y, matching the independent X/Y tile sizing + // above. Deliberately never flips Y (unlike the page's own baseCtm): TilePaintEvaluator's + // own sampling math (wrappedY -> ty) performs no flip either, so both sides of the + // pattern-space <-> tile-pixel-space mapping must agree on the same (unflipped) + // convention. + var cellTransform = + Matrix3x2.CreateTranslation(-(float)pattern.BBox![0], -(float)pattern.BBox[1]) * + Matrix3x2.CreateScale(tileWidth / xStepAbs, tileHeight / yStepAbs); + + var savedSurface = _surface; + var savedResources = _resources; + var savedGs = _gs; + var savedGsStack = _gsStack; + var savedPathBuilder = _pathBuilder; + var savedCurrentPoint = _currentPoint; + var savedSubpathStart = _subpathStart; + var savedHasOpenSubpath = _hasOpenSubpath; + _formNestingDepth++; + try + { + _surface = tileSurface; + _resources = pattern.Resources; + _gs = new GraphicsState { CurrentTransform = cellTransform }; + _gsStack = new Stack(); + + // The outer path-painting operator ('f'/'S'/etc.) that led here may already have + // called _pathBuilder.Build() without yet calling _pathBuilder.Clear() (that clear + // only happens after PaintCurrentPath's fill/stroke branches return) - so, unlike + // OpDrawFormXObject (which is only ever invoked via 'Do', never mid-path-paint), this + // nested ExecuteOperators call must not reuse the live outer path-builder state: a + // fresh one is substituted for the duration of the tile's own content stream, and the + // original is restored afterward, so the tile's own 're'/'m'/'l'/... operators cannot + // corrupt the host's own in-flight path. + _pathBuilder = new PathBuilder(); + _currentPoint = default; + _subpathStart = default; + _hasOpenSubpath = false; + ExecuteOperators(pattern.ContentBytes!); + } + finally + { + _formNestingDepth--; + _surface = savedSurface; + _resources = savedResources; + _gs = savedGs; + _gsStack = savedGsStack; + _pathBuilder = savedPathBuilder; + _currentPoint = savedCurrentPoint; + _subpathStart = savedSubpathStart; + _hasOpenSubpath = savedHasOpenSubpath; + } - if (pattern.PaintType == 2 && tint is { } tintColor) + if (pattern.PaintType == 2 && tint is { } tintColor) + { + RecolorTileSurface(tileSurface, tintColor); + } + } + catch { - RecolorTileSurface(tileSurface, tintColor); + tileSurface.Dispose(); + throw; } return new TilePaint(tileSurface, patternToDevice, pattern.XStep, pattern.YStep); diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.cs index cd7cb4dc..1796f411 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.cs @@ -146,6 +146,16 @@ namespace DemaConsulting.CanvasNet.Pdf; /// fail closed with , each with its own /// distinguishable token. /// +/// +/// Thread safety: a instance is not +/// thread-safe. and every other +/// public member lazily populate and read shared per-instance caches (parsed pages, +/// fonts, and resolved object-stream entries) without synchronization, so calling any of +/// them concurrently from multiple threads on the same instance is unsupported +/// and may corrupt those caches or throw. Open one instance per +/// thread when concurrent access is needed, or otherwise synchronize all calls to a +/// single shared instance externally. +/// /// public sealed partial class PdfDocument : IDisposable { diff --git a/src/DemaConsulting.CanvasNet.Pptx/DemaConsulting.CanvasNet.Pptx.csproj b/src/DemaConsulting.CanvasNet.Pptx/DemaConsulting.CanvasNet.Pptx.csproj new file mode 100644 index 00000000..be788eb7 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/DemaConsulting.CanvasNet.Pptx.csproj @@ -0,0 +1,83 @@ + + + + net8.0;net9.0;net10.0 + latest + enable + enable + + + DemaConsulting.CanvasNet.Pptx + 0.0.0 + DEMA Consulting + DEMA Consulting + DemaConsulting.CanvasNet.Pptx provides a decode/rasterize-only PowerPoint (.pptx) presentation-rendering codec for the CanvasNet canvas-based drawing and rendering API. + MIT + https://github.com/demaconsulting/CanvasNet + https://github.com/demaconsulting/CanvasNet + README.md + Icon.png + canvas;dotnet-library;pptx + Copyright DEMA Consulting + CanvasNet.Pptx + DemaConsulting.CanvasNet.Pptx + + + true + snupkg + true + true + true + + + true + true + true + true + latest + + + true + $(PackageId) + $(Version) + Organization: $(Company) + + + true + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + diff --git a/src/DemaConsulting.CanvasNet.Pptx/NamespaceDoc.cs b/src/DemaConsulting.CanvasNet.Pptx/NamespaceDoc.cs new file mode 100644 index 00000000..62e28822 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/NamespaceDoc.cs @@ -0,0 +1,53 @@ +namespace DemaConsulting.CanvasNet.Pptx; + +// cspell:ignore rasterizing rasterize pptx ooxml patt + +/// +/// The namespace provides read access to +/// PowerPoint (.pptx) presentation documents via : the OOXML +/// (Office Open XML) package layer, the presentation/theme/master/layout/slide structural +/// model, DrawingML shape geometry and paint resolution, DrawingML text layout and rendering, +/// picture/table/recursive-shape-tree resolution, and a public, per-slide rendering API. This +/// namespace is distributed as the separate DemaConsulting.CanvasNet.Pptx NuGet +/// package, which references the core DemaConsulting.CanvasNet package. +/// +/// +/// +/// This feature has been delivered incrementally across several phases, each additive to +/// the last: Phase 1a (the underlying OOXML package layer - opening a .pptx file +/// as a ZIP archive, resolving [Content_Types].xml, and resolving package- and +/// part-level relationships); Phase 1b (parsing ppt/presentation.xml's slide size +/// and slide list; theme color/font scheme parsing; slide master/layout/slide structural +/// models; and a placeholder property-inheritance resolver); Phase 1c (DrawingML shape +/// geometry - position/rotation/flip transforms, group child-coordinate-space composition, +/// preset and custom geometry resolution - and paint resolution - solid/gradient fills and +/// line styles - into the core DemaConsulting.CanvasNet.Drawing/ +/// DemaConsulting.CanvasNet.Canvas types); Phase 1d (DrawingML text structural +/// parsing, an attribute-level run/paragraph property-inheritance resolver, word-wrap/ +/// alignment/vertical-anchor/autofit text layout, and glyph-ink text rendering reusing the +/// core / +/// infrastructure); Phase 1e (<p:pic> picture-shape decoding/cropping/ +/// compositing, <a:tbl> table structure/cell-rect/paint resolution, and +/// recursive, full <p:spTree> shape-tree parsing - including nested +/// <p:grpSp> enumeration - exposed via the internal shape-tree model); and +/// Phase 1f (the public, slide-level / +/// rendering API, a +/// document-order walk of a slide's full shape tree dispatching each node to the already- +/// verified Phase 1c/1d/1e resolvers and painters). Subsequent Phase 2 Follow-Ups added +/// slide/layout/master <p:bg> background-fill resolution ahead of that +/// shape-tree walk, <a:buChar>/<a:buAutoNum> bullet/numbering +/// rendering, and <p:cxnSp> connector-shape rendering. +/// +/// +/// As of the current release (through Phase 1f and its subsequent Phase 2 Follow-Ups), +/// slide/layout/master pattern and picture background fills are now resolved and painted +/// (see 's own +/// remarks for the exact fidelity achieved). Radial/path gradients, full text +/// justification, spAutoFit shape-resize behavior, kerning, text clipping on +/// overflow, nested tables, and table auto-sizing/banding remain explicitly deferred - +/// see 's own remarks for the exact, current scope boundary. +/// +/// +internal static class NamespaceDoc +{ +} diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxArrowheadGeometry.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxArrowheadGeometry.cs new file mode 100644 index 00000000..1f9663fd --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxArrowheadGeometry.cs @@ -0,0 +1,140 @@ +using System.Numerics; +using DemaConsulting.CanvasNet.Geometry; +using Path = DemaConsulting.CanvasNet.Geometry.Path; + +namespace DemaConsulting.CanvasNet.Pptx; + +// cspell:ignore pptx stealth + +/// +/// Builds the local-space geometry for a single <a:headEnd>/ +/// <a:tailEnd> arrowhead (Phase 2 Follow-Up: Connector Shape Rendering), following +/// 's own "single, fixed, documented proportion" approximation +/// philosophy - OOXML does not specify exact arrowhead proportions for its sm/med/ +/// lg w/len size keys, only that they are relative to each other. +/// +/// +/// +/// Every builder below sizes its output relative to lineWidthEmu (the connector's own +/// resolved stroke width - see ): an +/// arrowhead's w/len keys are always proportions of its own line's thickness, +/// not absolute sizes, consistent with how PowerPoint itself scales an arrowhead as its line +/// gets thicker or thinner. +/// +/// +/// Local coordinate space. Every builder below places the arrowhead's own tip at the +/// local origin (0,0), pointing along local +x (i.e. the arrowhead's own body +/// extends toward local -x, "behind" the tip) - a caller orients/positions it by +/// composing a rotation (aligning local +x with the connector's own tangent direction +/// at that endpoint) and a translation (to that endpoint's own position), exactly like +/// 's own "local box, caller transforms it into place" +/// convention. +/// +/// +internal static class PptxArrowheadGeometry +{ + /// + /// The fixed, documented base proportion (relative to the line's own width) an arrowhead's + /// half-width is built at when its own w key is "med" (the schema default) - + /// chosen so a "med" arrowhead reads clearly against its own line without + /// overwhelming a thin stroke, consistent with typical PowerPoint proportions. + /// + private const float BaseHalfWidthFactor = 1.5f; + + /// + /// The fixed, documented base proportion (relative to the line's own width) an arrowhead's + /// own length is built at when its own len key is "med" - slightly longer + /// than it is wide, matching a typical triangular PowerPoint arrowhead's own aspect ratio. + /// + private const float BaseLengthFactor = 3.6f; + + /// + /// Builds 's own local-space , sized relative to + /// (see this class's own remarks). + /// + /// The resolved arrowhead style to build. + /// The connector line's own resolved stroke width, in EMU. + /// + /// The built local-space (see this class's own remarks for its + /// coordinate convention), or when + /// is non-positive (an arrowhead has no meaningful size to scale from). + /// // + /// / each return + /// a closed, fillable path; returns a small, open, + /// two-segment path meant to be stroked, not filled - see + /// 's own dispatch. + /// + internal static Path Build(PptxArrowheadStyle style, float lineWidthEmu) + { + if (lineWidthEmu <= 0f) + { + return Path.Empty; + } + + var halfWidth = lineWidthEmu * BaseHalfWidthFactor * SizeScale(style.WidthKey); + var length = lineWidthEmu * BaseLengthFactor * SizeScale(style.LengthKey); + + return style.Kind switch + { + PptxArrowheadKind.Triangle => Polygon( + Vector2.Zero, + new Vector2(-length, -halfWidth), + new Vector2(-length, halfWidth)), + + PptxArrowheadKind.Stealth => Polygon( + Vector2.Zero, + new Vector2(-length, -halfWidth), + new Vector2(-length * 0.6f, 0f), + new Vector2(-length, halfWidth)), + + PptxArrowheadKind.Diamond => Polygon( + new Vector2(length / 2f, 0f), + new Vector2(0f, -halfWidth), + new Vector2(-length / 2f, 0f), + new Vector2(0f, halfWidth)), + + PptxArrowheadKind.Oval => PptxPresetGeometry.Ellipse(0f, 0f, length / 2f, halfWidth), + + PptxArrowheadKind.Arrow => OpenChevron(length, halfWidth), + + _ => Path.Empty, + }; + } + + /// + /// Maps an arrowhead size key ("sm"/"med"/"lg") to a fixed, documented + /// scale factor relative to / + /// - an unrecognized or absent key resolves to the same scale as "med" (the OOXML + /// schema default), matching 's own fallback. + /// + private static float SizeScale(string key) => key switch + { + "sm" => 0.75f, + "lg" => 1.5f, + _ => 1f, + }; + + /// Builds a closed polygon from the given vertices, in order (used by every filled arrowhead kind). + private static Path Polygon(params Vector2[] points) + { + var builder = new PathBuilder().MoveTo(points[0]); + for (var i = 1; i < points.Length; i++) + { + builder.LineTo(points[i]); + } + + return builder.Close().Build(); + } + + /// + /// Builds the open, two-segment "chevron" shape for : + /// a "V" formed by the two line segments meeting at the tip - stroked, not filled, by the + /// caller (see 's own return-value remarks). + /// + private static Path OpenChevron(float length, float halfWidth) => + new PathBuilder() + .MoveTo(new Vector2(-length, -halfWidth)) + .LineTo(Vector2.Zero) + .LineTo(new Vector2(-length, halfWidth)) + .Build(); +} diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxArrowheadStyle.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxArrowheadStyle.cs new file mode 100644 index 00000000..553ae5b7 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxArrowheadStyle.cs @@ -0,0 +1,45 @@ +namespace DemaConsulting.CanvasNet.Pptx; + +// cspell:ignore pptx stealth + +/// +/// The recognized <a:headEnd>/<a:tailEnd> arrowhead type +/// values this phase supports (Phase 2 Follow-Up: Connector Shape Rendering) - see +/// for how each kind's geometry is built. +/// +internal enum PptxArrowheadKind +{ + /// No arrowhead - the schema default, and the result of an unrecognized type value. + None, + + /// A solid, closed, filled triangle - the most common PowerPoint connector arrowhead. + Triangle, + + /// A solid, closed, filled triangle with a concave ("notched") back edge. + Stealth, + + /// A solid, closed, filled diamond (rhombus), centered on the line's own endpoint. + Diamond, + + /// A solid, closed, filled oval (ellipse), centered on the line's own endpoint. + Oval, + + /// An open, unfilled "chevron" - two line segments meeting at the tip, stroked rather than filled. + Arrow, +} + +/// +/// A resolved <a:headEnd>/<a:tailEnd> arrowhead style (Phase 2 +/// Follow-Up: Connector Shape Rendering): its kind, and its w/len size keys - +/// everything needs to build the arrowhead's own +/// local-space geometry. +/// +/// The arrowhead's recognized kind - never (see , which returns instead for that case). +/// +/// The arrowhead's own w attribute value ("sm"/"med"/"lg"), or +/// "med" when omitted - the OOXML schema default. See +/// for the fixed, documented size-key-to-proportion +/// mapping this phase uses. +/// +/// The arrowhead's own len attribute value, resolved the same way as . +internal sealed record PptxArrowheadStyle(PptxArrowheadKind Kind, string WidthKey, string LengthKey); diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Background.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Background.cs new file mode 100644 index 00000000..068ae25f --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Background.cs @@ -0,0 +1,224 @@ +using System.Globalization; +using System.Xml.Linq; +using DemaConsulting.CanvasNet.Canvas; + +namespace DemaConsulting.CanvasNet.Pptx; + +// cspell:ignore bgpr bgref pptx phclr patt + +/// +/// Implements the slide/layout/master background-fill resolver +/// (Phase 2 Follow-Up): resolves a slide's own <p:cSld>/<p:bg> background +/// fill - falling back to its layout's, then its master's, own <p:bg> when the +/// slide declares none - into a concrete , reusing the existing Phase +/// 1c / pipeline verbatim rather than +/// re-implementing fill/color resolution for backgrounds - see pptx-document.md's +/// "Phase 2 Follow-Up: Slide/Layout/Master Background Fill (<p:bg>)" design section for +/// the full resolution algorithm and the exact fidelity/deferred boundary. +/// +public sealed partial class PptxDocument +{ + /// + /// A <p:bgRef idx="..."/> idx value of 0 or 1000 means + /// "no background fill" (per ECMA-376's CT_StyleMatrixReference style-matrix-index + /// convention). + /// + private const uint BgRefNoFillIdxLow = 0; + + /// See 's remarks. + private const uint BgRefNoFillIdxHigh = 1000; + + /// + /// A <p:bgRef idx="..."/> idx value of 1001 or above indexes into the + /// theme's <a:fmtScheme>/<a:bgFillStyleLst>, 0-based from this offset + /// (idx 1001 is 's first entry). + /// + private const uint BgFillStyleListIdxOffset = 1001; + + /// + /// Resolves the first-present-wins slide -> layout -> master <p:bg> + /// background fill into a concrete , matching the Phase 1b + /// placeholder-inheritance "first element present at all wins" precedent exactly (see + /// 's remarks) - an empty-but-present + /// <p:bg/> still wins over a lower-priority tier's own <p:bg>. + /// + /// The slide's own <p:cSld>/<p:bg> element, or when absent. + /// The slide's layout's own <p:bg> element, or when absent. + /// That layout's master's own <p:bg> element, or when absent. + /// The resolved theme, used to resolve any <a:schemeClr> and, for a <p:bgRef>, its . + /// The slide's own declared width, in EMU (needed to position a gradient background fill). + /// The slide's own declared height, in EMU (needed to position a gradient background fill). + /// + /// The effective color map consulted when the resolved background declares an + /// <a:schemeClr val="bg1"/>-shaped token, or (the + /// default) - see 's matching parameter. + /// + /// + /// Passed through to only when + /// is the tier that wins (the slide's own <p:bg> declares an + /// <a:blipFill>) - a blip's r:embed relationship is scoped to its own + /// owning part (the slide part, in this case), so each tier needs its own resolver bound + /// to its own part's path. See 's matching parameter. + /// + /// + /// The layout-tier counterpart of , passed through + /// only when is the tier that wins. + /// + /// + /// The master-tier counterpart of , passed through + /// only when is the tier that wins. + /// + /// + /// The resolved , or when none of + /// // + /// declare a <p:bg> at all - the caller + /// falls back to in that case (see + /// ). + /// + /// + /// Thrown when the resolved <p:bg> declares neither <p:bgPr> nor + /// <p:bgRef>, when a <p:bgRef>'s idx attribute is missing + /// or not a valid non-negative integer, or when a <p:bgRef idx> of 1001 or + /// above indexes past the end of (or into an empty) - + /// propagated unchanged from / for any + /// other malformed DrawingML construct. + /// + /// + /// Thrown when a <p:bgRef idx> is in the 1-999 range (the rare, + /// not-used-for-backgrounds-in-practice <a:fillStyleLst> half of the style + /// matrix - feature token "pptx-bg-fill-style-ref"), or propagated unchanged from + /// (a pattern fill, or a radial/path gradient). + /// + internal static PptxPaint? ResolveSlideBackgroundFill( + XElement? slideBackground, + XElement? layoutBackground, + XElement? masterBackground, + PptxTheme theme, + float widthEmu, + float heightEmu, + PptxColorMap? colorMap = null, + Func? slideResolveBlipImage = null, + Func? layoutResolveBlipImage = null, + Func? masterResolveBlipImage = null) + { + var background = slideBackground ?? layoutBackground ?? masterBackground; + if (background is null) + { + return null; + } + + Func? resolveBlipImage; + if (ReferenceEquals(background, slideBackground)) + { + resolveBlipImage = slideResolveBlipImage; + } + else if (ReferenceEquals(background, layoutBackground)) + { + resolveBlipImage = layoutResolveBlipImage; + } + else + { + resolveBlipImage = masterResolveBlipImage; + } + + return ResolveBackgroundElement(background, theme, widthEmu, heightEmu, colorMap, resolveBlipImage); + } + + /// + /// Dispatches a single <p:bg> element's <p:bgPr> (an explicit + /// fill, resolved via the existing ) or <p:bgRef> + /// (a theme format-scheme style reference, resolved via + /// ). + /// + /// Thrown when neither child is present. + private static PptxPaint ResolveBackgroundElement( + XElement backgroundElement, PptxTheme theme, float widthEmu, float heightEmu, PptxColorMap? colorMap = null, + Func? resolveBlipImage = null) + { + var bgPr = backgroundElement.Element(PresentationNamespace + "bgPr"); + if (bgPr is not null) + { + // directly contains its fill-definition child (// + // //), same as - ResolveFill's existing + // child-dispatch logic applies unchanged. + return ResolveFill(bgPr, theme, widthEmu, heightEmu, colorMap: colorMap, resolveBlipImage: resolveBlipImage); + } + + var bgRef = backgroundElement.Element(PresentationNamespace + "bgRef"); + if (bgRef is not null) + { + return ResolveBackgroundStyleReference(bgRef, theme, widthEmu, heightEmu, colorMap, resolveBlipImage); + } + + throw new InvalidDataException("A element has neither nor ."); + } + + /// + /// Resolves a <p:bgRef idx="..."/> theme-indexed background reference: maps + /// idx to the theme's (per ECMA-376's + /// CT_StyleMatrixReference style-matrix-index convention - 0/1000 + /// mean "no background"; 1-999 index the rare, out-of-scope + /// <a:fillStyleLst>; 1001 and above index + /// <a:bgFillStyleLst>, 0-based from that offset), substituting + /// <p:bgRef>'s own single color-definition child (resolved via the existing + /// , with no override of its own - this is the base phClr + /// value itself) for every <a:schemeClr val="phClr"/> token the matched + /// style-list entry declares. + /// + /// + /// Thrown when idx is missing or not a valid non-negative integer, or when an + /// idx of 1001 or above indexes past the end of (or into an empty) + /// . + /// + /// + /// Thrown when idx is in the 1-999 range (feature token + /// "pptx-bg-fill-style-ref"). + /// + private static PptxPaint ResolveBackgroundStyleReference( + XElement bgRefElement, PptxTheme theme, float widthEmu, float heightEmu, PptxColorMap? colorMap = null, + Func? resolveBlipImage = null) + { + var idxValue = (string?)bgRefElement.Attribute("idx") ?? + throw new InvalidDataException("A element has no 'idx' attribute."); + if (!uint.TryParse(idxValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var idx)) + { + throw new InvalidDataException($"A element has a non-numeric 'idx' attribute value '{idxValue}'."); + } + + if (idx is BgRefNoFillIdxLow or BgRefNoFillIdxHigh) + { + return PptxNoFill.Instance; + } + + if (idx < BgFillStyleListIdxOffset) + { + throw new PptxUnsupportedFeatureException( + "pptx-bg-fill-style-ref", + $"A references the theme's (idx 1-999), which is not supported for slide backgrounds."); + } + + var listIndex = idx - BgFillStyleListIdxOffset; + if (listIndex >= theme.BgFillStyleList.Count) + { + throw new InvalidDataException( + $"A indexes past the end of the theme's (which has {theme.BgFillStyleList.Count} entries)."); + } + + var styleEntry = theme.BgFillStyleList[(int)listIndex]; + + // 's own single color-definition child (when present) supplies the concrete + // phClr substitution value for the matched style-list entry's own token(s) - resolved with no override of its own, since this color IS the + // base phClr value, not itself parameterized by a further override. + var colorElement = bgRefElement.Elements().FirstOrDefault(); + var phClrOverride = colorElement is null ? (Rgba32?)null : ResolveColor(colorElement, theme, colorMap: colorMap); + + // theme.BgFillStyleList's entries are themselves fill-definition elements (e.g. + // ), not a parent containing one - wrap in a synthetic parent so + // ResolveFill's existing "look up a direct named child" dispatch logic applies unchanged + // (adding an XElement that already has a parent clones it, leaving the theme's own tree + // untouched). + var syntheticFillParent = new XElement("pptxSyntheticBgFillParent", styleEntry); + return ResolveFill(syntheticFillParent, theme, widthEmu, heightEmu, phClrOverride, colorMap, resolveBlipImage); + } +} diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Bullets.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Bullets.cs new file mode 100644 index 00000000..a2f85efc --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Bullets.cs @@ -0,0 +1,125 @@ +using System.Globalization; +using System.Text; + +namespace DemaConsulting.CanvasNet.Pptx; + +/// +/// Implements the <a:buAutoNum> rendered-string +/// formatter (Phase 2 Follow-Up: Bullets and Numbering). +/// +/// +/// +/// The OOXML schema defines a much larger ST_TextAutonumberScheme enumeration (for +/// example double-byte/Asian/Thai/Hindi numbering schemes) than this phase supports. This +/// deliberately supports only the eleven Latin-numeral variants listed below - the +/// overwhelmingly common case in real-world decks - and fails gracefully, per-bullet, for +/// every other (recognized-but-unsupported) type value: +/// returns , and the caller (PptxDocument.TextLayout.cs's +/// BuildLines) simply paints no glyph for that one paragraph while the paragraph's +/// text itself still renders normally and the auto-number counter state still advances - +/// this mirrors the broader codebase's established convention for optional, nullable- +/// returning resolvers (see PptxDocument.TextInheritance.cs's GetFontSizeEmu/ +/// ParseLineSpacing et al.), rather than the separate +/// convention used for malformed/unsupported +/// constructs that abort an entire render. +/// +/// +public sealed partial class PptxDocument +{ + /// + /// Formats a 1-based auto-number counter value into its rendered bullet string for a + /// <a:buAutoNum type="..."/> scheme, or for an + /// unrecognized/unsupported (see this class's remarks). + /// + /// The 1-based counter value (see PptxDocument.TextLayout.cs's auto-number counter state machine). + /// The <a:buAutoNum type="..."/> scheme name, for example "arabicPeriod". + /// The rendered bullet string, or for an unsupported . + internal static string? FormatAutoNumber(int value, string type) => + type switch + { + "arabicPeriod" => value.ToString(CultureInfo.InvariantCulture) + ".", + "arabicParenR" => value.ToString(CultureInfo.InvariantCulture) + ")", + "arabicPlain" => value.ToString(CultureInfo.InvariantCulture), + "alphaLcPeriod" => FormatAlpha(value, upper: false) + ".", + "alphaUcPeriod" => FormatAlpha(value, upper: true) + ".", + "alphaLcParenR" => FormatAlpha(value, upper: false) + ")", + "alphaUcParenR" => FormatAlpha(value, upper: true) + ")", + "romanLcPeriod" => FormatRoman(value, upper: false) + ".", + "romanUcPeriod" => FormatRoman(value, upper: true) + ".", + "romanLcParenR" => FormatRoman(value, upper: false) + ")", + "romanUcParenR" => FormatRoman(value, upper: true) + ")", + _ => null, + }; + + /// + /// Formats a 1-based counter value as a base-26 "spreadsheet column"-style alphabetic + /// sequence (a, b, ..., z, aa, ab, ...), matching PowerPoint's own + /// alphaLc*/alphaUc* auto-number rendering for values beyond 26. + /// + private static string FormatAlpha(int value, bool upper) + { + if (value < 1) + { + value = 1; + } + + var builder = new StringBuilder(); + var remaining = value; + while (remaining > 0) + { + remaining--; + var digit = (char)('a' + (remaining % 26)); + builder.Insert(0, digit); + remaining /= 26; + } + + var result = builder.ToString(); + return upper ? result.ToUpperInvariant() : result; + } + + /// + /// Formats a 1-based counter value as a standard subtractive-notation Roman numeral, + /// matching PowerPoint's own romanLc*/romanUc* auto-number rendering. No + /// upper value cap is applied - large values naturally produce a long run of repeated + /// "M" thousands, mirroring the unbounded nature of a real auto-numbered list. + /// + private static string FormatRoman(int value, bool upper) + { + if (value < 1) + { + value = 1; + } + + var builder = new StringBuilder(); + var remaining = value; + foreach (var (romanValue, symbol) in RomanNumerals) + { + while (remaining >= romanValue) + { + builder.Append(symbol); + remaining -= romanValue; + } + } + + var result = builder.ToString(); + return upper ? result : result.ToLowerInvariant(); + } + + /// The standard subtractive-notation Roman numeral value/symbol table, in descending order. + private static readonly (int Value, string Symbol)[] RomanNumerals = + [ + (1000, "M"), + (900, "CM"), + (500, "D"), + (400, "CD"), + (100, "C"), + (90, "XC"), + (50, "L"), + (40, "XL"), + (10, "X"), + (9, "IX"), + (5, "V"), + (4, "IV"), + (1, "I"), + ]; +} diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Charts.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Charts.cs new file mode 100644 index 00000000..0da43412 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Charts.cs @@ -0,0 +1,98 @@ +using System.Xml.Linq; +using DemaConsulting.CanvasNet.Charts; +using DemaConsulting.CanvasNet.Charts.OpenXml; + +namespace DemaConsulting.CanvasNet.Pptx; + +// cspell:ignore graphicframe chartspace + +/// +/// Implements the chart-graphic-frame resolver (Phase 4): +/// locating a <p:graphicFrame>'s <a:graphicData>/<c:chart +/// r:id="..."/> relationship reference, resolving and loading the referenced +/// chart#.xml OPC part, and parsing it into a via +/// - the integration point that lets +/// DemaConsulting.CanvasNet.Pptx render a <p:graphicFrame> chart instead +/// of throwing (feature token +/// "pptx-graphic-frame-kind") the way every earlier phase did. See +/// pptx-document.md's "Chart Graphic Frames (Phase 4)" design section for the full +/// rationale and canvas-net-charts.md's "Dependencies" section for the (Charts-never- +/// references-Pptx) dependency-direction constraint this integration must never violate. +/// +public sealed partial class PptxDocument +{ + /// The DrawingML-Charts XML namespace (prefix c: in a real chart#.xml part). + private static readonly XNamespace ChartNamespace = "http://schemas.openxmlformats.org/drawingml/2006/chart"; + + /// + /// Resolves a <p:graphicFrame>'s declared <a:graphicData>/ + /// <c:chart> chart into a validated . + /// + /// + /// The <a:graphicData> element whose uri attribute ends in + /// "/chart" (already dispatched by - this method does + /// not itself re-check 's uri). + /// + /// + /// Resolves a chart relationship id (the <c:chart> element's own + /// r:id attribute, scoped to the owning slide/layout/master part's own + /// .rels file) to that referenced part's root (a + /// c:chartSpace root) - typically + /// id => LoadPartXmlRoot(ResolveRelationship(ownerPartPath, id)), bound to the + /// owning part's own path by each of // + /// . + /// + /// The parsed, validated . + /// + /// Thrown when has no <c:chart> child, or that + /// child has no r:id attribute - a malformed chart graphic frame, distinct from a + /// recognized-but-unsupported chart kind (see + /// below). Also thrown (wrapping the original ) when the + /// referenced chart part's own data is otherwise malformed in a way + /// only detects by delegating to the + /// // model + /// constructors (for example a cached point count that does not match its series' point + /// count) - every malformed chart part must surface as , + /// never as a model-constructor escaping unwrapped. + /// + /// + /// Thrown (wrapping a thrown by + /// ) with feature token + /// "pptx-chart-" + ex.Feature (for example "pptx-chart-charts-openxml-radar-chart" + /// for a radar chart) when the referenced chart part declares a recognized-but-unsupported + /// chart kind or data shape - see 's own remarks for the + /// complete supported/deferred boundary this phase inherits unchanged from + /// DemaConsulting.CanvasNet.Charts. + /// + internal static Chart ParseChart(XElement graphicData, Func resolveChartPart) + { + var chartElement = graphicData.Element(ChartNamespace + "chart") ?? + throw new InvalidDataException("An chart element has no child."); + + var relationshipId = (string?)chartElement.Attribute(RelationshipRefNamespace + "id") ?? + throw new InvalidDataException("A element has no 'r:id' attribute."); + + var chartPartRoot = resolveChartPart(relationshipId); + + try + { + return OpenXmlChartParser.Parse(chartPartRoot); + } + catch (ChartUnsupportedFeatureException ex) + { + throw new PptxUnsupportedFeatureException( + "pptx-chart-" + ex.Feature, + $"Chart feature '{ex.Feature}' is not supported.", + ex); + } + catch (ArgumentException ex) + { + // OpenXmlChartParser.Parse delegates cache/count/range validation to the Chart/ + // ChartSeries/ChartAxis constructors, which throw ArgumentException for malformed + // chart data (for example a cached point count that disagrees with its series' + // actual point count). The Pptx requirement promises InvalidDataException for any + // malformed chart part, so rewrap rather than letting this escape as-is. + throw new InvalidDataException($"Chart part referenced by relationship '{relationshipId}' has malformed data.", ex); + } + } +} diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Connectors.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Connectors.cs new file mode 100644 index 00000000..a2aa8025 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Connectors.cs @@ -0,0 +1,195 @@ +using System.Numerics; +using System.Xml.Linq; +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Geometry; +using Path = DemaConsulting.CanvasNet.Geometry.Path; + +namespace DemaConsulting.CanvasNet.Pptx; + +// cspell:ignore cxnsp pptx lnref prstdash headend tailend + +/// +/// Implements the connector-shape (<p:cxnSp>) resolvers +/// (Phase 2 Follow-Up: Connector Shape Rendering): merging a connector's own <a:ln> +/// with its <p:style>/<a:lnRef> fallback into a single effective line style, +/// resolving its <a:headEnd>/<a:tailEnd> arrowheads, and computing its +/// geometry's own start/end points and tangent directions (needed to orient an arrowhead) - see +/// for how these are composed into a painted +/// connector, and pptx-document.md's "Phase 2 Follow-Up: Connector Shape Rendering" +/// design section for the full rationale. +/// +public sealed partial class PptxDocument +{ + /// + /// Resolves a connector shape's effective line style, merging its own <a:ln> + /// with its <p:style>/<a:lnRef> fallback on a per-attribute + /// basis (width/paint/dash independently), unlike 's own + /// "a present <a:ln> always wins outright over style, in full" policy. + /// + /// + /// A real-world connector (for example PowerPoint's own "Straight Arrow Connector" preset) + /// commonly declares an <a:ln> that carries only an arrowhead + /// (<a:tailEnd>), no w attribute and no fill child at all, relying + /// entirely on its <p:style>/<a:lnRef> for its own visible width and + /// color - under 's own "whole-element" fallback policy, such a + /// connector would resolve to "no stroke" (a present but attribute-sparse <a:ln> + /// still wins outright over the style, in full, under that policy), which is the opposite + /// of its real, intended, visible appearance. This merge instead independently falls each + /// of width/paint/dash back to the <p:style>/<a:lnRef>-resolved style + /// only when the connector's own <a:ln> does not itself declare that specific + /// attribute. + /// + /// The connector's own <p:spPr> element. + /// The connector's own sibling <p:style> element, or . + /// The resolved theme, used to resolve and any <a:schemeClr>. + /// The slide's own effective color map - see 's matching parameter. + /// + /// Threaded unchanged into this method's own / + /// calls - see 's + /// matching parameter. + /// + /// + /// The resolved, merged , or meaning "no + /// stroke": when the connector's own <a:ln> declares an explicit + /// <a:noFill/> (which always wins outright, regardless of style - a + /// connector explicitly opting out of a stroke must stay invisible), when the merged width + /// is non-positive, or when the merged paint resolves to . + /// + /// + /// Thrown when the connector's own <a:ln>'s w attribute is present but + /// not a finite floating-point number - see . + /// + internal static PptxLineStyle? ResolveConnectorLineStyle( + XElement spPrElement, XElement? styleElement, PptxTheme theme, PptxColorMap colorMap, + Func? resolveBlipImage = null) + { + var lnElement = spPrElement.Element(DrawingNamespace + "ln"); + + // An explicit directly on the connector's own always wins outright, + // even when a / would otherwise resolve to a visible stroke - matching + // RenderShape's own "explicit fill-definition child always wins" precedent. + if (lnElement?.Element(DrawingNamespace + "noFill") is not null) + { + return null; + } + + var styleLineStyle = ResolveShapeStyleLineStyle(styleElement, theme, colorMap, resolveBlipImage); + + var ownWidthEmu = ParseOptionalLineWidthAttribute(lnElement); + var widthEmu = ownWidthEmu is > 0f ? ownWidthEmu.Value : styleLineStyle?.WidthEmu ?? 0f; + if (widthEmu <= 0f) + { + return null; + } + + var paint = lnElement is not null && HasExplicitFillChild(lnElement) + ? ResolveFill(lnElement, theme, 1f, 1f, colorMap: colorMap, resolveBlipImage: resolveBlipImage) + : styleLineStyle?.Paint ?? PptxNoFill.Instance; + if (paint is PptxNoFill) + { + return null; + } + + var dashArray = lnElement is not null && lnElement.Element(DrawingNamespace + "prstDash") is not null + ? ResolveDashArray(lnElement, widthEmu) + : styleLineStyle?.DashArray; + + return new PptxLineStyle(widthEmu, paint, dashArray); + } + + /// + /// Resolves one of a connector's <a:headEnd>/<a:tailEnd> + /// arrowhead declarations. + /// + /// The connector's own <a:ln> element, or . + /// Either "headEnd" or "tailEnd". + /// + /// The resolved , or when + /// is , declares no matching end + /// element, or that end element's own type attribute is absent or + /// "none"/unrecognized (the OOXML schema default, and this phase's own + /// unsupported-type fallback - a cosmetic degrade to "no arrowhead" rather than a thrown + /// exception, consistent with 's own + /// unsupported-preset-name philosophy). + /// + internal static PptxArrowheadStyle? ResolveArrowhead(XElement? lnElement, string endElementName) + { + var endElement = lnElement?.Element(DrawingNamespace + endElementName); + if (endElement is null) + { + return null; + } + + var kind = (string?)endElement.Attribute("type") switch + { + "triangle" => PptxArrowheadKind.Triangle, + "stealth" => PptxArrowheadKind.Stealth, + "diamond" => PptxArrowheadKind.Diamond, + "oval" => PptxArrowheadKind.Oval, + "arrow" => PptxArrowheadKind.Arrow, + _ => PptxArrowheadKind.None, + }; + if (kind == PptxArrowheadKind.None) + { + return null; + } + + var widthKey = (string?)endElement.Attribute("w") ?? "med"; + var lengthKey = (string?)endElement.Attribute("len") ?? "med"; + return new PptxArrowheadStyle(kind, widthKey, lengthKey); + } + + /// + /// Computes a connector geometry path's own start/end points and tangent directions, in + /// the same local coordinate space itself is expressed in + /// - the inputs needs to orient/position a + /// <a:headEnd>/<a:tailEnd> arrowhead (see + /// 's own local-space convention). + /// + /// + /// The connector's own resolved, local-space geometry (see + /// ) - expected to have exactly one subpath with at + /// least one non- command, as every connector preset + /// this phase builds does (see 's connector builders). + /// + /// + /// The connector's start point/outgoing tangent and end point/incoming tangent. A tangent + /// falls back to (pointing along local +x) for a + /// degenerate (zero-length) first/last segment, so an arrowhead always has some + /// orientation rather than none. + /// + internal static (Vector2 StartPoint, Vector2 StartTangent, Vector2 EndPoint, Vector2 EndTangent) ComputeEndpointsAndTangents( + Path geometryPath) + { + var subpath = geometryPath.Subpaths[0]; + var startPoint = subpath.Start; + + var firstCommand = subpath.Commands[0]; + var startTangent = firstCommand.ComputeTangents(subpath.Start).Outgoing ?? Vector2.UnitX; + + // Walk every command up to (but not including) a trailing Close, tracking the previous + // vertex so the final segment's own incoming tangent can be computed - a Close command + // carries no EndPoint of its own (see PathCommand.EndPoint's remarks) and none of this + // phase's connector presets ever emit one (they are all open polylines/arcs), but this + // loop tolerates one defensively rather than assuming it. + var previousVertex = subpath.Start; + var lastNonCloseCommand = firstCommand; + var lastNonCloseCommandStart = subpath.Start; + foreach (var command in subpath.Commands) + { + if (command.Type == PathCommandType.Close) + { + break; + } + + lastNonCloseCommand = command; + lastNonCloseCommandStart = previousVertex; + previousVertex = command.EndPoint; + } + + var endPoint = previousVertex; + var endTangent = lastNonCloseCommand.ComputeTangents(lastNonCloseCommandStart).Incoming ?? Vector2.UnitX; + + return (startPoint, startTangent, endPoint, endTangent); + } +} diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Geometry.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Geometry.cs new file mode 100644 index 00000000..ef9c139c --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Geometry.cs @@ -0,0 +1,386 @@ +using System.Globalization; +using System.Numerics; +using System.Xml.Linq; +using DemaConsulting.CanvasNet.Geometry; +using Path = DemaConsulting.CanvasNet.Geometry.Path; + +namespace DemaConsulting.CanvasNet.Pptx; + +// cspell:ignore xfrm chOff chExt flipH flipV lnTo cubicBezTo quadBezTo prstGeom custGeom pathLst pptx prst unrotated unflipped + +/// +/// Implements the DrawingML shape geometry resolvers (Phase 1c): +/// <a:xfrm> position/size/rotation/flip, <p:grpSp> child-transform +/// composition, <a:prstGeom> preset shapes (delegated to +/// ), and <a:custGeom> custom path geometry - see +/// pptx-document.md's "Geometry and Paint (Phase 1c)" design section for the full +/// transform math and the exact supported/deferred preset-geometry boundary. +/// +public sealed partial class PptxDocument +{ + /// + /// Resolves a shape's <a:xfrm> element into a : + /// the transform mapping that shape's own local, unrotated/unflipped geometry coordinate + /// space ((0,0) to (ext.cx, ext.cy)) into its parent's coordinate space. + /// + /// The shape's <a:xfrm> element (from <p:spPr>). + /// The resolved . + /// + /// + /// Per ECMA-376 (ISO/IEC 29500) §20.1.7.6, a shape's local geometry is first placed at + /// <a:off x= y=/> with size <a:ext cx= cy=/>; flipH/ + /// flipV then mirror the shape about its own center, and rot (in 60,000ths + /// of a degree, clockwise) then rotates it about that same center - flip is applied + /// before rotation, matching PowerPoint's own documented and empirically-verified + /// transform order. + /// + /// + /// Because this library's coordinate space is y-down (screen-like - see + /// 's own clockwise-in-y-down convention), a positive OOXML + /// rot (clockwise on screen) maps directly to + /// applied to that same y-down space with no sign negation: a mathematically + /// "counterclockwise" rotation matrix, interpreted in a y-down frame, already reads as + /// clockwise on screen. + /// + /// + /// + /// Thrown when has no <a:off>/<a:ext> + /// child, or either child has a missing or non-numeric required attribute. + /// + internal static PptxShapeFrame ResolveShapeFrame(XElement xfrmElement) + { + var (offX, offY) = ParseOff(xfrmElement); + var (extCx, extCy) = ParseExt(xfrmElement); + + var rotAttr = (string?)xfrmElement.Attribute("rot"); + var rot60000ths = 0; + if (rotAttr is not null && !int.TryParse(rotAttr, NumberStyles.Integer, CultureInfo.InvariantCulture, out rot60000ths)) + { + throw new InvalidDataException($"An element has a non-numeric 'rot' attribute value '{rotAttr}'."); + } + + var flipH = (bool?)xfrmElement.Attribute("flipH") ?? false; + var flipV = (bool?)xfrmElement.Attribute("flipV") ?? false; + + var transform = BuildLocalToParentTransform(extCx, extCy, rot60000ths, flipH, flipV, offX, offY); + return new PptxShapeFrame(transform, extCx, extCy); + } + + /// + /// Resolves a <p:grpSp>'s own <a:xfrm> (which, unlike an + /// ordinary shape's, additionally declares <a:chOff>/<a:chExt> - + /// the child coordinate space every immediate child shape's own <a:off>/ + /// <a:ext> is expressed in) into the transform mapping that child coordinate + /// space into the group's parent coordinate space. + /// + /// The group's own <a:xfrm> element (from <p:grpSpPr>). + /// + /// The transform mapping a point in the group's child coordinate space (chOff to + /// chOff + chExt) into the group's parent coordinate space. A child shape's own + /// full parent-relative transform is ResolveShapeFrame(childXfrm).Transform + /// composed with this returned transform (child-local first, then this transform - row- + /// vector convention, matching ). + /// + /// + /// A group has its own position/size (<a:off>/<a:ext>, resolved + /// via exactly like any other shape, including its own + /// flip/rotation about its own center) AND a child coordinate space + /// (<a:chOff>/<a:chExt>) every immediate child's own off/ + /// ext is expressed in - composing these correctly requires first mapping a child + /// coordinate into the group's own local (0,0)-(ext.cx, ext.cy) box (a + /// translate by -chOff then a scale by ext/chExt), and only then applying + /// the group's own resolved frame transform. + /// + /// + /// Thrown when has no <a:off>/<a:ext> + /// child, or either child (or a present <a:chOff>/<a:chExt> + /// child) has a missing or non-numeric required attribute. + /// + internal static Matrix3x2 ResolveGroupChildTransform(XElement groupXfrmElement) + { + var groupFrame = ResolveShapeFrame(groupXfrmElement); + + var (chOffX, chOffY) = ParseOptionalPoint(groupXfrmElement, "chOff", default, default); + var (chExtCx, chExtCy) = ParseOptionalPoint(groupXfrmElement, "chExt", groupFrame.WidthEmu, groupFrame.HeightEmu); + + // A zero-width/height child coordinate space cannot be meaningfully scaled from - fall + // back to an identity scale factor of 1 along that axis rather than dividing by zero. + var scaleX = chExtCx == 0f ? 1f : groupFrame.WidthEmu / chExtCx; + var scaleY = chExtCy == 0f ? 1f : groupFrame.HeightEmu / chExtCy; + + var childToLocal = Matrix3x2.CreateTranslation(-chOffX, -chOffY) * Matrix3x2.CreateScale(scaleX, scaleY); + return childToLocal * groupFrame.Transform; + } + + /// + /// Resolves a shape's geometry - its <p:spPr>'s <a:prstGeom> or + /// <a:custGeom> child - into a sized to this shape's own + /// local (0,0)-(widthEmu, heightEmu) coordinate space (the same space + /// 's returned maps + /// from). + /// + /// The shape's <p:spPr> element. + /// The shape's own declared width, in EMU (). + /// The shape's own declared height, in EMU (). + /// The resolved, shape-local . + /// + /// Thrown when declares neither <a:prstGeom> + /// nor <a:custGeom>. + /// + /// + /// Thrown when <a:prstGeom> names a preset this phase does not support - see + /// . + /// + internal static Path ResolveShapeGeometry(XElement spPrElement, float widthEmu, float heightEmu) + { + var prstGeom = spPrElement.Element(DrawingNamespace + "prstGeom"); + if (prstGeom is not null) + { + var prst = (string?)prstGeom.Attribute("prst") ?? + throw new InvalidDataException("An element has no 'prst' attribute."); + return PptxPresetGeometry.Build(prst, widthEmu, heightEmu); + } + + var custGeom = spPrElement.Element(DrawingNamespace + "custGeom"); + if (custGeom is not null) + { + return ResolveCustomGeometry(custGeom, widthEmu, heightEmu); + } + + throw new InvalidDataException("A element has neither nor ."); + } + + /// + /// Resolves an <a:custGeom> element's <a:pathLst> into a + /// , scaling each <a:path>'s own declared w/h + /// coordinate space to the shape's actual / + /// . + /// + /// The <a:custGeom> element. + /// The shape's own declared width, in EMU. + /// The shape's own declared height, in EMU. + /// The resolved, shape-local (one subpath per <a:path>/<a:moveTo>). + /// + /// Every <a:path> under <a:pathLst> is resolved (not only the + /// first), each independently scaled by its own declared w/h (defaulting to + /// /, i.e. an identity scale, when + /// omitted - per the OOXML schema default). Supported path commands are + /// <a:moveTo>, <a:lnTo>, <a:cubicBezTo>, + /// <a:quadBezTo>, and <a:close>; <a:arcTo> (a + /// distinct, OOXML-specific center-parameterized arc command, not this phase's scope) and + /// any other command element are ignored rather than throwing, since a custom-geometry + /// path commonly mixes supported and (rare) unsupported commands and Phase 1c's goal is a + /// reasonable best-effort outline rather than full fidelity for every custom path. + /// + internal static Path ResolveCustomGeometry(XElement custGeomElement, float widthEmu, float heightEmu) + { + var builder = new PathBuilder(); + var pathLst = custGeomElement.Element(DrawingNamespace + "pathLst"); + if (pathLst is null) + { + return Path.Empty; + } + + foreach (var pathElement in pathLst.Elements(DrawingNamespace + "path")) + { + AppendCustomPath(builder, pathElement, widthEmu, heightEmu); + } + + return builder.Build(); + } + + /// + /// Appends one <a:path> element's commands to , + /// scaled to the shape's actual size. + /// + /// + /// Thrown when 's own w/h attribute, or any + /// <a:pt> child's x/y attribute, is present but non-numeric - + /// see . + /// + private static void AppendCustomPath(PathBuilder builder, XElement pathElement, float widthEmu, float heightEmu) + { + var pathW = ParseOptionalFloatAttribute(pathElement, "w", widthEmu); + var pathH = ParseOptionalFloatAttribute(pathElement, "h", heightEmu); + var scaleX = pathW == 0f ? 1f : widthEmu / pathW; + var scaleY = pathH == 0f ? 1f : heightEmu / pathH; + + Vector2 Scale(XElement ptElement) + { + var x = ParseOptionalFloatAttribute(ptElement, "x", 0f); + var y = ParseOptionalFloatAttribute(ptElement, "y", 0f); + return new Vector2(x * scaleX, y * scaleY); + } + + foreach (var command in pathElement.Elements()) + { + var points = command.Elements(DrawingNamespace + "pt").Select(Scale).ToArray(); + if (command.Name == DrawingNamespace + "moveTo" && points.Length >= 1) + { + builder.MoveTo(points[0]); + } + else if (command.Name == DrawingNamespace + "lnTo" && points.Length >= 1) + { + builder.LineTo(points[0]); + } + else if (command.Name == DrawingNamespace + "cubicBezTo" && points.Length >= 3) + { + builder.CubicBezierTo(points[0], points[1], points[2]); + } + else if (command.Name == DrawingNamespace + "quadBezTo" && points.Length >= 2) + { + builder.QuadraticBezierTo(points[0], points[1]); + } + else if (command.Name == DrawingNamespace + "close") + { + builder.Close(); + } + } + } + + /// Parses an <a:off x= y=/> element, required to be present. + /// + /// Thrown when <a:off> is missing, or its x/y attribute is + /// missing or non-numeric. + /// + private static (float X, float Y) ParseOff(XElement xfrmElement) + { + var off = xfrmElement.Element(DrawingNamespace + "off") ?? + throw new InvalidDataException("An element has no element."); + return ( + ParseRequiredFloatAttribute(off, "x"), + ParseRequiredFloatAttribute(off, "y")); + } + + /// Parses an <a:ext cx= cy=/> element, required to be present. + /// + /// Thrown when <a:ext> is missing, or its cx/cy attribute is + /// missing or non-numeric. + /// + private static (float Cx, float Cy) ParseExt(XElement xfrmElement) + { + var ext = xfrmElement.Element(DrawingNamespace + "ext") ?? + throw new InvalidDataException("An element has no element."); + return ( + ParseRequiredFloatAttribute(ext, "cx"), + ParseRequiredFloatAttribute(ext, "cy")); + } + + /// + /// Parses an optional two-attribute child element (<a:chOff x= y=/> or + /// <a:chExt cx= cy=/>), falling back to the given defaults when the element + /// itself is absent (both elements are schema-optional on a group's <a:xfrm>) + /// - but requiring both attributes, failing closed, whenever the element IS present, since + /// a present-but-incomplete <a:chOff>/<a:chExt> is malformed + /// input, not an "absent element" the schema-default fallback is meant to cover. + /// + /// + /// Thrown when the element is present but its first/second required attribute is missing + /// or non-numeric. + /// + private static (float First, float Second) ParseOptionalPoint(XElement xfrmElement, string elementName, float defaultFirst, float defaultSecond) + { + var element = xfrmElement.Element(DrawingNamespace + elementName); + if (element is null) + { + return (defaultFirst, defaultSecond); + } + + var firstAttrName = elementName == "chOff" ? "x" : "cx"; + var secondAttrName = elementName == "chOff" ? "y" : "cy"; + return ( + ParseRequiredFloatAttribute(element, firstAttrName), + ParseRequiredFloatAttribute(element, secondAttrName)); + } + + /// + /// Parses 's attribute as an + /// invariant-culture floating-point value, used by every <a:xfrm>-family + /// attribute parse in this file so a non-numeric attribute value (for example + /// x="abc") fails with the documented contract + /// rather than letting an explicit (float?) cast's raw + /// propagate uncaught - mirroring PptxDocument.Paint.cs's own + /// ReadPercentageChild precedent for the same class of malformed-attribute input. + /// + /// + /// Thrown when the attribute is missing or not a finite floating-point number. + /// + private static float ParseRequiredFloatAttribute(XElement element, string attributeName) + { + var value = (string?)element.Attribute(attributeName) ?? + throw new InvalidDataException($"An <{element.Name.LocalName}> element has no '{attributeName}' attribute."); + + if (!float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)) + { + throw new InvalidDataException( + $"An <{element.Name.LocalName}> element has a non-numeric '{attributeName}' attribute value '{value}'."); + } + + if (!float.IsFinite(parsed)) + { + throw new InvalidDataException( + $"An <{element.Name.LocalName}> element has a non-finite '{attributeName}' attribute value '{value}'."); + } + + return parsed; + } + + /// + /// Parses 's attribute as an + /// invariant-culture floating-point value, falling back to + /// when the attribute itself is absent, but failing closed with + /// (not a silent default) whenever the attribute IS + /// present with a non-numeric/non-finite value - used by AppendCustomPath's own + /// schema-optional <a:path> w/h and <a:pt> x/ + /// y attributes, each of which previously read via an explicit (float?) + /// cast whose raw / would + /// otherwise propagate uncaught for a crafted/corrupted custom-geometry path - mirroring + /// this file's own precedent + /// for the always-required case. + /// + /// + /// Thrown when the attribute is present but not a finite floating-point number. + /// + private static float ParseOptionalFloatAttribute(XElement element, string attributeName, float defaultValue) + { + var value = (string?)element.Attribute(attributeName); + if (value is null) + { + return defaultValue; + } + + if (!float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)) + { + throw new InvalidDataException( + $"An <{element.Name.LocalName}> element has a non-numeric '{attributeName}' attribute value '{value}'."); + } + + if (!float.IsFinite(parsed)) + { + throw new InvalidDataException( + $"An <{element.Name.LocalName}> element has a non-finite '{attributeName}' attribute value '{value}'."); + } + + return parsed; + } + + /// + /// Builds the transform mapping a shape's local (0,0)-(extCx, extCy) + /// geometry coordinate space into its parent's coordinate space: flip about the shape's + /// own center, then rotate about that same center, then translate to (offX, offY) + /// - see 's remarks for the verified operation order. + /// + private static Matrix3x2 BuildLocalToParentTransform( + float extCx, float extCy, int rot60000ths, bool flipH, bool flipV, float offX, float offY) + { + var center = new Vector2(extCx / 2f, extCy / 2f); + var flipScale = new Vector2(flipH ? -1f : 1f, flipV ? -1f : 1f); + var angleRadians = rot60000ths / 60000f * (MathF.PI / 180f); + + return Matrix3x2.CreateTranslation(-center) + * Matrix3x2.CreateScale(flipScale) + * Matrix3x2.CreateRotation(angleRadians) + * Matrix3x2.CreateTranslation(center) + * Matrix3x2.CreateTranslation(offX, offY); + } +} diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Images.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Images.cs new file mode 100644 index 00000000..215d3cbf --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Images.cs @@ -0,0 +1,685 @@ +using System.Globalization; +using System.Numerics; +using System.Xml.Linq; +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Codecs; +using DemaConsulting.CanvasNet.Drawing; +using Path = DemaConsulting.CanvasNet.Geometry.Path; + +namespace DemaConsulting.CanvasNet.Pptx; + +// cspell:ignore blipfill srcrect pptx embed asvg prst cust reimplementation fillrect + +/// +/// Implements the picture-shape resolvers and painting primitive +/// (Phase 1e): decoding a <p:pic>'s <p:blipFill> into a core +/// (), resolving its optional +/// <a:srcRect> crop (), and compositing the decoded +/// image onto a destination () - see +/// pptx-document.md's "Images (Phase 1e)" design section for the full dispatch and +/// compositing rationale. is reused, unchanged, by +/// PptxDocument.Paint.cs's to decode an ordinary shape's own +/// background picture fill (<p:spPr>/<a:blipFill>) too - see this +/// file's own , which resolves that fill-context +/// <a:blipFill>'s own <a:stretch>/<a:tile> region +/// mapping into a . +/// +public sealed partial class PptxDocument +{ + /// + /// The uri attribute value identifying the Microsoft SVG blip extension + /// (<a:ext uri="{96DAC541-7B7A-43D3-8B79-37D633B846F1}"><asvg:svgBlip + /// .../></a:ext>) - see . + /// + private const string SvgBlipExtensionUri = "{96DAC541-7B7A-43D3-8B79-37D633B846F1}"; + + /// + /// Resolves a <p:pic> shape's <p:blipFill> element into a + /// fully decoded raster : resolves its <a:blip r:embed="..."/> + /// relationship to the owning media part, resolves that part's content type, and + /// dispatches to the matching sibling raster codec's own Load(Stream) entry point. + /// + /// + /// The part path of the slide (or other part) that owns - + /// needed to resolve the r:embed relationship, which is scoped to its source part's + /// own .rels file (see ). + /// + /// The shape's <p:blipFill> element. + /// The fully decoded raster image, as a core . + /// + /// Thrown when or is null. + /// + /// + /// Thrown when has no <a:blip> child, when + /// that child has neither an r:embed nor an r:link attribute, or when the + /// resolved media part/relationship is otherwise malformed (propagated unchanged from + /// //). + /// + /// + /// Thrown (feature token "pptx-image-link") when <a:blip> declares only + /// an r:link (a linked, non-embedded image - requires external/network resolution, + /// out of scope this phase), (feature token "pptx-image-svg-only") when + /// <a:blip> declares neither r:embed nor r:link but instead + /// carries only a Microsoft SVG extension (<a:extLst>/<a:ext uri="{96DAC541- + /// 7B7A-43D3-8B79-37D633B846F1}"><asvg:svgBlip>) with no raster fallback - a + /// well-formed, valid "Insert Icon"-style SVG-only picture this package does not yet + /// decode, or (feature token "pptx-image-format") when the resolved media part's + /// content type is not one of the five raster formats this package's codecs decode (for + /// example an EMF/WMF vector picture, or an SVG image - common PowerPoint picture formats + /// this phase's raster-only codecs cannot decode). + /// + /// + /// A codec decode failure (corrupt image bytes) is not wrapped here - it + /// propagates as whatever exception the matching codec's own Load(Stream) throws + /// (typically or + /// ), consistent with how this + /// package never wraps a sibling package's own, already-well-defined exception types. + /// + internal Surface ResolvePictureSurface(string ownerPartPath, XElement blipFillElement) + { + ArgumentNullException.ThrowIfNull(ownerPartPath); + ArgumentNullException.ThrowIfNull(blipFillElement); + + var blip = blipFillElement.Element(DrawingNamespace + "blip") ?? + throw new InvalidDataException("A element has no child."); + + var embedId = (string?)blip.Attribute(RelationshipRefNamespace + "embed"); + if (embedId is null) + { + if (blip.Attribute(RelationshipRefNamespace + "link") is not null) + { + throw new PptxUnsupportedFeatureException( + "pptx-image-link", + "A linked (not embedded) image is not supported."); + } + + if (HasSvgOnlyExtensionFallback(blip)) + { + throw new PptxUnsupportedFeatureException( + "pptx-image-svg-only", + "An declares only a Microsoft SVG extension () with no " + + "r:embed/r:link raster fallback; SVG-only pictures are not supported."); + } + + throw new InvalidDataException("An element has neither an 'r:embed' nor an 'r:link' attribute."); + } + + var mediaPartPath = ResolveRelationship(ownerPartPath, embedId); + var contentType = ResolvePart(mediaPartPath); + var decoder = ResolveRasterDecoder(contentType) ?? throw new PptxUnsupportedFeatureException( + "pptx-image-format", + $"Image content type '{contentType}' is not a supported raster format."); + + var bytes = GetPartBytes(mediaPartPath); + using var stream = new MemoryStream(bytes); + return decoder(stream); + } + + /// + /// Resolves a picture surface exactly like , then + /// records the decoded in so it is + /// disposed by . + /// + /// + /// Used only by the table-cell <a:blipFill> resolution path threaded through + /// ParseShapeTree/ParseTable at GetMaster/GetLayout/ + /// GetSlide parse time - the one picture-fill path whose resolved + /// is cached inside a master/layout/slide's own cached shape tree + /// and therefore reused across every subsequent + /// call, rather than being created fresh (and disposable) within a single call - see + /// Render(int, int, int, PptxRenderOptions?)'s own render-time picture resolution, + /// which instead tracks each resolved image in a call-local list threaded through that one + /// call (see ResolveAndTrackPictureSurface). + /// + /// See . + /// See . + /// See . + private Surface ResolveAndOwnPictureSurface(string ownerPartPath, XElement blipFillElement) + { + var surface = ResolvePictureSurface(ownerPartPath, blipFillElement); + _ownedImageSurfaces.Add(surface); + return surface; + } + + /// + /// Maps a media part's resolved OPC content type to the matching sibling raster codec's + /// own Load(Stream) entry point - pure dispatch, mirroring + /// DemaConsulting.CanvasNet.Svg's SvgCodec.Image.cs's own + /// ResolveRasterDecoder MIME-type dispatcher exactly (same five codecs, same + /// recognized content-type strings). + /// + /// The media part's resolved OPC content type. + /// + /// , , + /// , , or + /// for the matching recognized content type; otherwise + /// for any unrecognized content type (for example + /// "image/x-emf", "image/x-wmf", or "image/svg+xml"). + /// + private static Func? ResolveRasterDecoder(string contentType) => contentType switch + { + "image/png" => PngCodec.Load, + "image/jpeg" or "image/jpg" => JpegCodec.Load, + "image/bmp" or "image/x-bmp" or "image/x-ms-bmp" => BmpCodec.Load, + "image/tiff" or "image/x-tiff" => TiffCodec.Load, + "image/gif" => GifCodec.Load, + _ => null + }; + + /// + /// Detects the Microsoft "SVG-only" <a:blip> fallback pattern: an + /// <a:extLst> child containing an <a:ext uri="{96DAC541-7B7A-43D3-8B79- + /// 37D633B846F1}"> wrapping an <asvg:svgBlip> extension element - a + /// well-formed, valid OOXML picture ("Insert Icon"-style SVG with no raster fallback) that + /// this package does not yet decode, distinct from a genuinely malformed <a:blip> + /// with neither an embed/link attribute nor any recognized extension. + /// + /// The <a:blip> element to inspect. + /// + /// when has an <a:extLst> child + /// with an <a:ext> whose uri attribute matches + /// ; otherwise . + /// + private static bool HasSvgOnlyExtensionFallback(XElement blip) + { + var extLst = blip.Element(DrawingNamespace + "extLst"); + if (extLst is null) + { + return false; + } + + foreach (var ext in extLst.Elements(DrawingNamespace + "ext")) + { + if ((string?)ext.Attribute("uri") == SvgBlipExtensionUri) + { + return true; + } + } + + return false; + } + + /// + /// Resolves a <p:blipFill>'s optional <a:srcRect> crop element + /// into a . + /// + /// The shape's <p:blipFill> element. + /// + /// The resolved , or when + /// declares no <a:srcRect> (meaning "no + /// crop" - the whole source image is sampled). + /// + /// Thrown when is null. + /// + /// Thrown when a present <a:srcRect>'s l/t/r/b + /// attribute is present but not a valid, finite number. + /// + internal static PptxSrcRect? ResolveSrcRect(XElement blipFillElement) + { + ArgumentNullException.ThrowIfNull(blipFillElement); + + var srcRect = blipFillElement.Element(DrawingNamespace + "srcRect"); + if (srcRect is null) + { + return null; + } + + return new PptxSrcRect( + ParseSrcRectEdge(srcRect, "l"), + ParseSrcRectEdge(srcRect, "t"), + ParseSrcRectEdge(srcRect, "r"), + ParseSrcRectEdge(srcRect, "b")); + } + + /// + /// Parses a single <a:srcRect> edge attribute (l/t/r/ + /// b), each OOXML's own 1/100000-of-a-percent unit, into a [0,1]-scaled + /// fraction, defaulting to 0 (no crop on that edge) when the attribute itself is + /// absent (per the OOXML schema default). + /// + /// + /// Thrown when the attribute is present but not a valid, finite number. + /// + private static float ParseSrcRectEdge(XElement srcRectElement, string attributeName) + { + var value = (string?)srcRectElement.Attribute(attributeName); + if (value is null) + { + return 0f; + } + + if (!float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)) + { + throw new InvalidDataException( + $"An element has a non-numeric '{attributeName}' attribute value '{value}'."); + } + + if (!float.IsFinite(parsed)) + { + throw new InvalidDataException( + $"An element has a non-finite '{attributeName}' attribute value '{value}'."); + } + + return parsed / 100000f; + } + + /// + /// The OOXML/DrawingML EMU-per-pixel constant for a raster image sampled at its own native + /// 96-DPI pixel size (914400 EMU per inch / 96 pixels per inch) - the scale an + /// <a:tile>'s 100000 (100%) sx/sy maps a decoded image's + /// pixel dimensions to in the owning shape's own local EMU space. + /// + private const float EmuPerPixelAt96Dpi = 9525f; + + /// + /// Resolves a fill-context <a:blipFill>'s own <a:stretch> + /// (optionally cropped by a nested <a:fillRect>) or <a:tile> + /// region mapping into a that maps 's own + /// pixel-space coordinates into the owning shape's local EMU coordinate space - the + /// value + /// stores on the it returns. + /// + /// The shape fill's own <a:blipFill> element. + /// The already-decoded raster image (see ). + /// The owning shape's own declared width, in EMU. + /// The owning shape's own declared height, in EMU. + /// + /// The resolved image-pixel-space-to-local-EMU-space transform - see this method's own + /// remarks for the <a:tile>/<a:stretch> derivations. + /// + /// + /// + /// <a:tile> (consulted first when present - this is the + /// load-bearing case for this package's own pythonpptx-dml-fill.pptx fixture): + /// its tx/ty attributes are EMU offsets and its sx/sy + /// attributes are 1/100000-of-a-percent scale factors (each defaulting to + /// 100000, 100%, per the OOXML schema default) applied to 's + /// own native 96-DPI pixel size (). This package's own + /// <a:flip>/<a:algn> refinements are not resolved (a + /// documented scope reduction - see the design document) - every tile repeats from the + /// shape's own local origin, unflipped. + /// + /// + /// <a:stretch> (consulted when no <a:tile> + /// is present - including when neither element is present at all, the OOXML schema + /// default for an otherwise-unqualified <a:blipFill>, treated identically + /// to an empty <a:stretch/>): its optional nested <a:fillRect + /// l="..." t="..." r="..." b="..."/> crops the image, each edge a + /// 1/100000-of-a-percent fraction of the image's own width/height (parsed with + /// 's identical convention, defaulting each absent edge + /// to 0); the remaining cropped span is then linearly stretched to exactly fill + /// x . A degenerate crop (the + /// left+right, or top+bottom, edges summing to 100% or more) falls back to an + /// uncropped full stretch on that axis, rather than producing a non-finite or + /// divide-by-zero transform. + /// + /// + internal static Matrix3x2 ResolveImageFillTransform(XElement blipFillElement, Surface image, float widthEmu, float heightEmu) + { + var tile = blipFillElement.Element(DrawingNamespace + "tile"); + if (tile is not null) + { + var tx = ParseOptionalFloatAttribute(tile, "tx") ?? 0f; + var ty = ParseOptionalFloatAttribute(tile, "ty") ?? 0f; + var sx = ParseTilePercentAttribute(tile, "sx"); + var sy = ParseTilePercentAttribute(tile, "sy"); + + var scaleX = sx * EmuPerPixelAt96Dpi; + var scaleY = sy * EmuPerPixelAt96Dpi; + return new Matrix3x2(scaleX, 0f, 0f, scaleY, tx, ty); + } + + var fillRect = blipFillElement.Element(DrawingNamespace + "stretch")?.Element(DrawingNamespace + "fillRect"); + var left = fillRect is null ? 0f : ParseSrcRectEdge(fillRect, "l"); + var top = fillRect is null ? 0f : ParseSrcRectEdge(fillRect, "t"); + var right = fillRect is null ? 0f : ParseSrcRectEdge(fillRect, "r"); + var bottom = fillRect is null ? 0f : ParseSrcRectEdge(fillRect, "b"); + + var hSpan = 1f - left - right; + if (!float.IsFinite(hSpan) || hSpan <= 0f) + { + left = 0f; + hSpan = 1f; + } + + var vSpan = 1f - top - bottom; + if (!float.IsFinite(vSpan) || vSpan <= 0f) + { + top = 0f; + vSpan = 1f; + } + + var stretchScaleX = widthEmu / (hSpan * image.Width); + var stretchScaleY = heightEmu / (vSpan * image.Height); + var translateX = -left * widthEmu / hSpan; + var translateY = -top * heightEmu / vSpan; + return new Matrix3x2(stretchScaleX, 0f, 0f, stretchScaleY, translateX, translateY); + } + + /// + /// Parses an <a:tile> element's optional sx/sy 1/100000-of-a- + /// percent scale attribute into a [0,+inf)-scaled fraction, defensively falling back + /// to 1f (100%) when the attribute is absent, non-numeric, non-finite, or + /// non-positive - a malformed or degenerate tile scale must not silently produce a + /// zero-sized or non-finite step. + /// + private static float ParseTilePercentAttribute(XElement tileElement, string attributeName) + { + var value = (string?)tileElement.Attribute(attributeName); + if (value is null) + { + return 1f; + } + + if (!float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) || + !float.IsFinite(parsed) || parsed <= 0f) + { + return 1f; + } + + return parsed / 100000f; + } + + /// + /// Parses an <a:tile> element's optional tx/ty EMU-offset + /// attribute, returning when absent. + /// + /// + /// Thrown when the attribute is present but not a valid, finite number. + /// + private static float? ParseOptionalFloatAttribute(XElement element, string attributeName) + { + var value = (string?)element.Attribute(attributeName); + if (value is null) + { + return null; + } + + if (!float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)) + { + throw new InvalidDataException( + $"An element has a non-numeric '{attributeName}' attribute value '{value}'."); + } + + if (!float.IsFinite(parsed)) + { + throw new InvalidDataException( + $"An element has a non-finite '{attributeName}' attribute value '{value}'."); + } + + return parsed; + } + + /// + /// Resolves a <p:pic> picture shape's own <p:spPr> clip geometry - + /// its <a:prstGeom> or <a:custGeom> child - into a + /// sized to the picture's own local (0,0)-(widthEmu, + /// heightEmu) coordinate space, reusing the exact same preset/custom-geometry dispatch + /// already uses for an ordinary auto-shape + /// (/) - this is a + /// thin, picture-specific wrapper around that identical dispatch, not a reimplementation + /// (see pptx-document.md's "Phase 2 Follow-Up: Picture Preset-Geometry Clipping" + /// design section). + /// + /// The picture's own <p:spPr> element. + /// The picture's own declared width, in EMU (). + /// The picture's own declared height, in EMU (). + /// + /// (meaning "no clip - paint the full bounding-box rectangle", + /// preserving this package's original, pre-fix behavior) when + /// declares neither <a:prstGeom> nor <a:custGeom> at all, or when + /// it declares <a:prstGeom prst="rect"> (an explicit full-rectangle preset + /// resolves to the same full bounding-box rectangle a "no clip" paint already produces, so + /// clipping to it would be a pure no-op); otherwise the resolved, shape-local + /// to clip the picture's painted image to. + /// + /// Thrown when is null. + /// + /// Thrown when a present <a:prstGeom> element has no prst attribute - + /// mirrors 's identical check for an auto-shape. + /// + /// + /// Thrown when <a:prstGeom> names a preset this phase does not support - see + /// . Propagated unchanged, exactly as it already + /// propagates for an auto-shape with the same unsupported preset name. + /// + /// + /// Unlike (which always resolves some geometry or + /// throws for a shape with neither <a:prstGeom> nor <a:custGeom> + /// at all), this method tolerates "no geometry at all" by returning : + /// a <p:pic>'s <p:spPr> declaring neither element is a + /// schema-valid, historically-unclipped picture, not a malformed document - an auto-shape + /// with no resolvable geometry is instead already skipped entirely upstream in + /// RenderShape, a different (shape-tree-level, not geometry-resolver-level) point + /// of tolerance. <a:custGeom> on a picture is clipped via the same reused + /// resolver (not scoped out), since it costs no + /// additional implementation beyond this one dispatch branch. + /// + internal static Path? ResolvePictureClipPath(XElement spPrElement, float widthEmu, float heightEmu) + { + ArgumentNullException.ThrowIfNull(spPrElement); + + var prstGeom = spPrElement.Element(DrawingNamespace + "prstGeom"); + if (prstGeom is not null) + { + var prst = (string?)prstGeom.Attribute("prst") ?? + throw new InvalidDataException("An element has no 'prst' attribute."); + return prst == "rect" ? null : PptxPresetGeometry.Build(prst, widthEmu, heightEmu); + } + + var custGeom = spPrElement.Element(DrawingNamespace + "custGeom"); + return custGeom is not null ? ResolveCustomGeometry(custGeom, widthEmu, heightEmu) : null; + } + + /// + /// Resolves a <p:pic> picture shape's own <p:spPr> geometry - its + /// <a:prstGeom> or <a:custGeom> child - into a + /// ALWAYS representing a concrete, closed boundary suitable for stroke-outlining the + /// picture's own <a:ln> (see RenderPicture) - unlike + /// , which deliberately collapses "no geometry at all" + /// and "explicit <a:prstGeom prst="rect">" to ("no clip + /// needed", a pure optimization for its own image-content-clipping use case, where a full + /// bounding-box rectangle clip is a no-op). A stroke outline has no equivalent "no-op" + /// shortcut: an implicit/explicit full-rectangle picture with an <a:ln> must + /// still stroke an actual rectangle boundary, exactly as an ordinary <p:sp> + /// auto-shape with an implicit/explicit rect preset already does via + /// - so this method always returns a real path, reusing + /// the identical preset/custom-geometry dispatch (/ + /// ) itself already + /// reuses, rather than reimplementing it (see pptx-document.md's "Phase 2 Follow-Up: + /// Picture Own-Stroke Outline Rendering" design section). + /// + /// The picture's own <p:spPr> element. + /// The picture's own declared width, in EMU (). + /// The picture's own declared height, in EMU (). + /// + /// The resolved, shape-local to stroke: the resolved preset/custom + /// geometry when declares either, or the implicit full + /// bounding-box rectangle (PptxPresetGeometry.Build("rect", widthEmu, heightEmu)) + /// when it declares neither - never . + /// + /// Thrown when is null. + /// + /// Thrown when a present <a:prstGeom> element has no prst attribute - + /// mirrors 's/'s + /// identical check for an auto-shape. + /// + /// + /// Thrown when <a:prstGeom> names a preset this phase does not support - see + /// . Propagated unchanged, exactly as it already + /// propagates for an auto-shape or for with the same + /// unsupported preset name. + /// + internal static Path ResolvePictureGeometryPath(XElement spPrElement, float widthEmu, float heightEmu) + { + ArgumentNullException.ThrowIfNull(spPrElement); + + var prstGeom = spPrElement.Element(DrawingNamespace + "prstGeom"); + if (prstGeom is not null) + { + var prst = (string?)prstGeom.Attribute("prst") ?? + throw new InvalidDataException("An element has no 'prst' attribute."); + return PptxPresetGeometry.Build(prst, widthEmu, heightEmu); + } + + var custGeom = spPrElement.Element(DrawingNamespace + "custGeom"); + if (custGeom is not null) + { + return ResolveCustomGeometry(custGeom, widthEmu, heightEmu); + } + + // Neither geometry child is present: an implicit full-rectangle boundary - the same + // bounding-box footprint an unclipped picture already paints its image content into - + // so an on a geometry-less picture still strokes a complete, closed rectangle + // rather than being silently dropped. + return PptxPresetGeometry.Build("rect", widthEmu, heightEmu); + } + + /// + /// Composites a decoded picture onto , + /// mapping its (optionally -cropped) unit square through a + /// CreateScale(widthEmu, heightEmu) * shapeToSurfaceTransform transform and + /// nearest-neighbor sampling the source pixel for every destination pixel in the + /// transformed footprint's device-space bounding box - mirroring + /// DemaConsulting.CanvasNet.Pdf's PdfDocument.Images.cs's own + /// CompositeImageOntoSurface bounding-box rasterization loop exactly, with one + /// deliberate difference: no (1 - v) row flip is applied, because + /// (unlike PDF's own y-up image-space convention) this package's shape-local/surface space + /// is already y-down (see PaintTextLayout's own documented y-down convention) - + /// image row 0 is already the image's own top row in this same y-down sense, so no + /// flip is needed to land it at the shape's own top edge. Each sampled source pixel is + /// alpha-blended "over" the existing destination pixel via + /// (standard Porter-Duff "over" compositing), exactly + /// matching CompositeImageOntoSurface's own per-pixel blending - not overwritten + /// outright - so a source pixel with a non-opaque (including fully transparent) alpha + /// channel lets the existing destination content show through correctly instead of being + /// replaced by whatever RGB value happens to be stored alongside that transparent alpha. + /// + /// The destination surface to paint onto. + /// The already fully decoded source image (see ). + /// + /// The resolved <a:srcRect> crop (see ), or + /// for "no crop" (the whole source image is sampled). + /// + /// + /// The transform mapping the owning shape's own local (0,0)-(widthEmu, + /// heightEmu) coordinate space into surface pixel space (the shape's own + /// , or a further group-composed transform). + /// + /// The owning shape's own declared width, in EMU (). + /// The owning shape's own declared height, in EMU (). + /// + /// The picture's own resolved, shape-local clip geometry (see + /// ), or (the default) to paint + /// the full bounding-box rectangle unclipped - this package's original behavior. When + /// non-null, is transformed by + /// (the same transform 's caller already uses to place + /// the picture itself) and filled as an opaque white mask onto a fresh, same-size, + /// transparent via + /// (reusing the exact same anti-aliased path-fill rasterizer every shape/table fill in + /// this codebase already uses, rather than inventing a new clip primitive); each sampled + /// source pixel's own alpha channel is then scaled by that mask pixel's alpha (0 + /// outside the clip geometry, 255 fully inside it, an anti-aliased in-between value + /// exactly on its edge) before compositing via - see + /// pptx-document.md's "Phase 2 Follow-Up: Picture Preset-Geometry Clipping" design + /// section for the full rationale. + /// + /// Thrown when or is null. + /// + /// A non-invertible (degenerate, zero-area) composed transform silently paints nothing, + /// rather than throwing - mirroring CompositeImageOntoSurface's own documented + /// degenerate-transform behavior. + /// + internal static void PaintPicture( + Surface surface, + Surface image, + PptxSrcRect? srcRect, + Matrix3x2 shapeToSurfaceTransform, + float widthEmu, + float heightEmu, + Path? clipPath = null) + { + ArgumentNullException.ThrowIfNull(surface); + ArgumentNullException.ThrowIfNull(image); + + var unitToSurface = Matrix3x2.CreateScale(widthEmu, heightEmu) * shapeToSurfaceTransform; + if (!Matrix3x2.Invert(unitToSurface, out var surfaceToUnit)) + { + return; + } + + var corner00 = Vector2.Transform(new Vector2(0, 0), unitToSurface); + var corner10 = Vector2.Transform(new Vector2(1, 0), unitToSurface); + var corner01 = Vector2.Transform(new Vector2(0, 1), unitToSurface); + var corner11 = Vector2.Transform(new Vector2(1, 1), unitToSurface); + + var minX = MathF.Min(MathF.Min(corner00.X, corner10.X), MathF.Min(corner01.X, corner11.X)); + var maxX = MathF.Max(MathF.Max(corner00.X, corner10.X), MathF.Max(corner01.X, corner11.X)); + var minY = MathF.Min(MathF.Min(corner00.Y, corner10.Y), MathF.Min(corner01.Y, corner11.Y)); + var maxY = MathF.Max(MathF.Max(corner00.Y, corner10.Y), MathF.Max(corner01.Y, corner11.Y)); + + var startX = Math.Max(0, (int)MathF.Floor(minX)); + var endX = Math.Min(surface.Width - 1, (int)MathF.Ceiling(maxX)); + var startY = Math.Max(0, (int)MathF.Floor(minY)); + var endY = Math.Min(surface.Height - 1, (int)MathF.Ceiling(maxY)); + + var left = srcRect?.Left ?? 0f; + var top = srcRect?.Top ?? 0f; + var right = srcRect?.Right ?? 0f; + var bottom = srcRect?.Bottom ?? 0f; + + // Build the clip-coverage mask (see this method's own remarks) + // once, up front, rather than per-pixel - the same opaque-white-path-fill-onto-a-fresh- + // transparent-surface pattern already used by DemaConsulting.CanvasNet.Svg's own + // ApplyClipPath, repurposed here as a per-pixel alpha-scaling mask instead of a + // post-composite coverage clip. + using var clipMask = clipPath is null ? null : new Surface(surface.Width, surface.Height); + if (clipPath is not null && clipMask is not null) + { + var transformedClip = clipPath.Transform(shapeToSurfaceTransform); + PathFiller.Fill(clipMask, transformedClip, new Rgba32(255, 255, 255, 255)); + } + + for (var y = startY; y <= endY; y++) + { + for (var x = startX; x <= endX; x++) + { + var devicePoint = new Vector2(x + 0.5f, y + 0.5f); + var unitPoint = Vector2.Transform(devicePoint, surfaceToUnit); + var u = unitPoint.X; + var v = unitPoint.Y; + if (u < 0 || u >= 1 || v < 0 || v >= 1) + { + continue; + } + + byte maskAlpha = 255; + if (clipMask is not null) + { + maskAlpha = clipMask[x, y].A; + if (maskAlpha == 0) + { + continue; + } + } + + // Remap the unit-square sample point through the srcRect crop: imageU/imageV are + // the fraction of the way across the *uncropped* source image this destination + // pixel samples from - no (1 - v) flip, per this method's own remarks. + var imageU = left + u * (1f - left - right); + var imageV = top + v * (1f - top - bottom); + + var column = Math.Clamp((int)MathF.Floor(imageU * image.Width), 0, image.Width - 1); + var row = Math.Clamp((int)MathF.Floor(imageV * image.Height), 0, image.Height - 1); + var sourcePixel = image[column, row]; + if (maskAlpha != 255) + { + var scaledAlpha = (byte)Math.Clamp(MathF.Round(sourcePixel.A * (maskAlpha / 255f)), 0, 255); + sourcePixel = new Rgba32(sourcePixel.R, sourcePixel.G, sourcePixel.B, scaledAlpha); + } + + surface[x, y] = Rgba32.CompositeOver(surface[x, y], sourcePixel); + } + } + } +} diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Inheritance.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Inheritance.cs new file mode 100644 index 00000000..0d9019bb --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Inheritance.cs @@ -0,0 +1,236 @@ +using System.Xml.Linq; + +namespace DemaConsulting.CanvasNet.Pptx; + +// cspell:ignore sppr txbody lststyle sptree pptx ctrtitle sldnum xfrm prst cust + +/// +/// Implements the placeholder property-inheritance resolver +/// (Phase 1b): the isolated, independently-testable implementation of ECMA-376 (ISO/IEC +/// 29500) §19.3.1.36's placeholder matching algorithm. +/// +/// +/// +/// The verified matching algorithm (confirmed against the `python-pptx` +/// reference implementation's actual matching code and independent empirical +/// PowerPoint-repackaging notes - see the companion planning report for the full +/// evidentiary trail) is not a single "type+idx exact match, then type-only +/// fallback" test applied uniformly - it is two different, level-specific, +/// single-criterion matches: +/// +/// +/// +/// +/// Slide -> layout: matched by idx ALONE. type is +/// not consulted at this hop at all. A miss (no layout placeholder shares the +/// slide placeholder's idx) is a genuine miss - it is not retried by type. +/// +/// +/// +/// +/// Layout -> master: matched by type ALONE, through the +/// fixed remapping table, using the +/// matched layout placeholder's own type (not the slide placeholder's +/// type). idx is not consulted at this hop. This hop only runs when hop 1 +/// found a layout match - a hop 1 miss short-circuits the chain (there is no +/// layout type to remap/match against), it does not fall back to matching the +/// master directly against the slide's own type. +/// +/// +/// +/// +/// The theme is not part of this matching chain itself - a master/layout references its +/// theme via an ordinary OPC relationship, supplying scheme-level color/font tokens a +/// shape's own property fragment may reference, rather than theme itself containing +/// placeholder-shaped XML to match against. It is therefore carried through +/// unchanged, as context for a later phase, +/// not selected via the same first-non-null fallback used for +/// / +/// . +/// +/// +public sealed partial class PptxDocument +{ + /// + /// Resolves 's effective property fragments by matching + /// it against and + /// per the verified ECMA-376 placeholder matching algorithm (see this class's remarks), + /// then independently walking each named property category (<p:spPr>, + /// <p:txBody>/<a:lstStyle>) slide -> matched layout -> matched + /// master, taking the first element present at all - no deeper per-attribute merging is + /// performed in Phase 1b. For <p:spPr> itself, an empty element still counts + /// as present, stopping the fallback for that category (fill/line resolution is a Phase + /// 1c no-op either way - see 's + /// remarks). <a:lstStyle> is the one named exception: it is resolved via + /// , which walks past a tier's own + /// empty-but-present <a:lstStyle/> that declares no <a:lvl{N}pPr> + /// level-override child, mirroring 's own precedent for + /// <a:xfrm>/geometry. + /// + /// The slide-level placeholder to resolve. + /// The owning slide's layout's placeholder shapes. + /// That layout's master's placeholder shapes. + /// The resolved theme, carried through unchanged as context. + /// The resolved . + /// + /// The owning slide master's own <p:txStyles> (Phase 1d), carried through + /// unchanged into the returned - + /// optional (defaults to ) so Phase 1b/1c call sites continue to + /// compile unchanged. + /// + internal static PptxPlaceholderProperties ResolvePlaceholderProperties( + PptxPlaceholder slidePlaceholder, + IReadOnlyList layoutPlaceholders, + IReadOnlyList masterPlaceholders, + PptxTheme theme, + PptxMasterTextStyles? masterTextStyles = null) + { + // Hop 1: slide -> layout, matched by idx ALONE (type is not consulted here - verified, + // see this class's remarks). A miss is a genuine miss, not retried by type. + PptxPlaceholder? matchedLayoutPlaceholder = null; + foreach (var candidate in layoutPlaceholders) + { + if (candidate.Idx == slidePlaceholder.Idx) + { + matchedLayoutPlaceholder = candidate; + break; + } + } + + // Hop 2: layout -> master, matched by TYPE ALONE through the fixed remapping table (idx + // is not consulted here - verified). Only runs when hop 1 found a match; a hop 1 miss + // short-circuits the chain rather than falling back to matching the slide's own type. + PptxPlaceholder? matchedMasterPlaceholder = null; + if (matchedLayoutPlaceholder is not null) + { + var remappedType = RemapPlaceholderType(matchedLayoutPlaceholder.Type); + foreach (var candidate in masterPlaceholders) + { + if (candidate.Type == remappedType) + { + matchedMasterPlaceholder = candidate; + break; + } + } + } + + var effectiveSpPr = + slidePlaceholder.ShapeElement.Element(PresentationNamespace + "spPr") ?? + matchedLayoutPlaceholder?.ShapeElement.Element(PresentationNamespace + "spPr") ?? + matchedMasterPlaceholder?.ShapeElement.Element(PresentationNamespace + "spPr"); + + var effectiveTxBodyListStyle = + GetTxBodyListStyleWithLevelOverride(slidePlaceholder) ?? + (matchedLayoutPlaceholder is null ? null : GetTxBodyListStyleWithLevelOverride(matchedLayoutPlaceholder)) ?? + (matchedMasterPlaceholder is null ? null : GetTxBodyListStyleWithLevelOverride(matchedMasterPlaceholder)); + + // Resolved independently of effectiveSpPr (see PptxPlaceholderProperties.EffectiveXfrmElement's + // own remarks): a real-world placeholder frequently declares its own empty (no + // child, deliberately relying on the layout/master for position/size) - that empty + // element still "wins" effectiveSpPr's own element-level fallback above, so looking up + // effectiveSpPr's own xfrm child directly would never see the layout/master's geometry at + // all. is therefore looked up per-tier (slide's own , then the matched + // layout placeholder's, then the matched master placeholder's), independently continuing + // the chain past an empty-but-present that itself declares no . + var effectiveXfrmElement = + GetXfrm(slidePlaceholder.ShapeElement) ?? + (matchedLayoutPlaceholder is null ? null : GetXfrm(matchedLayoutPlaceholder.ShapeElement)) ?? + (matchedMasterPlaceholder is null ? null : GetXfrm(matchedMasterPlaceholder.ShapeElement)); + + // Resolved the same per-tier way as effectiveXfrmElement, and for the same reason (see + // PptxPlaceholderProperties.EffectiveGeometrySpPr's own remarks): a real-world + // placeholder's own empty commonly declares neither nor + // either, relying on the slide master's own placeholder shape (which always + // declares one, per ECMA-376's standard master content) - unlike fill/line, geometry has + // no graceful "absent means no-op" fallback, so this must walk past an empty-but-present + // explicitly rather than stopping at it. + var effectiveGeometrySpPr = + GetSpPrWithGeometry(slidePlaceholder.ShapeElement) ?? + (matchedLayoutPlaceholder is null ? null : GetSpPrWithGeometry(matchedLayoutPlaceholder.ShapeElement)) ?? + (matchedMasterPlaceholder is null ? null : GetSpPrWithGeometry(matchedMasterPlaceholder.ShapeElement)); + + // Resolved the same hop-1-idx-matched way as effectiveXfrmElement/effectiveGeometrySpPr + // (see PptxPlaceholderProperties.EffectivePlaceholderType's own remarks): the slide + // placeholder's own declared type when explicitly present, otherwise the idx-matched + // layout placeholder's type, otherwise the slide placeholder's schema-defaulted type as a + // last resort (no layout match found at all). A slide that omits type must inherit + // the matched layout placeholder's type for master text-style-bucket selection, exactly + // as it already does for position/size/geometry above - using the schema-defaulted "obj" + // directly here (as prior code did) incorrectly collapses an omitted-type title/subtitle + // placeholder into the body-style bucket instead of its true, inherited type. + var effectivePlaceholderType = + slidePlaceholder.DeclaredType ?? + matchedLayoutPlaceholder?.Type ?? + slidePlaceholder.Type; + + return new PptxPlaceholderProperties(effectiveSpPr, effectiveTxBodyListStyle, theme, masterTextStyles, effectiveXfrmElement, effectiveGeometrySpPr, effectivePlaceholderType); + } + + /// Extracts a placeholder's <p:txBody>/<a:lstStyle> element, if present. + private static XElement? GetTxBodyListStyle(PptxPlaceholder placeholder) => + placeholder.ShapeElement.Element(PresentationNamespace + "txBody")?.Element(DrawingNamespace + "lstStyle"); + + /// + /// Extracts a placeholder's <p:txBody>/<a:lstStyle> element, but only + /// when that element itself declares at least one <a:lvl1pPr> through + /// <a:lvl9pPr> level-override child (see + /// 's own lvl{N+1}pPr + /// naming convention, N = 1..9) - mirroring 's + /// pattern of only accepting a <p:spPr> when it declares the one child that + /// actually matters for that property category, rather than merely being present at all. + /// + private static XElement? GetTxBodyListStyleWithLevelOverride(PptxPlaceholder placeholder) + { + var lstStyle = GetTxBodyListStyle(placeholder); + if (lstStyle is null) + { + return null; + } + + for (var level = 1; level <= 9; level++) + { + if (lstStyle.Element(DrawingNamespace + $"lvl{level}pPr") is not null) + { + return lstStyle; + } + } + + return null; + } + + /// Extracts a <p:sp>-shaped shape element's own <p:spPr>/<a:xfrm> element, if present. + private static XElement? GetXfrm(XElement shapeElement) => + shapeElement.Element(PresentationNamespace + "spPr")?.Element(DrawingNamespace + "xfrm"); + + /// + /// Extracts a <p:sp>-shaped shape element's own <p:spPr> + /// element, but only when that <p:spPr> itself declares an + /// <a:prstGeom> or <a:custGeom> child (see + /// 's remarks). + /// + private static XElement? GetSpPrWithGeometry(XElement shapeElement) + { + var spPr = shapeElement.Element(PresentationNamespace + "spPr"); + var hasGeometry = spPr?.Element(DrawingNamespace + "prstGeom") is not null || spPr?.Element(DrawingNamespace + "custGeom") is not null; + return hasGeometry ? spPr : null; + } + + /// + /// Remaps a layout placeholder's type to the type used to match against a master + /// placeholder, per the verified python-pptx table: "body", "chart", + /// "bitmap", "orgChart", "mediaClip", "obj", "pic", + /// "subTitle", "tbl", "clipArt", "dgm", and "media" all + /// remap to "body"; "ctrTitle" remaps to "title"; every other type + /// (including "title", "dt", "ftr", "sldNum", and any + /// unrecognized value) maps to itself - a defensive, non-throwing fallback, since an + /// unrecognized type value is tolerated input, not a structural package error. + /// + private static string RemapPlaceholderType(string type) => + type switch + { + "body" or "chart" or "bitmap" or "orgChart" or "mediaClip" or "obj" or "pic" + or "subTitle" or "tbl" or "clipArt" or "dgm" or "media" => "body", + "ctrTitle" => "title", + _ => type, + }; +} diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Layouts.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Layouts.cs new file mode 100644 index 00000000..68a392f5 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Layouts.cs @@ -0,0 +1,78 @@ +namespace DemaConsulting.CanvasNet.Pptx; + +// cspell:ignore sldlayout csld pptx + +/// +/// Implements the slide layout parser (Phase 1b): parses +/// ppt/slideLayouts/slideLayoutN.xml, resolving its /slideMaster relationship and +/// enumerating its placeholder shapes. Lazy and cached by resolved part path. +/// +public sealed partial class PptxDocument +{ + /// Caches each slide layout's parsed form, keyed by its resolved part path, on first access. + private readonly Dictionary _layoutCache; + + /// + /// Returns the parsed slide layout at , parsing and + /// caching it on first access. + /// + /// The layout part's resolved path (for example "ppt/slideLayouts/slideLayout1.xml"). + /// The parsed . + /// + /// Thrown when the layout part is missing or not well-formed XML, when its root element is + /// not a PresentationML <p:sldLayout> element, when its + /// <p:cSld>/<p:spTree> element is missing, when a placeholder's + /// idx attribute is invalid, or when it has no /slideMaster relationship. + /// + /// + /// This layout's own shape tree is parsed with 's + /// containUnsupportedGraphicFrames parameter set to : a + /// <p:graphicFrame> of a recognized-but-unsupported (non-table) kind placed + /// directly on this layout's own shape tree is skipped rather than aborting this entire + /// cached parse - which would otherwise fail every slide using this layout. A slide's own + /// such graphic frame is unaffected and still hard-fails . + /// + internal PptxLayout GetLayout(string layoutPartPath) + { + if (_layoutCache.TryGetValue(layoutPartPath, out var cached)) + { + return cached; + } + + var root = LoadPartXmlRoot(layoutPartPath); + if (root.Name != PresentationNamespace + "sldLayout") + { + throw new InvalidDataException($"Part '{layoutPartPath}' is not a part."); + } + + var cSld = root.Element(PresentationNamespace + "cSld") ?? + throw new InvalidDataException($"Part '{layoutPartPath}' has no element."); + var spTree = cSld.Element(PresentationNamespace + "spTree") ?? + throw new InvalidDataException($"Slide layout '{layoutPartPath}' has no / element."); + + var placeholders = PptxPlaceholderParser.ParsePlaceholderShapes(spTree); + var masterPartPath = ResolveRelationshipByType(layoutPartPath, "/slideMaster"); + var background = cSld.Element(PresentationNamespace + "bg"); + var clrMapOvr = root.Element(PresentationNamespace + "clrMapOvr"); + + // The shape tree's tables resolve their cell fills against the layout's + // own master's theme. Resolving the master -> theme relationship chain is deferred into + // this lambda (mirroring GetSlide's own lazy theme resolver) so layouts with no tables at + // all never require it to be walked. + // + // colorMapResolver is intentionally omitted: this layout's own _layoutCache entry is + // shared by every slide using it (keyed by part path, not by slide), each of which may + // declare its own distinct - baking any single color map into a + // layout-owned table's cached fill/border here would be correct for some consuming + // slides and wrong for others. See ParseTable's own colorMap parameter XmlDoc for the + // full rationale and GetSlide's own colorMapResolver for the case where this is safe. + var shapeTree = ParseShapeTree( + spTree, () => GetTheme(GetMaster(masterPartPath).ThemePartPath), tableStyleResolver: TryResolveTableStyle, + containUnsupportedGraphicFrames: true, resolveBlipImage: blip => ResolveAndOwnPictureSurface(layoutPartPath, blip), + resolveChartPart: id => LoadPartXmlRoot(ResolveRelationship(layoutPartPath, id))); + + var layout = new PptxLayout(layoutPartPath, masterPartPath, placeholders, background, shapeTree, clrMapOvr); + _layoutCache[layoutPartPath] = layout; + return layout; + } +} diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Masters.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Masters.cs new file mode 100644 index 00000000..78998349 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Masters.cs @@ -0,0 +1,139 @@ +using System.Xml.Linq; + +namespace DemaConsulting.CanvasNet.Pptx; + +// cspell:ignore sldmaster csld pptx txstyles titlestyle bodystyle otherstyle + +/// +/// Implements the slide master parser (Phase 1b): parses +/// ppt/slideMasters/slideMasterN.xml, resolving its /theme relationship and +/// enumerating its placeholder shapes. As of Phase 1d, also parses the master root's own +/// <p:txStyles> child (a sibling of <p:cSld>, not nested inside it) +/// into a . As of the <p:clrMap>/ +/// <p:clrMapOvr> color-map fix, also parses the master root's own required +/// <p:clrMap> child into a . Lazy and cached by +/// resolved part path. +/// +public sealed partial class PptxDocument +{ + /// Caches each slide master's parsed form, keyed by its resolved part path, on first access. + private readonly Dictionary _masterCache; + + /// + /// Returns the parsed slide master at , parsing and + /// caching it on first access. + /// + /// The master part's resolved path (for example "ppt/slideMasters/slideMaster1.xml"). + /// The parsed . + /// + /// Thrown when the master part is missing or not well-formed XML, when its root element is + /// not a PresentationML <p:sldMaster> element, when its + /// <p:cSld>/<p:spTree> element is missing, when a placeholder's + /// idx attribute is invalid, when it has no /theme relationship, or when + /// its <p:clrMap> element (or any of its required bg1/tx1/ + /// bg2/tx2 attributes) is missing. + /// + /// + /// This master's own shape tree is parsed with 's + /// containUnsupportedGraphicFrames parameter set to : a + /// <p:graphicFrame> of a recognized-but-unsupported (non-table) kind placed + /// directly on this master's own shape tree is skipped rather than aborting this entire + /// cached parse - which would otherwise fail every slide using this master. A slide's own + /// such graphic frame is unaffected and still hard-fails . + /// + internal PptxMaster GetMaster(string masterPartPath) + { + if (_masterCache.TryGetValue(masterPartPath, out var cached)) + { + return cached; + } + + var root = LoadPartXmlRoot(masterPartPath); + if (root.Name != PresentationNamespace + "sldMaster") + { + throw new InvalidDataException($"Part '{masterPartPath}' is not a part."); + } + + var cSld = root.Element(PresentationNamespace + "cSld") ?? + throw new InvalidDataException($"Slide master '{masterPartPath}' has no element."); + var spTree = cSld.Element(PresentationNamespace + "spTree") ?? + throw new InvalidDataException($"Slide master '{masterPartPath}' has no / element."); + + var placeholders = PptxPlaceholderParser.ParsePlaceholderShapes(spTree); + var themePartPath = ResolveRelationshipByType(masterPartPath, "/theme"); + var txStyles = ParseMasterTextStyles(root.Element(PresentationNamespace + "txStyles")); + var background = cSld.Element(PresentationNamespace + "bg"); + + var clrMapElement = root.Element(PresentationNamespace + "clrMap") ?? + throw new InvalidDataException($"Slide master '{masterPartPath}' has no element."); + var colorMap = ParseColorMap(clrMapElement, masterPartPath); + + // The master's theme is already eagerly resolved above (themePartPath), so there is no + // added laziness concern in also eagerly resolving it here for the shape tree's own + // tables (see ParseShapeTree's themeResolver parameter). + // + // colorMapResolver is intentionally omitted: this master's own _masterCache entry is + // shared by every slide using it (keyed by part path, not by slide), each of which may + // declare its own distinct - baking any single color map into a + // master-owned table's cached fill/border here would be correct for some consuming + // slides and wrong for others. See ParseTable's own colorMap parameter XmlDoc for the + // full rationale and GetSlide's own colorMapResolver for the case where this is safe. + var shapeTree = ParseShapeTree( + spTree, () => GetTheme(themePartPath), tableStyleResolver: TryResolveTableStyle, + containUnsupportedGraphicFrames: true, resolveBlipImage: blip => ResolveAndOwnPictureSurface(masterPartPath, blip), + resolveChartPart: id => LoadPartXmlRoot(ResolveRelationship(masterPartPath, id))); + + var master = new PptxMaster(masterPartPath, themePartPath, placeholders, txStyles, background, shapeTree, colorMap); + _masterCache[masterPartPath] = master; + return master; + } + + /// + /// Parses a slide master's required <p:clrMap bg1="..." tx1="..." bg2="..." + /// tx2="..." accent1="accent1" .../> element (a sibling of <p:cSld>) + /// into a , reading only its bg1/tx1/bg2/ + /// tx2 attributes - its accentN/hlink/folHlink attributes are + /// read-and-ignored per 's own documented simplification (a + /// real-world <p:clrMap> always maps those six to themselves). + /// + /// The master's <p:clrMap> element. + /// The master's own resolved part path, used only for exception messages. + /// The parsed . + /// + /// Thrown when any of <p:clrMap>'s required bg1/tx1/bg2/ + /// tx2 attributes is missing (all four are schema-required on <p:sldMaster>). + /// + private static PptxColorMap ParseColorMap(XElement clrMapElement, string masterPartPath) + { + string RequiredAttribute(string name) => + (string?)clrMapElement.Attribute(name) ?? + throw new InvalidDataException($"Slide master '{masterPartPath}' element has no '{name}' attribute."); + + return new PptxColorMap( + RequiredAttribute("bg1"), + RequiredAttribute("tx1"), + RequiredAttribute("bg2"), + RequiredAttribute("tx2")); + } + + /// + /// Parses a slide master's own <p:txStyles> element (Phase 1d) into a + /// , each of its three schema-optional named styles + /// (<p:titleStyle>/<p:bodyStyle>/<p:otherStyle>) + /// retained unparsed as its own <a:lstStyle>-shaped element. + /// + /// The master's <p:txStyles> element, or when absent. + /// The parsed . + private static PptxMasterTextStyles ParseMasterTextStyles(XElement? txStylesElement) + { + if (txStylesElement is null) + { + return new PptxMasterTextStyles(null, null, null); + } + + return new PptxMasterTextStyles( + txStylesElement.Element(PresentationNamespace + "titleStyle"), + txStylesElement.Element(PresentationNamespace + "bodyStyle"), + txStylesElement.Element(PresentationNamespace + "otherStyle")); + } +} diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Package.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Package.cs new file mode 100644 index 00000000..bed49e63 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Package.cs @@ -0,0 +1,741 @@ +using System.IO.Compression; +using System.Xml; +using System.Xml.Linq; +using DemaConsulting.CanvasNet.Canvas; + +namespace DemaConsulting.CanvasNet.Pptx; + +// cspell:ignore pptx ooxml navigations ordinally mistargeted + +/// +/// Implements the OOXML (Office Open XML) package layer: opening +/// the .pptx file as a ZIP archive, resolving [Content_Types].xml, and +/// resolving package- and part-level relationships. This file implements only the +/// package-layer (OPC/ZIP/content-types/relationships) mechanics - the other +/// PptxDocument.*.cs partials layer presentation/theme/master/layout/slide/text +/// parsing on top of it; see 's own remarks for this class's full, +/// current scope. +/// +public sealed partial class PptxDocument +{ + /// The well-known part name of the package's content-types stream. + private const string ContentTypesPartName = "[Content_Types].xml"; + + /// The well-known part name of the package-level relationships part. + private const string PackageRelationshipsPartName = "_rels/.rels"; + + /// + /// The maximum number of characters allows an + /// to read while parsing [Content_Types].xml or any + /// .rels part, bounding the worst-case parse-time memory/CPU cost of an + /// attacker-controlled, highly inflated ZIP entry (an "XML bomb") before it is ever + /// materialized into an in-memory DOM. 2,000,000 characters is far larger than any + /// real-world [Content_Types].xml or .rels part (these parts list only the + /// package's own extension/override/relationship declarations, never arbitrary document + /// content), while remaining small enough to keep worst-case memory bounded to a small, + /// practical amount - mirroring SvgCodec.MaxDocumentCharacters's own "generous but + /// bounded" sizing rationale for the equivalent attacker-controlled-XML risk. + /// + private const int MaxPartCharacters = 2_000_000; + + /// + /// The maximum number of decompressed bytes allows a single + /// part's ZIP entry to expand to, bounding the worst-case memory cost of an + /// attacker-controlled, highly inflated media part (a "zip bomb" - for example a few + /// hundred kilobytes of highly-compressible data that decompresses to gigabytes) before it + /// is ever materialized into an in-memory byte array. This is the binary-part counterpart + /// to 's XML-part character bound, reusing + /// 's own value (256 MiB) since a single part cannot + /// legitimately need to exceed the whole package's own documented bound. + /// + private const long MaxPartBytes = MaxPackageBytes; + + /// The XML namespace used by [Content_Types].xml. + private static readonly XNamespace ContentTypesNamespace = + "http://schemas.openxmlformats.org/package/2006/content-types"; + + /// The XML namespace used by every .rels relationships part. + private static readonly XNamespace RelationshipsNamespace = + "http://schemas.openxmlformats.org/package/2006/relationships"; + + /// + /// The non-writable, in-memory stream wrapping the package's buffered bytes that backs + /// for the lifetime of this instance. + /// + private readonly MemoryStream _stream; + + /// The opened, read-only ZIP archive view of this package. + private readonly ZipArchive _archive; + + /// + /// Every part name present in the package, for existence checks, keyed case-sensitively + /// per OPC's own ordinal part-name comparison rule (see ). + /// + private readonly IReadOnlyDictionary _entriesByPath; + + /// + /// The [Content_Types].xml default extension-to-content-type mappings (for + /// example "xml" -> "application/xml"), keyed by extension without a leading dot, + /// case-insensitively. + /// + private readonly Dictionary _defaultContentTypes; + + /// + /// The [Content_Types].xml part-specific content-type overrides, keyed by the + /// normalized part path (no leading slash), which take precedence over + /// for the same part. + /// + private readonly Dictionary _overrideContentTypes; + + /// + /// Caches each part's parsed relationships the first time + /// resolves against it, keyed by the normalized source part path (the empty string + /// represents the package root, whose relationships live in + /// ). + /// + private readonly Dictionary> _relationshipCache; + + /// + /// Set once has been called. + /// + private bool _disposed; + + /// + /// The decoded picture s owned by this document - populated only by + /// (table-cell <a:blipFill> images, + /// resolved once at parse time and cached inside a master/layout/slide's own cached shape + /// tree, so they outlive any single + /// call and must instead be disposed here, by ). + /// + private readonly List _ownedImageSurfaces = []; + + /// + /// A single parsed <Relationship> element from a .rels part. + /// + /// The relationship's raw (not yet resolved) Target attribute value. + /// + /// The relationship's required Type attribute value (a URI, for example + /// "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument") + /// - a missing or empty Type attribute is rejected by + /// before a is ever constructed, so this field is never + /// empty (see , the Phase 1b consumer of this field). + /// + /// Whether TargetMode="External" was declared. + private readonly record struct PackageRelationship(string Target, string Type, bool IsExternal); + + /// + /// Opens as a read-only , wrapping any + /// failure to do so (an unreadable or corrupt ZIP, or a stream too short/malformed to be a + /// ZIP at all) in . + /// + private static ZipArchive OpenZipArchive(Stream stream) + { + try + { + return new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true); + } + catch (InvalidDataException) + { + // Already the correct, documented exception type - propagate unchanged. + throw; + } + catch (Exception ex) when (ex is IOException or ArgumentException or NotSupportedException or EndOfStreamException) + { + throw new InvalidDataException("The supplied stream is not a valid ZIP/OPC package.", ex); + } + } + + /// + /// Builds a case-sensitive lookup of every entry in by its full + /// path, per the Open Packaging Conventions (OPC, ECMA-376 Part 2) specification's own + /// part-name comparison rule: part names are compared ordinally (case-sensitively), so a + /// package legitimately may contain both ppt/slides/Slide1.xml and + /// ppt/slides/slide1.xml as two distinct parts, and an incorrectly-cased reserved + /// name (for example [content_types].xml) must never be accepted as if it were + /// [Content_Types].xml. + /// + /// + /// Thrown when the archive contains two entries whose full names are exactly identical + /// (a malformed/corrupt ZIP, since a well-formed ZIP never duplicates an entry name). + /// + private static IReadOnlyDictionary BuildEntryLookup(ZipArchive archive) + { + var entries = new Dictionary(StringComparer.Ordinal); + foreach (var entry in archive.Entries) + { + try + { + entries.Add(entry.FullName, entry); + } + catch (ArgumentException ex) + { + throw new InvalidDataException( + $"The package contains duplicate entries named '{entry.FullName}'.", ex); + } + } + + return entries; + } + + /// + /// Parses [Content_Types].xml and validates the package-level + /// _rels/.rels part is present and parseable. Called once from the private + /// constructor. + /// + /// + /// Thrown when [Content_Types].xml or _rels/.rels is missing, or either is + /// malformed XML. + /// + private void InitializePackage() + { + if (!_entriesByPath.TryGetValue(ContentTypesPartName, out var contentTypesEntry)) + { + throw new InvalidDataException( + $"The package is missing the required '{ContentTypesPartName}' part."); + } + + ParseContentTypes(contentTypesEntry); + + if (!_entriesByPath.TryGetValue(PackageRelationshipsPartName, out var packageRelsEntry)) + { + throw new InvalidDataException( + $"The package is missing the required '{PackageRelationshipsPartName}' part."); + } + + // Eagerly parse and cache the package-level relationships (keyed by the empty string, + // representing the package root) so a malformed package-level rels part fails fast at + // Open() time rather than lazily on first use. + _relationshipCache[string.Empty] = ParseRelationships(packageRelsEntry); + } + + /// + /// Parses [Content_Types].xml's <Default> and <Override> + /// elements into /. + /// + /// + /// Thrown when the part is not well-formed XML, its root element is not the OPC + /// content-types namespace's Types element, or any <Default>/ + /// <Override> child is missing a required attribute (Extension/ + /// ContentType for Default, PartName/ContentType for + /// Override) - malformed input must fail outright rather + /// than silently resolving an incomplete content-type map. + /// + private void ParseContentTypes(ZipArchiveEntry entry) + { + var root = LoadXmlRoot(entry); + if (root.Name != ContentTypesNamespace + "Types") + { + throw new InvalidDataException( + $"'{entry.FullName}' root element is '{root.Name}', not the expected '{ContentTypesNamespace + "Types"}'."); + } + + foreach (var defaultElement in root.Elements(ContentTypesNamespace + "Default")) + { + var extension = (string?)defaultElement.Attribute("Extension"); + var contentType = (string?)defaultElement.Attribute("ContentType"); + if (string.IsNullOrEmpty(extension) || string.IsNullOrEmpty(contentType)) + { + throw new InvalidDataException( + $"'{entry.FullName}' has a element with a missing or empty 'Extension'/'ContentType' attribute."); + } + + _defaultContentTypes[extension] = contentType; + } + + foreach (var overrideElement in root.Elements(ContentTypesNamespace + "Override")) + { + var partName = (string?)overrideElement.Attribute("PartName"); + var contentType = (string?)overrideElement.Attribute("ContentType"); + if (string.IsNullOrEmpty(partName) || string.IsNullOrEmpty(contentType)) + { + throw new InvalidDataException( + $"'{entry.FullName}' has an element with a missing or empty 'PartName'/'ContentType' attribute."); + } + + _overrideContentTypes[NormalizePartPath(partName)] = contentType; + } + } + + /// + /// Parses a .rels part's <Relationship> elements into a lookup from + /// relationship ID to its . + /// + /// + /// Thrown when the part is not well-formed XML, its root element is not the OPC + /// relationships namespace's Relationships element, any + /// <Relationship> child is missing a required attribute (Id, + /// Type, or Target), or two <Relationship> children declare the + /// same Id - malformed input must fail resolution outright rather than silently + /// skipping the malformed relationship or overwriting the first relationship with the + /// duplicate. + /// + private static IReadOnlyDictionary ParseRelationships(ZipArchiveEntry entry) + { + var root = LoadXmlRoot(entry); + if (root.Name != RelationshipsNamespace + "Relationships") + { + throw new InvalidDataException( + $"'{entry.FullName}' root element is '{root.Name}', not the expected '{RelationshipsNamespace + "Relationships"}'."); + } + + var relationships = new Dictionary(StringComparer.Ordinal); + foreach (var relationshipElement in root.Elements(RelationshipsNamespace + "Relationship")) + { + var id = (string?)relationshipElement.Attribute("Id"); + var target = (string?)relationshipElement.Attribute("Target"); + var type = (string?)relationshipElement.Attribute("Type"); + if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(target) || string.IsNullOrEmpty(type)) + { + throw new InvalidDataException( + $"'{entry.FullName}' has a element with a missing or empty 'Id'/'Type'/'Target' attribute."); + } + + var targetMode = (string?)relationshipElement.Attribute("TargetMode"); + var isExternal = string.Equals(targetMode, "External", StringComparison.OrdinalIgnoreCase); + + if (!relationships.TryAdd(id, new PackageRelationship(target, type, isExternal))) + { + throw new InvalidDataException( + $"'{entry.FullName}' has a element with a duplicate 'Id' attribute value '{id}'."); + } + } + + return relationships; + } + + /// + /// Loads and returns 's root XML element, wrapping any XML parse + /// failure in . + /// + /// + /// Both [Content_Types].xml and every .rels part are attacker-controlled ZIP + /// entries (a malicious package can declare any content for them); parsing them with an + /// unbounded would let a small ZIP containing a highly inflated + /// XML part (an "XML bomb"/zip-bomb pattern) exhaust memory during . + /// bounds the reader's total character count before + /// ever begins materializing the DOM + /// tree, and disabling DTD processing (with no XML resolver) hardens against XML External + /// Entity (XXE) injection - mirroring SvgCodec.LoadRootElement's own established + /// hardening pattern for the same class of attacker-controlled-XML risk. + /// + private static XElement LoadXmlRoot(ZipArchiveEntry entry) + { + try + { + using var stream = entry.Open(); + var settings = new XmlReaderSettings + { + MaxCharactersInDocument = MaxPartCharacters, + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null + }; + using var reader = XmlReader.Create(stream, settings); + var document = XDocument.Load(reader, LoadOptions.None); + return document.Root ?? throw new InvalidDataException( + $"'{entry.FullName}' does not contain a root XML element."); + } + catch (XmlException ex) + { + throw new InvalidDataException($"'{entry.FullName}' is not well-formed XML.", ex); + } + } + + /// Strips a single leading '/' from an OPC part name, if present. + private static string NormalizePartPath(string partPath) => + partPath.StartsWith('/') ? partPath[1..] : partPath; + + /// + /// Resolves the given OPC part's content type, consulting + /// (an exact, part-specific match) before falling back + /// to (a match by file extension). + /// + /// + /// The part's path within the package (for example "ppt/presentation.xml"), with + /// or without a leading slash. + /// + /// The resolved content type string. + /// Thrown when is null. + /// + /// Thrown when the package does not contain a part named , or + /// when no content type can be resolved for it (no matching override and no default + /// mapping for its extension). + /// + internal string ResolvePart(string partPath) + { + ArgumentNullException.ThrowIfNull(partPath); + + var normalized = NormalizePartPath(partPath); + if (!_entriesByPath.ContainsKey(normalized)) + { + throw new InvalidDataException($"The package does not contain a part named '{partPath}'."); + } + + if (_overrideContentTypes.TryGetValue(normalized, out var overrideType)) + { + return overrideType; + } + + var extension = GetExtension(normalized); + if (extension is not null && _defaultContentTypes.TryGetValue(extension, out var defaultType)) + { + return defaultType; + } + + throw new InvalidDataException($"No content type could be resolved for part '{partPath}'."); + } + + /// + /// Resolves the target part path of the relationship identified by + /// within 's + /// relationships, following OPC's relative-target resolution rules (a target is resolved + /// relative to the source part's own directory, unless it begins with '/', in + /// which case it is resolved relative to the package root). + /// + /// + /// The source part's path (for example "ppt/slides/slide1.xml"), or the empty + /// string to resolve a package-level relationship (from _rels/.rels). + /// + /// The relationship's Id attribute value (for example "rId1"). + /// The resolved target part path, with no leading slash. + /// + /// Thrown when or is null. + /// + /// + /// Thrown when has no relationship named + /// , when that relationship targets an external resource + /// (TargetMode="External", not supported by this phase), or when its resolved + /// target would escape the package root (an unbalanced leading "../"). + /// + internal string ResolveRelationship(string sourcePartPath, string relationshipId) + { + ArgumentNullException.ThrowIfNull(sourcePartPath); + ArgumentNullException.ThrowIfNull(relationshipId); + + var normalizedSource = NormalizePartPath(sourcePartPath); + var relationships = GetRelationships(normalizedSource); + + if (!relationships.TryGetValue(relationshipId, out var relationship)) + { + throw new InvalidDataException( + $"Relationship '{relationshipId}' was not found for part '{sourcePartPath}'."); + } + + if (relationship.IsExternal) + { + throw new InvalidDataException( + $"Relationship '{relationshipId}' for part '{sourcePartPath}' targets an external resource, which is not supported."); + } + + return ResolveRelativeTarget(normalizedSource, relationship.Target); + } + + /// + /// Returns the Type attribute value of the relationship identified by + /// within 's + /// relationships, used by callers (for example ParseSlideIdList) that must confirm + /// a relationship points at the expected kind of part before trusting its resolved target + /// - resolving a relationship's target path alone () + /// cannot distinguish "points at a slide" from "points at an arbitrary, wrongly-typed + /// part". + /// + /// + /// The source part's path, or the empty string for a package-level relationship. + /// + /// The relationship's Id attribute value. + /// The relationship's Type attribute value (never null or empty). + /// + /// Thrown when or is null. + /// + /// + /// Thrown when has no relationship named + /// . + /// + internal string GetRelationshipType(string sourcePartPath, string relationshipId) + { + ArgumentNullException.ThrowIfNull(sourcePartPath); + ArgumentNullException.ThrowIfNull(relationshipId); + + var normalizedSource = NormalizePartPath(sourcePartPath); + var relationships = GetRelationships(normalizedSource); + + if (!relationships.TryGetValue(relationshipId, out var relationship)) + { + throw new InvalidDataException( + $"Relationship '{relationshipId}' was not found for part '{sourcePartPath}'."); + } + + return relationship.Type; + } + + /// + /// Resolves the target part path of the single relationship of + /// whose Type attribute ends with + /// , used for navigations that have no explicit + /// r:id reference in the source part's own XML content (for example the package + /// root's relationship to ppt/presentation.xml, or a slide's relationship to its + /// layout) - see for navigations that do carry an + /// explicit r:id. + /// + /// The source part's path, or empty string for the package root. + /// + /// The relationship Type URI suffix to match (for example "/officeDocument"), + /// compared with an ordinal, case-sensitive . + /// + /// The resolved target part path, with no leading slash. + /// + /// Thrown when or + /// is null. + /// + /// + /// Thrown when no non-external relationship of has a + /// Type ending with . + /// + internal string ResolveRelationshipByType(string sourcePartPath, string relationshipTypeSuffix) + { + ArgumentNullException.ThrowIfNull(sourcePartPath); + ArgumentNullException.ThrowIfNull(relationshipTypeSuffix); + + var normalizedSource = NormalizePartPath(sourcePartPath); + var relationships = GetRelationships(normalizedSource); + + foreach (var relationship in relationships.Values) + { + if (!relationship.IsExternal && + relationship.Type.EndsWith(relationshipTypeSuffix, StringComparison.Ordinal)) + { + return ResolveRelativeTarget(normalizedSource, relationship.Target); + } + } + + throw new InvalidDataException( + $"No relationship of part '{sourcePartPath}' has a Type ending with '{relationshipTypeSuffix}'."); + } + + /// + /// Non-throwing sibling of : resolves the target + /// part path of the single relationship of whose + /// Type attribute ends with , returning + /// instead of throwing when no such relationship exists. Used for + /// navigations to an optional, auxiliary part (for example ppt/tableStyles.xml, via + /// ) whose absence must degrade gracefully rather than + /// fail the whole parse closed, unlike 's own + /// fail-closed behavior for a required navigation. + /// + /// The source part's path, or empty string for the package root. + /// + /// The relationship Type URI suffix to match, compared with an ordinal, case-sensitive + /// - see + /// 's matching parameter. + /// + /// + /// The resolved target part path, with no leading slash, or when no + /// non-external relationship of has a Type ending + /// with (including when + /// has no relationships at all). + /// + /// + /// Thrown when or + /// is null. + /// + internal string? TryResolveRelationshipByType(string sourcePartPath, string relationshipTypeSuffix) + { + ArgumentNullException.ThrowIfNull(sourcePartPath); + ArgumentNullException.ThrowIfNull(relationshipTypeSuffix); + + var normalizedSource = NormalizePartPath(sourcePartPath); + var relationships = GetRelationships(normalizedSource); + + foreach (var relationship in relationships.Values) + { + if (!relationship.IsExternal && + relationship.Type.EndsWith(relationshipTypeSuffix, StringComparison.Ordinal)) + { + return ResolveRelativeTarget(normalizedSource, relationship.Target); + } + } + + return null; + } + + /// + /// Loads and returns the root XML element of the part at , + /// wrapping a missing part or malformed XML in . Shared + /// by every Phase 1b presentation-specific parser (Presentation/Theme/Masters/Layouts/ + /// Slides) so each implements only its own element-shape parsing, not part lookup/XML-load + /// error handling. + /// + /// The part's path within the package, with or without a leading slash. + /// The part's root . + /// Thrown when is null. + /// + /// Thrown when the package does not contain a part named , or + /// when the part is not well-formed XML. + /// + internal XElement LoadPartXmlRoot(string partPath) + { + ArgumentNullException.ThrowIfNull(partPath); + + var normalized = NormalizePartPath(partPath); + if (!_entriesByPath.TryGetValue(normalized, out var entry)) + { + throw new InvalidDataException($"The package does not contain a part named '{partPath}'."); + } + + return LoadXmlRoot(entry); + } + + /// + /// Reads and returns the raw, undecoded bytes of the part at - + /// the Phase 1e counterpart to for a part whose content is + /// not XML at all (a media part, for example ppt/media/image1.png, referenced by a + /// <p:pic>'s <a:blip r:embed="..."/> - see + /// , this method's sole Phase 1e caller). + /// + /// The part's path within the package, with or without a leading slash. + /// The part's full, raw byte content. + /// Thrown when is null. + /// + /// Thrown when the package does not contain a part named , or + /// when the part's decompressed content exceeds . + /// + /// + /// The part's decompressed byte size is bounded by , enforced + /// incrementally during the copy itself (never trusting the ZIP entry's own declared + /// ) - this guards against a single attacker-controlled + /// part being a "zip bomb" that decompresses far beyond its compressed size, a risk + /// alone does not cover since it only bounds the *compressed* + /// bytes read from the caller's input stream at time. + /// + internal byte[] GetPartBytes(string partPath) + { + ArgumentNullException.ThrowIfNull(partPath); + + var normalized = NormalizePartPath(partPath); + if (!_entriesByPath.TryGetValue(normalized, out var entry)) + { + throw new InvalidDataException($"The package does not contain a part named '{partPath}'."); + } + + using var entryStream = entry.Open(); + using var buffer = new MemoryStream(); + CopyBounded( + entryStream, + buffer, + MaxPartBytes, + $"The part '{partPath}' exceeds the maximum supported decompressed size of {MaxPartBytes} bytes (the compressed ZIP entry may be a decompression bomb)."); + return buffer.ToArray(); + } + + /// + /// Returns the parsed relationships for , + /// parsing and caching them (in ) on first access. A + /// part with no .rels file of its own simply has no relationships - that is not an + /// error, since most parts are never a relationship source. + /// + private IReadOnlyDictionary GetRelationships(string normalizedSourcePartPath) + { + if (_relationshipCache.TryGetValue(normalizedSourcePartPath, out var cached)) + { + return cached; + } + + var relsPartPath = GetRelationshipsPartPath(normalizedSourcePartPath); + var relationships = _entriesByPath.TryGetValue(relsPartPath, out var relsEntry) + ? ParseRelationships(relsEntry) + : new Dictionary(StringComparer.Ordinal); + + _relationshipCache[normalizedSourcePartPath] = relationships; + return relationships; + } + + /// + /// Computes the .rels part path that would hold 's + /// own relationships, per the OPC convention of a sibling _rels subfolder (for + /// example "ppt/slides/slide1.xml" -> "ppt/slides/_rels/slide1.xml.rels"). + /// The empty string (the package root) maps to . + /// + private static string GetRelationshipsPartPath(string normalizedPartPath) + { + if (normalizedPartPath.Length == 0) + { + return PackageRelationshipsPartName; + } + + var lastSlash = normalizedPartPath.LastIndexOf('/'); + var directory = lastSlash < 0 ? string.Empty : normalizedPartPath[..lastSlash]; + var fileName = lastSlash < 0 ? normalizedPartPath : normalizedPartPath[(lastSlash + 1)..]; + + return directory.Length == 0 + ? $"_rels/{fileName}.rels" + : $"{directory}/_rels/{fileName}.rels"; + } + + /// + /// Resolves (a relationship's raw Target attribute) + /// relative to 's own directory, per OPC's + /// relative-reference resolution rules: a target beginning with '/' is resolved + /// relative to the package root instead, and any "."/".." segment in the + /// combined path is normalized away (mirroring familiar filesystem-path traversal). + /// + /// + /// Thrown when normalization would require popping past the package root (more ".." + /// segments than preceding directory segments). + /// + private static string ResolveRelativeTarget(string normalizedBasePartPath, string target) + { + if (target.StartsWith('/')) + { + return NormalizeSegments(target[1..]); + } + + var lastSlash = normalizedBasePartPath.LastIndexOf('/'); + var baseDirectory = lastSlash < 0 ? string.Empty : normalizedBasePartPath[..lastSlash]; + + var combined = baseDirectory.Length == 0 ? target : $"{baseDirectory}/{target}"; + return NormalizeSegments(combined); + } + + /// + /// Splits on '/' and resolves "." (current + /// directory, dropped) and ".." (parent directory, pops the previous segment) + /// segments, returning the normalized, re-joined path. + /// + private static string NormalizeSegments(string path) + { + var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + var stack = new List(segments.Length); + + foreach (var segment in segments) + { + switch (segment) + { + case ".": + continue; + case "..": + if (stack.Count == 0) + { + throw new InvalidDataException( + $"Relationship target '{path}' escapes the package root."); + } + + stack.RemoveAt(stack.Count - 1); + continue; + default: + stack.Add(segment); + continue; + } + } + + return string.Join('/', stack); + } + + /// Returns 's extension with no leading dot, or null if it has none. + private static string? GetExtension(string normalizedPartPath) + { + var lastDot = normalizedPartPath.LastIndexOf('.'); + var lastSlash = normalizedPartPath.LastIndexOf('/'); + return lastDot > lastSlash && lastDot < normalizedPartPath.Length - 1 + ? normalizedPartPath[(lastDot + 1)..] + : null; + } +} diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Paint.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Paint.cs new file mode 100644 index 00000000..b779c024 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Paint.cs @@ -0,0 +1,1228 @@ +using System.Globalization; +using System.Numerics; +using System.Xml.Linq; +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Drawing; +using Path = DemaConsulting.CanvasNet.Geometry.Path; + +namespace DemaConsulting.CanvasNet.Pptx; + +// cspell:ignore xfrm srgbclr schemeclr lummod lumoff pptx gsLst prstDash lgDash sysDash sysDot dashDot patt hlink scrgb prst positionable + +/// +/// Implements the DrawingML paint resolvers (Phase 1c): fill +/// (<a:noFill>/<a:solidFill>/<a:gradFill>), color +/// (<a:srgbClr>/<a:sysClr>/<a:schemeClr> plus the +/// lumMod/lumOff/shade/tint/alpha color-transform chain), +/// and line/stroke resolution (<a:ln>) - see pptx-document.md's "Geometry +/// and Paint (Phase 1c)" design section for the full color-transform math and the +/// supported/deferred fill-kind boundary. +/// +public sealed partial class PptxDocument +{ + /// + /// Resolves a shape's fill: the single recognized fill-definition child + /// (<a:noFill>/<a:solidFill>/<a:gradFill>/ + /// <a:pattFill>/<a:blipFill>) of , + /// into a concrete . + /// + /// + /// The element that may directly contain a fill-definition child - typically a shape's + /// <p:spPr>, or an <a:ln> line-properties element (both declare + /// their fill the same way). + /// + /// The resolved theme, used to resolve any <a:schemeClr>. + /// The owning shape's own declared width, in EMU (needed to position a gradient fill). + /// The owning shape's own declared height, in EMU (needed to position a gradient fill). + /// + /// The concrete color to substitute for an <a:schemeClr val="phClr"/> + /// ("placeholder color") token, or (the default) to preserve the + /// existing fallback - see + /// 's remarks. Supplied by + /// when resolving a <p:bgRef>'s own + /// theme format-scheme fill-style entry, which is always phClr-templated; every + /// other, pre-existing call site omits this parameter, leaving its behavior unchanged. + /// + /// + /// The effective <p:clrMap>/<p:clrMapOvr> color map consulted + /// when resolving an <a:schemeClr val="bg1"/>-shaped token, or + /// (the default, resolving to ) + /// to preserve this unit's pre-existing hardcoded bg/tx aliasing - see + /// 's remarks. + /// + /// + /// Lazily invoked, only when declares an + /// <a:blipFill> child, to decode that element's own <a:blip> + /// relationship into a concrete - typically a closure over + /// , bound to the owning part's own path (an + /// <a:blipFill>'s r:embed relationship is part-scoped, so this method, + /// which has no owning-part context of its own, cannot resolve it directly). Defaults to + /// ("no resolver available"), preserving this method's pre-existing + /// behavior (throwing ) for every call site + /// that does not supply one - see this method's own <exception> documentation. + /// + /// + /// The resolved - when + /// is , declares an explicit + /// <a:noFill/>, declares no recognized fill-definition child at all (a + /// deliberate simplification: this phase does not resolve fill inheritance from a + /// placeholder/layout/master/theme format scheme - see the design document), or declares a + /// non-conformant, attribute-less <a:pattFill/> with no prst attribute + /// (observed in the project's own real pythonpptx-dml-fill.pptx fixture - treated + /// as "no override", not a named preset); a resolved when it + /// declares an <a:pattFill prst="..."/> naming a covered + /// (see that enum's own remarks for the full covered/ + /// deferred preset-name boundary); a resolved when it declares + /// an <a:blipFill> and is supplied + /// (non-). + /// + /// + /// Thrown when the recognized fill-definition child is <a:pattFill prst="..."/> + /// naming a preset not covered by (pattern fill - every + /// other preset name is deferred to a later phase, see the design document's rationale and + /// that enum's own remarks), or when it is <a:blipFill> (picture fill) and + /// is (no resolver supplied); + /// otherwise propagated unchanged from itself - see + /// 's own <exception> documentation for + /// every cause (a linked, non-embedded image; an SVG-only fallback blip; or an unsupported + /// raster image format). + /// + internal static PptxPaint ResolveFill( + XElement? fillParentElement, PptxTheme theme, float widthEmu, float heightEmu, Rgba32? phClrOverride = null, + PptxColorMap? colorMap = null, Func? resolveBlipImage = null) + { + colorMap ??= PptxColorMap.Default; + + if (fillParentElement is null) + { + return PptxNoFill.Instance; + } + + if (fillParentElement.Element(DrawingNamespace + "noFill") is not null) + { + return PptxNoFill.Instance; + } + + var solidFill = fillParentElement.Element(DrawingNamespace + "solidFill"); + if (solidFill is not null) + { + var colorElement = solidFill.Elements().FirstOrDefault() ?? + throw new InvalidDataException("An element has no color-definition child."); + return new PptxSolidFill(ResolveColor(colorElement, theme, phClrOverride, colorMap)); + } + + var gradFill = fillParentElement.Element(DrawingNamespace + "gradFill"); + if (gradFill is not null) + { + return ResolveGradientFill(gradFill, theme, widthEmu, heightEmu, phClrOverride, colorMap); + } + + var pattFill = fillParentElement.Element(DrawingNamespace + "pattFill"); + if (pattFill is not null) + { + return ResolvePatternFill(pattFill, theme, phClrOverride, colorMap); + } + + var blipFill = fillParentElement.Element(DrawingNamespace + "blipFill"); + if (blipFill is not null) + { + if (resolveBlipImage is null) + { + throw new PptxUnsupportedFeatureException("pptx-picture-fill", "Picture fill () is not supported."); + } + + var image = resolveBlipImage(blipFill); + var transform = ResolveImageFillTransform(blipFill, image, widthEmu, heightEmu); + return new PptxImageFill(image, transform); + } + + return PptxNoFill.Instance; + } + + /// + /// Resolves an <a:pattFill> element into either + /// (a non-conformant, attribute-less <a:pattFill/> with no prst + /// attribute - see 's own remarks) or a resolved + /// for a covered . + /// + /// The <a:pattFill> element. + /// The resolved theme, used to resolve the <a:fgClr>/<a:bgClr> colors. + /// + /// The concrete color to substitute for an <a:schemeClr val="phClr"/> token + /// in either color, or (the default) - see 's + /// matching parameter. + /// + /// + /// The effective color map consulted when either color declares an <a:schemeClr + /// val="bg1"/>-shaped token - see 's matching parameter. + /// + /// + /// when has no + /// prst attribute; otherwise a resolved , defaulting an + /// absent <a:fgClr> to black and an absent <a:bgClr> to white + /// (both a documented, OOXML-adjacent convention - every real-world preset fill this + /// project has encountered declares both explicitly). + /// + /// + /// Thrown when prst names a preset not covered by + /// (feature token "pptx-pattern-fill") - see that enum's own remarks for the full + /// covered/deferred preset-name boundary. + /// + private static PptxPaint ResolvePatternFill( + XElement pattFillElement, PptxTheme theme, Rgba32? phClrOverride, PptxColorMap? colorMap) + { + var prst = (string?)pattFillElement.Attribute("prst"); + if (prst is null) + { + // Non-conformant, attribute-less (observed in the project's own real + // pythonpptx-dml-fill.pptx fixture) - treated as "no override", not a named preset. + return PptxNoFill.Instance; + } + + if (!TryResolvePresetPattern(prst, out var preset)) + { + throw new PptxUnsupportedFeatureException( + "pptx-pattern-fill", $"Pattern fill preset '{prst}' is not supported."); + } + + var fgColorElement = pattFillElement.Element(DrawingNamespace + "fgClr")?.Elements().FirstOrDefault(); + var bgColorElement = pattFillElement.Element(DrawingNamespace + "bgClr")?.Elements().FirstOrDefault(); + var foreground = fgColorElement is null + ? new Rgba32(0, 0, 0, 255) + : ResolveColor(fgColorElement, theme, phClrOverride, colorMap); + var background = bgColorElement is null + ? new Rgba32(255, 255, 255, 255) + : ResolveColor(bgColorElement, theme, phClrOverride, colorMap); + + return new PptxPatternFill(preset, foreground, background); + } + + /// + /// Maps an <a:pattFill prst="..."/> attribute value to its matching + /// , when covered - see that enum's own remarks for the + /// full covered/deferred preset-name boundary. + /// + /// The prst attribute's string value (an ECMA-376 ST_PresetPatternVal name). + /// The resolved , when this method returns . + /// when names a covered preset; otherwise . + private static bool TryResolvePresetPattern(string prst, out PptxPresetPattern preset) + { + switch (prst) + { + case "horz": preset = PptxPresetPattern.Horz; return true; + case "vert": preset = PptxPresetPattern.Vert; return true; + case "ltHorz": preset = PptxPresetPattern.LtHorz; return true; + case "ltVert": preset = PptxPresetPattern.LtVert; return true; + case "dkHorz": preset = PptxPresetPattern.DkHorz; return true; + case "dkVert": preset = PptxPresetPattern.DkVert; return true; + case "dnDiag": preset = PptxPresetPattern.DnDiag; return true; + case "upDiag": preset = PptxPresetPattern.UpDiag; return true; + case "ltDnDiag": preset = PptxPresetPattern.LtDnDiag; return true; + case "ltUpDiag": preset = PptxPresetPattern.LtUpDiag; return true; + case "dkDnDiag": preset = PptxPresetPattern.DkDnDiag; return true; + case "dkUpDiag": preset = PptxPresetPattern.DkUpDiag; return true; + case "wdDnDiag": preset = PptxPresetPattern.WdDnDiag; return true; + case "wdUpDiag": preset = PptxPresetPattern.WdUpDiag; return true; + case "cross": preset = PptxPresetPattern.Cross; return true; + case "diagCross": preset = PptxPresetPattern.DiagCross; return true; + case "pct5": preset = PptxPresetPattern.Pct5; return true; + case "pct10": preset = PptxPresetPattern.Pct10; return true; + case "pct20": preset = PptxPresetPattern.Pct20; return true; + case "pct25": preset = PptxPresetPattern.Pct25; return true; + case "pct30": preset = PptxPresetPattern.Pct30; return true; + case "pct40": preset = PptxPresetPattern.Pct40; return true; + case "pct50": preset = PptxPresetPattern.Pct50; return true; + case "pct60": preset = PptxPresetPattern.Pct60; return true; + case "pct70": preset = PptxPresetPattern.Pct70; return true; + case "pct75": preset = PptxPresetPattern.Pct75; return true; + case "pct80": preset = PptxPresetPattern.Pct80; return true; + case "pct90": preset = PptxPresetPattern.Pct90; return true; + case "divot": preset = PptxPresetPattern.Divot; return true; + case "wave": preset = PptxPresetPattern.Wave; return true; + default: + preset = default; + return false; + } + } + + /// + /// Resolves an <a:gradFill> element's <a:gsLst> gradient stops + /// and <a:lin> linear direction into a . + /// + /// The <a:gradFill> element. + /// The resolved theme, used to resolve any stop's <a:schemeClr>. + /// The owning shape's own declared width, in EMU. + /// The owning shape's own declared height, in EMU. + /// + /// The concrete color to substitute for each stop's <a:schemeClr val="phClr"/> + /// token, or (the default) - see 's + /// matching parameter. + /// + /// + /// The effective color map consulted when resolving a stop's <a:schemeClr + /// val="bg1"/>-shaped token, or (the default) - see + /// 's matching parameter. + /// + /// The resolved , positioned in the shape's own local geometry coordinate space. + /// + /// Thrown when has no <a:gsLst>, or + /// <a:gsLst> has no <a:gs> children, or a <a:gs> has + /// a missing, non-numeric, or non-finite pos attribute, or no color-definition + /// child. + /// + /// + /// Thrown when declares a path gradient + /// (<a:path>) rather than a linear one (<a:lin>), or declares + /// neither - both radial/path gradients and the (rare) gradient with no direction element + /// at all are deferred to a later phase (feature token "pptx-gradient-path"). + /// + internal static PptxGradientFill ResolveGradientFill( + XElement gradFillElement, PptxTheme theme, float widthEmu, float heightEmu, Rgba32? phClrOverride = null, + PptxColorMap? colorMap = null) + { + colorMap ??= PptxColorMap.Default; + + var gsLst = gradFillElement.Element(DrawingNamespace + "gsLst") ?? + throw new InvalidDataException("An element has no element."); + + var gsElements = gsLst.Elements(DrawingNamespace + "gs").ToList(); + if (gsElements.Count == 0) + { + throw new InvalidDataException("An 's element has no children."); + } + + var stops = new List(gsElements.Count); + foreach (var gs in gsElements) + { + var posValue = (string?)gs.Attribute("pos") ?? + throw new InvalidDataException("An element has no 'pos' attribute."); + if (!float.TryParse(posValue, NumberStyles.Float, CultureInfo.InvariantCulture, out var pos)) + { + throw new InvalidDataException($"An element has a non-numeric 'pos' attribute value '{posValue}'."); + } + + if (!float.IsFinite(pos)) + { + throw new InvalidDataException($"An element has a non-finite 'pos' attribute value '{posValue}'."); + } + + var colorElement = gs.Elements().FirstOrDefault() ?? + throw new InvalidDataException("An element has no color-definition child."); + var offset = Math.Clamp(pos / 100000f, 0f, 1f); + stops.Add(new GradientStop(offset, ResolveColor(colorElement, theme, phClrOverride, colorMap))); + } + + if (gradFillElement.Element(DrawingNamespace + "lin") is not { } lin) + { + throw new PptxUnsupportedFeatureException( + "pptx-gradient-path", + "Only a linear gradient () is supported; radial/path gradients () are not."); + } + + var ang60000ths = ParseOptionalIntAttribute(lin, "ang") ?? 0; + var angleRadians = ang60000ths / 60000f * (MathF.PI / 180f); + var direction = new Vector2(MathF.Cos(angleRadians), MathF.Sin(angleRadians)); + var extent = (MathF.Abs(direction.X) * widthEmu + MathF.Abs(direction.Y) * heightEmu) / 2f; + var center = new Vector2(widthEmu / 2f, heightEmu / 2f); + + var gradient = new LinearGradient(center - direction * extent, center + direction * extent, stops); + return new PptxGradientFill(gradient); + } + + /// + /// Resolves a single color-definition element + /// (<a:srgbClr>/<a:sysClr>/<a:schemeClr>/ + /// <a:prstClr>) and its child color-transform chain into a concrete + /// value. + /// + /// + /// + /// <a:schemeClr val="..."/>'s 12 ordinary slot names (dk1/lt1/ + /// dk2/lt2/accent1-6/hlink/folHlink) map directly to + /// 's like-named property; the four special aliases + /// bg1/tx1/bg2/tx2 map to lt1/dk1/lt2/ + /// dk2 respectively (per ECMA-376's documented background/text-color aliasing), + /// and phClr ("placeholder color" - a shape-style-reference token meaning "whatever + /// color this shape's style already resolved to") resolves to + /// when supplied (the one caller that threads shape-style-reference context through - + /// 's <p:bgRef> resolution); every + /// other call site - which does not have that surrounding context available - omits + /// , so phClr resolves to + /// as a deterministic, documented fallback. + /// + /// + /// The color-transform chain is applied in a fixed pipeline order regardless of the + /// transform elements' own order in the XML: lumMod/lumOff together (in HSL + /// luminance space: L' = L * lumMod + lumOff, each as a val/100000 + /// fraction), then shade (RGB-space: c' = c * val/100000), then tint + /// (RGB-space: c' = c * val/100000 + 255 * (1 - val/100000)), then alpha + /// (a' = val/100000 * 255, replacing the alpha channel outright). This is a + /// deliberate, documented simplification of OOXML's true colorimetric transform model - + /// see the design document. + /// + /// + /// The color-definition element. + /// The resolved theme, used to resolve an <a:schemeClr>'s named slot. + /// + /// The concrete color to substitute for an <a:schemeClr val="phClr"/> token, + /// or (the default) to preserve the existing + /// fallback documented below. Every pre-existing call + /// site omits this parameter, leaving its behavior unchanged; only + /// 's <p:bgRef> resolution supplies + /// it. + /// + /// + /// The effective <p:clrMap>/<p:clrMapOvr> color map consulted + /// when is an <a:schemeClr val="bg1"/>-, + /// "tx1"-, "bg2"-, or "tx2"-shaped token, or + /// (the default, resolving to ) to preserve this unit's + /// pre-existing hardcoded bg/tx aliasing - see 's remarks. + /// + /// The resolved, fully color-transformed value. + /// + /// Thrown when is an <a:srgbClr>/ + /// <a:sysClr>/<a:schemeClr>/<a:prstClr> missing its + /// required val attribute (or, for <a:sysClr>, missing + /// lastClr), has an invalid hex value, or names an unrecognized + /// <a:schemeClr val="..."/> slot. + /// + /// + /// Thrown when is a color-definition kind other than + /// <a:srgbClr>/<a:sysClr>/<a:schemeClr>/ + /// <a:prstClr> (for example <a:scrgbClr>/<a:hslClr>) + /// - deferred to a later phase (feature token "pptx-color-kind"); also thrown when + /// is an <a:prstClr val="..."/> naming an + /// ST_PresetColorVal other than the two names this project resolves (white/ + /// black - see 's own remarks for the narrowly-scoped + /// rationale), with the same feature token. + /// + internal static Rgba32 ResolveColor( + XElement colorElement, PptxTheme theme, Rgba32? phClrOverride = null, PptxColorMap? colorMap = null) + { + var baseColor = ResolveBaseColor(colorElement, theme, phClrOverride, colorMap); + return ApplyColorTransforms(baseColor, colorElement); + } + + /// + /// Resolves a color-definition element's own base color, before any color-transform chain + /// is applied. + /// + /// + /// <a:prstClr val="..."/> (an ECMA-376 ST_PresetColorVal named color - + /// over 140 X11/SVG-keyword-style names in the full schema) resolves only its two names + /// actually required by this project's own real-world fixture corpus today (white + /// and black) - a deliberately minimal, narrowly-scoped companion fix to pattern + /// fill () support, whose own <a:bgClr> always + /// uses <a:prstClr val="white"/> in that fixture. Every other preset-color + /// name throws the same narrowed (feature + /// token "pptx-color-kind") every other unsupported color-definition kind already + /// throws - not a full ST_PresetColorVal table, which remains explicitly out of + /// scope. + /// + private static Rgba32 ResolveBaseColor( + XElement colorElement, PptxTheme theme, Rgba32? phClrOverride = null, PptxColorMap? colorMap = null) + { + if (colorElement.Name == DrawingNamespace + "srgbClr") + { + var hex = (string?)colorElement.Attribute("val") ?? + throw new InvalidDataException("An element has no 'val' attribute."); + return ParseHexColor(hex, "srgbClr"); + } + + if (colorElement.Name == DrawingNamespace + "sysClr") + { + var hex = (string?)colorElement.Attribute("lastClr") ?? + throw new InvalidDataException("An element has no 'lastClr' attribute."); + return ParseHexColor(hex, "sysClr"); + } + + if (colorElement.Name == DrawingNamespace + "schemeClr") + { + var val = (string?)colorElement.Attribute("val") ?? + throw new InvalidDataException("An element has no 'val' attribute."); + if (val == "phClr" && phClrOverride is { } overrideColor) + { + return overrideColor; + } + + return ResolveSchemeColor(val, theme.ColorScheme, colorMap ?? PptxColorMap.Default); + } + + if (colorElement.Name == DrawingNamespace + "prstClr") + { + var val = (string?)colorElement.Attribute("val") ?? + throw new InvalidDataException("An element has no 'val' attribute."); + return val switch + { + "white" => new Rgba32(255, 255, 255, 255), + "black" => new Rgba32(0, 0, 0, 255), + _ => throw new PptxUnsupportedFeatureException( + "pptx-color-kind", $"Preset color '{val}' is not supported."), + }; + } + + throw new PptxUnsupportedFeatureException( + "pptx-color-kind", + $"Color-definition element '{colorElement.Name.LocalName}' is not supported."); + } + + /// + /// Parses a required #RRGGBB/RRGGBB hex color value, rejecting any value + /// that is not exactly six hexadecimal digits. + /// + /// + /// OOXML's srgbClr/@val and sysClr/@lastClr are always exactly six hex + /// digits (RRGGBB); an eight-digit AARRGGBB value, which + /// would otherwise also accept, misinterpreting its + /// first byte as alpha, must be rejected rather than silently misread - mirroring + /// PptxDocument.Theme.cs's own ParseSchemeColor six-digit validation for the + /// same class of value. + /// + /// + /// Thrown when (after stripping any leading '#') is not + /// exactly six hexadecimal digits, or is not valid hexadecimal at all. + /// + private static Rgba32 ParseHexColor(string hex, string elementName) + { + var digits = hex.StartsWith('#') ? hex[1..] : hex; + if (digits.Length != 6) + { + throw new InvalidDataException( + $"An element has a color value '{hex}' that is not exactly six hexadecimal digits (OOXML's RRGGBB form)."); + } + + try + { + return Rgba32.Parse("#" + digits); + } + catch (FormatException ex) + { + throw new InvalidDataException($"An element has an invalid hexadecimal color value '{hex}'.", ex); + } + } + + /// + /// Maps an <a:schemeClr val="..."/> slot name to its resolved + /// color, consulting 's own + /// bg1/tx1/bg2/tx2 indirection targets for those four special + /// aliases (per ECMA-376's <p:clrMap>/<p:clrMapOvr> background/ + /// text-color mapping - see 's own remarks) before resolving the + /// redirected slot name via . Every other, ordinary slot + /// name (dk1/lt1/dk2/lt2/accent1-6/hlink/ + /// folHlink/phClr) resolves directly, unaffected by . + /// + internal static Rgba32 ResolveSchemeColor(string val, PptxColorScheme scheme, PptxColorMap colorMap) => + val switch + { + // Background/text aliases indirect through the effective color map - see this + // method's own remarks and PptxColorMap's remarks for the ECMA-376 background/ + // text-color mapping this implements. + "bg1" => ResolveNamedSlot(colorMap.Bg1, scheme), + "tx1" => ResolveNamedSlot(colorMap.Tx1, scheme), + "bg2" => ResolveNamedSlot(colorMap.Bg2, scheme), + "tx2" => ResolveNamedSlot(colorMap.Tx2, scheme), + _ => ResolveNamedSlot(val, scheme), + }; + + /// + /// Maps a direct (non-bg/tx-aliased) color-scheme slot name to its resolved + /// color - the 12 canonical slot names plus phClr, + /// deliberately excluding the bg1/tx1/bg2/tx2 aliasing arms + /// itself already resolves, so a malformed + /// <p:clrMap> redirecting, for example, bg1 to "bg1" itself + /// cannot recurse indefinitely - it instead deterministically throws + /// below. + /// + private static Rgba32 ResolveNamedSlot(string slotName, PptxColorScheme scheme) => + slotName switch + { + "dk1" => scheme.Dark1, + "lt1" => scheme.Light1, + "dk2" => scheme.Dark2, + "lt2" => scheme.Light2, + "accent1" => scheme.Accent1, + "accent2" => scheme.Accent2, + "accent3" => scheme.Accent3, + "accent4" => scheme.Accent4, + "accent5" => scheme.Accent5, + "accent6" => scheme.Accent6, + "hlink" => scheme.Hyperlink, + "folHlink" => scheme.FollowedHyperlink, + // "Placeholder color" - unresolvable without shape-style-reference context; see ResolveColor's remarks. + "phClr" => scheme.Dark1, + _ => throw new InvalidDataException($"An element names an unrecognized slot '{slotName}'."), + }; + + /// Applies a color-definition element's lumMod/lumOff/shade/tint/alpha transform chain, in that fixed order. + private static Rgba32 ApplyColorTransforms(Rgba32 color, XElement colorElement) + { + var lumMod = ReadPercentageChild(colorElement, "lumMod"); + var lumOff = ReadPercentageChild(colorElement, "lumOff"); + if (lumMod is not null || lumOff is not null) + { + color = ApplyLuminance(color, lumMod ?? 1f, lumOff ?? 0f); + } + + if (ReadPercentageChild(colorElement, "shade") is { } shade) + { + color = new Rgba32( + (byte)Math.Clamp(color.R * shade, 0f, 255f), + (byte)Math.Clamp(color.G * shade, 0f, 255f), + (byte)Math.Clamp(color.B * shade, 0f, 255f), + color.A); + } + + if (ReadPercentageChild(colorElement, "tint") is { } tint) + { + color = new Rgba32( + (byte)Math.Clamp(color.R * tint + 255f * (1f - tint), 0f, 255f), + (byte)Math.Clamp(color.G * tint + 255f * (1f - tint), 0f, 255f), + (byte)Math.Clamp(color.B * tint + 255f * (1f - tint), 0f, 255f), + color.A); + } + + if (ReadPercentageChild(colorElement, "alpha") is { } alpha) + { + color = new Rgba32(color.R, color.G, color.B, (byte)Math.Clamp(alpha * 255f, 0f, 255f)); + } + + return color; + } + + /// + /// Reads a color-transform child element's own val attribute (OOXML's "thousandths + /// of a percent", 100000 = 100%) as a [0,1]-scaled (or, for lumOff, signed) + /// fraction, or when the child is absent. + /// + private static float? ReadPercentageChild(XElement colorElement, string childName) + { + var child = colorElement.Element(DrawingNamespace + childName); + if (child is null) + { + return null; + } + + var val = (string?)child.Attribute("val") ?? + throw new InvalidDataException($"An element has no 'val' attribute."); + if (!int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val100000ths)) + { + throw new InvalidDataException($"An element has a non-numeric 'val' attribute value '{val}'."); + } + + return val100000ths / 100000f; + } + + /// Applies L' = L * lumMod + lumOff in HSL space, preserving hue/saturation/alpha. + private static Rgba32 ApplyLuminance(Rgba32 color, float lumMod, float lumOff) + { + var (h, s, l) = RgbToHsl(color); + l = Math.Clamp(l * lumMod + lumOff, 0f, 1f); + return HslToRgb(h, s, l, color.A); + } + + /// Converts an color's RGB channels to HSL (hue in turns [0,1), saturation/lightness in [0,1]). + private static (float H, float S, float L) RgbToHsl(Rgba32 color) + { + var r = color.R / 255f; + var g = color.G / 255f; + var b = color.B / 255f; + + // Identify the dominant (max) channel by comparing the original byte values rather than + // their derived float equivalents, so no floating-point equality comparison is needed. + var maxByte = Math.Max(color.R, Math.Max(color.G, color.B)); + var minByte = Math.Min(color.R, Math.Min(color.G, color.B)); + var max = maxByte / 255f; + var min = minByte / 255f; + var l = (max + min) / 2f; + + if (maxByte == minByte) + { + return (0f, 0f, l); + } + + var delta = max - min; + var s = l > 0.5f ? delta / (2f - max - min) : delta / (max + min); + + float h; + if (maxByte == color.R) + { + h = (g - b) / delta + (g < b ? 6f : 0f); + } + else if (maxByte == color.G) + { + h = (b - r) / delta + 2f; + } + else + { + h = (r - g) / delta + 4f; + } + + h /= 6f; + return (h, s, l); + } + + /// Converts an HSL color (hue in turns [0,1), saturation/lightness in [0,1]) back to RGB, with the given alpha. + private static Rgba32 HslToRgb(float h, float s, float l, byte alpha) + { + if (s <= 0f) + { + var gray = (byte)Math.Clamp(l * 255f, 0f, 255f); + return new Rgba32(gray, gray, gray, alpha); + } + + var q = l < 0.5f ? l * (1f + s) : l + s - l * s; + var p = 2f * l - q; + + var r = HueToChannel(p, q, h + 1f / 3f); + var g = HueToChannel(p, q, h); + var b = HueToChannel(p, q, h - 1f / 3f); + + return new Rgba32( + (byte)Math.Clamp(r * 255f, 0f, 255f), + (byte)Math.Clamp(g * 255f, 0f, 255f), + (byte)Math.Clamp(b * 255f, 0f, 255f), + alpha); + } + + /// Standard HSL-to-RGB single-channel helper (see the CSS Color Module's reference algorithm). + private static float HueToChannel(float p, float q, float t) + { + if (t < 0f) + { + t += 1f; + } + + if (t > 1f) + { + t -= 1f; + } + + if (t < 1f / 6f) + { + return p + (q - p) * 6f * t; + } + + if (t < 1f / 2f) + { + return q; + } + + if (t < 2f / 3f) + { + return p + (q - p) * (2f / 3f - t) * 6f; + } + + return p; + } + + /// + /// The conventional default stroke width PowerPoint applies to an <a:ln> that + /// declares a fill but omits its own w attribute entirely: 0.75pt + /// (0.75 * 12700 EMU). This is an application-level convention observed in + /// PowerPoint's own default new-shape border weight - the ECMA-376/ISO-29500 + /// CT_LineProperties schema does not declare a formal XSD default="..." for + /// w, so this value is cross-checked against community/library documentation (for + /// example python-pptx's own documented default) rather than cited as a schema default. + /// + private const float DefaultLineWidthEmu = 0.75f * 12700f; + + /// + /// Parses an <a:ln> element's optional w (width, EMU) attribute, + /// returning when or its w + /// attribute is absent, shared by every <a:ln w="..."/> call site in this + /// partial class that merges or defaults an explicit line width (, + /// , and ). + /// + /// The <a:ln> element to inspect, or when absent. + /// + /// The parsed, finite w attribute value, or when + /// is or declares no w attribute. + /// + /// + /// Thrown when 's w attribute is present but not a + /// finite floating-point number. + /// + private static float? ParseOptionalLineWidthAttribute(XElement? lnElement) + { + var widthAttribute = lnElement?.Attribute("w"); + if (widthAttribute is null) + { + return null; + } + + try + { + var width = (float?)widthAttribute; + if (width is not null && !float.IsFinite(width.Value)) + { + throw new InvalidDataException( + $"An element has a non-finite 'w' attribute value '{(string?)widthAttribute}'."); + } + + return width; + } + catch (FormatException ex) + { + throw new InvalidDataException( + $"An element has a non-numeric 'w' attribute value '{(string?)widthAttribute}'.", + ex); + } + } + + /// + /// Resolves an <a:ln> line-properties element into a . + /// + /// The <a:ln> element, or for "no line properties declared". + /// The resolved theme, used to resolve any <a:schemeClr> in the line's fill. + /// + /// The effective color map consulted when the line's fill declares an <a:schemeClr + /// val="bg1"/>-shaped token, or (the default) - see + /// 's matching parameter. + /// + /// + /// The concrete color to substitute for an <a:schemeClr val="phClr"/> token + /// in the line's fill, or (the default) - see + /// 's matching parameter. Supplied by + /// when resolving a <p:style>/ + /// <a:lnRef>'s own theme format-scheme line-style entry, which is always + /// phClr-templated; every other, pre-existing call site omits this parameter, + /// leaving its behavior unchanged. + /// + /// + /// The resolved , or meaning "no + /// stroke": when is , its resolved fill + /// is (including an explicit <a:noFill/>, or no + /// recognized fill-definition child at all), or its w attribute is explicitly + /// present with a non-positive (<= 0) value. A w attribute that is + /// genuinely absent (as opposed to explicitly zero/negative) instead resolves to + /// , PowerPoint's own observed default stroke weight - + /// this mirrors real-world documents that declare only a line color/fill and rely on + /// PowerPoint's own default width, which must not render invisibly. See the design + /// document's "Stroke/Line Style Resolution" section for the full rationale; this + /// default-width fallback applies only to this method, not to + /// 's case-3 branch or + /// 's own width-merge logic, both of which + /// deliberately retain their existing "a present, width-less <a:ln> never + /// gains a width from style or default" behavior. + /// + /// + /// Threaded unchanged into this method's own call - see + /// 's matching parameter. + /// + /// + /// Thrown when 's w attribute is present but not a + /// finite floating-point number - see . + /// + internal static PptxLineStyle? ResolveLineStyle( + XElement? lnElement, PptxTheme theme, PptxColorMap? colorMap = null, Rgba32? phClrOverride = null, + Func? resolveBlipImage = null) + { + if (lnElement is null) + { + return null; + } + + var ownWidthEmu = ParseOptionalLineWidthAttribute(lnElement); + float widthEmu; + if (ownWidthEmu is null) + { + // Genuinely absent "w" - fall back to PowerPoint's own observed default stroke width + // rather than treating this the same as an explicit zero/negative width. + widthEmu = DefaultLineWidthEmu; + } + else + { + widthEmu = ownWidthEmu.Value; + if (widthEmu <= 0f) + { + return null; + } + } + + // A line's fill carries no shape width/height of its own to position a gradient against - + // gradient-filled lines are not meaningfully positionable this phase, so 1x1 is used as a + // neutral placeholder extent (only reachable if a document declares ). + var paint = ResolveFill(lnElement, theme, 1f, 1f, phClrOverride, colorMap, resolveBlipImage); + if (paint is PptxNoFill) + { + return null; + } + + var dashArray = ResolveDashArray(lnElement, widthEmu); + return new PptxLineStyle(widthEmu, paint, dashArray); + } + + /// + /// Resolves an <a:ln>'s <a:prstDash val="..."/> preset dash + /// pattern name into a dash-length array scaled to (matching + /// OOXML's own width-relative dash/gap proportions), or for a + /// solid line. + /// + /// + /// Supports the six most common preset names (dash, dashDot, dot, + /// lgDash, lgDashDot, sysDash, sysDot); solid, an + /// absent <a:prstDash>, and any other (rarer) preset name all resolve to + /// (solid line) - a documented simplification, since an unsupported + /// dash name degrading to a solid line is a purely cosmetic difference that should not + /// block resolving an otherwise well-formed line style. + /// + private static IReadOnlyList? ResolveDashArray(XElement lnElement, float widthEmu) + { + var prstDash = lnElement.Element(DrawingNamespace + "prstDash"); + var val = (string?)prstDash?.Attribute("val"); + return val switch + { + "dash" => [widthEmu * 4f, widthEmu * 3f], + "dashDot" => [widthEmu * 4f, widthEmu * 3f, widthEmu, widthEmu * 3f], + "dot" => [widthEmu, widthEmu * 3f], + "lgDash" => [widthEmu * 8f, widthEmu * 3f], + "lgDashDot" => [widthEmu * 8f, widthEmu * 3f, widthEmu, widthEmu * 3f], + "sysDash" => [widthEmu * 3f, widthEmu * 2f], + "sysDot" => [widthEmu, widthEmu * 2f], + _ => null, + }; + } + + /// + /// Resolves a shape's own <p:style>/<a:fillRef idx="..."/> shape-style + /// reference (Phase 2 Follow-Up) into a concrete : maps idx + /// directly, 1-based, into the theme's (unlike + /// <p:bgRef>'s own <a:bgFillStyleLst> 1001-offset convention - + /// an ordinary fillRef/lnRef indexes its own style list with no offset at + /// all), substituting <a:fillRef>'s own single color-definition child + /// (resolved via the existing ) for every <a:schemeClr + /// val="phClr"/> token the matched style-list entry declares - reusing the exact + /// phClr-substitution pattern already + /// establishes for <p:bgRef>. + /// + /// + /// The shape's own sibling <p:style> element (see ), + /// or when the shape declares no <p:style> at all. + /// + /// The resolved theme, used to resolve and any <a:schemeClr>. + /// The owning shape's own declared width, in EMU (needed to position a gradient fill). + /// The owning shape's own declared height, in EMU (needed to position a gradient fill). + /// + /// The effective color map consulted when the matched style-list entry declares an + /// <a:schemeClr val="bg1"/>-shaped token, or (the + /// default) - see 's matching parameter. + /// + /// + /// Threaded unchanged into this method's own call - see + /// 's matching parameter. + /// + /// + /// The resolved , or when + /// is , declares no + /// <a:fillRef> at all, or declares a <a:fillRef idx="0"/> + /// (the schema-defined "no fill" sentinel). + /// + /// + /// Thrown when <a:fillRef>'s idx attribute is missing, not a valid + /// non-negative integer, or outside [0,3] (a real theme's + /// <a:fillStyleLst> always declares exactly 3 entries, so any larger value, + /// or a value indexing past the end of (or into an empty) , + /// is rejected rather than silently clamped). + /// + internal static PptxPaint ResolveShapeStyleFill( + XElement? styleElement, PptxTheme theme, float widthEmu, float heightEmu, PptxColorMap? colorMap = null, + Func? resolveBlipImage = null) + { + colorMap ??= PptxColorMap.Default; + + var fillRef = styleElement?.Element(DrawingNamespace + "fillRef"); + if (fillRef is null) + { + return PptxNoFill.Instance; + } + + var idx = ParseStyleRefIdx(fillRef, "fillRef"); + if (idx == 0) + { + return PptxNoFill.Instance; + } + + if (idx is < 1 or > 3 || idx - 1 >= theme.FillStyleList.Count) + { + throw new InvalidDataException( + $"A / indexes outside the theme's (which has {theme.FillStyleList.Count} entries, expected exactly 3)."); + } + + var styleEntry = theme.FillStyleList[(int)(idx - 1)]; + + // 's own single color-definition child (when present) supplies the concrete + // phClr substitution value for the matched style-list entry's own token(s) - same pattern as ResolveBackgroundStyleReference's own + // resolution. + var colorElement = fillRef.Elements().FirstOrDefault(); + var phClrOverride = colorElement is null ? (Rgba32?)null : ResolveColor(colorElement, theme, colorMap: colorMap); + + // theme.FillStyleList's entries are themselves fill-definition elements (e.g. + // ), not a parent containing one - wrap in a synthetic parent so + // ResolveFill's existing "look up a direct named child" dispatch logic applies unchanged. + var syntheticFillParent = new XElement("pptxSyntheticStyleFillParent", styleEntry); + return ResolveFill(syntheticFillParent, theme, widthEmu, heightEmu, phClrOverride, colorMap, resolveBlipImage); + } + + /// + /// Resolves a shape's own <p:style>/<a:lnRef idx="..."/> shape-style + /// reference (Phase 2 Follow-Up) into a concrete : maps + /// idx directly, 1-based, into the theme's - + /// each entry is itself an already <a:ln>-shaped element, so it is fed + /// directly to the existing after substituting + /// <a:lnRef>'s own single color-definition child as that entry's + /// phClr override (same pattern as ). + /// + /// + /// The shape's own sibling <p:style> element (see ), + /// or when the shape declares no <p:style> at all. + /// + /// The resolved theme, used to resolve and any <a:schemeClr>. + /// + /// The effective color map consulted when the matched style-list entry's fill declares an + /// <a:schemeClr val="bg1"/>-shaped token, or (the + /// default) - see 's matching parameter. + /// + /// + /// Threaded unchanged into this method's own call - see + /// 's matching parameter. + /// + /// + /// The resolved , or meaning "no + /// stroke": when is , declares no + /// <a:lnRef> at all, declares a <a:lnRef idx="0"/> (the + /// schema-defined "no line" sentinel), or the matched style-list entry itself resolves to + /// "no stroke" per 's own rules. + /// + /// + /// Thrown when <a:lnRef>'s idx attribute is missing, not a valid + /// non-negative integer, or outside [0,3] - see + /// 's matching exception documentation. + /// + internal static PptxLineStyle? ResolveShapeStyleLineStyle( + XElement? styleElement, PptxTheme theme, PptxColorMap? colorMap = null, + Func? resolveBlipImage = null) + { + colorMap ??= PptxColorMap.Default; + + var lnRef = styleElement?.Element(DrawingNamespace + "lnRef"); + if (lnRef is null) + { + return null; + } + + var idx = ParseStyleRefIdx(lnRef, "lnRef"); + if (idx == 0) + { + return null; + } + + if (idx is < 1 or > 3 || idx - 1 >= theme.LnStyleList.Count) + { + throw new InvalidDataException( + $"A / indexes outside the theme's (which has {theme.LnStyleList.Count} entries, expected exactly 3)."); + } + + var styleEntry = theme.LnStyleList[(int)(idx - 1)]; + + var colorElement = lnRef.Elements().FirstOrDefault(); + var phClrOverride = colorElement is null ? (Rgba32?)null : ResolveColor(colorElement, theme, colorMap: colorMap); + + return ResolveLineStyle(styleEntry, theme, colorMap, phClrOverride, resolveBlipImage); + } + + /// + /// Resolves a shape's effective line style by combining its own <a:ln> + /// ('s own <p:spPr>/<a:ln> element) with its + /// <p:style>/<a:lnRef> fallback (Phase 2 Follow-Up: Shape Style + /// References), per the corrected 4-case precedence documented on : + /// an explicit fill-definition child (including <a:noFill/>) on the shape's + /// own <a:ln> wins outright over the style; a present <a:ln> + /// with no recognized fill-definition child of its own keeps its own width/dash but + /// defers only its color to the style <a:lnRef>; a fully absent + /// <a:ln> defers entirely to the style. + /// + /// + /// This is a deliberately narrower merge than 's + /// own per-attribute (width/paint/dash independently) merge for connector shapes: here, + /// width and dash always come from the shape's own <a:ln> when it is present + /// at all (never pulled from the style, even when the shape's own <a:ln> + /// declares no w/<a:prstDash>) - only the paint/color ever falls back + /// to the style. When the style itself supplies no usable color (no <p:style> + /// at all, no <a:lnRef>, an <a:lnRef idx="0"/>, or a style entry + /// that itself resolves to ), the result is "no stroke" - the same + /// default every other fill-less-line call site already produces (see + /// 's own remarks), and the same default + /// already establishes as precedent + /// (styleLineStyle?.Paint ?? PptxNoFill.Instance) - no new "default color" is + /// invented, since OOXML's own schema default for an absent/idx="0" style + /// reference is "no line at all", not a specific color. + /// + /// + /// The shape's own <p:spPr>/<a:ln> element, or + /// when the shape declares no <a:ln> at all. + /// + /// + /// The shape's own sibling <p:style> element (see ), + /// or when the shape declares no <p:style> at all. + /// + /// The resolved theme, used to resolve and any <a:schemeClr>. + /// + /// The effective color map consulted when a fill declares an <a:schemeClr + /// val="bg1"/>-shaped token, or (the default) - see + /// 's matching parameter. + /// + /// + /// Threaded unchanged into this method's own / + /// calls - see 's + /// matching parameter. + /// + /// + /// The resolved , or meaning "no + /// stroke" - see this method's own remarks and / + /// 's matching documentation for the full set of + /// "no stroke" conditions each delegated-to case produces. + /// + /// + /// Thrown when 's w attribute is present but not a + /// finite floating-point number - see . + /// + internal static PptxLineStyle? ResolveShapeLineStyle( + XElement? lnElement, XElement? styleElement, PptxTheme theme, PptxColorMap? colorMap = null, + Func? resolveBlipImage = null) + { + // Case 4: a fully absent defers entirely to the style . + if (lnElement is null) + { + return ResolveShapeStyleLineStyle(styleElement, theme, colorMap, resolveBlipImage); + } + + // Cases 1/2: an explicit fill-definition child (including ) on the shape's own + // always wins outright over the style - ResolveLineStyle already correctly returns + // null for an explicit , distinguishing it from case 3 below. + if (HasExplicitFillChild(lnElement)) + { + return ResolveLineStyle(lnElement, theme, colorMap, resolveBlipImage: resolveBlipImage); + } + + // Case 3: a present with no recognized fill-definition child of its own keeps its + // own width/dash, but defers only its color to the style - never merging width + // or dash from style, unlike ResolveConnectorLineStyle's broader per-attribute merge. + var widthEmu = ParseOptionalLineWidthAttribute(lnElement) ?? 0f; + if (widthEmu <= 0f) + { + return null; + } + + var styleLineStyle = ResolveShapeStyleLineStyle(styleElement, theme, colorMap, resolveBlipImage); + var paint = styleLineStyle?.Paint ?? PptxNoFill.Instance; + if (paint is PptxNoFill) + { + return null; + } + + var dashArray = ResolveDashArray(lnElement, widthEmu); + return new PptxLineStyle(widthEmu, paint, dashArray); + } + + /// + /// Parses a shape-style reference element's (<a:fillRef>/<a:lnRef>) + /// required idx attribute. + /// + /// + /// Thrown when idx is missing or not a valid non-negative integer. + /// + private static uint ParseStyleRefIdx(XElement refElement, string refElementName) + { + var idxValue = (string?)refElement.Attribute("idx") ?? + throw new InvalidDataException($"A element has no 'idx' attribute."); + if (!uint.TryParse(idxValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var idx)) + { + throw new InvalidDataException($"A element has a non-numeric 'idx' attribute value '{idxValue}'."); + } + + return idx; + } + + /// + /// Converts a shape's local-space outline into its stroked, + /// fillable outline per , mirroring the exact + /// -construction/ call pattern + /// the PDF renderer uses (PdfDocument.PaintStroke). + /// + /// The shape's own local-space geometry (see ). + /// The resolved line style to stroke with. + /// + /// The transform that will later be applied to the returned outline (see + /// PptxDocument.Render.cs's callers) - used only to derive a coordinate-scale-aware + /// flattenTolerance (see + /// 's remarks); the returned outline itself remains in + /// 's own untransformed local coordinate space, exactly as + /// before - the caller still applies this same transform afterward. + /// + /// + /// The stroked outline , in the same local coordinate space as + /// - a later rendering phase fills it (via + /// , + /// ) with 's own resolved paint; + /// this phase has no rendering surface yet, so only the outline geometry is produced here. + /// + internal static Path ResolveStrokeOutline(Path shapePath, PptxLineStyle lineStyle, Matrix3x2 localToSurface) + { + var style = new StrokeStyle(lineStyle.WidthEmu, dashArray: lineStyle.DashArray); + var flattenTolerance = ResolveFlattenTolerance(localToSurface); + return PathStroker.Stroke(shapePath, style, flattenTolerance); + } + + /// + /// Derives a flattenTolerance, in + /// 's own pre-transform (local, native-EMU) coordinate + /// space, that keeps the EFFECTIVE post-transform flattening deviation close to + /// 's own pixel-tuned default (0.25f, i.e. a quarter + /// device pixel) - regardless of how large a shape's own native coordinate magnitude is. + /// + /// + /// strokes shapePath in its own local, untransformed + /// coordinate space - a real-world PPTX shape's native EMU coordinates (hundreds of + /// thousands to millions of units) - and only applies + /// afterward (see PptxDocument.Render.cs's callers). Using PathStroker.Stroke's + /// literal pixel-tuned default flattenTolerance directly against that native EMU + /// magnitude tessellates every curved join/cap and every Bezier-flattened curve segment + /// roughly 200x finer than the shape will ever actually be rendered at (a quarter of one + /// EMU, rather than a quarter of one device pixel) - a severe, unnecessary tessellation + /// performance cost, and (see the regression this fix accompanies) the direct trigger for + /// 's inner-ring collapse detector to false- + /// positive on a thin, closed, curved stroke (e.g. a noFill ellipse/roundRect with a thin + /// color-only <a:ln>) at this coordinate magnitude and tessellation density. + /// + /// Dividing the library's own 0.25f target by 's + /// own scale factor keeps the post-transform deviation at that same intended ~0.25 device + /// pixels, independent of the shape's own native coordinate magnitude - exactly like + /// PdfDocument.PaintStroke already achieves by construction (it strokes an + /// already-device-space-baked path, so the untouched 0.25f default is already + /// correct there). The LARGER of the transform's own X/Y axis scales is used (rather than, + /// say, their average) so that even a non-uniformly scaled shape's more-magnified axis + /// still stays within the intended device-pixel deviation (the other axis then tessellates + /// somewhat finer than strictly necessary - a negligible, not a correctness, cost). + /// + /// + private static float ResolveFlattenTolerance(Matrix3x2 localToSurface) + { + const float defaultFlattenTolerance = 0.25f; + + var scaleX = new Vector2(localToSurface.M11, localToSurface.M12).Length(); + var scaleY = new Vector2(localToSurface.M21, localToSurface.M22).Length(); + var scale = Math.Max(scaleX, scaleY); + + // A degenerate (zero, negative-impossible-but-defensive, infinite, or NaN) transform scale + // - e.g. a shape collapsed to zero size - falls back to the library's own pixel-tuned + // default rather than producing a zero/negative/non-finite flattenTolerance that + // PathStroker.Stroke itself would reject. + if (!float.IsFinite(scale) || scale <= 0f) + { + return defaultFlattenTolerance; + } + + return defaultFlattenTolerance / scale; + } +} diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Presentation.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Presentation.cs new file mode 100644 index 00000000..4a303e9b --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Presentation.cs @@ -0,0 +1,214 @@ +using System.Xml.Linq; + +namespace DemaConsulting.CanvasNet.Pptx; + +// cspell:ignore sldsz sldidlst sldid pptx mistargeted + +/// +/// Implements the presentation-root parser (Phase 1b): +/// ppt/presentation.xml's declared slide size (<p:sldSz>) and ordered +/// slide list (<p:sldIdLst>), located via the package root's +/// /officeDocument relationship (not an explicit r:id reference, since the +/// presentation part is the package's single navigation entry point). +/// +public sealed partial class PptxDocument +{ + /// + /// The OOXML relationship Type URI suffix identifying a slide part, used to + /// validate each <p:sldId r:id="..."/>'s resolved relationship actually + /// targets a slide (rather than, for example, a theme or an arbitrary mistyped part) - + /// see . + /// + private const string SlideRelationshipTypeSuffix = "/slide"; + + /// The XML namespace used by PresentationML parts (ppt/presentation.xml, slides, layouts, masters). + internal static readonly XNamespace PresentationNamespace = + "http://schemas.openxmlformats.org/presentationml/2006/main"; + + /// The XML namespace used by the r:id relationship-reference attribute throughout OOXML parts. + private static readonly XNamespace RelationshipRefNamespace = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + + /// The resolved part path of ppt/presentation.xml, set by . + private string _presentationPartPath = string.Empty; + + /// The presentation's declared slide size, set by . + private PptxSlideSize _slideSize; + + /// + /// The resolved part path of each slide declared by <p:sldIdLst>, in document + /// order, set by . + /// + private IReadOnlyList _slidePartPaths = []; + + /// + /// Parses ppt/presentation.xml (located via the package root's /officeDocument + /// relationship): validates it is a presentation part, reads its declared slide size, and + /// resolves its ordered slide list. Called once from the constructor, immediately after + /// . + /// + /// + /// Only the presentation part itself is validated eagerly. Each resolved slide part's own + /// existence/well-formedness, and its layout/master/theme chain, are validated lazily on + /// first access via - mirroring Phase 1a's own eager + /// (package-level relationships)/lazy (per-part relationships) split, so + /// only costs "is this a navigable presentation with at least one declared slide", not + /// "is every slide/layout/master/theme already fully valid". + /// + /// + /// Thrown when the package has no /officeDocument relationship, when that part is + /// missing or not well-formed XML, when its root element is not a PresentationML + /// <p:presentation> element, when <p:sldSz> is missing or has a + /// missing/non-numeric/non-positive cx/cy, or when <p:sldIdLst> + /// is missing, has zero <p:sldId> children, or any child has a missing or + /// unresolvable r:id, or an r:id resolving to a relationship whose + /// Type does not identify a slide part. + /// + private void InitializePresentation() + { + _presentationPartPath = ResolveRelationshipByType(string.Empty, "/officeDocument"); + var root = LoadPartXmlRoot(_presentationPartPath); + + if (root.Name != PresentationNamespace + "presentation") + { + throw new InvalidDataException( + $"The officeDocument part '{_presentationPartPath}' is not a presentation part."); + } + + _slideSize = ParseSlideSize(root); + _slidePartPaths = ParseSlideIdList(root, _presentationPartPath); + } + + /// + /// Parses the root's <p:sldSz cx="..." cy="..."/> child into a + /// . + /// + /// + /// Thrown when <p:sldSz> is missing, or either of its cx/cy + /// attributes is missing, non-numeric, or not a positive value. + /// + private static PptxSlideSize ParseSlideSize(XElement presentationRoot) + { + var sldSz = presentationRoot.Element(PresentationNamespace + "sldSz") ?? + throw new InvalidDataException("The presentation part has no element."); + + var cx = ParsePositiveEmuAttribute(sldSz, "cx"); + var cy = ParsePositiveEmuAttribute(sldSz, "cy"); + return new PptxSlideSize(cx, cy); + } + + /// + /// Reads and validates a single positive-integer EMU attribute (cx or cy) of + /// <p:sldSz>. + /// + /// + /// Thrown when the attribute is missing, non-numeric, or not strictly positive. + /// + private static long ParsePositiveEmuAttribute(XElement sldSz, string attributeName) + { + var value = (string?)sldSz.Attribute(attributeName); + if (string.IsNullOrEmpty(value) || + !long.TryParse(value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var parsed) || + parsed <= 0) + { + throw new InvalidDataException( + $"The presentation part's has a missing or invalid '{attributeName}' attribute."); + } + + return parsed; + } + + /// + /// Parses the root's <p:sldIdLst> child, resolving each + /// <p:sldId r:id="..."/> child's relationship into a slide part path, in + /// document order. + /// + /// + /// Thrown when <p:sldIdLst> is missing, declares zero <p:sldId> + /// children (an empty slide list), any child has a missing r:id attribute or an + /// r:id that does not resolve to a relationship of the presentation part, or a + /// resolved relationship's Type is not the OOXML slide-relationship type (a + /// malformed package pointing a declared slide ID at a theme, layout, or other + /// mistargeted part must fail here, at time, rather than only + /// later surfacing as a confusing failure from ). + /// + private IReadOnlyList ParseSlideIdList(XElement presentationRoot, string presentationPartPath) + { + var sldIdLst = presentationRoot.Element(PresentationNamespace + "sldIdLst") ?? + throw new InvalidDataException("The presentation part has no element."); + + var slidePartPaths = new List(); + foreach (var sldId in sldIdLst.Elements(PresentationNamespace + "sldId")) + { + var relationshipId = (string?)sldId.Attribute(RelationshipRefNamespace + "id"); + if (string.IsNullOrEmpty(relationshipId)) + { + throw new InvalidDataException("A element has a missing or empty 'r:id' attribute."); + } + + var relationshipType = GetRelationshipType(presentationPartPath, relationshipId); + if (!relationshipType.EndsWith(SlideRelationshipTypeSuffix, StringComparison.Ordinal)) + { + throw new InvalidDataException( + $"A element's relationship '{relationshipId}' has Type '{relationshipType}', which does not identify a slide part."); + } + + slidePartPaths.Add(ResolveRelationship(presentationPartPath, relationshipId)); + } + + if (slidePartPaths.Count == 0) + { + throw new InvalidDataException("The presentation declares no slides ( is empty)."); + } + + return slidePartPaths; + } + + /// + /// Converts from its native EMU unit to pixels at the given + /// resolution - the EMU analogue of PdfDocument.Render(int, float)'s + /// points-to-pixels conversion, provided now (ahead of Phase 1b having any rendering + /// surface) so a later rendering phase reuses this exact, already-tested conversion rather + /// than re-deriving it. + /// + /// The resolution to convert at, in dots (pixels) per inch. + /// + /// The slide size in pixels at , each dimension rounded to the + /// nearest pixel (). + /// + /// + /// Thrown when is not a positive, finite number. + /// + /// Thrown when this document has been disposed. + /// + /// Thrown when either converted dimension, rounded to the nearest pixel, would fall + /// outside the representable range - <p:sldSz> accepts any + /// positive EMU value, so a sufficiently large declared dimension (or a sufficiently high + /// ) must fail closed here rather than silently overflowing into a + /// negative or wrapped pixel size. + /// + public (int Width, int Height) GetSlideSizeInPixels(float dpi) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!float.IsFinite(dpi) || dpi <= 0) + { + throw new ArgumentOutOfRangeException(nameof(dpi), dpi, "DPI must be a positive, finite number."); + } + + const double emuPerInch = 914400d; + var scale = dpi / emuPerInch; + var roundedWidth = Math.Round(SlideSize.WidthEmu * scale, MidpointRounding.AwayFromZero); + var roundedHeight = Math.Round(SlideSize.HeightEmu * scale, MidpointRounding.AwayFromZero); + + // Reject a rounded dimension outside int's representable range before casting - a direct + // (int) cast on an out-of-range double silently wraps/truncates to an incorrect value + // rather than failing closed, which would otherwise be worse than a documented exception. + if (roundedWidth is < int.MinValue or > int.MaxValue || roundedHeight is < int.MinValue or > int.MaxValue) + { + throw new InvalidDataException( + $"The slide size converted to pixels at {dpi} DPI is outside the representable range."); + } + + return ((int)roundedWidth, (int)roundedHeight); + } +} diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Render.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Render.cs new file mode 100644 index 00000000..90a7d540 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Render.cs @@ -0,0 +1,910 @@ +using System.Numerics; +using System.Xml.Linq; +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Charts; +using Path = DemaConsulting.CanvasNet.Geometry.Path; + +namespace DemaConsulting.CanvasNet.Pptx; + +// cspell:ignore xfrm grpsppr sppr pptx prst cust unrenderable patt + +/// +/// Implements the public, slide-level rendering API (Phase 1f): +/// walks a slide's full shape tree (, produced by +/// ) in document order, threading an accumulating +/// transform through nested <p:grpSp> groups, and +/// dispatches each leaf node kind to the already-verified Phase 1c/1d/1e resolvers/painters - +/// see pptx-document.md's "Full Slide Rendering (Phase 1f)" design section for the +/// full per-node-kind dispatch and the deferred-items list this phase leaves unimplemented. +/// As of the Phase 2 Follow-Up background-fill hardening pass, also paints the slide's own +/// (or, failing that, its layout's/master's) <p:bg> background fill before the +/// shape-tree walk - see . +/// +public sealed partial class PptxDocument +{ + /// + /// Renders the specified slide into a new of the given dimensions, + /// walking the slide's full shape tree () in document + /// order and painting each recognized shape kind (see the + /// class remarks for this phase's dispatch summary, and pptx-document.md's "Full + /// Slide Rendering (Phase 1f)" design section for the full algorithm). + /// + /// The zero-based index of the slide to render. + /// The width of the rendered surface, in pixels. + /// The height of the rendered surface, in pixels. + /// + /// Optional slide-rendering configuration. When (the default), + /// is used, which clears the surface to opaque + /// white before the slide's shape tree is painted. + /// + /// + /// A new of the requested x + /// , painted with the slide's interpreted shape tree. The surface + /// is first cleared to 's + /// (opaque white by default); the slide's + /// own <p:cSld>/<p:bg> background fill (falling back to its layout's, + /// then its master's, own <p:bg> - see + /// ) is then painted across the full slide, before + /// any shape is walked, so slide content continues to draw on top of it; when none of + /// slide/layout/master declare a <p:bg> at all, 's + /// remains the only background a slide + /// with an empty shape tree renders as. Pass + /// new PptxRenderOptions { BackgroundColor = new Rgba32(0, 0, 0, 0) } to reproduce a + /// fully transparent fallback background. + /// + /// + /// Thrown when is negative or greater than or equal to + /// , or when / is + /// outside 's own valid dimension range (propagated, unwrapped, from + /// the constructor). + /// + /// + /// Thrown when the slide, its layout/master/theme relationship chain, or any shape's own + /// DrawingML (<a:xfrm>, <a:prstGeom>/<a:custGeom>, + /// <a:solidFill>/<a:gradFill>, <p:txBody>, a + /// <p:pic>'s embedded image relationship, or an <a:tbl>) is + /// malformed - propagated unchanged from the Phase 1b-1e resolvers this method dispatches + /// to (see // + /// // + /// ) - including a <p:pic>'s own + /// <a:prstGeom>/<a:custGeom> clip geometry, propagated unchanged + /// from exactly as it already propagates for an + /// auto-shape's own geometry. + /// + /// + /// Thrown when a shape declares a well-formed-but-unsupported DrawingML construct - + /// propagated unchanged from (an unsupported + /// <a:prstGeom> preset), (a pattern fill or + /// a radial/path gradient), (a linked, non-embedded + /// image, or an unsupported raster image format), or + /// (a <p:pic>'s own unsupported <a:prstGeom> clip preset). + /// + /// Thrown when this document has been disposed. + /// + /// + /// A shape (placeholder or freeform), picture, or graphic-frame whose fully-resolved + /// geometry element declares no <a:xfrm> anywhere in its own ancestry is + /// skipped silently, not treated as an error - a position-less shape + /// is a genuinely unrenderable (not malformed) construct this phase tolerates, mirroring + /// 's own established "tolerant tree walk" precedent. + /// + /// + /// A nested table, table auto-sizing/banding, group-level style cascading, and picture + /// effects/shadows are not rendered this phase - see pptx-document.md's "Full Slide + /// Rendering (Phase 1f)" design section for the complete deferred-items list, its "Phase 2 + /// Follow-Up: Slide/Layout/Master Background Fill (<p:bg>)" section for the + /// background-fill fidelity achieved (solid and theme-indexed <p:bgRef> + /// fills: full; linear gradient: best-effort; picture background fill: full; pattern + /// background fill: full for a covered preset subset, since ResolveFill is + /// shared across every fill context), and its "Phase 2 Follow-Up: Connector Shape Rendering (<p:cxnSp>)" + /// section for the connector-line rendering since added. + /// + /// + public Surface Render(int slideIndex, int width, int height, PptxRenderOptions? options = null) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (slideIndex < 0 || slideIndex >= _slidePartPaths.Count) + { + throw new ArgumentOutOfRangeException(nameof(slideIndex), slideIndex, "Slide index is out of range."); + } + + var surface = new Surface(width, height); + + // Render-time-only picture Surfaces decoded by this single call (via + // ResolveAndTrackPictureSurface - background/shape/connector fills, and the direct + // picture-shape case) are tracked in this call-local list, threaded explicitly + // through every recursive RenderNode/RenderShape/RenderPicture/RenderConnector call rather + // than a shared instance field - a field would be silently corrupted (and disposed images + // used-after-dispose, or leaked entirely) by two Render calls on the same PptxDocument + // racing concurrently on separate threads. Unlike the table-cell path (see + // ResolveAndOwnPictureSurface), every picture in this list is created fresh within, and + // painted fully by the end of, this one call - never stored in a cached slide/layout/ + // master shape tree - so it is always safe to dispose every entry in the finally block + // below. + var renderImages = new List(); + try + { + surface.Clear((options ?? PptxRenderOptions.Default).BackgroundColor); + + var slide = GetSlide(slideIndex); + var layout = GetLayout(slide.LayoutPartPath); + var master = GetMaster(layout.MasterPartPath); + var theme = GetTheme(master.ThemePartPath); + + var baseTransform = Matrix3x2.CreateScale(width / (float)SlideSize.WidthEmu, height / (float)SlideSize.HeightEmu); + + var colorMap = ResolveEffectiveColorMap(slide.ClrMapOvr, layout.ClrMapOvr, master.ColorMap); + + var backgroundFill = ResolveSlideBackgroundFill( + slide.Background, layout.Background, master.Background, theme, SlideSize.WidthEmu, SlideSize.HeightEmu, colorMap, + blip => ResolveAndTrackPictureSurface(slide.PartPath, blip, renderImages), + blip => ResolveAndTrackPictureSurface(layout.PartPath, blip, renderImages), + blip => ResolveAndTrackPictureSurface(master.PartPath, blip, renderImages)); + if (backgroundFill is not null) + { + var backgroundPath = Path.Rectangle(0, 0, SlideSize.WidthEmu, SlideSize.HeightEmu).Transform(baseTransform); + FillPaintComposingLocalTransform(surface, backgroundPath, backgroundFill, baseTransform); + } + + // Phase 2 Follow-Up: paint the master's, then the layout's, own non-placeholder + // decorative shapes (pictures, autoshapes, groups, freeform shapes) before the slide's + // own shape tree, so paint order becomes background -> master decoration -> layout + // decoration -> slide content (each later tier painting on top of the earlier ones). Each + // walk's own owner part path (not the slide's) is threaded through so a master/layout- + // owned 's embedded-image relationship resolves against its own .rels file, not + // the slide's. skipPlaceholderShapes: true means a master/layout's own placeholder shapes + // (its "Click to edit..." prompt content) are never painted directly - only their + // non-placeholder siblings are. + foreach (var node in master.ShapeTree) + { + RenderNode(surface, node, master.PartPath, layout, master, theme, baseTransform, colorMap, renderImages, skipPlaceholderShapes: true); + } + + foreach (var node in layout.ShapeTree) + { + RenderNode(surface, node, layout.PartPath, layout, master, theme, baseTransform, colorMap, renderImages, skipPlaceholderShapes: true); + } + + foreach (var node in slide.ShapeTree) + { + RenderNode(surface, node, slide.PartPath, layout, master, theme, baseTransform, colorMap, renderImages); + } + + return surface; + } + catch + { + // Dispose the partially-rendered surface only on the exceptional path - a failure + // part-way through the slide/layout/master/shape walk must not leak the (potentially + // large) pixel buffer until the next GC. The surface is still returned, undisposed, on + // the normal successful-return path above since ownership transfers to the caller. + surface.Dispose(); + throw; + } + finally + { + // Every picture Surface decoded by ResolveAndTrackPictureSurface during this single + // call (background/shape/connector fills, and the direct picture-shape case) + // is render-time-only - already fully painted onto surface above (or never reached, on + // the exceptional path) and never stored anywhere the caller or a cached slide/layout/ + // master shape tree can reach it afterward - so it is always safe, and necessary, to + // dispose each one here rather than leaking it until the next GC. This is distinct + // from the / table-cell picture path (see + // ResolveAndOwnPictureSurface), whose resolved Surfaces are cached inside the + // master/layout/slide's own cached shape tree and reused by later Render calls, so + // those are instead owned and disposed by Dispose. + foreach (var image in renderImages) + { + image.Dispose(); + } + } + } + + /// + /// Renders the specified slide at a given resolution, preserving the slide's own aspect + /// ratio. + /// + /// + /// A convenience wrapper over for + /// the common case of wanting undistorted, uniformly-scaled output: this overload reads + /// (in EMU, 914,400 EMU/inch), scales both dimensions by + /// / 914400, and rounds to the nearest pixel before delegating to + /// . Callers needing independent + /// X/Y scaling (for example, non-square pixels, or a specific pixel size regardless of + /// aspect ratio) should call + /// directly instead. + /// + /// The zero-based index of the slide to render. + /// The resolution to render at, in dots (pixels) per inch. + /// + /// Optional slide-rendering configuration, forwarded unchanged to + /// . When + /// (the default), is used, which clears the + /// surface to opaque white before the slide's shape tree is painted. + /// + /// + /// A new sized to the slide's aspect ratio at , + /// painted with the slide's interpreted shape tree. The surface is first cleared to + /// 's (opaque + /// white by default) - see for + /// details, including how to reproduce a fully transparent background. + /// + /// + /// Thrown when is negative or greater than or equal to + /// , when is not a positive, finite number, + /// or when the computed pixel width/height is non-finite, non-positive, or exceeds + /// (validated directly in this method, against + /// , before the narrowing cast to ). + /// + /// + /// See . + /// + /// + /// See . + /// + /// Thrown when this document has been disposed. + public Surface Render(int slideIndex, float dpi, PptxRenderOptions? options = null) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!float.IsFinite(dpi) || dpi <= 0) + { + throw new ArgumentOutOfRangeException(nameof(dpi), dpi, "DPI must be a positive, finite number."); + } + + // SlideSize is in EMU (914400 EMU/inch); dpi is pixels/inch. SlideSize's own getter + // re-validates disposal, so no separate ObjectDisposedException check is needed here + // beyond the one above (kept for a clear, slideIndex-independent failure point before any + // EMU math runs). + var slideSize = SlideSize; + if (slideIndex < 0 || slideIndex >= _slidePartPaths.Count) + { + throw new ArgumentOutOfRangeException(nameof(slideIndex), slideIndex, "Slide index is out of range."); + } + + var scale = dpi / 914400.0; + var widthPixels = slideSize.WidthEmu * scale; + var heightPixels = slideSize.HeightEmu * scale; + if (!double.IsFinite(widthPixels) || !double.IsFinite(heightPixels) || + widthPixels <= 0 || heightPixels <= 0 || + widthPixels > Surface.MaxDimension || heightPixels > Surface.MaxDimension) + { + throw new ArgumentOutOfRangeException( + nameof(dpi), dpi, + $"The computed pixel dimensions must be positive and not exceed {Surface.MaxDimension}x{Surface.MaxDimension}."); + } + + var width = (int)Math.Round(widthPixels, MidpointRounding.AwayFromZero); + var height = (int)Math.Round(heightPixels, MidpointRounding.AwayFromZero); + return Render(slideIndex, width, height, options); + } + + /// + /// Dispatches a single shape-tree node to its own per-kind rendering helper, recursing + /// into a group's own children with its composed child transform. + /// + /// The destination surface to paint onto. + /// The shape-tree node to dispatch. + /// + /// The part path that owns (the slide's, layout's, or master's own + /// part path) - needed so a 's embedded-image + /// relationship (see ) resolves against the owning + /// part's own .rels file, not always the slide's (OPC relationships are part-scoped + /// - see the companion planning report's bug-fix rationale). + /// + /// The slide's own resolved layout, consulted for placeholder-property inheritance. + /// The slide's own resolved master, consulted for placeholder-property inheritance. + /// The slide's own resolved theme, consulted for color/font resolution. + /// The accumulated transform from this node's own parent space into surface pixel space. + /// + /// The slide's own effective color map (see ), + /// computed once in and threaded + /// unchanged through every recursive call - consulted whenever a resolved fill/line/text + /// color declares an <a:schemeClr val="bg1"/>-shaped token. + /// + /// + /// The calling call's own + /// call-local picture-tracking list, threaded unchanged through every recursive call and + /// into // + /// - see . + /// + /// + /// When (the master/layout decorative-shape walks in + /// ), a + /// with a non-null is skipped without recursing + /// into - a master/layout's own placeholder shapes are + /// edit-mode-only "Click to edit..." prompt content in real PowerPoint and must stay + /// invisible when rendering an actual slide; only their non-placeholder siblings (and a + /// group's own non-placeholder descendants) are painted. Propagated unchanged into + /// recursive calls for a group's own children. Always for the + /// slide's own shape-tree walk (a slide's placeholder shapes, unlike a master's/layout's + /// own, are real content and must render normally). This same flag also gates + /// exception-containment: when , a + /// thrown while painting a single master/layout shape (including one nested inside a + /// group) is caught and only that one shape is skipped, so one already-deferred, well- + /// formed-but-unsupported decorative shape (for example an EMF picture) cannot abort the + /// rest of the slide's rendering - see pptx-document.md's "Phase 2 Follow-Up: + /// Master/Layout Decorative Shape Rendering" section. A slide's own shape + /// () is never caught here and continues to hard-fail + /// exactly as before this + /// containment was added. + /// + /// + /// The number of levels already entered to reach + /// (0 for a slide/layout/master's own top-level shape-tree + /// walk, the default for every pre-existing call site). Incremented on the recursive + /// self-call; once it would exceed + /// , that group's own children are not walked and an + /// is thrown instead. Mirrors 's + /// own identical depth parameter/cap - see 's + /// own remarks for why this bound exists independently at render time: a shape tree is + /// parsed once (/ cache theirs) but may be + /// rendered many times, so this walker must not rely solely on the parser's own cap having + /// run first. + /// + /// + /// Thrown when a is nested more than + /// levels deep. + /// + private void RenderNode( + Surface surface, + PptxShapeTreeNode node, + string ownerPartPath, + PptxLayout layout, + PptxMaster master, + PptxTheme theme, + Matrix3x2 parentToSurface, + PptxColorMap colorMap, + List renderImages, + bool skipPlaceholderShapes = false, + int depth = 0) + { + switch (node) + { + case PptxGroupShapeNode group: + if (depth >= MaxGroupShapeNestingDepth) + { + throw new InvalidDataException( + $"A group shape is nested beyond the maximum supported depth of {MaxGroupShapeNestingDepth} levels."); + } + + var childToSurface = group.ChildTransform * parentToSurface; + foreach (var child in group.Children) + { + RenderNode(surface, child, ownerPartPath, layout, master, theme, childToSurface, colorMap, renderImages, skipPlaceholderShapes, depth + 1); + } + + break; + + case PptxSpShapeNode sp: + if (skipPlaceholderShapes && sp.Placeholder is not null) + { + break; + } + + try + { + RenderShape(surface, sp, ownerPartPath, layout, master, theme, parentToSurface, colorMap, renderImages); + } + catch (PptxUnsupportedFeatureException) when (skipPlaceholderShapes) + { + // A master/layout's own decorative shape using an already-deferred, well- + // formed-but-unsupported feature must not abort the rest of the slide - see + // pptx-document.md's "Phase 2 Follow-Up: Master/Layout Decorative Shape + // Rendering" (graceful-skip containment). The `when (skipPlaceholderShapes)` + // filter means a slide's own shape (skipPlaceholderShapes == false) is never + // caught here and continues to hard-fail Render exactly as before this fix. + } + + break; + + case PptxPictureShapeNode pic: + try + { + RenderPicture(surface, pic, ownerPartPath, theme, parentToSurface, colorMap, renderImages); + } + catch (PptxUnsupportedFeatureException) when (skipPlaceholderShapes) + { + // See the PptxSpShapeNode case's own remarks above. + } + + break; + + case PptxGraphicFrameShapeNode graphicFrame: + try + { + RenderGraphicFrame(surface, graphicFrame, theme, parentToSurface, colorMap); + } + catch (PptxUnsupportedFeatureException) when (skipPlaceholderShapes) + { + // See the PptxSpShapeNode case's own remarks above. + } + + break; + + case PptxConnectorShapeNode connector: + try + { + RenderConnector(surface, connector, ownerPartPath, theme, parentToSurface, colorMap, renderImages); + } + catch (PptxUnsupportedFeatureException) when (skipPlaceholderShapes) + { + // See the PptxSpShapeNode case's own remarks above. RenderConnector itself + // additionally, and unconditionally (regardless of skipPlaceholderShapes), + // already narrowly contains the one sub-step realistically able to throw this + // same exception type (an exotic/unimplemented connector preset name) to just + // that one connector - see RenderConnector's own remarks - so this outer catch + // exists only for parity with every other node kind's own containment and is + // not expected to ever actually trigger for a connector in practice. + } + + break; + } + + // No default arm: PptxShapeTreeNode is a closed hierarchy over exactly these five + // subtypes (see PptxShapeTree.cs's own remarks) - ParseShapeTree already excludes every + // other unrecognized element kind before a PptxShapeTreeNode is ever constructed, so + // there is no sixth case to handle here. + } + + /// + /// Renders a (an ordinary or placeholder shape): resolves + /// its geometry/fill/stroke via the Phase 1c pipeline and, when it declares a + /// <p:txBody>, its text via the Phase 1d pipeline. + /// + private void RenderShape( + Surface surface, + PptxSpShapeNode node, + string ownerPartPath, + PptxLayout layout, + PptxMaster master, + PptxTheme theme, + Matrix3x2 parentToSurface, + PptxColorMap colorMap, + List renderImages) + { + Surface ResolveBlipImage(XElement blip) => ResolveAndTrackPictureSurface(ownerPartPath, blip, renderImages); + + XElement? spPrElement; + XElement? xfrmElement; + XElement? geometrySpPrElement; + PptxPlaceholderProperties placeholderProperties; + string placeholderType; + + if (node.Placeholder is { } placeholder) + { + placeholderProperties = ResolvePlaceholderProperties( + placeholder, layout.Placeholders, master.Placeholders, theme, master.TxStyles); + spPrElement = placeholderProperties.EffectiveSpPr; + xfrmElement = placeholderProperties.EffectiveXfrmElement; + geometrySpPrElement = placeholderProperties.EffectiveGeometrySpPr; + // Use the resolved effective type (own declared type, else the idx-matched layout + // placeholder's type, else the schema-defaulted type) rather than the slide + // placeholder's own raw/schema-defaulted type - see + // PptxPlaceholderProperties.EffectivePlaceholderType's remarks for why using the raw + // type here under-resolves an omitted-type title/subtitle placeholder's master + // text-style bucket to body style. + placeholderType = placeholderProperties.EffectivePlaceholderType ?? placeholder.Type; + } + else + { + placeholderProperties = new PptxPlaceholderProperties(null, null, theme, master.TxStyles); + spPrElement = node.ShapeElement.Element(PresentationNamespace + "spPr"); + xfrmElement = spPrElement?.Element(DrawingNamespace + "xfrm"); + geometrySpPrElement = spPrElement; + placeholderType = string.Empty; + } + + if (spPrElement is null || xfrmElement is null || geometrySpPrElement is null) + { + // No resolvable (or no resolvable /) anywhere in + // this shape's ancestry - skip silently rather than throwing (see this file's Render + // remarks, Assumption 1 of the companion planning report). + return; + } + + var frame = ResolveShapeFrame(xfrmElement); + var localToSurface = frame.Transform * parentToSurface; + + var geometryPath = ResolveShapeGeometry(geometrySpPrElement, frame.WidthEmu, frame.HeightEmu); + var transformedPath = geometryPath.Transform(localToSurface); + + // A shape's own "Shape Styles" gallery reference (p:style's fillRef/lnRef) is always read + // from the slide shape's own element, never inherited from its layout/master placeholder, + // unlike the fill-position/xfrm/geometry resolution above. This is a documented, + // narrower-scope limitation - see pptx-document.md's "Phase 2 Follow-Up: Shape Style + // References" design section. + var styleElement = node.ShapeElement.Element(PresentationNamespace + "style"); + + // An explicit fill-definition child on spPr always wins over a style fillRef + // (ResolveFill's own "nothing at all" and "explicit noFill" cases are otherwise + // indistinguishable from its return value alone). + var fill = HasExplicitFillChild(spPrElement) + ? ResolveFill(spPrElement, theme, frame.WidthEmu, frame.HeightEmu, colorMap: colorMap, resolveBlipImage: ResolveBlipImage) + : ResolveShapeStyleFill(styleElement, theme, frame.WidthEmu, frame.HeightEmu, colorMap, ResolveBlipImage); + FillPaintComposingLocalTransform(surface, transformedPath, fill, localToSurface); + + // An explicit fill-definition child (including ) on the shape's own + // wins outright over a style lnRef. A present with no recognized fill-definition + // child of its own keeps its own width/dash but defers only its color to the style + // lnRef (falling back to "no stroke" when no style color is available). A fully absent + // defers entirely to the style lnRef. See ResolveShapeLineStyle's own remarks. + var lnElement = spPrElement.Element(DrawingNamespace + "ln"); + var lineStyle = ResolveShapeLineStyle(lnElement, styleElement, theme, colorMap, ResolveBlipImage); + if (lineStyle is not null) + { + var strokedOutline = ResolveStrokeOutline(geometryPath, lineStyle, localToSurface).Transform(localToSurface); + FillPaintComposingLocalTransform(surface, strokedOutline, lineStyle.Paint, localToSurface); + } + + // A placeholder's own text content is always read from the slide-level shape element + // itself - only styling inherits from the layout/master, never content (see the + // companion planning report's Risk 2). + var txBodyElement = node.ShapeElement.Element(PresentationNamespace + "txBody"); + if (txBodyElement is not null) + { + var textBody = ParseTextBody(txBodyElement); + var layoutResult = ResolveTextLayout( + textBody, placeholderProperties, theme, placeholderType, frame.WidthEmu, frame.HeightEmu, ResolveTextFont, colorMap); + PaintTextLayout(surface, layoutResult, localToSurface); + } + } + + /// + /// Determines whether declares an explicit + /// fill-definition child (<a:noFill>/<a:solidFill>/ + /// <a:gradFill>/<a:pattFill>/<a:blipFill>) at all - + /// needed because itself collapses "explicit <a:noFill/>" + /// and "no recognized fill-definition child at all" to the same + /// return value, so that return value alone cannot distinguish "this shape explicitly wins + /// over its own <p:style>/<a:fillRef>" from "this shape falls back to + /// it" (see ). + /// + private static bool HasExplicitFillChild(XElement fillParentElement) => + fillParentElement.Element(DrawingNamespace + "noFill") is not null || + fillParentElement.Element(DrawingNamespace + "solidFill") is not null || + fillParentElement.Element(DrawingNamespace + "gradFill") is not null || + fillParentElement.Element(DrawingNamespace + "pattFill") is not null || + fillParentElement.Element(DrawingNamespace + "blipFill") is not null; + + /// + /// Resolves a picture surface exactly like , then + /// records the decoded in so the + /// currently-executing call + /// disposes it once painting completes. + /// + /// See . + /// See . + /// + /// The calling call's own + /// call-local tracking list - threaded explicitly through the render call chain, rather + /// than read from a shared instance field, so two concurrent + /// calls on the same + /// each dispose only the images they themselves decoded. + /// + /// See . + private Surface ResolveAndTrackPictureSurface(string ownerPartPath, XElement blipFillElement, List renderImages) + { + var surface = ResolvePictureSurface(ownerPartPath, blipFillElement); + renderImages.Add(surface); + return surface; + } + + /// + /// Renders a : decodes and composites its embedded + /// image via the Phase 1e picture pipeline, clipped to its own resolved + /// <p:spPr>/<a:prstGeom>/<a:custGeom> geometry (see + /// ) when it declares a non-rect preset or a + /// custom geometry - Phase 2 Follow-Up: Picture Preset-Geometry Clipping - and then, on + /// top of the composited image, strokes the picture's own <a:ln> (when + /// present) around its resolved geometry (see ) - + /// Phase 2 Follow-Up: Picture Own-Stroke Outline Rendering. Mirrors 's + /// own fill-then-stroke painting order: the image content paints first, and the stroke + /// frames it on top, matching real-world PowerPoint's own visual stacking. + /// + /// The destination surface to paint onto. + /// The picture shape-tree node to render. + /// + /// The part path that owns (the slide's, layout's, or master's own + /// part path) - the <a:blip r:embed="..."/> relationship is scoped to this + /// part's own .rels file (see ). + /// + /// + /// The resolved theme, used to resolve the picture's own stroke line style (see + /// 's theme parameter) - needed because a + /// <p:pic>'s own <a:ln> can defer its color to a sibling + /// <p:style>/<a:lnRef>, exactly like an ordinary <p:sp>. + /// + /// The accumulated transform from this node's own parent space into surface pixel space. + /// + /// The effective color map consulted when the resolved stroke paint declares an + /// <a:schemeClr val="bg1"/>-shaped token - see 's + /// matching parameter. + /// + /// + /// The calling call's own + /// call-local picture-tracking list - see . + /// + private void RenderPicture( + Surface surface, + PptxPictureShapeNode node, + string ownerPartPath, + PptxTheme theme, + Matrix3x2 parentToSurface, + PptxColorMap colorMap, + List renderImages) + { + var spPrElement = node.PicElement.Element(PresentationNamespace + "spPr"); + var xfrmElement = spPrElement?.Element(DrawingNamespace + "xfrm"); + if (xfrmElement is null) + { + // Schema-malformed (a well-formed always declares this), but tolerated + // defensively for consistency with the shape/graphic-frame skip policy (see + // this file's Render remarks). + return; + } + + var blipFillElement = node.PicElement.Element(PresentationNamespace + "blipFill"); + if (blipFillElement is null) + { + return; + } + + var frame = ResolveShapeFrame(xfrmElement); + var localToSurface = frame.Transform * parentToSurface; + + var image = ResolveAndTrackPictureSurface(ownerPartPath, blipFillElement, renderImages); + var srcRect = ResolveSrcRect(blipFillElement); + + // spPrElement is guaranteed non-null here: xfrmElement (checked above) is resolved via + // spPrElement?.Element(...), so a null xfrmElement would already have returned. + var clipPath = ResolvePictureClipPath(spPrElement!, frame.WidthEmu, frame.HeightEmu); + PaintPicture(surface, image, srcRect, localToSurface, frame.WidthEmu, frame.HeightEmu, clipPath); + + // The picture's own "Shape Styles" gallery reference (p:style's lnRef), mirroring + // RenderShape's identical sibling-element lookup - a can carry a sibling + // under ECMA-376's CT_Picture schema exactly like a 's CT_Shape. + var styleElement = node.PicElement.Element(PresentationNamespace + "style"); + + // The picture's own : absent entirely until this fix (the root cause this phase + // resolves - see this method's own remarks and the companion planning report). Resolved + // via the exact same ResolveShapeLineStyle an ordinary auto-shape already uses - no new + // line-style-resolution logic needed. + var lnElement = spPrElement!.Element(DrawingNamespace + "ln"); + var lineStyle = ResolveShapeLineStyle(lnElement, styleElement, theme, colorMap); + if (lineStyle is not null) + { + // Unlike the clip path above (which collapses "no geometry"/"rect" to null - a pure + // image-content-clip optimization), the stroke outline always needs a concrete + // geometry path - see ResolvePictureGeometryPath's own remarks for why this is a + // deliberately separate resolution from clipPath above. + var geometryPath = ResolvePictureGeometryPath(spPrElement, frame.WidthEmu, frame.HeightEmu); + var strokedOutline = ResolveStrokeOutline(geometryPath, lineStyle, localToSurface).Transform(localToSurface); + FillPaintComposingLocalTransform(surface, strokedOutline, lineStyle.Paint, localToSurface); + } + } + + /// + /// Renders a (a table or, as of Phase 4, a chart): + /// paints its already-parsed via the + /// Phase 1e table pipeline, or its already-parsed + /// by rendering it onto its own, dedicated (sized via + /// ) with + /// and compositing that surface onto with the exact same + /// primitive already uses for a + /// <p:pic> - no new compositing code, only a small pixel-size-derivation + /// helper, since a chart (unlike a picture) has no source image of its own whose pixel + /// dimensions could be reused directly. + /// + private static void RenderGraphicFrame( + Surface surface, PptxGraphicFrameShapeNode node, PptxTheme theme, Matrix3x2 parentToSurface, PptxColorMap colorMap) + { + // Per ECMA-376's CT_GraphicalObjectFrame, a 's own position is a direct + // child - not wrapped in a , unlike an ordinary shape or picture. + var xfrmElement = node.GraphicFrameElement.Element(PresentationNamespace + "xfrm"); + if (xfrmElement is null) + { + return; + } + + var frame = ResolveShapeFrame(xfrmElement); + var localToSurface = frame.Transform * parentToSurface; + + if (node.Table is not null) + { + PaintTable(surface, node.Table, theme, localToSurface, ResolveTextFont, colorMap); + } + else if (node.Chart is not null) + { + var (pixelWidth, pixelHeight) = ResolveGraphicFramePixelSize(frame, localToSurface); + using var chartSurface = ChartRenderer.Render(node.Chart, pixelWidth, pixelHeight); + PaintPicture(surface, chartSurface, srcRect: null, localToSurface, frame.WidthEmu, frame.HeightEmu); + } + } + + /// + /// Derives the pixel dimensions a <p:graphicFrame> chart should be rendered at + /// (Phase 4), so + /// produces a whose own pixel size roughly matches the graphic + /// frame's own on-slide footprint - mirroring 's own corner- + /// transform technique for computing a destination bounding box, reused here only to pick + /// a render resolution ( itself is still the method that + /// actually composites the rendered chart onto the destination surface). + /// + /// The graphic frame's own resolved local frame (width/height in EMU). + /// The transform mapping the frame's own local space into surface pixel space. + /// + /// The chart's own render width/height, in pixels, each clamped to + /// [1, ] so a degenerate (zero/negative) or + /// excessively large transformed footprint can never produce an invalid + /// size. + /// + private static (int Width, int Height) ResolveGraphicFramePixelSize(PptxShapeFrame frame, Matrix3x2 localToSurface) + { + var unitToSurface = Matrix3x2.CreateScale(frame.WidthEmu, frame.HeightEmu) * localToSurface; + var corner00 = Vector2.Transform(new Vector2(0, 0), unitToSurface); + var corner10 = Vector2.Transform(new Vector2(1, 0), unitToSurface); + var corner01 = Vector2.Transform(new Vector2(0, 1), unitToSurface); + + var width = (int)MathF.Round(Vector2.Distance(corner00, corner10)); + var height = (int)MathF.Round(Vector2.Distance(corner00, corner01)); + + return ( + Math.Clamp(width, 1, Surface.MaxDimension), + Math.Clamp(height, 1, Surface.MaxDimension)); + } + + /// + /// Renders a (Phase 2 Follow-Up: Connector Shape + /// Rendering): a <p:cxnSp> straight/elbow/curved connector line, typically + /// drawn between two other shapes in a flowchart or diagram, with no text body and + /// (unless it explicitly declares one) no fill of its own. + /// + /// + /// + /// Painting order mirrors 's own fill-then-stroke order: an + /// explicit fill (rare for a connector, but valid - see hard requirement #4 of the + /// companion planning report) is painted first, then the stroked line outline, then + /// either endpoint's own resolved arrowhead. + /// + /// + /// Exception containment. Only - the one + /// realistically able to throw a for a + /// connector (an exotic/unimplemented preset name; see + /// ) - is wrapped in its own, narrow, + /// unconditional try/catch here, deliberately returning (skipping just this one + /// connector) regardless of the caller's own skipPlaceholderShapes containment + /// policy. This is a deliberate, connector-specific deviation from every other node kind's + /// policy (which only ever skips gracefully for a master/layout's own decorative shapes, + /// per 's own remarks): an unsupported connector preset is common + /// enough in real-world decks (this phase supports only the straight/bent/curved + /// connector families - see ) that it should degrade to + /// "this one connector is invisible" rather than aborting an entire slide's own rendering, + /// even for a slide's own (non-placeholder) shape tree. + /// + /// + private void RenderConnector( + Surface surface, PptxConnectorShapeNode node, string ownerPartPath, PptxTheme theme, Matrix3x2 parentToSurface, + PptxColorMap colorMap, List renderImages) + { + Surface ResolveBlipImage(XElement blip) => ResolveAndTrackPictureSurface(ownerPartPath, blip, renderImages); + + var spPrElement = node.CxnSpElement.Element(PresentationNamespace + "spPr"); + var xfrmElement = spPrElement?.Element(DrawingNamespace + "xfrm"); + if (spPrElement is null || xfrmElement is null) + { + // No resolvable - skip silently rather than throwing, matching RenderShape's + // own policy for a shape with no resolvable frame. + return; + } + + var frame = ResolveShapeFrame(xfrmElement); + var localToSurface = frame.Transform * parentToSurface; + + Path geometryPath; + try + { + geometryPath = ResolveShapeGeometry(spPrElement, frame.WidthEmu, frame.HeightEmu); + } + catch (PptxUnsupportedFeatureException) + { + // See this method's own remarks: an unsupported connector preset degrades to "this + // one connector is invisible", unconditionally, rather than aborting the slide. + return; + } + + if (geometryPath.Subpaths.Count == 0) + { + return; + } + + // A connector has no fill by default (hard requirement #4) - only paint one when the + // shape's own spPr explicitly declares a recognized fill-definition child, exactly like + // RenderShape's own "explicit fill-definition child always wins" HasExplicitFillChild + // check (a connector has no / style fallback to consult at all). + if (HasExplicitFillChild(spPrElement)) + { + var fill = ResolveFill(spPrElement, theme, frame.WidthEmu, frame.HeightEmu, colorMap: colorMap, resolveBlipImage: ResolveBlipImage); + var transformedPath = geometryPath.Transform(localToSurface); + FillPaintComposingLocalTransform(surface, transformedPath, fill, localToSurface); + } + + var styleElement = node.CxnSpElement.Element(PresentationNamespace + "style"); + var lineStyle = ResolveConnectorLineStyle(spPrElement, styleElement, theme, colorMap, ResolveBlipImage); + if (lineStyle is null) + { + return; + } + + var strokedOutline = ResolveStrokeOutline(geometryPath, lineStyle, localToSurface).Transform(localToSurface); + FillPaintComposingLocalTransform(surface, strokedOutline, lineStyle.Paint, localToSurface); + + var lnElement = spPrElement.Element(DrawingNamespace + "ln"); + var (startPoint, startTangent, endPoint, endTangent) = ComputeEndpointsAndTangents(geometryPath); + + // headEnd is this connector's own start vertex, oriented pointing backward (away from the + // line, continuing past the start in the reverse direction of travel) - see + // PptxDocument.Connectors.cs's own remarks for why this mapping (rather than the other, + // superficially equally plausible one) matches real PowerPoint-authored connectors. + var headEnd = ResolveArrowhead(lnElement, "headEnd"); + if (headEnd is not null) + { + PaintArrowhead(surface, headEnd, lineStyle, startPoint, -startTangent, localToSurface); + } + + // tailEnd is this connector's own end vertex, oriented pointing forward (continuing past + // the end in the same direction of travel). + var tailEnd = ResolveArrowhead(lnElement, "tailEnd"); + if (tailEnd is not null) + { + PaintArrowhead(surface, tailEnd, lineStyle, endPoint, endTangent, localToSurface); + } + } + + /// + /// Builds, orients, positions, and paints a single resolved connector arrowhead. + /// + /// The destination surface to paint onto. + /// The resolved arrowhead style to paint. + /// The connector's own resolved line style (supplies the arrowhead's own size-scale basis and, for an open/stroked arrowhead, its paint/width). + /// The arrowhead's own tip position, in the connector's local (pre-) coordinate space. + /// The direction the arrowhead's own tip points toward, in that same local coordinate space - need not be unit length. + /// The connector's own resolved local-to-surface transform. + private static void PaintArrowhead( + Surface surface, PptxArrowheadStyle style, PptxLineStyle lineStyle, Vector2 point, Vector2 direction, Matrix3x2 localToSurface) + { + if (direction == Vector2.Zero) + { + direction = Vector2.UnitX; + } + + var arrowheadPath = PptxArrowheadGeometry.Build(style, lineStyle.WidthEmu); + if (arrowheadPath.Subpaths.Count == 0) + { + return; + } + + // Rotating/translating in the connector's own local space (before localToSurface is + // applied) - rather than transforming direction/point into surface space first and + // rotating there - keeps an arrowhead consistent with any non-uniform scale or flip + // localToSurface itself carries, exactly like the connector's own stroked line outline + // (also built and transformed in local space; see RenderConnector). + var angle = MathF.Atan2(direction.Y, direction.X); + var orientToSurface = Matrix3x2.CreateRotation(angle) * Matrix3x2.CreateTranslation(point) * localToSurface; + var transformedArrowhead = arrowheadPath.Transform(orientToSurface); + + if (style.Kind == PptxArrowheadKind.Arrow) + { + // The "arrow" open-chevron kind is stroked with the connector's own line paint/width, + // never dashed (an arrowhead should always read as a solid mark, even on a dashed + // connector) - built directly from PptxArrowheadGeometry.Build's own open path rather + // than ResolveStrokeOutline's usual "closed shape outline" path. + var arrowheadLineStyle = lineStyle with { DashArray = null }; + var strokedArrowhead = ResolveStrokeOutline(arrowheadPath, arrowheadLineStyle, orientToSurface).Transform(orientToSurface); + FillPaintComposingLocalTransform(surface, strokedArrowhead, lineStyle.Paint, orientToSurface); + } + else + { + FillPaintComposingLocalTransform(surface, transformedArrowhead, lineStyle.Paint, orientToSurface); + } + } +} diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.ShapeTree.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.ShapeTree.cs new file mode 100644 index 00000000..69091858 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.ShapeTree.cs @@ -0,0 +1,232 @@ +using System.Xml.Linq; +using DemaConsulting.CanvasNet.Canvas; + +namespace DemaConsulting.CanvasNet.Pptx; + +// cspell:ignore grpsp cxnsp sptree pptx xfrm + +/// +/// Implements the shape-tree walker (Phase 1e, extended by the +/// Phase 2 Follow-Up connector-rendering work and Phase 4's chart-graphic-frame dispatch): +/// recursively parses a <p:spTree> +/// (or a <p:grpSp>, whose own shape children are direct siblings of its +/// <p:nvGrpSpPr>/<p:grpSpPr> rather than a nested +/// <p:spTree>, per ECMA-376's CT_GroupShape content model) into a +/// hierarchy, dispatching each recognized child element kind to +/// its own node type and silently skipping anything else. +/// +public sealed partial class PptxDocument +{ + /// + /// The maximum number of nested <p:grpSp> levels + /// (and, mirroring it at render time, PptxDocument.Render.cs's own + /// RenderNode) will recurse into before failing closed, bounding the worst-case + /// call-stack depth a single, attacker-controlled shape tree can drive. 100 levels is far + /// beyond any legitimate PowerPoint-authored group nesting (real decks rarely nest more + /// than a handful of groups deep; PowerPoint's own UI makes deeper nesting increasingly + /// impractical to construct by hand), while still leaving comfortable headroom below the + /// default platform stack size - unlike the ZIP-entry-size (), + /// XML-character-count (), and chart cached-point-count + /// (OpenXmlChartParser.MaxCachedPointCount) caps, group-shape nesting depth has no + /// bound of its own: a crafted .pptx easily fits thousands of nested + /// <p:grpSp> elements within the existing ~2,000,000-character XML-part cap, + /// which - left unchecked - drives a that cannot be + /// caught, crashing the process rather than a clean, fail-closed rejection. + /// + private const int MaxGroupShapeNestingDepth = 100; + + /// + /// Walks 's immediate children in document order, + /// dispatching each recognized shape element to its own : + /// <p:sp> → (via + /// ), <p:pic> → + /// , <p:graphicFrame> → + /// (eagerly parsing its <a:graphicData>'s + /// own uri attribute: a chart kind, when is + /// non-null, is dispatched to (Phase 4); every other kind, + /// including a chart when is , + /// is dispatched to the pre-existing , which itself still throws + /// for any non-table kind), and + /// <p:grpSp> → + /// (resolving its child transform via + /// and recursing into the same group element for + /// its own children, since a group's children are direct siblings of its own + /// <p:nvGrpSpPr>/<p:grpSpPr>, not wrapped in a nested + /// <p:spTree>), and <p:cxnSp> → + /// (Phase 2 Follow-Up: Connector Shape Rendering). + /// As of the companion planning report's parse-time containment fix, + /// gates whether a + /// <p:graphicFrame> declaring a recognized-but-unsupported (non-table, + /// non-chart, or recognized-but-unsupported chart) kind + /// is skipped (master/layout-owned shape trees) or still propagates (a slide's own shape + /// tree), mirroring PptxDocument.Render.cs's own RenderNode + /// skipPlaceholderShapes render-time convention, extended to parse time. + /// + /// + /// The <p:spTree> (slide-level) or <p:grpSp> (nested-group-level) + /// element whose immediate children are walked. + /// + /// + /// Lazily invoked to resolve the slide's theme (see ) only when a + /// <p:graphicFrame> table is actually encountered, then threaded through to + /// so its cells' fills can be resolved against theme-referenced + /// colors/fonts at shape-tree-build time rather than deferring theme resolution to paint + /// time. Deferred (rather than eagerly resolved by the caller) so slides with no tables at + /// all - the overwhelming majority - never require their layout/master/theme relationship + /// chain to be walked. + /// + /// + /// Lazily invoked (via 's own matching parameter) only when a + /// <p:graphicFrame> table actually declares a non-empty + /// <a:tableStyleId>, to resolve it against ppt/tableStyles.xml - see + /// . Threaded through the recursive <p:grpSp> + /// self-call unchanged. Defaults to ("no table style available") so + /// every pre-existing call site keeps compiling and behaving unchanged. + /// + /// + /// Lazily invoked (via 's own colorMap parameter) only when + /// a <p:graphicFrame> table is actually encountered, to resolve the effective + /// a cell's own fill/border scheme-color token should be + /// resolved against - see 's own colorMap parameter and its + /// documented residual (master/layout-owned-table-only) limitation. Threaded through the + /// recursive <p:grpSp> self-call unchanged, mirroring + /// 's own threading pattern. Defaults to + /// (resolving to ) so every + /// pre-existing call site keeps compiling and behaving unchanged. + /// + /// + /// When , a <p:graphicFrame> whose + /// <a:graphicData> declares a recognized-but-unsupported (non-table) kind - + /// a chart, SmartArt, OLE object, etc. - has its + /// caught and that node silently skipped (its other siblings still parse normally), + /// mirroring PptxDocument.Render.cs's own RenderNode + /// skipPlaceholderShapes-gated render-time convention. When + /// (the default), the exception propagates unchanged, aborting the rest of this shape + /// tree's parse. keeps the default (a + /// slide's own unsupported graphic frame must still hard-fail + /// - see + /// the three protected PptxFixturesCorpusTests.cs slide-level-throw tests); + /// / pass , since each + /// is parsed once and cached, shared by every slide using that layout/master - an + /// uncontained exception there would otherwise abort every slide sharing it. Threaded + /// through the recursive <p:grpSp> self-call unchanged. + /// + /// + /// The recognized shape-tree nodes, in document order. Unrecognized element kinds (for + /// example <p:nvGrpSpPr>, <p:grpSpPr>, or + /// <p:contentPart>) are silently skipped - a tree-walk tolerance, not a + /// fail-closed feature rejection, matching the existing Phase 1b precedent of silently + /// excluding non-placeholder shapes from . + /// + /// + /// Thrown when a <p:grpSp> element has no <p:grpSpPr>/<a:xfrm> + /// element, or (via , , or + /// ) when a <p:graphicFrame>'s table + /// or chart, or a <p:grpSp>'s own transform, is otherwise malformed; or when a + /// <p:grpSp> is nested more than + /// levels deep. + /// + /// + /// Thrown (via or, for a chart, via ) + /// when a <p:graphicFrame>'s <a:graphicData> declares a + /// recognized-but-unsupported kind (a non-table kind for ; a + /// recognized-but-unsupported chart kind, such as a radar chart, for + /// ) and is + /// (the default). When + /// is , this exception is instead caught and the offending node + /// silently skipped. + /// + /// + /// Threaded unchanged into and the recursive + /// <p:grpSp> self-call - see 's matching parameter. + /// + /// + /// Resolves a <c:chart> element's own r:id relationship, scoped to the + /// owning slide/layout/master part, to that chart part's root - + /// see 's matching parameter. When a <p:graphicFrame>'s + /// <a:graphicData> declares a chart (its uri attribute ends in + /// "/chart") and this parameter is non-null, is called + /// instead of . Defaults to (Phase 4's own + /// graceful-degradation safety net): a resolver falls back to + /// today's pre-existing call for every <p:graphicFrame>, + /// preserving every pre-existing call site's exact behavior unchanged. Threaded through + /// the recursive <p:grpSp> self-call unchanged. + /// + /// + /// The number of <p:grpSp> levels already entered to reach + /// (0 for the slide/layout/master's own + /// top-level <p:spTree>, the default for every pre-existing call site). + /// Incremented on the recursive <p:grpSp> self-call; once it would exceed + /// , that nested group's own children are not + /// walked and an is thrown instead - see + /// 's own remarks for why this bound exists. + /// + internal static IReadOnlyList ParseShapeTree( + XElement spTreeOrGroupElement, Func themeResolver, Func? tableStyleResolver = null, + Func? colorMapResolver = null, bool containUnsupportedGraphicFrames = false, + Func? resolveBlipImage = null, Func? resolveChartPart = null, + int depth = 0) + { + var nodes = new List(); + + foreach (var child in spTreeOrGroupElement.Elements()) + { + if (child.Name == PresentationNamespace + "sp") + { + nodes.Add(new PptxSpShapeNode(child, PptxPlaceholderParser.TryParsePlaceholder(child))); + } + else if (child.Name == PresentationNamespace + "pic") + { + nodes.Add(new PptxPictureShapeNode(child)); + } + else if (child.Name == PresentationNamespace + "graphicFrame") + { + try + { + var graphicData = child.Element(DrawingNamespace + "graphic")?.Element(DrawingNamespace + "graphicData"); + var uri = (string?)graphicData?.Attribute("uri") ?? string.Empty; + if (resolveChartPart is not null && graphicData is not null && uri.EndsWith("/chart", StringComparison.Ordinal)) + { + nodes.Add(new PptxGraphicFrameShapeNode(child, Table: null, Chart: ParseChart(graphicData, resolveChartPart))); + } + else + { + nodes.Add(new PptxGraphicFrameShapeNode( + child, ParseTable(child, themeResolver(), colorMapResolver?.Invoke(), tableStyleResolver, resolveBlipImage), Chart: null)); + } + } + catch (PptxUnsupportedFeatureException) when (containUnsupportedGraphicFrames) + { + // A master/layout-owned graphic frame of an unsupported (non-table, non-chart, + // or recognized-but-unsupported chart) kind is skipped rather than aborting + // this entire cached shape tree - see this parameter's own XmlDoc remarks. + } + } + else if (child.Name == PresentationNamespace + "grpSp") + { + if (depth >= MaxGroupShapeNestingDepth) + { + throw new InvalidDataException( + $"A element is nested beyond the maximum supported depth of {MaxGroupShapeNestingDepth} levels."); + } + + var groupXfrm = child.Element(PresentationNamespace + "grpSpPr")?.Element(DrawingNamespace + "xfrm") ?? + throw new InvalidDataException("A element has no / element."); + var childTransform = ResolveGroupChildTransform(groupXfrm); + var children = ParseShapeTree( + child, themeResolver, tableStyleResolver, colorMapResolver, containUnsupportedGraphicFrames, + resolveBlipImage, resolveChartPart, depth + 1); + nodes.Add(new PptxGroupShapeNode(child, childTransform, children)); + } + else if (child.Name == PresentationNamespace + "cxnSp") + { + nodes.Add(new PptxConnectorShapeNode(child)); + } + + // Any other element kind (, , , or anything + // unrecognized) is silently skipped. + } + + return nodes; + } +} diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Slides.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Slides.cs new file mode 100644 index 00000000..df9201a7 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Slides.cs @@ -0,0 +1,103 @@ +namespace DemaConsulting.CanvasNet.Pptx; + +// cspell:ignore csld pptx + +/// +/// Implements the slide parser (Phase 1b): parses +/// ppt/slides/slideN.xml, resolving its /slideLayout relationship and enumerating +/// its immediate placeholder shapes. Lazy and cached by slide index. +/// +public sealed partial class PptxDocument +{ + /// Caches each slide's parsed form, keyed by its zero-based slide index, on first access. + private readonly Dictionary _slideCache; + + /// + /// Returns the parsed slide at (into the ordered slide list + /// resolved by ), parsing and caching it on first + /// access. + /// + /// The zero-based index of the slide, in <p:sldIdLst> document order. + /// The parsed . + /// + /// Thrown when is negative or greater than or equal to + /// . + /// + /// Thrown when this document has been disposed. + /// + /// Thrown when the slide part is missing or not well-formed XML, when its root element is + /// not a PresentationML <p:sld> element, when its + /// <p:cSld>/<p:spTree> element is missing, when a placeholder's + /// idx attribute is invalid, when it has no /slideLayout relationship, or + /// (via , lazily resolving this slide's layout/master/theme + /// chain only when its shape tree actually declares a <p:graphicFrame> table) + /// when any part along that chain is malformed. + /// + /// + /// Thrown (via ) when this slide's own + /// <p:graphicFrame>'s <a:graphicData> declares a + /// recognized-but-unsupported (non-table) kind - is called + /// here with its default containUnsupportedGraphicFrames: false, so (unlike + /// /, which pass ) + /// this always propagates rather than being silently skipped. + /// + internal PptxSlide GetSlide(int slideIndex) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (slideIndex < 0 || slideIndex >= _slidePartPaths.Count) + { + throw new ArgumentOutOfRangeException(nameof(slideIndex), slideIndex, "Slide index is out of range."); + } + + if (_slideCache.TryGetValue(slideIndex, out var cached)) + { + return cached; + } + + var slidePartPath = _slidePartPaths[slideIndex]; + var root = LoadPartXmlRoot(slidePartPath); + if (root.Name != PresentationNamespace + "sld") + { + throw new InvalidDataException($"Part '{slidePartPath}' is not a part."); + } + + var cSld = root.Element(PresentationNamespace + "cSld") ?? + throw new InvalidDataException($"Slide '{slidePartPath}' has no element."); + var spTree = cSld.Element(PresentationNamespace + "spTree") ?? + throw new InvalidDataException($"Slide '{slidePartPath}' has no / element."); + + var placeholders = PptxPlaceholderParser.ParsePlaceholderShapes(spTree); + var layoutPartPath = ResolveRelationshipByType(slidePartPath, "/slideLayout"); + var background = cSld.Element(PresentationNamespace + "bg"); + var clrMapOvr = root.Element(PresentationNamespace + "clrMapOvr"); + + // The shape tree's tables resolve their cell fills against the slide's + // own theme (see ParseTable's theme parameter). Resolving the layout -> master -> theme + // relationship chain (the same chain a caller would otherwise walk manually - see + // PptxSystemIntegrationTests.cs's own ResolveTheme helper) is deferred into this lambda + // so slides with no tables at all never require it to be walked. + var shapeTree = ParseShapeTree(spTree, () => + { + var layout = GetLayout(layoutPartPath); + var master = GetMaster(layout.MasterPartPath); + return GetTheme(master.ThemePartPath); + }, tableStyleResolver: TryResolveTableStyle, + colorMapResolver: () => + { + // Safe to compute lazily (mirroring themeResolver's own laziness) because + // _slideCache is keyed 1:1 by slide index: this slide's own effective color map is + // always the same deterministic value for every future consumer of this cached + // PptxSlide - unlike GetLayout's/GetMaster's own shared caches, which are reused by + // every slide through that layout/master and therefore intentionally left without a + // colorMapResolver (see ParseTable's own colorMap parameter XmlDoc). + var layout = GetLayout(layoutPartPath); + var master = GetMaster(layout.MasterPartPath); + return ResolveEffectiveColorMap(clrMapOvr, layout.ClrMapOvr, master.ColorMap); + }, resolveBlipImage: blip => ResolveAndOwnPictureSurface(slidePartPath, blip), + resolveChartPart: id => LoadPartXmlRoot(ResolveRelationship(slidePartPath, id))); + + var slide = new PptxSlide(slidePartPath, layoutPartPath, placeholders, shapeTree, background, clrMapOvr); + _slideCache[slideIndex] = slide; + return slide; + } +} diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.TableStyles.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.TableStyles.cs new file mode 100644 index 00000000..6821e7f3 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.TableStyles.cs @@ -0,0 +1,277 @@ +using System.Xml.Linq; +using DemaConsulting.CanvasNet.Canvas; + +namespace DemaConsulting.CanvasNet.Pptx; + +// cspell:ignore tbl tblstyle tblstylelst tcstyle tcbdr wholetbl bandrow firstrow insideh insidev pptx tblpr tcpr gridlines + +/// +/// Implements the table-style resolver (Phase 2 Follow-Up: Table +/// Style/Banding Resolution): resolves a <a:tbl>/<a:tblPr>'s +/// <a:tableStyleId> against the optional, auxiliary ppt/tableStyles.xml +/// part () and implements the core +/// wholeTbl/band1H/band2H/firstRow/explicit-cell-override +/// precedence cascade () consulted by +/// - see pptx-document.md's "Phase 2 Follow-Up: Table +/// Style/Banding Resolution" design section for the full algorithm, the confirmed +/// ground-truth row-parity evidence, and the explicitly deferred scope (firstCol/ +/// lastCol/lastRow/corner cells, bandCol/band1V/band2V, and +/// table-style-driven font color). +/// +public sealed partial class PptxDocument +{ + /// Caches each ppt/tableStyles.xml part's parsed styleId -> <a:tblStyle> lookup, keyed by its resolved part path, on first access. + private readonly Dictionary> _tableStylesCache; + + /// + /// Resolves the <a:tblStyle> element matching + /// within this package's ppt/tableStyles.xml part (located via the presentation + /// part's own /tableStyles relationship), tolerating every cause of "not found" by + /// returning rather than throwing - a table-style-sheet is an + /// optional, auxiliary part; its absence, or an unresolvable , + /// must gracefully fall back to plain, style-less cell rendering (see + /// 's own matchedTblStyle is null fallback), not + /// fail the whole table parse closed. + /// + /// The <a:tableStyleId> element's own text content (a GUID, by convention). + /// + /// The matched <a:tblStyle> element, or when this + /// package has no /tableStyles relationship from its presentation part, when + /// ppt/tableStyles.xml is missing or not well-formed XML, or when no + /// <a:tblStyle> child declares a styleId attribute matching + /// (an ordinal, case-sensitive comparison, consistent with + /// every other ID-keyed cache in this unit). + /// + internal XElement? TryResolveTableStyle(string styleId) + { + var tableStylesPartPath = TryResolveRelationshipByType(_presentationPartPath, "/tableStyles"); + if (tableStylesPartPath is null) + { + return null; + } + + var tableStyles = GetTableStyles(tableStylesPartPath); + return tableStyles.TryGetValue(styleId, out var tblStyle) ? tblStyle : null; + } + + /// + /// Returns the parsed styleId -> <a:tblStyle> lookup for the + /// ppt/tableStyles.xml-shaped part at , + /// parsing and caching it on first access. + /// + /// The table-styles part's resolved path (for example "ppt/tableStyles.xml"). + /// + /// The parsed lookup, or an empty lookup when the part is missing, not well-formed XML, or + /// declares no <a:tblStyleLst>/<a:tblStyle> children at all - tolerated + /// rather than treated as malformed, mirroring PptxDocument.Theme.cs's own + /// ParseBgFillStyleList/ParseFillStyleList/ParseLnStyleList + /// leniency precedent for optional, rarely-consulted style-sheet content. + /// + private IReadOnlyDictionary GetTableStyles(string tableStylesPartPath) + { + if (_tableStylesCache.TryGetValue(tableStylesPartPath, out var cached)) + { + return cached; + } + + var tableStyles = new Dictionary(StringComparer.Ordinal); + try + { + var root = LoadPartXmlRoot(tableStylesPartPath); + foreach (var tblStyle in root.Elements(DrawingNamespace + "tblStyle")) + { + var styleId = (string?)tblStyle.Attribute("styleId"); + if (styleId is not null) + { + tableStyles[styleId] = tblStyle; + } + } + } + catch (InvalidDataException) + { + // Missing or malformed ppt/tableStyles.xml is tolerated - an empty lookup means every + // styleId resolves to "not found", degrading to plain, style-less cell rendering. + } + + _tableStylesCache[tableStylesPartPath] = tableStyles; + return tableStyles; + } + + /// + /// Implements the core table-cell style precedence cascade: a cell's own explicit + /// <a:tcPr> fill/border always wins outright; otherwise the matched + /// <a:tblStyle>'s <a:firstRow> tier (only for the header row, when + /// ) wins over its <a:band1H>/ + /// <a:band2H> banding tier (only when , + /// alternating by row parity with the header row excluded from the count), which in turn + /// wins over its <a:wholeTbl> base tier - the only non-null tier applied when + /// neither nor select + /// a higher tier for this cell. + /// + /// + /// + /// Row-parity convention (confirmed against a real-world ground-truth + /// render): a styled header row (, rowIndex == + /// 0) is excluded from band-row counting entirely, and band1H is applied to + /// the first row after the header, alternating with band2H thereafter + /// (bandRowIndex = firstRowEnabled ? rowIndex - 1 : rowIndex; even + /// bandRowIndex selects band1H, odd selects band2H) - the same + /// convention ECMA-376 itself documents for a styled, banded table. + /// + /// + /// Border edge-name mapping: each of a tier's own <a:tcBdr> + /// declares up to six named edges (left/right/top/bottom/ + /// insideH/insideV). A cell edge on the table's own true outer boundary + /// (determined structurally from // + /// for left/right, and / + /// / for top/bottom) consults the + /// literal edge name; every interior edge instead consults insideV (left/right) + /// or insideH (top/bottom) - the ECMA-376-defined mapping, required so a style + /// that visually differentiates its outer frame from its interior gridlines (unlike + /// the one available real-file fixture, whose style uses an identical line for all six + /// edges) renders correctly. + /// + /// + /// The cell's own (possibly ) <a:tcPr> element. + /// + /// The resolved <a:tblStyle> element (see ), + /// or for "no table style available" - every tier below contributes + /// nothing, so the cascade degrades to exactly today's pre-existing cell-only behavior. + /// + /// The table's own <a:tblPr bandRow="1"> attribute. + /// The table's own <a:tblPr firstRow="1"> attribute. + /// The cell's zero-based row index within the table. + /// The table's total declared <a:tr> row count. + /// The cell's zero-based starting grid-column index. + /// The cell's own gridSpan (see ). + /// The cell's own rowSpan (see ). + /// The table's total declared <a:tblGrid>/<a:gridCol> column count. + /// The cell's own (unmerged, single-column) width, in EMU - needed to position a gradient fill. + /// The cell's own (unmerged, single-row) height, in EMU - needed to position a gradient fill. + /// The resolved theme, used to resolve any <a:schemeClr> in a style tier's fill/border. + /// The effective color map - see 's matching parameter. + /// + /// Threaded unchanged into this method's own call - see + /// 's matching parameter. + /// + /// + /// The resolved fill and four border edges, in the same shape + /// already stores on . + /// + internal static (PptxPaint Fill, PptxLineStyle? LeftBorder, PptxLineStyle? RightBorder, PptxLineStyle? TopBorder, PptxLineStyle? BottomBorder) + ResolveTableCellStyle( + XElement? tcPrElement, + XElement? matchedTblStyle, + bool bandRowEnabled, + bool firstRowEnabled, + int rowIndex, + int totalRows, + int columnIndex, + int gridSpan, + int rowSpan, + int totalColumns, + float cellWidthEmu, + float cellHeightEmu, + PptxTheme theme, + PptxColorMap? colorMap, + Func? resolveBlipImage = null) + { + var isHeaderRow = firstRowEnabled && rowIndex == 0; + + var firstRowTcStyle = isHeaderRow + ? matchedTblStyle?.Element(DrawingNamespace + "firstRow")?.Element(DrawingNamespace + "tcStyle") + : null; + + XElement? bandTcStyle = null; + if (bandRowEnabled && !isHeaderRow && matchedTblStyle is not null) + { + var bandRowIndex = firstRowEnabled ? rowIndex - 1 : rowIndex; + var bandElementName = bandRowIndex % 2 == 0 ? "band1H" : "band2H"; + bandTcStyle = matchedTblStyle.Element(DrawingNamespace + bandElementName)?.Element(DrawingNamespace + "tcStyle"); + } + + var wholeTcStyle = matchedTblStyle?.Element(DrawingNamespace + "wholeTbl")?.Element(DrawingNamespace + "tcStyle"); + + // Highest to lowest precedence - consulted in this order by both the fill and each border + // edge's own tier walk below. + var tiers = new[] { firstRowTcStyle, bandTcStyle, wholeTcStyle }; + + var fill = ResolveTableCellFill(tcPrElement, tiers, cellWidthEmu, cellHeightEmu, theme, colorMap, resolveBlipImage); + + var isFirstColumn = columnIndex == 0; + var isLastColumn = columnIndex + gridSpan >= totalColumns; + var isFirstPhysicalRow = rowIndex == 0; + var isLastPhysicalRow = rowIndex + rowSpan >= totalRows; + + var leftBorder = ResolveTableCellBorderEdge(tcPrElement, "lnL", tiers, "left", "insideV", isFirstColumn, theme, colorMap); + var rightBorder = ResolveTableCellBorderEdge(tcPrElement, "lnR", tiers, "right", "insideV", isLastColumn, theme, colorMap); + var topBorder = ResolveTableCellBorderEdge(tcPrElement, "lnT", tiers, "top", "insideH", isFirstPhysicalRow, theme, colorMap); + var bottomBorder = ResolveTableCellBorderEdge(tcPrElement, "lnB", tiers, "bottom", "insideH", isLastPhysicalRow, theme, colorMap); + + return (fill, leftBorder, rightBorder, topBorder, bottomBorder); + } + + /// + /// Resolves a cell's fill: the cell's own explicit <a:tcPr> fill-definition + /// child (including an explicit <a:noFill/>) wins outright when present at + /// all; otherwise the first of (highest to lowest precedence) + /// declaring an <a:fill> child element at all is resolved via the existing + /// ; when no tier declares one, the result is + /// - today's existing fallback, unchanged. + /// + private static PptxPaint ResolveTableCellFill( + XElement? tcPrElement, IReadOnlyList tiers, float cellWidthEmu, float cellHeightEmu, + PptxTheme theme, PptxColorMap? colorMap, Func? resolveBlipImage = null) + { + if (tcPrElement is not null && HasExplicitFillChild(tcPrElement)) + { + return ResolveFill(tcPrElement, theme, cellWidthEmu, cellHeightEmu, colorMap: colorMap, resolveBlipImage: resolveBlipImage); + } + + foreach (var tier in tiers) + { + var fillElement = tier?.Element(DrawingNamespace + "fill"); + if (fillElement is not null) + { + return ResolveFill(fillElement, theme, cellWidthEmu, cellHeightEmu, colorMap: colorMap, resolveBlipImage: resolveBlipImage); + } + } + + return PptxNoFill.Instance; + } + + /// + /// Resolves a single border edge: the cell's own explicit <a:tcPr> edge + /// element (, for example "lnL") wins + /// outright when present at all (including when it resolves to , an + /// explicit "no border"); otherwise the first of declaring the + /// structurally-selected <a:tcBdr> edge name ( + /// when , else ) is + /// resolved via the existing against that edge's own + /// <a:ln> child; when no tier declares that edge, the result is + /// - today's existing "no border" fallback, unchanged. + /// + private static PptxLineStyle? ResolveTableCellBorderEdge( + XElement? tcPrElement, string explicitEdgeElementName, IReadOnlyList tiers, + string outerEdgeName, string interiorEdgeName, bool isOuterEdge, + PptxTheme theme, PptxColorMap? colorMap) + { + var explicitEdgeElement = tcPrElement?.Element(DrawingNamespace + explicitEdgeElementName); + if (explicitEdgeElement is not null) + { + return ResolveLineStyle(explicitEdgeElement, theme, colorMap); + } + + var edgeName = isOuterEdge ? outerEdgeName : interiorEdgeName; + foreach (var tier in tiers) + { + var edgeElement = tier?.Element(DrawingNamespace + "tcBdr")?.Element(DrawingNamespace + edgeName); + if (edgeElement is not null) + { + return ResolveLineStyle(edgeElement.Element(DrawingNamespace + "ln"), theme, colorMap); + } + } + + return null; + } +} diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Tables.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Tables.cs new file mode 100644 index 00000000..ce55db95 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Tables.cs @@ -0,0 +1,589 @@ +using System.Globalization; +using System.Numerics; +using System.Xml.Linq; +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Drawing; +using DemaConsulting.CanvasNet.Fonts; +using DemaConsulting.CanvasNet.Geometry; +using Path = DemaConsulting.CanvasNet.Geometry.Path; + +namespace DemaConsulting.CanvasNet.Pptx; + +// cspell:ignore tbl tblgrid gridcol hmerge vmerge gridspan rowspan tcpr lnl lnr lnt lnb pptx graphicframe +// cspell:ignore tblpr tblstyleid bandrow firstrow + +/// +/// Implements the table resolvers and painting primitive +/// (Phase 1e): parsing a <p:graphicFrame>'s <a:tbl> into a +/// (, ), +/// resolving each cell's final, merge-aware rectangle (), and +/// painting the whole table - fill, borders, and cell text - onto a +/// () - see pptx-document.md's "Tables (Phase 1e)" design +/// section for the full merge/rect-resolution algorithm, documented deferrals (auto-sizing to +/// fit overflowing content, nested tables), and the "Phase 2 Follow-Up: Table Style/Banding +/// Resolution" section for the <a:tableStyleId>/wholeTbl/band1H/ +/// band2H/firstRow cascade implemented by PptxDocument.TableStyles.cs's +/// . +/// +public sealed partial class PptxDocument +{ + /// + /// Resolves a <p:graphicFrame>'s declared <a:tbl> table into a + /// . + /// + /// The <p:graphicFrame> element. + /// The resolved theme, used to resolve each cell's own fill/border colors. + /// + /// The effective color map consulted when a cell's own fill/border declares an + /// <a:schemeClr val="bg1"/>-shaped token, or (the + /// default, resolving to ). + /// supplies this slide's own true effective color map (see + /// ) for slide-owned tables - its cache is keyed + /// 1:1 by slide index, so there is no other consumer that could ever observe a different + /// value for the same cached table. Known limitation (master/layout-owned tables + /// only): a table placed directly on a master or layout's own shape tree (rather + /// than a slide's) is parsed once at master/layout load time and cached, shared + /// by every slide that uses that master/layout - because each such slide may have its own + /// distinct <p:clrMapOvr>, no single color map could be baked in without risk + /// of being wrong for some consuming slide, so / + /// intentionally omit this parameter and a + /// master/layout-owned table's own bg1/tx1 scheme color always resolves + /// against regardless of any slide's + /// <p:clrMapOvr>. Fixing this residual case would require restructuring + /// table shape-tree parsing from parse-time to render-time, a materially larger, + /// separately-scoped change. + /// + /// + /// Lazily invoked, when <a:tbl>/<a:tblPr> declares a non-empty + /// <a:tableStyleId>, to resolve that id against ppt/tableStyles.xml + /// (see ) - threaded the same way + /// 's own themeResolver parameter is, defaulting to + /// ("no table style available") so every pre-existing call site + /// keeps compiling and behaving unchanged. A result (from the + /// resolver itself, or from this parameter being ) means every cell + /// falls back to today's pre-existing cell-only fill/border resolution - see + /// 's own matchedTblStyle is null fallback. + /// + /// + /// Threaded unchanged into every cell's own call - see + /// 's matching parameter. + /// + /// The resolved . + /// + /// Thrown when has no <a:graphic>/ + /// <a:graphicData> child, <a:graphicData> has no <a:tbl> + /// child, <a:tbl> has no <a:tblGrid>, a <a:gridCol> + /// has a missing or non-numeric w attribute, or an <a:tr> has a + /// missing or non-numeric h attribute. + /// + /// + /// Thrown (feature token "pptx-graphic-frame-kind") when <a:graphicData>'s + /// uri attribute does not end in "/table" - a chart, SmartArt, OLE object, + /// or other non-table graphic-frame kind, all out of scope this phase. + /// + internal static PptxTable ParseTable( + XElement graphicFrameElement, PptxTheme theme, PptxColorMap? colorMap = null, + Func? tableStyleResolver = null, Func? resolveBlipImage = null) + { + var graphicData = graphicFrameElement.Element(DrawingNamespace + "graphic")?.Element(DrawingNamespace + "graphicData") ?? + throw new InvalidDataException("A element has no / child."); + + var uri = (string?)graphicData.Attribute("uri") ?? string.Empty; + if (!uri.EndsWith("/table", StringComparison.Ordinal)) + { + throw new PptxUnsupportedFeatureException( + "pptx-graphic-frame-kind", + $"Graphic frame kind '{uri}' is not a table and is not supported."); + } + + var tbl = graphicData.Element(DrawingNamespace + "tbl") ?? + throw new InvalidDataException("An table element has no child."); + + var tblGrid = tbl.Element(DrawingNamespace + "tblGrid") ?? + throw new InvalidDataException("An element has no child."); + + var columnWidthsEmu = tblGrid.Elements(DrawingNamespace + "gridCol") + .Select(gridCol => ParseRequiredFloatAttribute(gridCol, "w", "")) + .ToList(); + + // 's tableStyleId/firstRow/bandRow - parsed once per table, then threaded through + // every cell so ParseTableCell can consult ResolveTableCellStyle's precedence cascade. + // Absent/empty is tolerated throughout: a table with no at all, or one with no + // , degrades to exactly today's pre-existing cell-only behavior. + var tblPr = tbl.Element(DrawingNamespace + "tblPr"); + var tableStyleId = (string?)tblPr?.Element(DrawingNamespace + "tableStyleId"); + var firstRowEnabled = (bool?)tblPr?.Attribute("firstRow") ?? false; + var bandRowEnabled = (bool?)tblPr?.Attribute("bandRow") ?? false; + var matchedTblStyle = string.IsNullOrEmpty(tableStyleId) ? null : tableStyleResolver?.Invoke(tableStyleId); + + // Materialized eagerly (rather than the previous lazy .Select(...) chain) so each cell's + // own rowIndex/totalRows context is known before the per-row loop runs - needed by + // ResolveTableCellStyle's own band-row-parity and outer-edge-detection logic. + var trElements = tbl.Elements(DrawingNamespace + "tr").ToList(); + var totalRows = trElements.Count; + var totalColumns = columnWidthsEmu.Count; + + // Precomputed up front (rather than parsed once per row inside the loop below) so a + // rowSpan-N cell's own dimension peek (below) can look ahead into rows not yet built. + var rowHeightsEmu = trElements + .Select(tr => ParseRequiredFloatAttribute(tr, "h", "")) + .ToList(); + + var rows = new List(totalRows); + for (var rowIndex = 0; rowIndex < totalRows; rowIndex++) + { + var tr = trElements[rowIndex]; + var heightEmu = rowHeightsEmu[rowIndex]; + var cells = new List(); + var columnIndex = 0; + foreach (var tc in tr.Elements(DrawingNamespace + "tc")) + { + // Peeked here - ahead of ParseTableCell's own authoritative parse/validation of + // the same attributes - purely so a merged cell's gradient fill (if any) can be + // sized against its true summed GridSpan/RowSpan dimensions instead of only its + // first column/row's own span-1 dimensions. A non-positive peeked value safely + // sums to zero width/height here (SumConsecutive's own clamp) - ParseTableCell + // still performs the single authoritative validation and throws for that case. + var peekedGridSpan = ParseOptionalIntAttribute(tc, "gridSpan") ?? 1; + var peekedRowSpan = ParseOptionalIntAttribute(tc, "rowSpan") ?? 1; + var cellWidthEmu = SumConsecutive(columnWidthsEmu, columnIndex, peekedGridSpan); + var cellHeightEmu = SumConsecutive(rowHeightsEmu, rowIndex, peekedRowSpan); + var cell = ParseTableCell( + tc, theme, cellWidthEmu, cellHeightEmu, colorMap, + matchedTblStyle, bandRowEnabled, firstRowEnabled, rowIndex, totalRows, columnIndex, totalColumns, + resolveBlipImage); + cells.Add(cell); + columnIndex++; + } + + rows.Add(new PptxTableRow(heightEmu, cells)); + } + + return new PptxTable(columnWidthsEmu, rows); + } + + /// + /// Resolves a single <a:tc> table cell into a . + /// + /// The <a:tc> element. + /// The resolved theme, used to resolve the cell's own fill/border colors. + /// + /// The cell's own merged width, in EMU - needed to position a gradient fill. Already + /// summed across the cell's own declared gridSpan consecutive columns by + /// 's own dimension peek, so a merged cell's gradient is sized + /// against its true spanned width, not only its first column's own width. + /// + /// + /// The cell's own merged height, in EMU - needed to position a gradient fill. Already + /// summed across the cell's own declared rowSpan consecutive rows by + /// 's own dimension peek, so a merged cell's gradient is sized + /// against its true spanned height, not only its first row's own height. + /// + /// The effective color map - see 's matching parameter, including its documented limitation. + /// + /// The table's own resolved <a:tblStyle> (see 's + /// tableStyleResolver parameter and ), or + /// (the default) for "no table style available" - every call site + /// predating this parameter keeps compiling and behaving identically, since a + /// value degrades 's own cascade + /// to exactly this method's pre-existing cell-only fill/border resolution. + /// + /// The table's own <a:tblPr bandRow="1"> attribute, defaulting to . + /// The table's own <a:tblPr firstRow="1"> attribute, defaulting to . + /// The cell's zero-based row index within the table, defaulting to 0. + /// The table's total declared <a:tr> row count, defaulting to 1. + /// The cell's zero-based starting grid-column index, defaulting to 0. + /// The table's total declared <a:tblGrid>/<a:gridCol> column count, defaulting to 1. + /// + /// Threaded unchanged into this method's own call - + /// see 's matching parameter. + /// + /// The resolved . + /// + /// Thrown when a present gridSpan/rowSpan attribute is not a valid integer, + /// or is zero or negative. + /// + internal static PptxTableCell ParseTableCell( + XElement tcElement, PptxTheme theme, float cellWidthEmu, float cellHeightEmu, PptxColorMap? colorMap = null, + XElement? matchedTblStyle = null, bool bandRowEnabled = false, bool firstRowEnabled = false, + int rowIndex = 0, int totalRows = 1, int columnIndex = 0, int totalColumns = 1, + Func? resolveBlipImage = null) + { + var gridSpan = ParseOptionalIntAttribute(tcElement, "gridSpan") ?? 1; + if (gridSpan <= 0) + { + throw new InvalidDataException( + $"An element has a non-positive 'gridSpan' attribute value '{gridSpan}'."); + } + + var rowSpan = ParseOptionalIntAttribute(tcElement, "rowSpan") ?? 1; + if (rowSpan <= 0) + { + throw new InvalidDataException( + $"An element has a non-positive 'rowSpan' attribute value '{rowSpan}'."); + } + + var hMerge = (bool?)tcElement.Attribute("hMerge") ?? false; + var vMerge = (bool?)tcElement.Attribute("vMerge") ?? false; + + var tcPr = tcElement.Element(DrawingNamespace + "tcPr"); + var (fill, leftBorder, rightBorder, topBorder, bottomBorder) = ResolveTableCellStyle( + tcPr, matchedTblStyle, bandRowEnabled, firstRowEnabled, + rowIndex, totalRows, columnIndex, gridSpan, rowSpan, totalColumns, + cellWidthEmu, cellHeightEmu, theme, colorMap, resolveBlipImage); + + var txBody = tcElement.Element(DrawingNamespace + "txBody"); + var textBody = txBody is null ? null : ParseTextBody(txBody); + + return new PptxTableCell(gridSpan, rowSpan, hMerge, vMerge, fill, leftBorder, rightBorder, topBorder, bottomBorder, textBody); + } + + /// + /// Resolves every non-merge-continuation cell in to its final, + /// merge-aware, shape-local rectangle. + /// + /// + /// A conformant producer (PowerPoint itself) always follows a gridSpan="N" + /// governing cell with exactly (N-1) hMerge continuation <a:tc> + /// placeholders - but the DrawingML schema does not actually guarantee this: a + /// <a:tr>'s <a:tc> children are declared + /// minOccurs="0" maxOccurs="unbounded" with no cross-element constraint tying their + /// count to a governing cell's own gridSpan. This method therefore defensively + /// compensates (rather than assumes) for a malformed row that omits some or all of a + /// governing cell's own placeholder continuations: it counts how many of the immediately + /// following cells are themselves real hMerge continuations, and advances past any + /// shortfall itself, so a later, unrelated cell in that same row still lands at its + /// correct absolute column instead of silently overlapping the governing cell's own + /// merged region. For a well-formed row (the common, real-world case), the shortfall is + /// always zero and this method's behavior is unchanged. + /// + /// The parsed table. + /// + /// The resolved rectangles, in row-major document order. A cell with + /// or set is + /// skipped entirely - it is a merge continuation, not independently painted (the + /// governing cell's own / + /// already spans its full merged rectangle). + /// + internal static IReadOnlyList ResolveCellRects(PptxTable table) + { + var results = new List(); + + var yEmu = 0f; + for (var rowIndex = 0; rowIndex < table.Rows.Count; rowIndex++) + { + var row = table.Rows[rowIndex]; + var xEmu = 0f; + var columnIndex = 0; + + // Index-based (rather than a foreach) so a governing cell can look ahead at its own + // immediately-following siblings to count their real hMerge continuations - see this + // method's own for why that lookahead is necessary. + for (var cellIndex = 0; cellIndex < row.Cells.Count; cellIndex++) + { + var cell = row.Cells[cellIndex]; + + // Each entry (real or hMerge/vMerge continuation) represents exactly one + // physical grid column, regardless of the cell's own GridSpan. xEmu therefore + // always advances by this single column's own width per loop iteration, never by + // the governing cell's full (GridSpan-summed) merged width, or a merged cell's + // trailing continuation entries would double-count the columns the governing + // cell's own merged rectangle already spans. + var columnWidthEmu = columnIndex < table.ColumnWidthsEmu.Count ? table.ColumnWidthsEmu[columnIndex] : 0f; + if (!cell.HMerge && !cell.VMerge) + { + var cellWidthEmu = SumColumnWidths(table.ColumnWidthsEmu, columnIndex, cell.GridSpan); + var cellHeightEmu = SumRowHeights(table.Rows, rowIndex, cell.RowSpan); + results.Add(new PptxResolvedTableCell(xEmu, yEmu, cellWidthEmu, cellHeightEmu, cell)); + + // Count how many of the immediately-following cells are themselves real + // HMerge continuations of this governing cell (bounded by the expected count + // and the row's remaining length), then compensate for any shortfall - see + // this method's own . + var expectedContinuations = cell.GridSpan - 1; + var actualContinuations = 0; + while (actualContinuations < expectedContinuations && + cellIndex + 1 + actualContinuations < row.Cells.Count && + row.Cells[cellIndex + 1 + actualContinuations].HMerge) + { + actualContinuations++; + } + + var missingContinuations = expectedContinuations - actualContinuations; + if (missingContinuations > 0) + { + xEmu += SumColumnWidths(table.ColumnWidthsEmu, columnIndex + 1, missingContinuations); + columnIndex += missingContinuations; + } + } + + xEmu += columnWidthEmu; + columnIndex++; + } + + yEmu += row.HeightEmu; + } + + return results; + } + + /// + /// Paints a resolved onto : each + /// surviving (non-merge-continuation) cell's fill, its four border edges, and its text + /// content, in that order. + /// + /// The surface to paint onto. + /// The resolved table to paint. + /// The resolved theme, used to lay out each cell's own text content. + /// + /// The transform mapping the table's own local (0,0)-origin coordinate space into + /// surface pixel space (the owning <p:graphicFrame>'s own + /// , or a further group-composed transform). + /// + /// The font resolver delegate passed through to for each cell's own text content. + /// + /// The effective color map consulted when a cell's own text run/bullet color resolves an + /// <a:schemeClr val="bg1"/>-shaped token, or (the + /// default, resolving to ). Unlike a cell's own + /// fill/border colors (see 's matching parameter and its + /// documented limitation), a cell's text is re-resolved here at paint time - not cached at + /// parse time - so this parameter does see the slide's true effective color map + /// when supplied by a render-time caller. + /// + internal static void PaintTable( + Surface surface, + PptxTable table, + PptxTheme theme, + Matrix3x2 shapeToSurfaceTransform, + Func fontResolver, + PptxColorMap? colorMap = null) + { + foreach (var resolvedCell in ResolveCellRects(table)) + { + var cellRectPath = Path.Rectangle(resolvedCell.XEmu, resolvedCell.YEmu, resolvedCell.WidthEmu, resolvedCell.HeightEmu) + .Transform(shapeToSurfaceTransform); + FillPaint(surface, cellRectPath, resolvedCell.Cell.Fill, shapeToSurfaceTransform); + + PaintCellBorder(surface, resolvedCell.Cell.LeftBorder, shapeToSurfaceTransform, + resolvedCell.XEmu, resolvedCell.YEmu, resolvedCell.XEmu, resolvedCell.YEmu + resolvedCell.HeightEmu); + PaintCellBorder(surface, resolvedCell.Cell.RightBorder, shapeToSurfaceTransform, + resolvedCell.XEmu + resolvedCell.WidthEmu, resolvedCell.YEmu, resolvedCell.XEmu + resolvedCell.WidthEmu, resolvedCell.YEmu + resolvedCell.HeightEmu); + PaintCellBorder(surface, resolvedCell.Cell.TopBorder, shapeToSurfaceTransform, + resolvedCell.XEmu, resolvedCell.YEmu, resolvedCell.XEmu + resolvedCell.WidthEmu, resolvedCell.YEmu); + PaintCellBorder(surface, resolvedCell.Cell.BottomBorder, shapeToSurfaceTransform, + resolvedCell.XEmu, resolvedCell.YEmu + resolvedCell.HeightEmu, resolvedCell.XEmu + resolvedCell.WidthEmu, resolvedCell.YEmu + resolvedCell.HeightEmu); + + if (resolvedCell.Cell.TextBody is { } textBody) + { + var placeholderProperties = new PptxPlaceholderProperties(null, null, theme); + var layout = ResolveTextLayout( + textBody, placeholderProperties, theme, string.Empty, + resolvedCell.WidthEmu, resolvedCell.HeightEmu, fontResolver, colorMap); + + var cellLocalToSurface = Matrix3x2.CreateTranslation(resolvedCell.XEmu, resolvedCell.YEmu) * shapeToSurfaceTransform; + PaintTextLayout(surface, layout, cellLocalToSurface); + } + } + } + + /// + /// Paints a single cell border edge, when present: builds a local-space line segment from + /// (x1Emu, y1Emu) to (x2Emu, y2Emu), strokes it via + /// , transforms the stroked outline into surface space, + /// and fills it with the border's own resolved paint. + /// + private static void PaintCellBorder( + Surface surface, + PptxLineStyle? border, + Matrix3x2 shapeToSurfaceTransform, + float x1Emu, float y1Emu, float x2Emu, float y2Emu) + { + if (border is null) + { + return; + } + + var builder = new PathBuilder(); + builder.MoveTo(new Vector2(x1Emu, y1Emu)); + builder.LineTo(new Vector2(x2Emu, y2Emu)); + var linePath = builder.Build(); + + var strokedOutline = ResolveStrokeOutline(linePath, border, shapeToSurfaceTransform).Transform(shapeToSurfaceTransform); + FillPaint(surface, strokedOutline, border.Paint, shapeToSurfaceTransform); + } + + /// + /// Fills onto with , + /// composing into any coordinate-dependent paint + /// state (a 's , or a + /// /'s synthesized ) + /// first so its coordinate space matches 's own. + /// + /// + /// is already transformed into surface pixel space (see + /// 's own XmlDoc: + /// it has no transform parameter of its own and interprets + /// directly as surface-pixel-space coordinates). A 's own + /// , and a /'s + /// own synthesized , are both built in the shape's local EMU space + /// with their own transform left at the default identity, so each must be composed with + /// the same applied to - + /// via / - before + /// filling, or the paint would be evaluated against untransformed local-space coordinates + /// while the path it fills is in surface space, mismatching their coordinate spaces + /// (matching the only existing transform-composition precedent in the codebase, + /// Rendering.Canvas.cs's own paint.WithTransform(_current)). Used by every + /// call site in this shared partial class that paints a resolved + /// with a known local-to-surface transform in scope: this file's own two table call sites + /// ('s cell-fill call and 's + /// stroked-border call); PptxDocument.Render.cs's + /// RenderShape/RenderPicture/RenderConnector fill and stroke-outline + /// calls; PaintArrowhead's two arrowhead-paint calls (passing its own + /// orientToSurface, not the connector's localToSurface, since an arrowhead's + /// own local origin is rotated/translated relative to the connector - see + /// PaintArrowhead's own remarks); and the slide/layout/master background fill in + /// PptxDocument.Render.cs's own Render method (passing its own + /// baseTransform - the slide's EMU-to-pixel scale - since the background + /// rectangle's own local origin coincides with the slide's EMU origin, making + /// baseTransform play exactly the same role here as a shape's own local-to-surface + /// transform elsewhere). + /// + /// The surface to fill onto. + /// The already-surface-space-transformed path to fill. + /// The resolved paint to fill with. + /// + /// The same transform already applied to - composed into a + /// 's own , or a + /// /'s synthesized + /// , before filling, so the paint's own coordinate space matches + /// the path it fills. Unused for /, + /// which have no coordinate-space-dependent state. + /// + private static void FillPaint(Surface surface, Path path, PptxPaint paint, Matrix3x2 shapeToSurfaceTransform) + { + switch (paint) + { + case PptxSolidFill solidFill: + PathFiller.Fill(surface, path, solidFill.Color); + break; + + case PptxGradientFill gradientFill: + PathFiller.Fill(surface, path, gradientFill.Gradient.WithTransform(shapeToSurfaceTransform)); + break; + + case PptxImageFill imageFill: + var tilePaint = new TilePaint( + imageFill.Image, imageFill.ImageToLocalTransform, imageFill.Image.Width, imageFill.Image.Height) + .WithTransform(shapeToSurfaceTransform); + PathFiller.Fill(surface, path, tilePaint); + break; + + case PptxPatternFill patternFill: + using (var tileSurface = PptxPatternTileRenderer.RenderTile( + patternFill.Preset, patternFill.Foreground, patternFill.Background)) + { + var patternTransform = Matrix3x2.CreateScale(EmuPerPixelAt96Dpi); + var patternTilePaint = new TilePaint( + tileSurface, patternTransform, tileSurface.Width, tileSurface.Height) + .WithTransform(shapeToSurfaceTransform); + PathFiller.Fill(surface, path, patternTilePaint); + } + + break; + } + } + + /// + /// Fills onto with , + /// composing into any coordinate-dependent paint state via + /// the 4-parameter overload. + /// + /// + /// A thin, same-signature alias of the 4-parameter + /// overload, kept as its own named helper at its non-table call sites (shape/picture/connector + /// fills and stroke outlines, arrowhead paint, and the slide/layout/master background fill in + /// PptxDocument.Render.cs) to keep each call site's own local-to-surface transform + /// named consistently with the role it plays there. + /// + /// The surface to fill onto. + /// The already-surface-space-transformed path to fill. + /// The resolved paint to fill with. + /// + /// The shape/connector/line/arrowhead's own resolved local-to-surface transform, composed + /// into any coordinate-dependent paint state before filling. + /// + private static void FillPaintComposingLocalTransform(Surface surface, Path path, PptxPaint paint, Matrix3x2 localToSurface) => + FillPaint(surface, path, paint, localToSurface); + + /// Sums consecutive column widths starting at , clamped to the available column count. + private static float SumColumnWidths(IReadOnlyList columnWidthsEmu, int startIndex, int count) => + SumConsecutive(columnWidthsEmu, startIndex, count); + + /// + /// Sums consecutive values starting at , + /// clamped to 's own available element count - the shared + /// summation core behind and 's own + /// row-height dimension peek (both need the identical sum-and-clamp behavior, once for + /// column widths, once for row heights). + /// + private static float SumConsecutive(IReadOnlyList valuesEmu, int startIndex, int count) + { + var sum = 0f; + var endIndex = Math.Min(startIndex + count, valuesEmu.Count); + for (var i = startIndex; i < endIndex; i++) + { + sum += valuesEmu[i]; + } + + return sum; + } + + /// Sums consecutive row heights starting at , clamped to the available row count. + private static float SumRowHeights(IReadOnlyList rows, int startIndex, int count) + { + var sum = 0f; + var endIndex = Math.Min(startIndex + count, rows.Count); + for (var i = startIndex; i < endIndex; i++) + { + sum += rows[i].HeightEmu; + } + + return sum; + } + + /// Parses a required, numeric float attribute, throwing when missing, non-numeric, or non-finite. + private static float ParseRequiredFloatAttribute(XElement element, string attributeName, string elementDescription) + { + var value = (string?)element.Attribute(attributeName) ?? + throw new InvalidDataException($"A {elementDescription} element has no '{attributeName}' attribute."); + + if (!float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)) + { + throw new InvalidDataException($"A {elementDescription} element has a non-numeric '{attributeName}' attribute value '{value}'."); + } + + if (!float.IsFinite(parsed)) + { + throw new InvalidDataException($"A {elementDescription} element has a non-finite '{attributeName}' attribute value '{value}'."); + } + + return parsed; + } + + /// Parses an optional integer attribute, throwing when present but non-numeric. + private static int? ParseOptionalIntAttribute(XElement element, string attributeName) + { + var value = (string?)element.Attribute(attributeName); + if (value is null) + { + return null; + } + + if (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)) + { + throw new InvalidDataException($"An element has a non-numeric '{attributeName}' attribute value '{value}'."); + } + + return parsed; + } +} diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Text.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Text.cs new file mode 100644 index 00000000..6fc8eab2 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Text.cs @@ -0,0 +1,222 @@ +using System.Globalization; +using System.Xml.Linq; + +namespace DemaConsulting.CanvasNet.Pptx; + +// cspell:ignore txbody bodypr lstyle lnspc spcbef spcaft spcpct spcpts marl pptx + +/// +/// Implements the DrawingML text-body/paragraph/run parser (Phase +/// 1d): <p:txBody>/<a:bodyPr>/<a:p>/<a:pPr>/ +/// <a:r>/<a:rPr>/<a:t> - see +/// pptx-document.md's "Text Layout and Rendering (Phase 1d)" design section for the +/// full structural parsing boundary, and PptxDocument.TextInheritance.cs for how the +/// raw, unresolved properties this parser extracts are later resolved against the +/// placeholder/layout/master/theme inheritance chain. +/// +public sealed partial class PptxDocument +{ + /// The OOXML schema default left/right text inset, in EMU (0.1 inch). + private const float DefaultInsetLeftRightEmu = 91440f; + + /// The OOXML schema default top/bottom text inset, in EMU (0.05 inch). + private const float DefaultInsetTopBottomEmu = 45720f; + + /// The highest paragraph list level this phase resolves (<a:lvl9pPr>, 0-based). + internal const int MaxParagraphLevel = 8; + + /// + /// Parses a <p:txBody> element into a : its + /// resolved <a:bodyPr> properties plus its ordered <a:p> + /// paragraphs. + /// + /// The <p:txBody> element to parse. + /// The parsed . + internal static PptxTextBody ParseTextBody(XElement txBodyElement) + { + var properties = ParseBodyProperties(txBodyElement.Element(DrawingNamespace + "bodyPr")); + var paragraphs = txBodyElement.Elements(DrawingNamespace + "p").Select(ParseParagraph).ToList(); + return new PptxTextBody(properties, paragraphs); + } + + /// + /// Parses an <a:bodyPr> element into a , + /// applying the OOXML schema's documented defaults for every attribute/child the element + /// (or an absent element itself) omits. + /// + /// The <a:bodyPr> element to parse, or when absent. + /// The parsed . + /// + /// Thrown when 's lIns, tIns, rIns, + /// or bIns attribute is present but not a valid numeric value. + /// + internal static PptxBodyProperties ParseBodyProperties(XElement? bodyPrElement) + { + if (bodyPrElement is null) + { + return new PptxBodyProperties( + PptxTextAnchor.Top, + PptxTextWrap.Square, + DefaultInsetLeftRightEmu, + DefaultInsetTopBottomEmu, + DefaultInsetLeftRightEmu, + DefaultInsetTopBottomEmu, + null); + } + + var anchor = (string?)bodyPrElement.Attribute("anchor") switch + { + "ctr" => PptxTextAnchor.Middle, + "b" => PptxTextAnchor.Bottom, + _ => PptxTextAnchor.Top, + }; + + var wrap = (string?)bodyPrElement.Attribute("wrap") == "none" ? PptxTextWrap.None : PptxTextWrap.Square; + + var insetLeft = ParseInsetAttribute(bodyPrElement, "lIns", DefaultInsetLeftRightEmu); + var insetTop = ParseInsetAttribute(bodyPrElement, "tIns", DefaultInsetTopBottomEmu); + var insetRight = ParseInsetAttribute(bodyPrElement, "rIns", DefaultInsetLeftRightEmu); + var insetBottom = ParseInsetAttribute(bodyPrElement, "bIns", DefaultInsetTopBottomEmu); + + var autofitElement = + bodyPrElement.Element(DrawingNamespace + "noAutofit") ?? + bodyPrElement.Element(DrawingNamespace + "normAutofit") ?? + bodyPrElement.Element(DrawingNamespace + "spAutoFit"); + + return new PptxBodyProperties(anchor, wrap, insetLeft, insetTop, insetRight, insetBottom, autofitElement); + } + + /// + /// Parses one of an <a:bodyPr> element's lIns/tIns/rIns/ + /// bIns inset attributes, defaulting to when + /// absent. + /// + /// The <a:bodyPr> element to inspect. + /// The inset attribute's name ("lIns", "tIns", "rIns", or "bIns"). + /// The value to use, in EMU, when is absent. + /// The parsed inset value, in EMU. + /// + /// Thrown when is present but not a valid numeric value. + /// + private static float ParseInsetAttribute(XElement bodyPrElement, string attributeName, float defaultValueEmu) + { + try + { + return (float?)bodyPrElement.Attribute(attributeName) ?? defaultValueEmu; + } + catch (FormatException ex) + { + throw new InvalidDataException( + $"An element has a non-numeric '{attributeName}' attribute value '{(string?)bodyPrElement.Attribute(attributeName)}'.", + ex); + } + } + + /// + /// Parses an <a:p> element into a : its raw, + /// unresolved properties plus its ordered content items. An <a:p> with no + /// recognized child produces a valid, empty paragraph (a blank line) - not an error. + /// + /// + /// <a:br/> is DrawingML's explicit line-break element: it is preserved as a + /// in document order alongside <a:r> runs + /// (wrapped as ), rather than being dropped, so that the layout + /// stage (PptxDocument.TextLayout.cs) can honor it as an explicit line boundary. + /// <a:fld> (auto-text fields such as type="slidenum" or + /// type="datetime1") shares <a:r>'s <a:rPr>/<a:t> + /// structure and is parsed the same way, rendering PowerPoint's cached field text since + /// CanvasNet has no live engine to recompute the field's current value. + /// + /// The <a:p> element to parse. + /// The parsed . + internal static PptxParagraph ParseParagraph(XElement pElement) + { + var properties = ParseParagraphProperties(pElement.Element(DrawingNamespace + "pPr")); + var items = new List(); + foreach (var child in pElement.Elements()) + { + if (child.Name == DrawingNamespace + "r" || child.Name == DrawingNamespace + "fld") + { + // (e.g. type="slidenum"/"datetime") carries the same / + // shape as , but its holds PowerPoint's last-computed cached field + // value rather than literal authored text. CanvasNet has no live PowerPoint + // engine to recompute the field, so (matching how most static OOXML renderers + // handle fields) the cached text is rendered as-is. + items.Add(new PptxRunItem(ParseRun(child))); + } + else if (child.Name == DrawingNamespace + "br") + { + items.Add(PptxLineBreakItem.Instance); + } + } + + return new PptxParagraph(properties, items); + } + + /// + /// Parses an <a:pPr> element into a , + /// retaining every value as the paragraph's own, unresolved value - no inheritance + /// resolution happens here. + /// + /// The <a:pPr> element to parse, or when absent. + /// The parsed . + internal static PptxRawParagraphProperties ParseParagraphProperties(XElement? pPrElement) + { + if (pPrElement is null) + { + return new PptxRawParagraphProperties(0, null, null, null, null, null, null, null); + } + + var level = Math.Clamp(ParseOptionalInt(pPrElement, "lvl") ?? 0, 0, MaxParagraphLevel); + var algn = (string?)pPrElement.Attribute("algn"); + var marL = (float?)pPrElement.Attribute("marL"); + var indent = (float?)pPrElement.Attribute("indent"); + var lnSpc = pPrElement.Element(DrawingNamespace + "lnSpc"); + var spcBefore = pPrElement.Element(DrawingNamespace + "spcBef"); + var spcAfter = pPrElement.Element(DrawingNamespace + "spcAft"); + var defRPr = pPrElement.Element(DrawingNamespace + "defRPr"); + var bulletProperties = ParseBulletProperties(pPrElement); + + return new PptxRawParagraphProperties(level, algn, marL, indent, lnSpc, spcBefore, spcAfter, defRPr, bulletProperties); + } + + /// + /// Parses an <a:pPr>-shaped element's raw bullet/numbering markup into a + /// , retaining each of the four independent + /// choice-group elements (type, color, font, size) unresolved - see + /// for inheritance resolution. + /// Also used, with the same element shape, to extract bullet markup from a placeholder/ + /// master level-indexed <a:lvl{N}pPr> element. + /// + /// The <a:pPr>/<a:lvl{N}pPr> element to parse. + /// The parsed . + internal static PptxRawBulletProperties ParseBulletProperties(XElement pPrLikeElement) => + new( + GetBulletTypeElement(pPrLikeElement), + GetBulletColorElement(pPrLikeElement), + GetBulletFontElement(pPrLikeElement), + GetBulletSizeElement(pPrLikeElement)); + + /// + /// Parses an <a:r> element into a : its own raw, + /// unresolved <a:rPr> element plus its <a:t> text (defaulting to + /// when <a:t> is absent, per the OOXML schema). + /// + /// The <a:r> element to parse. + /// The parsed . + internal static PptxTextRun ParseRun(XElement rElement) + { + var rPr = rElement.Element(DrawingNamespace + "rPr"); + var text = rElement.Element(DrawingNamespace + "t")?.Value ?? string.Empty; + return new PptxTextRun(rPr, text); + } + + /// Parses an optional integer attribute, returning when absent or non-numeric. + private static int? ParseOptionalInt(XElement element, string attributeName) + { + var value = (string?)element.Attribute(attributeName); + return value is not null && int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) + ? parsed + : null; + } +} diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.TextInheritance.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.TextInheritance.cs new file mode 100644 index 00000000..1984356f --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.TextInheritance.cs @@ -0,0 +1,695 @@ +using System.Globalization; +using System.Xml.Linq; +using DemaConsulting.CanvasNet.Canvas; + +namespace DemaConsulting.CanvasNet.Pptx; + +// cspell:ignore defrpr lnspc spcpct spcpts lststyle txstyles sng pptx ufill ufilltx + +/// +/// Implements the run/paragraph property-inheritance resolver +/// (Phase 1d). +/// +/// +/// +/// This deliberately departs from Phase 1b's element-level +/// "first-non-null-element-wins" fallback (see +/// PptxDocument.Inheritance.cs's own remarks) - text run/paragraph property +/// resolution instead walks each individual attribute +/// (typeface/size/bold/italic/underline/color for a run; alignment/margin/indent/line +/// spacing for a paragraph) through its own, independent chain, each stopping at the +/// first tier that actually declares that one attribute: +/// +/// +/// The run's own <a:rPr> (or, for paragraph attributes, the paragraph's own <a:pPr>). +/// The paragraph's own <a:pPr>/<a:defRPr> (run attributes only). +/// +/// +/// The placeholder's , +/// indexed by the paragraph's own (0-based, clamped [0,8]) lvl into +/// its <a:lvl{N+1}pPr> child (run attributes read that level's own +/// <a:defRPr>; paragraph attributes read the level element directly). +/// +/// +/// +/// +/// The slide master's own <p:txStyles>, bucketed by the placeholder's +/// type (see ) and indexed by level the same +/// way. +/// +/// +/// A final, hard-coded, documented default (see each attribute's own resolver). +/// +/// +/// This attribute-level granularity is required because real-world presentations +/// routinely declare, for example, only a run's bold flag while leaving its typeface to be +/// inherited from several tiers higher - an element-level "first non-null wins" rule would +/// incorrectly discard every other attribute the instant any single attribute is locally +/// overridden. +/// +/// +public sealed partial class PptxDocument +{ + /// The OOXML schema default run font size, in EMU (18pt: 18 * 12700). + private const float DefaultFontSizeEmu = 18f * 12700f; + + /// Converts a sz attribute value (hundredths of a point) to EMU. + private const float HundredthsOfPointToEmu = 127f; + + /// Converts a spcPts val attribute value (hundredths of a point) to EMU. + private const float SpcPtsToEmu = 127f; + + /// + /// Resolves a single run's fully-effective character properties, walking the + /// attribute-level inheritance chain described in this class's remarks. + /// + /// The run being resolved. + /// The run's owning paragraph. + /// The owning shape's resolved placeholder property chain. + /// The resolved theme, used to resolve the hard-coded default typeface/color. + /// + /// The owning shape's placeholder type (for example "title"), or + /// for a non-placeholder shape - used to select the default typeface (title vs. body theme + /// font) and the master <p:txStyles> bucket (see ). + /// + /// + /// The effective color map consulted when a run's color resolves an <a:schemeClr + /// val="bg1"/>-shaped token, or (the default) - see + /// 's matching parameter. + /// + /// The resolved . + internal static PptxEffectiveRunProperties ResolveEffectiveRunProperties( + PptxTextRun run, + PptxParagraph paragraph, + PptxPlaceholderProperties placeholderProperties, + PptxTheme theme, + string placeholderType, + PptxColorMap? colorMap = null) + { + var level = paragraph.RawProperties.Level; + var placeholderLevelDefRPr = GetLevelDefRPr(placeholderProperties.EffectiveTxBodyListStyle, level); + var masterStyle = SelectMasterTextStyle(placeholderProperties, placeholderType); + var masterLevelDefRPr = GetLevelDefRPr(masterStyle, level); + + var runRPr = run.RawRPr; + var paragraphDefRPr = paragraph.RawProperties.DefRPrElement; + + var typeface = + ResolveTypeface(GetTypeface(runRPr), theme) ?? + ResolveTypeface(GetTypeface(paragraphDefRPr), theme) ?? + ResolveTypeface(GetTypeface(placeholderLevelDefRPr), theme) ?? + ResolveTypeface(GetTypeface(masterLevelDefRPr), theme) ?? + DefaultTypeface(theme, placeholderType); + + var sizeEmu = + GetFontSizeEmu(runRPr) ?? + GetFontSizeEmu(paragraphDefRPr) ?? + GetFontSizeEmu(placeholderLevelDefRPr) ?? + GetFontSizeEmu(masterLevelDefRPr) ?? + DefaultFontSizeEmu; + + var bold = + GetBoolAttribute(runRPr, "b") ?? + GetBoolAttribute(paragraphDefRPr, "b") ?? + GetBoolAttribute(placeholderLevelDefRPr, "b") ?? + GetBoolAttribute(masterLevelDefRPr, "b") ?? + false; + + var italic = + GetBoolAttribute(runRPr, "i") ?? + GetBoolAttribute(paragraphDefRPr, "i") ?? + GetBoolAttribute(placeholderLevelDefRPr, "i") ?? + GetBoolAttribute(masterLevelDefRPr, "i") ?? + false; + + var color = + GetRunColor(runRPr, theme, colorMap) ?? + GetRunColor(paragraphDefRPr, theme, colorMap) ?? + GetRunColor(placeholderLevelDefRPr, theme, colorMap) ?? + GetRunColor(masterLevelDefRPr, theme, colorMap) ?? + theme.ColorScheme.Dark1; + + var underlineStyle = + GetUnderlineStyle(runRPr) ?? + GetUnderlineStyle(paragraphDefRPr) ?? + GetUnderlineStyle(placeholderLevelDefRPr) ?? + GetUnderlineStyle(masterLevelDefRPr) ?? + PptxUnderlineStyle.None; + + var underlineColor = + GetUnderlineColor(runRPr, theme, colorMap) ?? + GetUnderlineColor(paragraphDefRPr, theme, colorMap) ?? + GetUnderlineColor(placeholderLevelDefRPr, theme, colorMap) ?? + GetUnderlineColor(masterLevelDefRPr, theme, colorMap) ?? + color; + + return new PptxEffectiveRunProperties(typeface, sizeEmu, bold, italic, underlineStyle, underlineColor, color); + } + + /// + /// Resolves a paragraph's fully-effective properties, walking the same attribute-level + /// inheritance chain described in this class's remarks (paragraph attributes only consult + /// the paragraph's own <a:pPr> - there is no paragraph-level "defPPr" tier + /// analogous to a run's <a:defRPr> tier, since <a:pPr> already is + /// the paragraph's own properties element). + /// + /// The paragraph being resolved. + /// The owning shape's resolved placeholder property chain. + /// The owning shape's placeholder type (see ). + /// + /// The paragraph's own first run's already-resolved effective properties, or + /// for a paragraph with no runs at all - used only as the + /// "follow text" (buClrTx/buFontTx/buSzTx) bullet-modifier fallback + /// (see ); every pre-existing call site + /// omits this parameter, leaving its own behavior unchanged. + /// + /// + /// The effective color map consulted when the bullet's own color resolves an + /// <a:schemeClr val="bg1"/>-shaped token, or (the + /// default) - see 's matching parameter. + /// + /// The resolved . + internal static PptxEffectiveParagraphProperties ResolveEffectiveParagraphProperties( + PptxParagraph paragraph, + PptxPlaceholderProperties placeholderProperties, + string placeholderType, + PptxEffectiveRunProperties? firstRunProperties = null, + PptxColorMap? colorMap = null) + { + var level = paragraph.RawProperties.Level; + var placeholderLevelElement = GetLevelElement(placeholderProperties.EffectiveTxBodyListStyle, level); + var masterStyle = SelectMasterTextStyle(placeholderProperties, placeholderType); + var masterLevelElement = GetLevelElement(masterStyle, level); + + var raw = paragraph.RawProperties; + + var algn = NormalizeAlignment( + raw.Algn ?? + (string?)placeholderLevelElement?.Attribute("algn") ?? + (string?)masterLevelElement?.Attribute("algn") ?? + "l"); + + var marL = + raw.MarLEmu ?? + ParseNullableFloatAttribute(placeholderLevelElement?.Attribute("marL")) ?? + ParseNullableFloatAttribute(masterLevelElement?.Attribute("marL")) ?? + 0f; + + var indent = + raw.IndentEmu ?? + ParseNullableFloatAttribute(placeholderLevelElement?.Attribute("indent")) ?? + ParseNullableFloatAttribute(masterLevelElement?.Attribute("indent")) ?? + 0f; + + var lineSpacing = + ParseLineSpacing(raw.LnSpcElement) ?? + ParseLineSpacing(placeholderLevelElement?.Element(DrawingNamespace + "lnSpc")) ?? + ParseLineSpacing(masterLevelElement?.Element(DrawingNamespace + "lnSpc")) ?? + PptxLineSpacing.Default; + + // Phase 2 Follow-Up: Paragraph Spacing - spacing-before and spacing-after share the same + // percentage/fixed-point shape as line spacing per ECMA-376, so the existing line-spacing + // parser is reused verbatim; a paragraph that never declares spacing-before/after at any + // tier resolves to the "zero extra gap" sentinel, not the full-line-height default. + var spaceBefore = + ParseLineSpacing(raw.SpcBeforeElement) ?? + ParseLineSpacing(placeholderLevelElement?.Element(DrawingNamespace + "spcBef")) ?? + ParseLineSpacing(masterLevelElement?.Element(DrawingNamespace + "spcBef")) ?? + PptxLineSpacing.None; + + var spaceAfter = + ParseLineSpacing(raw.SpcAfterElement) ?? + ParseLineSpacing(placeholderLevelElement?.Element(DrawingNamespace + "spcAft")) ?? + ParseLineSpacing(masterLevelElement?.Element(DrawingNamespace + "spcAft")) ?? + PptxLineSpacing.None; + + var bullet = ResolveEffectiveBulletProperties( + paragraph, + placeholderLevelElement, + masterLevelElement, + placeholderProperties.Theme, + placeholderType, + firstRunProperties, + colorMap); + + return new PptxEffectiveParagraphProperties(algn, marL, indent, lineSpacing, bullet, spaceBefore, spaceAfter); + } + + /// + /// Resolves a paragraph's fully-effective bullet/numbering properties, walking four + /// independent choice-group chains (type, color, font, size) - own <a:pPr> -> + /// placeholder level-indexed element -> master level-indexed element -> a conservative, + /// documented hard-coded default - each stopping at the first tier that declares any + /// member of that one choice-group, exactly mirroring this class's own established + /// attribute-level granularity (see this class's remarks). The four groups remain mutually + /// independent of each other: a paragraph may, for example, declare its own bullet + /// character while inheriting its color/font/size from the master. + /// + /// + /// The type choice-group's hard-coded default is conservative "no bullet" + /// () - a shape with literally no bullet markup anywhere + /// in its own chain (not even an inherited one) renders no bullet, matching this unit's + /// pre-existing baseline for that specific edge case while fixing every case where bullet + /// markup is present somewhere in the chain. A Kind == None result (an + /// explicit <a:buNone/> winning the type choice-group at whichever tier, or + /// the conservative default) short-circuits color/font/size resolution entirely - no + /// bullet is painted, so there is nothing for those three choice-groups to resolve. + /// + /// The TYPE choice-group additionally applies a master-tier type + /// exclusion for "sldNum"/"dt"/"ftr" placeholder types: + /// the master text-style bucket's own bullet-type element is skipped for these three + /// types only (see 's own remarks for why this + /// tier is mis-routed for them), while the paragraph's own raw.TypeElement and + /// the placeholder's own level-indexed element are consulted exactly as normal and + /// still win whenever present - an own-paragraph explicit bullet choice always takes + /// precedence over any style-bucket default, regardless of placeholder type, per real + /// OOXML/PowerPoint semantics. + /// + /// + /// The paragraph being resolved. + /// The placeholder's own level-indexed <a:lvl{N}pPr> element, or . + /// The master text-style bucket's own level-indexed <a:lvl{N}pPr> element, or . + /// The resolved theme, used to resolve a <a:buClr> color and the hard-coded default color/font. + /// The owning shape's placeholder type (see ). + /// The paragraph's own first run's effective properties, or for a run-less paragraph. + /// + /// The effective color map consulted when the resolved <a:buClr> color + /// resolves an <a:schemeClr val="bg1"/>-shaped token, or + /// (the default) - see 's matching + /// parameter. + /// + /// The resolved . + private static PptxEffectiveBulletProperties ResolveEffectiveBulletProperties( + PptxParagraph paragraph, + XElement? placeholderLevelElement, + XElement? masterLevelElement, + PptxTheme theme, + string placeholderType, + PptxEffectiveRunProperties? firstRunProperties, + PptxColorMap? colorMap = null) + { + var raw = paragraph.RawProperties.EffectiveBulletProperties; + + // Field placeholder types ("sldNum"/"dt"/"ftr") never carry an *inherited* list bullet + // from the master's bodyStyle in genuine PowerPoint output - PowerPoint's own UI does not + // expose list/bullet formatting for these types at all. This exclusion is required + // because SelectMasterTextStyle (see its own remarks) routes every non-title placeholder + // type, including these three, to the master's bodyStyle bucket rather than the + // bullet-free otherStyle bucket - a separate, pre-existing, out-of-scope routing bug left + // unchanged here (fixing it would also alter font/size/bold/italic/color resolution for + // these placeholder types, a materially larger blast radius than this narrow exclusion). + // The exclusion is scoped to only the master tier of the TYPE choice-group: a paragraph's + // own explicit override (`raw.TypeElement`) and the placeholder's own level-indexed + // element always take precedence as normal, per real OOXML/PowerPoint semantics, where an + // own-paragraph explicit bullet choice always wins over any style-bucket default + // regardless of placeholder type. + var typeElement = + raw.TypeElement ?? + GetBulletTypeElement(placeholderLevelElement) ?? + (placeholderType is "sldNum" or "dt" or "ftr" ? null : GetBulletTypeElement(masterLevelElement)); + + var (kind, character, autoNumType, autoNumStartAt) = ResolveBulletType(typeElement); + + if (kind == PptxBulletKind.None) + { + return PptxEffectiveBulletProperties.CreateNone(DefaultTypeface(theme, placeholderType), DefaultFontSizeEmu, theme.ColorScheme.Dark1); + } + + var colorElement = + raw.ColorElement ?? + GetBulletColorElement(placeholderLevelElement) ?? + GetBulletColorElement(masterLevelElement); + + var fontElement = + raw.FontElement ?? + GetBulletFontElement(placeholderLevelElement) ?? + GetBulletFontElement(masterLevelElement); + + var sizeElement = + raw.SizeElement ?? + GetBulletSizeElement(placeholderLevelElement) ?? + GetBulletSizeElement(masterLevelElement); + + var color = ResolveBulletColor(colorElement, theme, firstRunProperties, colorMap); + var fontFamily = ResolveBulletFont(fontElement, theme, firstRunProperties, placeholderType); + var sizeEmu = ResolveBulletSize(sizeElement, firstRunProperties); + + return new PptxEffectiveBulletProperties(kind, character, autoNumType, autoNumStartAt, fontFamily, sizeEmu, color); + } + + /// Resolves a <a:pPr>/<a:lvl{N}pPr>-shaped element's bullet-type choice-group child, if any. + private static XElement? GetBulletTypeElement(XElement? pPrLikeElement) => + pPrLikeElement?.Element(DrawingNamespace + "buNone") ?? + pPrLikeElement?.Element(DrawingNamespace + "buAutoNum") ?? + pPrLikeElement?.Element(DrawingNamespace + "buChar"); + + /// Resolves a <a:pPr>/<a:lvl{N}pPr>-shaped element's bullet-color choice-group child, if any. + private static XElement? GetBulletColorElement(XElement? pPrLikeElement) => + pPrLikeElement?.Element(DrawingNamespace + "buClrTx") ?? + pPrLikeElement?.Element(DrawingNamespace + "buClr"); + + /// Resolves a <a:pPr>/<a:lvl{N}pPr>-shaped element's bullet-font choice-group child, if any. + private static XElement? GetBulletFontElement(XElement? pPrLikeElement) => + pPrLikeElement?.Element(DrawingNamespace + "buFontTx") ?? + pPrLikeElement?.Element(DrawingNamespace + "buFont"); + + /// Resolves a <a:pPr>/<a:lvl{N}pPr>-shaped element's bullet-size choice-group child, if any. + private static XElement? GetBulletSizeElement(XElement? pPrLikeElement) => + pPrLikeElement?.Element(DrawingNamespace + "buSzTx") ?? + pPrLikeElement?.Element(DrawingNamespace + "buSzPct") ?? + pPrLikeElement?.Element(DrawingNamespace + "buSzPts"); + + /// + /// Resolves the one bullet-type choice-group element present (<a:buNone>/ + /// <a:buAutoNum>/<a:buChar>) into a + /// (Kind, Character, AutoNumType, AutoNumStartAt) tuple. <a:buAutoNum>'s + /// type attribute defaults to "arabicPeriod" and its startAt + /// attribute defaults to 1, both per the OOXML schema's own documented defaults. + /// + private static (PptxBulletKind Kind, string? Character, string? AutoNumType, int AutoNumStartAt) ResolveBulletType(XElement? typeElement) + { + if (typeElement is null || typeElement.Name == DrawingNamespace + "buNone") + { + return (PptxBulletKind.None, null, null, 1); + } + + if (typeElement.Name == DrawingNamespace + "buChar") + { + var character = (string?)typeElement.Attribute("char") ?? string.Empty; + return (PptxBulletKind.Char, character, null, 1); + } + + // . + var autoNumType = (string?)typeElement.Attribute("type") ?? "arabicPeriod"; + var startAt = ParseOptionalIntAttribute(typeElement, "startAt") ?? 1; + return (PptxBulletKind.AutoNum, null, autoNumType, startAt); + } + + /// + /// Resolves the bullet-color choice-group's effective color: <a:buClrTx> + /// (or no color markup at all) resolves to the "follow text" sentinel - the paragraph's + /// own first run's effective color, or for a run-less + /// paragraph; <a:buClr> resolves its own wrapped color-definition child via + /// , the same color resolver run/paragraph colors already use. + /// + private static Rgba32 ResolveBulletColor( + XElement? colorElement, PptxTheme theme, PptxEffectiveRunProperties? firstRunProperties, PptxColorMap? colorMap = null) + { + if (colorElement is null || colorElement.Name == DrawingNamespace + "buClrTx") + { + return firstRunProperties?.Color ?? theme.ColorScheme.Dark1; + } + + var inner = colorElement.Elements().FirstOrDefault(); + return inner is null ? theme.ColorScheme.Dark1 : ResolveColor(inner, theme, colorMap: colorMap); + } + + /// + /// Resolves the bullet-font choice-group's effective family name: <a:buFontTx> + /// (or no font markup at all) resolves to the "follow text" sentinel - the paragraph's own + /// first run's effective family, or the hard-coded default typeface for a run-less + /// paragraph; <a:buFont typeface="..."/> resolves its own typeface + /// attribute directly (the element itself carries the attribute, unlike a run's + /// <a:latin> child), still passing through so a + /// theme-font token is resolved identically to a run's own typeface. + /// + private static string ResolveBulletFont(XElement? fontElement, PptxTheme theme, PptxEffectiveRunProperties? firstRunProperties, string placeholderType) + { + if (fontElement is null || fontElement.Name == DrawingNamespace + "buFontTx") + { + return firstRunProperties?.FontFamily ?? DefaultTypeface(theme, placeholderType); + } + + var typeface = (string?)fontElement.Attribute("typeface"); + return ResolveTypeface(typeface, theme) ?? DefaultTypeface(theme, placeholderType); + } + + /// + /// Resolves the bullet-size choice-group's effective size, in EMU: <a:buSzTx> + /// (or no size markup at all) resolves to the "follow text" sentinel - the paragraph's own + /// first run's effective size, or the hard-coded default font size for a run-less + /// paragraph; <a:buSzPct val="..."/> resolves to that val/100000 + /// fraction of the same "follow text" base size; <a:buSzPts val="..."/> + /// resolves to an absolute size (hundredths of a point, converted to EMU), independent of + /// the run's own size entirely. + /// + private static float ResolveBulletSize(XElement? sizeElement, PptxEffectiveRunProperties? firstRunProperties) + { + var baseSizeEmu = firstRunProperties?.SizeEmu ?? DefaultFontSizeEmu; + + if (sizeElement is null || sizeElement.Name == DrawingNamespace + "buSzTx") + { + return baseSizeEmu; + } + + if (sizeElement.Name == DrawingNamespace + "buSzPct") + { + var val = ParseNullableFloatAttribute(sizeElement.Attribute("val")) ?? 100000f; + return baseSizeEmu * (val / 100000f); + } + + // : val is hundredths of a point, the same convention as . + var pts = ParseNullableFloatAttribute(sizeElement.Attribute("val")) ?? 0f; + return pts * HundredthsOfPointToEmu; + } + + /// + /// Selects which of a slide master's three <p:txStyles> buckets + /// (titleStyle/bodyStyle/otherStyle) a given placeholder type + /// consults: "title"/"ctrTitle" select titleStyle; an empty + /// (a non-placeholder shape) selects otherStyle; + /// every other type (including "body" and every remapped placeholder type) selects + /// bodyStyle. + /// + /// + /// This routes "sldNum"/"dt"/"ftr" field placeholder types to + /// bodyStyle too, even though real PowerPoint masters typically declare these + /// types' own bullet-free style in otherStyle instead - a known, pre-existing, + /// out-of-scope routing bug left unchanged here (correcting it would also alter + /// font/size/bold/italic/color resolution for these placeholder types). To compensate, + /// applies a master-tier type exclusion + /// for these three types: it skips this method's bodyStyle selection as a + /// contributor to the TYPE choice-group specifically, so it cannot contribute a stray + /// inherited bullet, while still consulting the paragraph's own explicit markup and the + /// placeholder's own level-indexed element exactly as normal - an own-paragraph explicit + /// <a:buChar>/<a:buAutoNum> override on these placeholder types + /// still resolves and wins, as real OOXML/PowerPoint semantics require. + /// + private static XElement? SelectMasterTextStyle(PptxPlaceholderProperties placeholderProperties, string placeholderType) => + placeholderType switch + { + "title" or "ctrTitle" => placeholderProperties.MasterTextStyles?.TitleStyle, + "" => placeholderProperties.MasterTextStyles?.OtherStyle, + _ => placeholderProperties.MasterTextStyles?.BodyStyle, + }; + + /// Resolves a level-indexed <a:lvl{N+1}pPr> element from an <a:lstStyle>-shaped parent. + private static XElement? GetLevelElement(XElement? listStyleLikeElement, int level) => + listStyleLikeElement?.Element(DrawingNamespace + $"lvl{level + 1}pPr"); + + /// Resolves a level-indexed <a:lvl{N+1}pPr>/<a:defRPr> element. + private static XElement? GetLevelDefRPr(XElement? listStyleLikeElement, int level) => + GetLevelElement(listStyleLikeElement, level)?.Element(DrawingNamespace + "defRPr"); + + /// Extracts an <a:rPr>/<a:defRPr>-shaped element's <a:latin typeface="..."/> value. + private static string? GetTypeface(XElement? rPrLikeElement) => + (string?)rPrLikeElement?.Element(DrawingNamespace + "latin")?.Attribute("typeface"); + + /// + /// Resolves a raw <a:latin typeface="..."/> value: DrawingML permits the six + /// theme-font tokens +mj-lt/+mn-lt (major/minor Latin), + /// +mj-ea/+mn-ea (major/minor East Asian), and +mj-cs/+mn-cs + /// (major/minor complex-script) in place of a literal family name, each referring back to + /// 's own rather than naming a + /// font directly - resolving these here, rather than passing the literal token string + /// through to the font resolver, is required so lookups actually find the theme's real + /// major/minor font instead of silently falling back to the font resolver's own default. + /// + /// The raw typeface attribute value, or when absent. + /// The resolved theme supplying the a token refers to. + /// + /// The resolved family name, or when + /// is (preserving the attribute's absence through the inheritance chain). + /// + private static string? ResolveTypeface(string? rawTypeface, PptxTheme theme) => + rawTypeface switch + { + null => null, + "+mj-lt" => theme.FontScheme.MajorFont.Latin, + "+mn-lt" => theme.FontScheme.MinorFont.Latin, + "+mj-ea" => theme.FontScheme.MajorFont.EastAsian, + "+mn-ea" => theme.FontScheme.MinorFont.EastAsian, + "+mj-cs" => theme.FontScheme.MajorFont.ComplexScript, + "+mn-cs" => theme.FontScheme.MinorFont.ComplexScript, + _ => rawTypeface, + }; + + /// Resolves the hard-coded default typeface: the theme's major font for a title-type placeholder, else its minor font. + private static string DefaultTypeface(PptxTheme theme, string placeholderType) => + placeholderType is "title" or "ctrTitle" ? theme.FontScheme.MajorFont.Latin : theme.FontScheme.MinorFont.Latin; + + /// + /// Extracts an <a:rPr>/<a:defRPr>-shaped element's sz + /// attribute (hundredths of a point), converted to EMU. + /// + private static float? GetFontSizeEmu(XElement? rPrLikeElement) + { + var sz = ParseNullableFloatAttribute(rPrLikeElement?.Attribute("sz")); + return sz is { } value ? value * HundredthsOfPointToEmu : null; + } + + /// + /// Parses a boolean-valued attribute ("1"/"true" are truthy, "0"/ + /// "false" are falsy, per the OOXML schema's xsd:boolean convention), or + /// when or the attribute is absent. + /// + private static bool? GetBoolAttribute(XElement? rPrLikeElement, string attributeName) + { + var value = (string?)rPrLikeElement?.Attribute(attributeName); + return value switch + { + "1" or "true" => true, + "0" or "false" => false, + _ => null, + }; + } + + /// + /// Resolves an <a:rPr>/<a:defRPr>-shaped element's u + /// (underline) attribute to a : absent resolves to + /// (so the attribute-level inheritance chain keeps falling through + /// to a shallower tier); "none" resolves to ; + /// "sng" resolves to ; "dbl" resolves + /// to ; every other recognized/unrecognized + /// non-empty value resolves to - see that enum's + /// own remarks for the full ECMA-376 variant mapping. + /// + private static PptxUnderlineStyle? GetUnderlineStyle(XElement? rPrLikeElement) + { + var value = (string?)rPrLikeElement?.Attribute("u"); + return value switch + { + null => null, + "none" => PptxUnderlineStyle.None, + "sng" => PptxUnderlineStyle.Single, + "dbl" => PptxUnderlineStyle.Double, + _ => PptxUnderlineStyle.Other, + }; + } + + /// + /// Resolves an <a:rPr>/<a:defRPr>-shaped element's underline-fill + /// choice group (<a:uFillTx>/<a:uFill>): <a:uFillTx/> + /// (or no underline-fill markup at all) resolves to - "inherit/keep + /// searching the chain, defaulting to the run's own text color" - identically, since + /// neither variant locally declares an explicit underline color; + /// <a:uFill>/<a:solidFill> resolves its own wrapped color-definition + /// child via , the same color resolver run colors already use. + /// + private static Rgba32? GetUnderlineColor(XElement? rPrLikeElement, PptxTheme theme, PptxColorMap? colorMap = null) + { + var uFill = rPrLikeElement?.Element(DrawingNamespace + "uFill"); + if (uFill is null) + { + return null; + } + + var colorElement = uFill.Element(DrawingNamespace + "solidFill")?.Elements().FirstOrDefault(); + return colorElement is null ? null : ResolveColor(colorElement, theme, colorMap: colorMap); + } + + /// Resolves an <a:rPr>/<a:defRPr>-shaped element's <a:solidFill> color child, if any. + private static Rgba32? GetRunColor(XElement? rPrLikeElement, PptxTheme theme, PptxColorMap? colorMap = null) + { + var colorElement = rPrLikeElement?.Element(DrawingNamespace + "solidFill")?.Elements().FirstOrDefault(); + return colorElement is null ? null : ResolveColor(colorElement, theme, colorMap: colorMap); + } + + /// + /// Normalizes a resolved algn value: "just"/"justLow" (full + /// justification) is deliberately resolved to "l", a documented simplification (see + /// the design document's "Text Layout and Rendering (Phase 1d)" section) - redistributing + /// inter-word spacing per line is a separable refinement, not implemented this phase. + /// + private static string NormalizeAlignment(string algn) => + algn switch + { + "ctr" => "ctr", + "r" => "r", + _ => "l", + }; + + /// + /// Parses an <a:lnSpc> element into a : exactly + /// one of its <a:spcPct val="..."/> (a val/100000 fraction of a single + /// line's natural height) or <a:spcPts val="..."/> (hundredths of a point, + /// converted to EMU) child is present per the OOXML schema. + /// + /// when is absent or declares neither child. + private static PptxLineSpacing? ParseLineSpacing(XElement? lnSpcElement) + { + if (lnSpcElement is null) + { + return null; + } + + var spcPct = lnSpcElement.Element(DrawingNamespace + "spcPct"); + if (spcPct is not null) + { + var val = ParseNullableFloatAttribute(spcPct.Attribute("val")) ?? 100000f; + return new PptxLineSpacing(val / 100000f, null); + } + + var spcPts = lnSpcElement.Element(DrawingNamespace + "spcPts"); + if (spcPts is not null) + { + var val = ParseNullableFloatAttribute(spcPts.Attribute("val")) ?? 0f; + return new PptxLineSpacing(null, val * SpcPtsToEmu); + } + + return null; + } + + /// + /// Parses 's value as an invariant-culture, finite + /// , the same way the explicit (float?) cast operator does, but + /// rejects a present-but-malformed (non-numeric, out-of-range, or non-finite) value with + /// instead of letting the cast operator's raw + /// / propagate uncaught - + /// mirroring PptxDocument.Geometry.cs's ParseRequiredFloatAttribute guarded- + /// parse convention, adapted to operate directly on an already-null-propagated + /// (several call sites in this file resolve the owning element + /// as nullable, e.g. placeholderLevelElement?.Attribute("marL"), before this helper + /// is ever reached). + /// + /// The attribute to parse, or . + /// + /// The parsed, finite value, or when + /// is . + /// + /// + /// Thrown when is present but its value is not a well-formed, + /// finite . + /// + private static float? ParseNullableFloatAttribute(XAttribute? attribute) + { + if (attribute is null) + { + return null; + } + + if (!float.TryParse(attribute.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)) + { + throw new InvalidDataException( + $"Attribute '{attribute.Name}' has a non-numeric value '{attribute.Value}'."); + } + + if (!float.IsFinite(parsed)) + { + throw new InvalidDataException( + $"Attribute '{attribute.Name}' has a non-finite value '{attribute.Value}'."); + } + + return parsed; + } +} diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.TextLayout.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.TextLayout.cs new file mode 100644 index 00000000..76b08aaa --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.TextLayout.cs @@ -0,0 +1,1052 @@ +using System.Text; +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Fonts; + +namespace DemaConsulting.CanvasNet.Pptx; + +// cspell:ignore fontscale lnspcreduction pptx + +/// +/// Implements the text layout engine (Phase 1d): word-wrap, +/// horizontal alignment, vertical anchor, and the three-tier autofit policy - see the design +/// document's "Text Layout and Rendering (Phase 1d)" section for the full algorithm narrative +/// and its documented simplifications/deferrals (full justification, bullets/numbering, +/// kerning, text clipping on overflow, growing a shape for spAutoFit). +/// +public sealed partial class PptxDocument +{ + /// The number of shrink-loop iterations attempted before giving up at the floor scale (Design Decision 4). + private const int MaxShrinkIterations = 9; + + /// The per-iteration font-scale reduction step used by the shrink loop (10%). + private const float ShrinkStep = 0.1f; + + /// The minimum font scale the shrink loop will reach before stopping regardless of fit. + private const float MinShrinkScale = 0.1f; + + /// + /// The default OOXML tab-stop interval, in EMU (914400 EMU = 1 inch), consulted when a + /// run's literal text contains a U+0009 TAB character (Phase 2 Follow-Up: Default + /// Tab-Stop Expansion). Confirmed against a real-world corpus file + /// ("ERF IWF Breadboard Peer Review.pptx", slide 9): both its slide master's + /// <p:txStyles> and the presentation's own <p:defaultTextStyle> + /// declare defTabSz="914400" on every list level, and independent ground-truth + /// pixel measurement of the rendered slide (three different bullet-number widths) matched + /// this value's predicted tab-stop column to within 1px. Explicit <a:tabLst> + /// tab stops are deliberately out of scope - see the design doc's matching follow-up note. + /// + private const float DefaultTabStopEmu = 914400f; + + /// + /// Returns the next default tab stop strictly greater than , + /// i.e. the smallest multiple of that exceeds + /// - standard tab semantics, guaranteeing a tab always + /// advances the cursor by at least a minimal, non-zero amount even when + /// already sits exactly on a stop boundary (a tab never + /// produces a zero-width advance). + /// + private static float GetNextTabStopEmu(float currentXEmu, float tabStopEmu) => + (MathF.Floor(currentXEmu / tabStopEmu) + 1f) * tabStopEmu; + + /// + /// Resolves a text body's full layout: every run/paragraph's effective properties, word- + /// wrapped into lines bounded by , positioned per the body's + /// horizontal alignment/vertical anchor, and scaled per its autofit policy. + /// + /// The parsed text body to lay out. + /// The owning shape's resolved placeholder property chain. + /// The resolved theme. + /// The owning shape's placeholder type, or for a non-placeholder shape. + /// The owning shape's own declared width, in EMU (see ). + /// The owning shape's own declared height, in EMU (see ). + /// + /// Resolves a (familyName, bold, italic) triple to a - + /// production callers supply ; tests may inject a delegate + /// that bypasses installed-font discovery entirely (for example calling + /// directly) for deterministic, + /// machine-independent pixel assertions. + /// + /// + /// The effective color map consulted when a run/bullet color resolves an + /// <a:schemeClr val="bg1"/>-shaped token, or (the + /// default) - see 's matching parameter. + /// + /// + /// Resolves a (bold, italic) pair to a bundled fallback , + /// consulted only when a specific character is missing from its run's own primary font + /// (per-character glyph-coverage fallback - see the private ResolveGlyph helper + /// this phase builds from it). Production callers leave this at its default + /// ( with the same + /// serif: false, fixedPitch: false convention already + /// uses); tests may inject a deterministic synthetic fallback font instead, mirroring + /// 's own test seam. + /// + /// The resolved . + internal static PptxTextLayout ResolveTextLayout( + PptxTextBody textBody, + PptxPlaceholderProperties placeholderProperties, + PptxTheme theme, + string placeholderType, + float widthEmu, + float heightEmu, + Func fontResolver, + PptxColorMap? colorMap = null, + Func? fallbackFontResolver = null) + { + fallbackFontResolver ??= static (bold, italic) => SystemFontCatalog.LoadBundledFallback(serif: false, fixedPitch: false, bold, italic); + var bodyProperties = textBody.Properties; + var insetLeft = bodyProperties.InsetLeftEmu; + var insetRight = bodyProperties.InsetRightEmu; + var insetTop = bodyProperties.InsetTopEmu; + var insetBottom = bodyProperties.InsetBottomEmu; + + var availableWidth = bodyProperties.Wrap == PptxTextWrap.None + ? float.MaxValue / 4f + : MathF.Max(0f, widthEmu - insetLeft - insetRight); + var availableHeight = MathF.Max(0f, heightEmu - insetTop - insetBottom); + + // Horizontal alignment (center/right) must always be computed against the shape's own + // declared box width, never against the effectively-infinite width used above to suppress + // word-wrapping for a wrap="none" (typically spAutoFit) shape: PositionLines centers (or + // right-aligns) each line within "availableWidthEmu" - passing the infinite wrap width + // through unchanged would place a centered/right-aligned line's glyphs at an astronomical + // X offset, far outside the shape (and surface) entirely, painting nothing. A wrap="none" + // shape still has a real, finite declared width (widthEmu/cx) that + // alignment must honor even though wrapping itself is suppressed. + var alignmentWidth = MathF.Max(0f, widthEmu - insetLeft - insetRight); + + var fontCache = new Dictionary<(string Family, bool Bold, bool Italic), TrueTypeFont>(); + TrueTypeFont ResolveFont(string family, bool bold, bool italic) + { + var key = (family, bold, italic); + if (!fontCache.TryGetValue(key, out var font)) + { + font = fontResolver(family, bold, italic); + fontCache[key] = font; + } + + return font; + } + + // Per-character glyph-coverage fallback - the tofu-box feature gap. A run is resolved + // to exactly one primary font above, but that font may not cover every character the + // run's text actually contains. ResolveGlyph below tries the primary font first and, + // only on an actual notdef miss for a non-whitespace character, consults a bundled + // fallback font, memoized per bold/italic pair the same way as fontCache above. When + // the fallback font covers the character, its own font and glyph index win for that one + // character only; otherwise the primary font's own notdef glyph is kept, the same "no + // candidate matched" convention PDF's simple-font resolver already uses. Whitespace is + // deliberately excluded, since a primary font's own missing space glyph is not a visible + // tofu box and does not warrant a fallback-font substitution. + var fallbackFontCache = new Dictionary<(bool Bold, bool Italic), TrueTypeFont>(); + TrueTypeFont ResolveFallbackFont(bool bold, bool italic) + { + var key = (bold, italic); + if (!fallbackFontCache.TryGetValue(key, out var font)) + { + font = fallbackFontResolver(bold, italic); + fallbackFontCache[key] = font; + } + + return font; + } + + (TrueTypeFont Font, int GlyphIndex) ResolveGlyph(TrueTypeFont primaryFont, int codepoint, bool bold, bool italic) + { + var glyphIndex = primaryFont.GetGlyphIndex(codepoint); + if (glyphIndex != 0 || Rune.IsWhiteSpace(new Rune(codepoint))) + { + return (primaryFont, glyphIndex); + } + + var fallbackFont = ResolveFallbackFont(bold, italic); + var fallbackGlyphIndex = fallbackFont.GetGlyphIndex(codepoint); + return fallbackGlyphIndex != 0 ? (fallbackFont, fallbackGlyphIndex) : (primaryFont, glyphIndex); + } + + var paragraphs = textBody.Paragraphs + .Select(paragraph => + { + // Run properties are resolved before paragraph properties (reversing the two + // Selects' former independence) because bullet resolution needs the paragraph's + // own first run's effective properties as its "follow text" + // (buClrTx/buFontTx/buSzTx) fallback - see ResolveEffectiveBulletProperties. + var runProperties = paragraph.Runs + .Select(run => ResolveEffectiveRunProperties(run, paragraph, placeholderProperties, theme, placeholderType, colorMap)) + .ToList(); + var firstRunProperties = runProperties.Count > 0 ? runProperties[0] : null; + var paragraphProperties = ResolveEffectiveParagraphProperties(paragraph, placeholderProperties, placeholderType, firstRunProperties, colorMap); + return new ResolvedParagraph(paragraph, paragraphProperties, runProperties); + }) + .ToList(); + + var (fontScale, lineSpacingFactor) = ResolveAutofitScale( + bodyProperties.AutofitElement, + paragraphs, + availableWidth, + availableHeight, + ResolveFont, + ResolveGlyph); + + var lines = BuildLines(paragraphs, availableWidth, fontScale, lineSpacingFactor, ResolveFont, ResolveGlyph); + var (glyphs, underlines) = PositionLines(lines, bodyProperties.Anchor, insetLeft, insetTop, alignmentWidth, availableHeight); + + return new PptxTextLayout(glyphs, fontScale, underlines); + } + + /// + /// Resolves the autofit font-scale/line-spacing-reduction factors to apply, per Design + /// Decision 4's three-tier policy: noAutofit/absent/spAutoFit apply no + /// scaling; an explicit normAutofit applies each of its fontScale/ + /// lnSpcReduction attributes independently (per OOXML, each is independently + /// optional - an element declaring only one still carries a meaningful, persisted factor + /// for that one, falling back to that one attribute's own spec-defined neutral default + /// (100000/no reduction) only when it is itself absent); a normAutofit + /// declaring neither attribute runs a bounded 10%-step shrink loop instead, stopping at + /// the first scale whose natural layout height fits or + /// at the 10% floor, whichever comes first. + /// + /// A (FontScale, LineSpacingFactor) pair, each 1.0 meaning "unscaled". + /// + /// Thrown when 's fontScale/lnSpcReduction + /// attribute is present but not a well-formed, finite - see + /// PptxDocument.TextInheritance.cs's ParseNullableFloatAttribute. + /// + private static (float FontScale, float LineSpacingFactor) ResolveAutofitScale( + System.Xml.Linq.XElement? autofitElement, + IReadOnlyList paragraphs, + float availableWidth, + float availableHeight, + Func resolveFont, + Func resolveGlyph) + { + if (autofitElement is null || autofitElement.Name.LocalName is "noAutofit" or "spAutoFit") + { + return (1f, 1f); + } + + var fontScaleAttribute = ParseNullableFloatAttribute(autofitElement.Attribute("fontScale")); + var lnSpcReductionAttribute = ParseNullableFloatAttribute(autofitElement.Attribute("lnSpcReduction")); + if (fontScaleAttribute is { } || lnSpcReductionAttribute is { }) + { + var fontScale = fontScaleAttribute is { } storedFontScale ? storedFontScale / 100000f : 1f; + var lineSpacingFactor = lnSpcReductionAttribute is { } storedReduction ? 1f - (storedReduction / 100000f) : 1f; + return (fontScale, lineSpacingFactor); + } + + // Attribute-less : bounded deterministic shrink loop (Design Decision 4). + var scale = 1f; + for (var iteration = 0; iteration < MaxShrinkIterations; iteration++) + { + var lines = BuildLines(paragraphs, availableWidth, scale, 1f, resolveFont, resolveGlyph); + var totalHeight = lines.Sum(line => line.LineHeightEmu + line.LeadingGapEmu); + if (totalHeight <= availableHeight || scale <= MinShrinkScale) + { + return (scale, 1f); + } + + scale = MathF.Max(MinShrinkScale, scale - ShrinkStep); + } + + return (scale, 1f); + } + + /// + /// Tokenizes run text into whitespace/non-whitespace runs, preserving every consecutive- + /// whitespace token's own advance width. A literal U+0009 TAB character (Phase 2 + /// Follow-Up: Default Tab-Stop Expansion) is always isolated as its own single-character + /// token - never merged with adjacent whitespace of another kind, nor with another + /// adjacent tab - since its effective wrap/render width depends on its own position in the + /// line (see ), not a fixed, pre-computable glyph advance. + /// + private static List<(string Text, bool IsWhitespace, bool IsTab)> Tokenize(string text) + { + var tokens = new List<(string Text, bool IsWhitespace, bool IsTab)>(); + var start = 0; + while (start < text.Length) + { + if (text[start] == '\t') + { + tokens.Add((text[start..(start + 1)], true, true)); + start++; + continue; + } + + var isWhitespace = char.IsWhiteSpace(text[start]); + var end = start + 1; + while (end < text.Length && text[end] != '\t' && char.IsWhiteSpace(text[end]) == isWhitespace) + { + end++; + } + + tokens.Add((text[start..end], isWhitespace, false)); + start = end; + } + + return tokens; + } + + /// A single word-wrap token, resolved to a specific run's effective properties/font for width measurement and emission. + /// The token's own text (empty for an token). + /// Whether the token is a collapsible run of whitespace rather than a visible word. + /// The owning run's effective, already font-scaled properties (unused for an token). + /// The owning run's resolved font (unused for an token). + /// The token's measured advance width, in EMU (always 0 for an token). + /// + /// When , this token represents an explicit <a:br> line + /// break rather than run text: it carries no text/font/width of its own and is never + /// added to a line's glyph content - instead consumes + /// it to force a new line boundary at this point in the paragraph's token stream. + /// + /// + /// When , this token represents a single literal U+0009 TAB run + /// character (Phase 2 Follow-Up: Default Tab-Stop Expansion) rather than an ordinary + /// glyph-measured word/whitespace token: its is unused (always + /// 0, since a tab's true width depends on its own position in the line, not a + /// fixed advance) - both and 's + /// own per-line loop instead compute its effective width dynamically via + /// from the current running X position at that point. + /// + private readonly record struct ResolvedToken( + string Text, + bool IsWhitespace, + PptxEffectiveRunProperties RunProperties, + TrueTypeFont Font, + float WidthEmu, + bool IsLineBreak = false, + 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); + + /// + /// A single contiguous underlined span on one line (Phase 2 Follow-Up: Underline + /// Rendering), positioned relative to the line's own start (alignment/margin not yet + /// applied) - mirrors 's own convention. + /// + /// The span's start X, relative to the line's own start, in EMU. + /// The span's end X, relative to the line's own start, in EMU. + /// + /// The underlined run's own resolved effective properties, supplying the style/color/size + /// carries into the final . + /// + private readonly record struct LineUnderlineSpan(float StartXEmu, float EndXEmu, PptxEffectiveRunProperties RunProperties); + + /// A single word-wrapped line, ready for alignment/vertical-anchor positioning. + /// The line's own text glyphs, positioned relative to the line's own start. + /// The line's natural (unaligned) text width, in EMU. + /// The line's resolved height, in EMU. + /// The line's resolved ascent (baseline offset from the line's top), in EMU. + /// The owning paragraph's resolved effective properties. + /// Whether this is the owning paragraph's first word-wrapped line. + /// + /// The paragraph's rendered bullet glyph(s) (Phase 2 Follow-Up: Bullets and Numbering), + /// positioned relative to X=0 (the hanging-indent gutter, not the line's own text start - + /// see ). Always empty except on a bulleted paragraph's own + /// first line (). + /// + /// + /// The bullet string's own total measured advance width, in EMU (0 when + /// is empty) - consulted by to + /// guarantee the paragraph's own text never shares an X range with the bullet glyph(s) it + /// is painted beside (the gutter-clearance fix). + /// + /// + /// The minimum additional gap, in EMU, reserves beyond + /// before the paragraph's own text may start (0 for + /// every bullet kind except - see + /// 's own remarks for why auto-numbered markers alone need + /// this extra, PowerPoint-like, tab-stop-style separation). + /// + /// + /// The line's resolved underlined spans (Phase 2 Follow-Up: Underline Rendering), + /// positioned relative to the line's own start, same convention as . + /// + /// + /// The extra vertical gap, in EMU, inserted immediately before this line's own box (Phase + /// 2 Follow-Up: Paragraph Spacing) - always 0 except a paragraph's first + /// word-wrapped line when a preceding paragraph exists, where it carries the additive + /// spcAft(previous paragraph) + spcBef(this paragraph) gap. Never applied before a + /// text body's first paragraph nor after its last (see 's own + /// remarks for the exact additive semantics this field implements). + /// + private sealed record LineBox( + IReadOnlyList Glyphs, + float LineWidthEmu, + float LineHeightEmu, + float AscentEmu, + PptxEffectiveParagraphProperties ParagraphProperties, + bool IsFirstLineOfParagraph, + IReadOnlyList BulletGlyphs, + float BulletWidthEmu, + float BulletTrailingGapEmu, + IReadOnlyList UnderlineSpans, + float LeadingGapEmu = 0f); + + /// A paragraph with its effective paragraph properties and every run's effective properties resolved up front. + private sealed record ResolvedParagraph( + PptxParagraph Paragraph, + PptxEffectiveParagraphProperties ParagraphProperties, + IReadOnlyList RunProperties); + + /// + /// Computes a resolved token's natural advance width, in EMU, summing each Unicode scalar + /// value (code point)'s font-metric advance - via , so a + /// code point the token's own primary lacks (and that a bundled + /// fallback font covers) measures using the fallback font's own advance width, not the + /// primary font's glyph-0 advance, keeping word-wrap width measurement consistent with + /// what 's own per-code-point loop actually paints. Enumerating + /// via text.EnumerateRunes() (rather than per-UTF-16 char) ensures a + /// supplementary-plane character (for example an emoji, codepoint above U+FFFF) is + /// measured as a single logical unit, not as two mis-measured surrogate halves. + /// + private static float MeasureTokenWidthEmu( + string text, + TrueTypeFont font, + float sizeEmu, + bool bold, + bool italic, + Func resolveGlyph) + { + var total = 0f; + foreach (var rune in text.EnumerateRunes()) + { + var (resolvedFont, glyphIndex) = resolveGlyph(font, rune.Value, bold, italic); + total += resolvedFont.GetAdvanceWidth(glyphIndex) / (float)resolvedFont.UnitsPerEm * sizeEmu; + } + + return total; + } + + /// + /// Word-wraps every paragraph's tokenized runs into lines bounded by + /// , applying to every + /// run's resolved size and to every paragraph's + /// resolved line spacing. A token that alone exceeds + /// is placed alone on its own (overflowing) line rather than looping indefinitely - a + /// documented overflow policy, not a crash. + /// + /// + /// Phase 2 Follow-Up: Paragraph Spacing - the gap between two adjacent paragraphs is + /// additive (spcAft(paragraph N) + spcBef(paragraph N+1)), applied only between + /// paragraphs - never before a text body's first paragraph nor after its last - via each + /// paragraph's own first line's . A percentage + /// (spcPct) spacing value resolves against the boundary line's own already-resolved + /// lineHeight (the first line for spcBef, the last line for spcAft) - + /// a documented scoping decision avoiding a second, separately-tracked "natural height". + /// + private static List BuildLines( + IReadOnlyList paragraphs, + float availableWidthEmu, + float fontScale, + float lineSpacingFactor, + Func resolveFont, + Func resolveGlyph) + { + var lines = new List(); + + // Auto-number counter/last-type state (Phase 2 Follow-Up: Bullets and Numbering), + // scoped per-call (one text body/shape) - see AdvanceBulletCounters' own remarks for the + // full per-level sequencing/reset state machine this threads across the paragraph loop. + var counters = new int[MaxParagraphLevel + 1]; + var lastTypes = new string?[MaxParagraphLevel + 1]; + + // Phase 2 Follow-Up: Paragraph Spacing - tracks the previous paragraph's own resolved + // SpaceAfter and its last line's resolved lineHeight (needed to resolve that SpaceAfter's + // own spcPct, if any), so the next paragraph's first line can compute its additive + // leading gap. isFirstParagraph suppresses the gap before the text body's very first + // paragraph, regardless of any spcBef it declares. + var isFirstParagraph = true; + var previousSpaceAfter = PptxLineSpacing.None; + var previousLastLineHeightEmu = 0f; + + foreach (var resolvedParagraph in paragraphs) + { + var paragraph = resolvedParagraph.Paragraph; + var paraProps = resolvedParagraph.ParagraphProperties; + var level = Math.Clamp(paragraph.RawProperties.Level, 0, MaxParagraphLevel); + var bulletText = AdvanceBulletCounters(level, paraProps.Bullet, counters, lastTypes); + + // Phase 2 Follow-Up: Bullets and Numbering - a paragraph with no run children + // (a blank/spacer paragraph, for example one containing only ) must not + // paint a bullet glyph, even though its bullet properties still resolve and its + // auto-number counter state must still advance above (PowerPoint itself never shows a + // bullet next to an empty line). A run whose text is whitespace-only still counts as + // "has a run" here - a documented, accepted minor limitation (see the design doc). + var hasRuns = paragraph.Items.Any(item => item is PptxRunItem); + + // Build the paragraph's scaled token stream, resolving each run's font once. An + // <a:br> item becomes a break-marker token that forces a new line in + // PackTokensIntoLines, preserving its position relative to surrounding runs. + var tokens = new List(); + var runIndex = 0; + foreach (var item in paragraph.Items) + { + if (item is PptxLineBreakItem) + { + tokens.Add(new ResolvedToken(string.Empty, false, default!, null!, 0f, IsLineBreak: true)); + continue; + } + + var run = ((PptxRunItem)item).Run; + var runProps = resolvedParagraph.RunProperties[runIndex]; + runIndex++; + var scaledSizeEmu = runProps.SizeEmu * fontScale; + var font = resolveFont(runProps.FontFamily, runProps.Bold, runProps.Italic); + var scaledRunProps = runProps with { SizeEmu = scaledSizeEmu }; + + foreach (var (text, isWhitespace, isTab) in Tokenize(run.Text)) + { + if (text.Length == 0) + { + continue; + } + + // A tab token's true width depends on its own position in the line - skip the + // (otherwise-misleading, font-glyph-based) measurement entirely rather than + // looking up a near-zero/.notdef glyph advance for it (see GetNextTabStopEmu). + var width = isTab + ? 0f + : MeasureTokenWidthEmu(text, font, scaledSizeEmu, runProps.Bold, runProps.Italic, resolveGlyph); + tokens.Add(new ResolvedToken(text, isWhitespace, scaledRunProps, font, width, IsTab: isTab)); + } + } + + var paragraphLines = PackTokensIntoLines(tokens, availableWidthEmu); + if (paragraphLines.Count == 0) + { + // A blank paragraph (no runs/tokens) still occupies a visible, empty line - its + // height falls back to the nominal default font size (see the blank-line branch + // below), since there is no run to resolve font metrics from. + paragraphLines.Add([]); + } + + for (var i = 0; i < paragraphLines.Count; i++) + { + var lineTokens = paragraphLines[i]; + var glyphs = new List(); + var underlineSpans = new List(); + LineUnderlineSpan? pendingUnderlineSpan = null; + var cursorX = 0f; + TrueTypeFont? tallestFont = null; + var tallestSize = 0f; + var tallestNaturalHeight = -1f; + + foreach (var token in lineTokens) + { + var tokenStartX = cursorX; + + // Compare each candidate's actual rendered natural height (its raw font-design- + // unit metrics scaled by its own UnitsPerEm and SizeEmu), not raw font-design + // units directly - otherwise a small-font run using a font with a tall em-box + // can out-rank a much larger run that happens to use a more compact em-box, + // producing the wrong line height/baseline. + var naturalHeight = (token.Font.Ascender - token.Font.Descender + token.Font.LineGap) / + (float)token.Font.UnitsPerEm * token.RunProperties.SizeEmu; + if (naturalHeight > tallestNaturalHeight) + { + tallestNaturalHeight = naturalHeight; + tallestFont = token.Font; + tallestSize = token.RunProperties.SizeEmu; + } + + if (token.IsTab) + { + // Phase 2 Follow-Up: Default Tab-Stop Expansion - expand the cursor to + // the next default tab stop instead of resolving a (near-zero, + // .notdef-glyph) font advance for the literal U+0009 TAB character; no + // glyph is emitted (mirroring the pre-existing IsWhitespace glyph- + // emission guard below for every other whitespace token kind). Explicit + // tab stops are out of scope - see the design doc note. + cursorX = GetNextTabStopEmu(cursorX, DefaultTabStopEmu); + } + else + { + // Enumerate by Unicode scalar value (Rune), not UTF-16 char, so a + // supplementary-plane character (codepoint above U+FFFF, e.g. an emoji) + // is measured/placed as one logical glyph unit instead of being split + // into two mis-measured surrogate halves. + foreach (var rune in token.Text.EnumerateRunes()) + { + var (resolvedFont, glyphIndex) = resolveGlyph(token.Font, rune.Value, token.RunProperties.Bold, token.RunProperties.Italic); + 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)); + } + + cursorX += advance; + } + } + + var tokenEndX = cursorX; + + // Phase 2 Follow-Up: Underline Rendering - accumulate contiguous underlined + // tokens belonging to the same run instance into a single span, so the span + // covers interior whitespace within one run (not just individual words) and + // flushes exactly at a run boundary (including a transition to a + // non-underlined run). RunProperties is reused, by reference, across every + // token of a single run (see BuildLines' own per-run loop above) and survives + // PackTokensIntoLines unchanged, so ReferenceEquals reliably detects "same + // run" here. + if (token.RunProperties.UnderlineStyle != PptxUnderlineStyle.None) + { + if (pendingUnderlineSpan is { } pending && ReferenceEquals(pending.RunProperties, token.RunProperties)) + { + pendingUnderlineSpan = pending with { EndXEmu = tokenEndX }; + } + else + { + if (pendingUnderlineSpan is { } toFlush) + { + underlineSpans.Add(toFlush); + } + + pendingUnderlineSpan = new LineUnderlineSpan(tokenStartX, tokenEndX, token.RunProperties); + } + } + else + { + if (pendingUnderlineSpan is { } toFlush) + { + underlineSpans.Add(toFlush); + } + + pendingUnderlineSpan = null; + } + } + + // The line's natural width is the cursor's own final accumulated position - + // authoritative for every token kind (including a tab, whose WidthEmu is unused - + // see ResolvedToken.IsTab), so a single post-loop read replaces a separate, + // now-inconsistent-for-tabs running accumulator. + var lineWidth = cursorX; + + if (pendingUnderlineSpan is { } finalSpan) + { + underlineSpans.Add(finalSpan); + } + + float lineHeight; + float ascent; + if (tallestFont is null) + { + // Blank line: fall back to the default font size's nominal proportions. + ascent = DefaultFontSizeEmu * 0.8f; + lineHeight = DefaultFontSizeEmu * 1.2f * lineSpacingFactor; + } + else + { + var naturalHeight = (tallestFont.Ascender - tallestFont.Descender + tallestFont.LineGap) / + (float)tallestFont.UnitsPerEm * tallestSize; + ascent = tallestFont.Ascender / (float)tallestFont.UnitsPerEm * tallestSize; + var spacingFactor = paraProps.LineSpacing.Percent is { } percent ? percent : 1f; + lineHeight = paraProps.LineSpacing.FixedEmu is { } fixedEmu + ? fixedEmu * lineSpacingFactor + : naturalHeight * spacingFactor * lineSpacingFactor; + } + + // Phase 2 Follow-Up: Paragraph Spacing - only the paragraph's own first + // word-wrapped line carries a leading gap, and only when a preceding paragraph + // exists (never before the text body's very first paragraph). The gap is additive: + // the preceding paragraph's own SpaceAfter (resolved against its own last line's + // lineHeight) plus this paragraph's own SpaceBefore (resolved against this, its + // first, line's lineHeight). + var leadingGapEmu = 0f; + if (i == 0 && !isFirstParagraph) + { + leadingGapEmu = ResolveSpacingEmu(previousSpaceAfter, previousLastLineHeightEmu) + + ResolveSpacingEmu(paraProps.EffectiveSpaceBefore, lineHeight); + } + + var isFirstLine = i == 0; + var bulletGlyphsResult = isFirstLine && hasRuns + ? BuildBulletGlyphs(bulletText, paraProps.Bullet, fontScale, resolveFont, resolveGlyph) + : new BulletGlyphsResult([], 0f, 0f); + + lines.Add(new LineBox(glyphs, lineWidth, lineHeight, ascent, paraProps, isFirstLine, bulletGlyphsResult.Glyphs, bulletGlyphsResult.WidthEmu, bulletGlyphsResult.TrailingGapEmu, underlineSpans, leadingGapEmu)); + + // The paragraph's own last line records its resolved SpaceAfter/lineHeight for + // the next paragraph's own leading-gap computation above. + if (i == paragraphLines.Count - 1) + { + previousSpaceAfter = paraProps.EffectiveSpaceAfter; + previousLastLineHeightEmu = lineHeight; + } + } + + isFirstParagraph = false; + } + + return lines; + } + + /// + /// Resolves a paragraph's spacing-before/after (Phase 2 Follow-Up: Paragraph Spacing) into + /// a concrete EMU gap: a fixed value (spcPts) is used verbatim; a percentage + /// (spcPct) resolves against - the + /// boundary line's own already-resolved lineHeight (already scaled by any + /// applicable autofit lnSpcReduction), deliberately not a second, unscaled "natural + /// height" (see 's own remarks for this scoping decision). + /// + private static float ResolveSpacingEmu(PptxLineSpacing spacing, float referenceLineHeightEmu) => + spacing.FixedEmu is { } fixedEmu ? fixedEmu : referenceLineHeightEmu * (spacing.Percent ?? 0f); + + /// + /// Advances this call's (one shape/text-body's) per-level auto-number counter/last-type + /// state machine for one paragraph in document order, and returns that paragraph's + /// rendered bullet string (Phase 2 Follow-Up: Bullets and Numbering) - or + /// when the paragraph has no bullet at all, is explicitly + /// , or is an auto-number whose type + /// does not recognize (that one bullet is then simply + /// skipped, per this phase's documented graceful-degradation policy - the paragraph's own + /// text still renders normally and the counter state still advances for later + /// paragraphs). + /// + /// + /// + /// For each paragraph, every counter/last-type slot strictly deeper than the + /// paragraph's own level is first zeroed/cleared - this closes out any nested list the + /// document has since left, so a later paragraph that returns to that deeper level + /// starts a fresh numbered run rather than resuming a stale one. The paragraph's own + /// level's slot is then either: + /// + /// + /// Reset to startAt (and the slot's last-type recorded) when this is the level's first auto-number run, or its type changed from the immediately-preceding paragraph at that same level (a new numbered run starting over). + /// Incremented by one, continuing the same numbered run. + /// Zeroed/cleared entirely for a non-auto-number (/) paragraph, so any later resumption of auto-numbering at that level starts over rather than continuing silently across an intervening non-numbered paragraph. + /// + /// + private static string? AdvanceBulletCounters(int level, PptxEffectiveBulletProperties? bullet, int[] counters, string?[] lastTypes) + { + level = Math.Clamp(level, 0, MaxParagraphLevel); + for (var deeper = level + 1; deeper <= MaxParagraphLevel; deeper++) + { + counters[deeper] = 0; + lastTypes[deeper] = null; + } + + if (bullet is null || bullet.Kind == PptxBulletKind.None) + { + counters[level] = 0; + lastTypes[level] = null; + return null; + } + + if (bullet.Kind == PptxBulletKind.Char) + { + counters[level] = 0; + lastTypes[level] = null; + return bullet.Character is { Length: > 0 } ? bullet.Character : null; + } + + // PptxBulletKind.AutoNum. + var autoNumType = bullet.AutoNumType ?? "arabicPeriod"; + if (lastTypes[level] != autoNumType) + { + counters[level] = bullet.AutoNumStartAt; + lastTypes[level] = autoNumType; + } + else + { + counters[level]++; + } + + return FormatAutoNumber(counters[level], autoNumType); + } + + /// + /// The result of measuring a paragraph's rendered bullet string (see + /// ): the bullet's own positioned glyphs plus the bullet + /// string's total measured advance width - the latter is what + /// needs to guarantee the paragraph's own text never shares an X range with the bullet + /// glyph(s) it is painted beside (the gutter-clearance fix; see the design document's + /// "Bullet/text gutter clearance" note). + /// + /// The bullet's own positioned glyphs (see ). + /// The bullet string's total measured advance width, in EMU; 0 when is empty. + /// + /// A minimum additional gap, in EMU, reserves beyond + /// before the paragraph's own text may start - 0 for every + /// bullet kind except (see + /// 's own remarks). + /// + private readonly record struct BulletGlyphsResult(IReadOnlyList Glyphs, float WidthEmu, float TrailingGapEmu = 0f); + + /// + /// Tokenizes and measures a paragraph's rendered bullet string (if any) into + /// s positioned relative to X=0 - the hanging-indent gutter + /// (marL+indent), not the line's own text start (see + /// 's separate bullet-gutter-X computation). Bullets are never + /// bold/italic regardless of any adjacent run's own style - a documented simplification + /// (see the design document). + /// + /// + /// For bullets, the returned + /// additionally reserves one space + /// character's own advance width, measured in the bullet's already-resolved font/size, as + /// a minimum gap beyond the marker's own measured width - approximating PowerPoint's own + /// visibly wider, tab-stop-like separation after a number marker (e.g. "1."), which + /// the plain gutter-clearance clamp alone (a "just touching" minimum) does not reproduce. + /// This is gated to only - a + /// bullet's own already-correct, already-verified "touching, zero extra gap" behavior is + /// unaffected (see the design document's "Closed risk (bullet/text gutter clearance)" + /// note). + /// + private static BulletGlyphsResult BuildBulletGlyphs( + string? bulletText, + PptxEffectiveBulletProperties? bullet, + float fontScale, + Func resolveFont, + Func resolveGlyph) + { + if (string.IsNullOrEmpty(bulletText) || bullet is null || bullet.Kind == PptxBulletKind.None) + { + return new BulletGlyphsResult([], 0f); + } + + var font = resolveFont(bullet.FontFamily, false, false); + var sizeEmu = bullet.SizeEmu * fontScale; + var glyphs = new List(); + var cursorX = 0f; + // Enumerate by Unicode scalar value (Rune), not UTF-16 char, so a supplementary-plane + // bullet character (e.g. an emoji) is measured/placed as one + // logical glyph unit instead of two mis-measured surrogate halves. + foreach (var rune in bulletText.EnumerateRunes()) + { + // Bullets are never bold/italic (see this method's own remarks), so the fallback + // lookup is always keyed (false, false), matching resolveFont's own call above. + var (resolvedFont, glyphIndex) = resolveGlyph(font, rune.Value, false, false); + var advance = resolvedFont.GetAdvanceWidth(glyphIndex) / (float)resolvedFont.UnitsPerEm * sizeEmu; + glyphs.Add(new LineGlyph(resolvedFont, glyphIndex, cursorX, sizeEmu, bullet.Color)); + cursorX += advance; + } + + var trailingGapEmu = 0f; + if (bullet.Kind == PptxBulletKind.AutoNum) + { + var spaceGlyphIndex = font.GetGlyphIndex(' '); + trailingGapEmu = font.GetAdvanceWidth(spaceGlyphIndex) / (float)font.UnitsPerEm * sizeEmu; + } + + return new BulletGlyphsResult(glyphs, cursorX, trailingGapEmu); + } + + /// + /// Greedily packs a paragraph's token stream into width-bounded lines, returning each + /// line's own token list. An token (from an + /// explicit <a:br>) is never itself added to a line - it instead force-flushes + /// the current line (even if empty) and starts a new one, independent of word-wrap width. + /// Only whitespace tokens (and line breaks) are legal wrap points: consecutive + /// non-whitespace tokens are accumulated into a single wrap-atomic "word group" and + /// packed as one unit, so a word happened to split across two + /// adjacent formatting runs (for example "Hel" in a bold run immediately followed + /// by "lo" in a plain run, with no whitespace between them) can never wrap at that + /// run boundary - it wraps only where whitespace actually separates the runs' combined + /// text. A word group that alone exceeds is still + /// placed whole on its own (overflowing) line, per the same documented overflow policy as + /// any other oversized token. + /// + private static List> PackTokensIntoLines(IReadOnlyList tokens, float availableWidthEmu) + { + var lines = new List>(); + var currentLine = new List(); + var currentWidth = 0f; + var sawAnyToken = false; + + var pendingGroup = new List(); + var pendingGroupWidth = 0f; + + void FlushPendingGroup() + { + if (pendingGroup.Count == 0) + { + return; + } + + Pack(pendingGroup, pendingGroupWidth); + pendingGroup.Clear(); + pendingGroupWidth = 0f; + } + + void Pack(IReadOnlyList unit, float unitWidth) + { + // Only a currentLine already carrying at least one visible (non-whitespace) token + // counts as "has content" for the wrap decision - a currentLine consisting solely of + // a stranded whitespace token (for example the space between two words, where the + // following word itself would overflow) must not be flushed as its own, visually + // blank, line. Instead the whitespace is carried forward and merged onto the same + // line as whatever comes next, matching standard word-processor wrap behavior (the + // trailing space before a wrapped word is swallowed at the wrap point). + var currentLineHasVisibleContent = currentLine.Any(static t => !t.IsWhitespace); + if (currentLineHasVisibleContent && currentWidth + unitWidth > availableWidthEmu) + { + lines.Add(currentLine); + currentLine = []; + currentWidth = 0f; + } + + currentLine.AddRange(unit); + currentWidth += unitWidth; + } + + foreach (var token in tokens) + { + sawAnyToken = true; + + if (token.IsLineBreak) + { + FlushPendingGroup(); + lines.Add(currentLine); + currentLine = []; + currentWidth = 0f; + continue; + } + + if (token.IsWhitespace) + { + FlushPendingGroup(); + + // A tab token's effective width for the wrap/fit decision depends on its own + // position in the line (Phase 2 Follow-Up: Default Tab-Stop Expansion) - compute + // it dynamically from the running currentWidth at this point, rather than using + // the token's static (unused, 0) WidthEmu, so an early tab's expanded width is + // correctly accounted for when deciding where word-wrap breaks occur. + var width = token.IsTab + ? GetNextTabStopEmu(currentWidth, DefaultTabStopEmu) - currentWidth + : token.WidthEmu; + Pack([token], width); + continue; + } + + // Non-whitespace: accumulate into the pending word group rather than packing + // immediately, so a word split across run boundaries is never wrapped mid-word. + pendingGroup.Add(token); + pendingGroupWidth += token.WidthEmu; + } + + FlushPendingGroup(); + + // The final (or only) line is always kept, even if empty - a trailing or a + // paragraph consisting solely of break(s) still produces the trailing blank line(s) the + // explicit break boundary implies. A paragraph with no tokens at all (no runs, no + // breaks) intentionally yields no lines here - BuildLines supplies its own single blank + // line for that case. + if (currentLine.Count > 0 || sawAnyToken) + { + lines.Add(currentLine); + } + + return lines; + } + + /// + /// Positions every vertically (per ) and + /// horizontally (per each line's own paragraph alignment/margin/indent), emitting the + /// final, shape-local-space and + /// streams. No clipping is applied - overflowing text + /// (under noAutofit/spAutoFit) is positioned exactly as computed, even past + /// the shape's own box, a documented limitation. + /// + private static (List Glyphs, List Underlines) PositionLines( + IReadOnlyList lines, + PptxTextAnchor anchor, + float insetLeftEmu, + float insetTopEmu, + float availableWidthEmu, + float availableHeightEmu) + { + var totalHeight = lines.Sum(line => line.LineHeightEmu + line.LeadingGapEmu); + var startY = anchor switch + { + PptxTextAnchor.Middle => insetTopEmu + MathF.Max(0f, (availableHeightEmu - totalHeight) / 2f), + PptxTextAnchor.Bottom => insetTopEmu + MathF.Max(0f, availableHeightEmu - totalHeight), + _ => insetTopEmu, + }; + + var glyphs = new List(); + var underlines = new List(); + var runningY = startY; + + foreach (var line in lines) + { + var marginLeft = line.ParagraphProperties.MarginLeftEmu; + + // Hanging-indent fix (Phase 2 Follow-Up: Bullets and Numbering): once a bullet glyph + // is painted, it alone occupies the indent gutter (marL+indent) - the paragraph's own + // text, even on its first line, starts flush at marL. Without a bullet, "indent" is + // applied to the first line's own text-X exactly as before (unaffected, pre-existing + // first-line-indent behavior for non-bulleted paragraphs). + var hasBullet = line.IsFirstLineOfParagraph && line.BulletGlyphs.Count > 0; + var indent = line.IsFirstLineOfParagraph && !hasBullet ? line.ParagraphProperties.IndentEmu : 0f; + var lineStartX = insetLeftEmu + marginLeft + indent; + + var startX = line.ParagraphProperties.Alignment switch + { + "ctr" => insetLeftEmu + marginLeft + MathF.Max(0f, (availableWidthEmu - marginLeft - line.LineWidthEmu) / 2f), + "r" => insetLeftEmu + MathF.Max(marginLeft, availableWidthEmu - line.LineWidthEmu), + _ => lineStartX, + }; + + if (hasBullet) + { + // Gutter-clearance fix: when the paragraph's resolved IndentEmu is zero or not + // negative enough to clear the bullet glyph's own rendered width, the unclamped + // text-start-X above would sit at or past the bullet's own gutter, overlapping it + // (confirmed on a real-world buAutoNum paragraph with marL="320040", lvl="1", no + // indent attribute - see the design document). Clamp the text's own start X so it + // never shares an X range with the bullet - a no-op whenever the existing gutter + // already clears the bullet's width (the already-correct, sufficiently-negative- + // indent case). BulletTrailingGapEmu additionally reserves a minimum + // PowerPoint-like, tab-stop-style gap beyond the bullet's own width for + // PptxBulletKind.AutoNum markers only (0 for every other bullet kind - a no-op + // there, see the design document's "buAutoNum gutter-clearance minimum gap" note). + // The clamp is applied unconditionally, after startX is resolved for whichever + // horizontal alignment the paragraph declares (left/center/right) - a bulleted + // paragraph's own text must never overlap its bullet regardless of alignment. + var bulletGutterXForClamp = insetLeftEmu + marginLeft + line.ParagraphProperties.IndentEmu; + var minTextStartX = bulletGutterXForClamp + line.BulletWidthEmu + line.BulletTrailingGapEmu; + startX = MathF.Max(startX, minTextStartX); + } + + // Phase 2 Follow-Up: Paragraph Spacing - the leading gap is "space before the line's + // own box": advance runningY past it before computing this line's baseline, so the + // gap sits strictly between the previous line's own box and this one. + runningY += line.LeadingGapEmu; + var baselineY = runningY + line.AscentEmu; + + foreach (var glyph in line.Glyphs) + { + glyphs.Add(new PptxGlyphPlacement(glyph.Font, glyph.GlyphIndex, startX + glyph.XInLineEmu, baselineY, glyph.SizeEmu, glyph.Color)); + } + + foreach (var span in line.UnderlineSpans) + { + underlines.Add(new PptxUnderlineSegment( + startX + span.StartXEmu, + startX + span.EndXEmu, + baselineY, + span.RunProperties.SizeEmu, + span.RunProperties.UnderlineStyle, + span.RunProperties.UnderlineColor)); + } + + if (hasBullet) + { + // The bullet itself is always anchored at the gutter (marL+indent), independent + // of the paragraph's own horizontal alignment - a documented, scoped limitation + // (see the design document): bullets are not re-justified for ctr/r paragraphs. + var bulletGutterX = insetLeftEmu + marginLeft + line.ParagraphProperties.IndentEmu; + foreach (var glyph in line.BulletGlyphs) + { + glyphs.Add(new PptxGlyphPlacement(glyph.Font, glyph.GlyphIndex, bulletGutterX + glyph.XInLineEmu, baselineY, glyph.SizeEmu, glyph.Color)); + } + } + + runningY += line.LineHeightEmu; + } + + return (glyphs, underlines); + } +} + diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.TextRender.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.TextRender.cs new file mode 100644 index 00000000..9ca9bed8 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.TextRender.cs @@ -0,0 +1,222 @@ +using System.Numerics; +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Drawing; +using DemaConsulting.CanvasNet.Fonts; +using DemaConsulting.CanvasNet.Geometry; +using Path = DemaConsulting.CanvasNet.Geometry.Path; + +namespace DemaConsulting.CanvasNet.Pptx; + +/// +/// Implements the text-painting primitive (Phase 1d): paints a +/// resolved 's glyph ink onto a , and the +/// DrawingML-specific system/bundled font-resolution helper this +/// phase's default fontResolver delegate is driven with (see +/// ). Mirrors PdfDocument.Text.cs's own glyph-painting +/// pattern and PdfDocument.FontFallback.cs's own system/bundled fallback pattern - +/// see those files' remarks for the established precedent this phase reuses rather than +/// duplicating design rationale for. +/// +public sealed partial class PptxDocument +{ + /// + /// The painted underline stroke's thickness, as a fraction of the underlined run's own + /// (Phase 2 Follow-Up: Underline Rendering). + /// This is a pragmatic, SizeEmu-proportional approximation - the OpenType + /// post table's own underlineThickness value is not parsed this phase, a + /// documented limitation (see the design document's "Phase 2 Follow-Up: Underline + /// Rendering" section). + /// + private const float UnderlineThicknessRatio = 0.05f; + + /// + /// The painted underline stroke's offset below the baseline, as a fraction of the + /// underlined run's own (Phase 2 Follow-Up: + /// Underline Rendering). This is a pragmatic, SizeEmu-proportional approximation - + /// the OpenType post table's own underlinePosition value is not parsed this + /// phase, a documented limitation (see the design document's "Phase 2 Follow-Up: + /// Underline Rendering" section). + /// + private const float UnderlineOffsetRatio = 0.08f; + + /// The additional gap, as a fraction of , between a double underline's two painted lines. + private const float DoubleUnderlineGapRatio = 0.06f; + + /// + /// Paints every glyph in onto , + /// composing each glyph's own 1/UnitsPerEm outline scale, its resolved size, its + /// shape-local baseline origin, and (the + /// owning shape's own , or a further-composed + /// transform for a shape nested inside a group) into a single glyph-to-surface matrix. + /// 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. + /// + /// The surface to paint onto. + /// The resolved text layout to paint. + /// The transform mapping the owning shape's own local coordinate space into surface pixel space. + internal static void PaintTextLayout(Surface surface, PptxTextLayout layout, Matrix3x2 shapeToSurfaceTransform) + { + foreach (var glyph in layout.Glyphs) + { + var outline = glyph.Font.GetGlyphOutline(glyph.GlyphIndex); + if (outline.Subpaths.Count == 0) + { + continue; + } + + // TrueType glyph outlines use a y-up font coordinate system (positive y points toward + // the ascender), but CanvasNet's shape-local/surface space is y-down (see + // Path.Rectangle's own doc comments for the authoritative convention) - the glyph + // scale's Y component must therefore be negative so outline coordinates above the + // baseline (positive font-space y) map to ink above OriginYEmu (negative shape-local + // 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 = + Matrix3x2.CreateScale(glyphScaleEmu, -glyphScaleEmu) * + Matrix3x2.CreateTranslation(glyph.OriginXEmu, glyph.OriginYEmu) * + shapeToSurfaceTransform; + + var builder = new PathBuilder(); + AppendTransformedGlyphOutline(builder, outline, glyphMatrix); + var path = builder.Build(); + + PathFiller.Fill(surface, path, glyph.Color, FillRule.NonZero); + } + + foreach (var segment in layout.Underlines) + { + PaintUnderlineSegment(surface, segment, shapeToSurfaceTransform); + } + } + + /// + /// Paints one (Phase 2 Follow-Up: Underline Rendering) + /// as one or two thin, axis-aligned, shape-local rectangles (built via + /// , matching every other shape-local rectangle fill in this + /// assembly - for example PptxDocument.Tables.cs's own cell-rectangle fills), + /// transformed through only (no glyph-space + /// Y-flip - unlike a glyph outline, a rectangle built directly in shape-local, already + /// y-down space needs no sign flip to orient correctly). + /// + private static void PaintUnderlineSegment(Surface surface, PptxUnderlineSegment segment, Matrix3x2 shapeToSurfaceTransform) + { + var widthEmu = segment.EndXEmu - segment.StartXEmu; + if (widthEmu <= 0f) + { + return; + } + + var thicknessEmu = segment.SizeEmu * UnderlineThicknessRatio; + var offsetEmu = segment.SizeEmu * UnderlineOffsetRatio; + + if (segment.Style == PptxUnderlineStyle.Double) + { + var lineThicknessEmu = thicknessEmu / 2f; + var gapEmu = segment.SizeEmu * DoubleUnderlineGapRatio; + FillUnderlineRectangle(surface, segment, shapeToSurfaceTransform, widthEmu, segment.BaselineYEmu + offsetEmu, lineThicknessEmu); + FillUnderlineRectangle(surface, segment, shapeToSurfaceTransform, widthEmu, segment.BaselineYEmu + offsetEmu + lineThicknessEmu + gapEmu, lineThicknessEmu); + return; + } + + // Single/Other/any unrecognized style: exactly one solid rectangle - never throw. + FillUnderlineRectangle(surface, segment, shapeToSurfaceTransform, widthEmu, segment.BaselineYEmu + offsetEmu, thicknessEmu); + } + + /// Fills one underline rectangle spanning starting at , tall. + private static void FillUnderlineRectangle( + Surface surface, PptxUnderlineSegment segment, Matrix3x2 shapeToSurfaceTransform, float widthEmu, float topYEmu, float heightEmu) + { + var rectanglePath = Path.Rectangle(segment.StartXEmu, topYEmu, widthEmu, heightEmu).Transform(shapeToSurfaceTransform); + PathFiller.Fill(surface, rectanglePath, segment.Color, FillRule.NonZero); + } + + /// + /// Resolves a DrawingML <a:latin typeface="..."/> family name hint to a run's + /// single primary only: searches the host operating + /// system's installed fonts via (DrawingML + /// never signals serif/fixed-pitch classification the way PDF's Standard-14/ + /// FontDescriptor flags do, so both are always passed as ), falling + /// back to a bundled Liberation Sans/Serif font (, + /// also always serif: false, fixedPitch: false) when no system font matches. This + /// is the default fontResolver delegate production callers drive + /// with; a test may inject its own delegate instead for + /// deterministic, machine-independent pixel assertions (see 's + /// own remarks). It deliberately never inspects a run's actual text/codepoints - a + /// per-character glyph-coverage fallback (consulted only when this primary font + /// turns out to lack a specific, individual character the run's text contains) is a + /// separate concern that lives in PptxDocument.TextLayout.cs's private + /// ResolveGlyph helper and its own fallbackFontResolver parameter, not here. + /// + /// The <a:latin typeface="..."/> family name hint. + /// Whether a bold variant is preferred. + /// Whether an italic variant is preferred. + /// The resolved . + internal static TrueTypeFont ResolveTextFont(string familyNameHint, bool bold, bool italic) + { + var match = SystemFontCatalog.FindBestMatch(familyNameHint, bold, italic, serif: false, fixedPitch: false); + return match is { } found + ? TrueTypeFont.Load(found.FilePath, found.FaceIndex) + : SystemFontCatalog.LoadBundledFallback(serif: false, fixedPitch: false, bold, italic); + } + + /// + /// Re-issues a glyph outline's subpaths/commands into , mapping + /// every point through - the same local re-implementation + /// pattern PdfDocument.Text.cs's own AppendTransformedGlyphOutline already + /// establishes (that method is private to the DemaConsulting.CanvasNet.Pdf assembly + /// and cannot be called from here). + /// + /// + /// Thrown for a command - + /// never produces one (TrueType glyph outlines are built only from lines and quadratic + /// Bezier curves), so encountering one indicates an internal defect, not a data-driven + /// condition. + /// + private static void AppendTransformedGlyphOutline(PathBuilder builder, Path outline, Matrix3x2 transform) + { + foreach (var subpath in outline.Subpaths) + { + builder.MoveTo(Vector2.Transform(subpath.Start, transform)); + foreach (var command in subpath.Commands) + { + switch (command.Type) + { + case PathCommandType.LineTo: + builder.LineTo(Vector2.Transform(command.EndPoint, transform)); + break; + + case PathCommandType.QuadraticBezierTo: + builder.QuadraticBezierTo( + Vector2.Transform(command.Control1, transform), + Vector2.Transform(command.EndPoint, transform)); + break; + + case PathCommandType.CubicBezierTo: + builder.CubicBezierTo( + Vector2.Transform(command.Control1, transform), + Vector2.Transform(command.Control2, transform), + Vector2.Transform(command.EndPoint, transform)); + break; + + case PathCommandType.Close: + builder.Close(); + break; + + default: + throw new NotSupportedException( + $"Unsupported path command type '{command.Type}' encountered while transforming a glyph outline."); + } + } + } + } +} diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Theme.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Theme.cs new file mode 100644 index 00000000..ccf847b7 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.Theme.cs @@ -0,0 +1,294 @@ +using System.Xml.Linq; +using DemaConsulting.CanvasNet.Canvas; + +namespace DemaConsulting.CanvasNet.Pptx; + +// cspell:ignore clrscheme fontscheme srgbclr sysclr hlink folhlink pptx + +/// +/// Implements the theme parser (Phase 1b): parses +/// ppt/theme/themeN.xml (located via a slide master's /theme relationship) into +/// a - its color scheme (<a:clrScheme>) and font scheme +/// (<a:fontScheme>). Lazy and cached by resolved theme part path, so multiple +/// masters sharing one theme file parse it only once. +/// +public sealed partial class PptxDocument +{ + /// The XML namespace used by DrawingML elements (<a:...>), including theme contents. + private static readonly XNamespace DrawingNamespace = + "http://schemas.openxmlformats.org/drawingml/2006/main"; + + /// Caches each theme's parsed form, keyed by its resolved part path, on first access. + private readonly Dictionary _themeCache; + + /// + /// Returns the parsed theme at , parsing and caching it on + /// first access. + /// + /// The theme part's resolved path (for example "ppt/theme/theme1.xml"). + /// The parsed . + /// + /// Thrown when the theme part is missing or not well-formed XML, when its + /// <a:theme>/<a:themeElements>/<a:clrScheme> or + /// .../<a:fontScheme> element is missing, when a color scheme slot's color + /// element is missing, of an unrecognized shape, or has an invalid hexadecimal color + /// value, or when a font scheme's major/minor font collection is missing its + /// <a:latin>/<a:ea>/<a:cs> element. + /// + internal PptxTheme GetTheme(string themePartPath) + { + if (_themeCache.TryGetValue(themePartPath, out var cached)) + { + return cached; + } + + var root = LoadPartXmlRoot(themePartPath); + var themeElements = root.Element(DrawingNamespace + "themeElements") ?? + throw new InvalidDataException($"Theme part '{themePartPath}' has no element."); + + var clrSchemeElement = themeElements.Element(DrawingNamespace + "clrScheme") ?? + throw new InvalidDataException($"Theme part '{themePartPath}' has no element."); + var colorScheme = ParseColorScheme(clrSchemeElement, themePartPath); + + var fontSchemeElement = themeElements.Element(DrawingNamespace + "fontScheme") ?? + throw new InvalidDataException($"Theme part '{themePartPath}' has no element."); + var fontScheme = ParseFontScheme(fontSchemeElement, themePartPath); + + var bgFillStyleList = ParseBgFillStyleList(themeElements); + var fillStyleList = ParseFillStyleList(themeElements); + var lnStyleList = ParseLnStyleList(themeElements); + + var theme = new PptxTheme(colorScheme, fontScheme, bgFillStyleList, fillStyleList, lnStyleList); + _themeCache[themePartPath] = theme; + return theme; + } + + /// + /// Resolves the effective for a single slide: the slide's own + /// <p:clrMapOvr> when it wraps an <a:overrideClrMapping>, else + /// its layout's own <p:clrMapOvr> when it wraps an + /// <a:overrideClrMapping>, else unchanged. + /// + /// + /// A <p:clrMapOvr> wrapping <a:masterClrMapping/> (rather than + /// <a:overrideClrMapping>) is an explicit, schema-defined sentinel meaning + /// "this tier declares no override" - it must fall through to the next tier exactly as if + /// <p:clrMapOvr> were absent entirely, not be misread as "override with an + /// empty map". + /// + /// The slide's own , or . + /// The slide's layout's own , or . + /// That layout's master's own - the final fallback. + /// The effective to use when resolving this slide's bg1/tx1/bg2/tx2 scheme colors. + internal static PptxColorMap ResolveEffectiveColorMap( + XElement? slideClrMapOvr, XElement? layoutClrMapOvr, PptxColorMap masterColorMap) => + TryParseOverrideClrMapping(slideClrMapOvr) ?? + TryParseOverrideClrMapping(layoutClrMapOvr) ?? + masterColorMap; + + /// + /// Parses a single <p:clrMapOvr> element's <a:overrideClrMapping + /// bg1="..." tx1="..." bg2="..." tx2="..." .../> child into a + /// , reading only its bg1/tx1/bg2/tx2 + /// attributes (see 's own remarks for why accentN/ + /// hlink/folHlink are not carried). + /// + /// The <p:clrMapOvr> element, or . + /// + /// The parsed , or when + /// is itself , declares no + /// recognized child at all, or wraps <a:masterClrMapping/> (the "no override + /// at this tier" sentinel - see 's remarks). + /// + /// + /// Thrown when wraps an + /// <a:overrideClrMapping> missing any of its required bg1/tx1/ + /// bg2/tx2 attributes. + /// + private static PptxColorMap? TryParseOverrideClrMapping(XElement? clrMapOvrElement) + { + var overrideElement = clrMapOvrElement?.Element(DrawingNamespace + "overrideClrMapping"); + if (overrideElement is null) + { + return null; + } + + string RequiredAttribute(string name) => + (string?)overrideElement.Attribute(name) ?? + throw new InvalidDataException($"A / element has no '{name}' attribute."); + + return new PptxColorMap( + RequiredAttribute("bg1"), + RequiredAttribute("tx1"), + RequiredAttribute("bg2"), + RequiredAttribute("tx2")); + } + + /// + /// Parses a theme's optional <a:fmtScheme>/<a:bgFillStyleLst> element + /// into its raw, unparsed fill-definition child elements, in document order. + /// + /// The theme's <a:themeElements> element. + /// + /// The <a:bgFillStyleLst>'s children, or an empty list when the theme + /// declares no <a:fmtScheme> or no <a:bgFillStyleLst> at all - + /// this content is rare in practice (only consulted by a <p:bgRef> background + /// reference), so its absence is tolerated rather than treated as malformed. + /// + private static IReadOnlyList ParseBgFillStyleList(XElement themeElementsElement) + { + var bgFillStyleLst = themeElementsElement.Element(DrawingNamespace + "fmtScheme")? + .Element(DrawingNamespace + "bgFillStyleLst"); + return bgFillStyleLst is null ? [] : bgFillStyleLst.Elements().ToList(); + } + + /// + /// Parses a theme's optional <a:fmtScheme>/<a:fillStyleLst> element + /// into its raw, unparsed fill-definition child elements, in document order - consulted + /// by a shape's own <p:style>/<a:fillRef> (see + /// ). + /// + /// The theme's <a:themeElements> element. + /// + /// The <a:fillStyleLst>'s children, or an empty list when the theme declares + /// no <a:fmtScheme> or no <a:fillStyleLst> at all. + /// + private static IReadOnlyList ParseFillStyleList(XElement themeElementsElement) + { + var fillStyleLst = themeElementsElement.Element(DrawingNamespace + "fmtScheme")? + .Element(DrawingNamespace + "fillStyleLst"); + return fillStyleLst is null ? [] : fillStyleLst.Elements().ToList(); + } + + /// + /// Parses a theme's optional <a:fmtScheme>/<a:lnStyleLst> element into + /// its raw, unparsed <a:ln> line-properties child elements, in document order + /// - consulted by a shape's own <p:style>/<a:lnRef> (see + /// ). + /// + /// The theme's <a:themeElements> element. + /// + /// The <a:lnStyleLst>'s children, or an empty list when the theme declares no + /// <a:fmtScheme> or no <a:lnStyleLst> at all. + /// + private static IReadOnlyList ParseLnStyleList(XElement themeElementsElement) + { + var lnStyleLst = themeElementsElement.Element(DrawingNamespace + "fmtScheme")? + .Element(DrawingNamespace + "lnStyleLst"); + return lnStyleLst is null ? [] : lnStyleLst.Elements().ToList(); + } + + /// Parses all 12 named slots of <a:clrScheme> into a . + private static PptxColorScheme ParseColorScheme(XElement clrSchemeElement, string themePartPath) => + new( + ParseSchemeColor(clrSchemeElement, "dk1", themePartPath), + ParseSchemeColor(clrSchemeElement, "lt1", themePartPath), + ParseSchemeColor(clrSchemeElement, "dk2", themePartPath), + ParseSchemeColor(clrSchemeElement, "lt2", themePartPath), + ParseSchemeColor(clrSchemeElement, "accent1", themePartPath), + ParseSchemeColor(clrSchemeElement, "accent2", themePartPath), + ParseSchemeColor(clrSchemeElement, "accent3", themePartPath), + ParseSchemeColor(clrSchemeElement, "accent4", themePartPath), + ParseSchemeColor(clrSchemeElement, "accent5", themePartPath), + ParseSchemeColor(clrSchemeElement, "accent6", themePartPath), + ParseSchemeColor(clrSchemeElement, "hlink", themePartPath), + ParseSchemeColor(clrSchemeElement, "folHlink", themePartPath)); + + /// + /// Parses a single named color-scheme slot (for example <a:dk1>), resolving + /// its single color-definition child element - either <a:srgbClr val="RRGGBB"/> + /// (resolved directly) or <a:sysClr val="..." lastClr="RRGGBB"/> (resolved via + /// its cached lastClr RGB equivalent) - into an value. + /// + /// + /// Thrown when the named slot element is missing, has no color-definition child, has an + /// unrecognized color-definition element, or that element's hex color value is missing or + /// not a valid six-digit RRGGBB string (OOXML's srgbClr/@val and + /// sysClr/@lastClr are always exactly six hex digits - an eight-digit + /// AARRGGBB value, which would otherwise also + /// accept, misinterpreting its first byte as alpha, must be rejected rather than silently + /// misread). + /// + private static Rgba32 ParseSchemeColor(XElement clrSchemeElement, string slotName, string themePartPath) + { + var slotElement = clrSchemeElement.Element(DrawingNamespace + slotName) ?? + throw new InvalidDataException($"Theme part '{themePartPath}' has no color scheme slot."); + + var colorElement = slotElement.Elements().FirstOrDefault() ?? + throw new InvalidDataException( + $"Theme part '{themePartPath}' color scheme slot '{slotName}' has no color definition."); + + string hex; + if (colorElement.Name == DrawingNamespace + "srgbClr") + { + hex = (string?)colorElement.Attribute("val") ?? + throw new InvalidDataException( + $"Theme part '{themePartPath}' color scheme slot '{slotName}' has an with no 'val' attribute."); + } + else if (colorElement.Name == DrawingNamespace + "sysClr") + { + hex = (string?)colorElement.Attribute("lastClr") ?? + throw new InvalidDataException( + $"Theme part '{themePartPath}' color scheme slot '{slotName}' has an with no 'lastClr' attribute."); + } + else + { + throw new InvalidDataException( + $"Theme part '{themePartPath}' color scheme slot '{slotName}' has an unrecognized color definition element '{colorElement.Name.LocalName}'."); + } + + if (hex.Length != 6) + { + throw new InvalidDataException( + $"Theme part '{themePartPath}' color scheme slot '{slotName}' has a color value '{hex}' that is not exactly six hexadecimal digits (OOXML's RRGGBB form)."); + } + + try + { + return Rgba32.Parse("#" + hex); + } + catch (FormatException ex) + { + throw new InvalidDataException( + $"Theme part '{themePartPath}' color scheme slot '{slotName}' has an invalid hexadecimal color value '{hex}'.", ex); + } + } + + /// Parses <a:fontScheme>'s major and minor font collections into a . + private static PptxFontScheme ParseFontScheme(XElement fontSchemeElement, string themePartPath) + { + var majorFontElement = fontSchemeElement.Element(DrawingNamespace + "majorFont") ?? + throw new InvalidDataException($"Theme part '{themePartPath}' has no element."); + var minorFontElement = fontSchemeElement.Element(DrawingNamespace + "minorFont") ?? + throw new InvalidDataException($"Theme part '{themePartPath}' has no element."); + + return new PptxFontScheme( + ParseFontCollection(majorFontElement, "majorFont", themePartPath), + ParseFontCollection(minorFontElement, "minorFont", themePartPath)); + } + + /// + /// Parses a single font collection's (<a:majorFont> or <a:minorFont>) + /// <a:latin>/<a:ea>/<a:cs> typeface attributes. + /// + /// + /// Thrown when any of the three required child elements is missing. A missing + /// typeface attribute on a present element is tolerated (resolved as + /// ), matching real-world themes that declare an empty East + /// Asian/complex-script typeface to mean "use the Latin typeface instead". + /// + private static PptxFontCollection ParseFontCollection(XElement fontCollectionElement, string collectionName, string themePartPath) + { + var latin = fontCollectionElement.Element(DrawingNamespace + "latin") ?? + throw new InvalidDataException($"Theme part '{themePartPath}' has no element."); + var ea = fontCollectionElement.Element(DrawingNamespace + "ea") ?? + throw new InvalidDataException($"Theme part '{themePartPath}' has no element."); + var cs = fontCollectionElement.Element(DrawingNamespace + "cs") ?? + throw new InvalidDataException($"Theme part '{themePartPath}' has no element."); + + return new PptxFontCollection( + (string?)latin.Attribute("typeface") ?? string.Empty, + (string?)ea.Attribute("typeface") ?? string.Empty, + (string?)cs.Attribute("typeface") ?? string.Empty); + } +} diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.cs new file mode 100644 index 00000000..5782f28b --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxDocument.cs @@ -0,0 +1,361 @@ +using System.Xml.Linq; +using DemaConsulting.CanvasNet.Canvas; + +namespace DemaConsulting.CanvasNet.Pptx; + +// cspell:ignore xfrm prst cust pptx patt + +/// +/// Provides read-only access to a PowerPoint (.pptx) presentation document. +/// +/// +/// +/// An instance is created by or , +/// which read and parse the OOXML (Office Open XML) package structure exactly once (the +/// entire input is buffered in memory first, so the caller's stream need not remain open +/// or seekable afterward). +/// +/// +/// Phase 1a implemented only the underlying OOXML package layer - +/// opening the .pptx file as a ZIP archive, resolving +/// [Content_Types].xml (both its default extension-to-content-type mappings and +/// any part-specific overrides), and resolving package-level (_rels/.rels) and +/// per-part ({dir}/_rels/{partName}.rels) relationships, including relative +/// target resolution (for example ../slideLayouts/slideLayout1.xml). +/// +/// +/// Phase 1b adds: parsing ppt/presentation.xml +/// (slide size, slide list - see /); +/// theme color/font scheme parsing; slide master/layout/slide structural models +/// (placeholder shapes only, not freeform shapes); and an isolated placeholder +/// property-inheritance resolver implementing ECMA-376's placeholder matching +/// algorithm. / now additionally +/// require the package to be a navigable presentation (a resolvable +/// ppt/presentation.xml part declaring a slide size and at least one slide). +/// +/// +/// Phase 1c adds DrawingML shape geometry and paint +/// resolution (PptxDocument.Geometry.cs/PptxDocument.Paint.cs): +/// <a:xfrm> position/rotation/flip transform resolution, <p:grpSp> +/// child-coordinate-space transform composition, preset (<a:prstGeom>) and +/// custom (<a:custGeom>) geometry resolution into the core +/// type, and fill/stroke resolution into core +/// DemaConsulting.CanvasNet.Drawing/DemaConsulting.CanvasNet.Canvas paint +/// types - see pptx-document.md's "Geometry and Paint (Phase 1c)" design section. +/// Freeform (non-placeholder) shape *enumeration* from a slide's full <p:spTree>, +/// font loading, and a rendering surface still do not exist yet - all deferred to a later +/// phase. +/// +/// +/// Phase 1d (this release) adds DrawingML text layout and rendering +/// (PptxDocument.Text.cs/PptxDocument.TextInheritance.cs/ +/// PptxDocument.TextLayout.cs/PptxDocument.TextRender.cs): +/// <p:txBody>/<a:bodyPr>/<a:p>/<a:pPr>/ +/// <a:r>/<a:rPr>/<a:t> structural parsing; an +/// attribute-level run/paragraph property-inheritance resolver walking the +/// placeholder/layout/master <p:txStyles>/theme chain; word-wrap, +/// horizontal alignment, vertical anchor, and a three-tier autofit policy; and a +/// glyph-ink painting primitive reusing the core / +/// infrastructure - see pptx-document.md's +/// "Text Layout and Rendering (Phase 1d)" design section. Bullets/numbering, full text +/// justification, spAutoFit shape-resize behavior, kerning, and text clipping on +/// overflow are explicitly deferred. +/// +/// +/// Phase 1e adds dedicated <p:pic> picture-shape support +/// (PptxDocument.Images.cs: content-type dispatch, <a:srcRect> +/// crop-rectangle resolution, and y-down nearest-neighbor compositing), <a:tbl> +/// table support (PptxDocument.Tables.cs: structure/cell parsing reusing the Phase +/// 1c/1d fill/border/text-body resolvers verbatim, merge-aware cell-rect resolution, and +/// cell fill/border/text painting), and recursive, full shape-tree parsing +/// (PptxDocument.ShapeTree.cs) across a slide's own <p:sp>/ +/// <p:pic>/<p:graphicFrame>/<p:grpSp> elements +/// (including nested <p:grpSp> child-transform composition), exposed as +/// , with lazy, invoke-on-demand theme resolution so a +/// shape tree with no <p:graphicFrame> never resolves a theme at all - see +/// pptx-document.md's "Images, Tables, and Shape Tree (Phase 1e)" design section. +/// A non-placeholder shape's own background fill via <a:blipFill>/ +/// <a:pattFill> inside <p:spPr>, picture effects/shadows, nested +/// tables, table auto-sizing/banding, master/layout full shape-tree enumeration, and a +/// full per-slide public Render API remained deferred. +/// +/// +/// Phase 1f (this release) adds the public, slide-level +/// / +/// rendering API +/// (PptxDocument.Render.cs): a recursive, document-order walk of a slide's full +/// , composing nested group transforms and dispatching +/// each shape/picture/table node to the already-verified Phase 1c/1d/1e resolvers and +/// painters onto a of the requested pixel dimensions - see +/// pptx-document.md's "Full Slide Rendering (Phase 1f)" design section for the +/// full per-node-kind dispatch and this phase's documented deferred items (a slide's own +/// <p:bg> background fill, <p:cxnSp> connector shapes, nested +/// tables, table auto-sizing/banding, group-level style cascading beyond transform +/// composition, and master/layout full shape-tree rendering). With this phase, the +/// planned PPTX 1.0 feature set is complete; any remaining gaps are candidates for a +/// future, corpus-driven hardening pass (pptx-phase-2), not a currently planned +/// phase. +/// +/// +/// Phase 2 Follow-Up: Slide/Layout/Master Background Fill (this release) +/// closes the single highest-visual-impact gap left by Phase 1f's own "a slide's own +/// <p:bg> background fill" deferral above: +/// now resolves and paints a slide's own <p:cSld>/<p:bg> background +/// fill - falling back to its layout's, then its master's, own <p:bg> when +/// the slide declares none, and to only +/// when none of the three declare one at all - before the shape-tree walk, reusing the +/// existing Phase 1c fill/color resolution pipeline verbatim (PptxDocument.Background.cs). +/// Solid-color fills and theme-indexed <p:bgRef> fills are fully supported +/// (including phClr substitution); a linear gradient background fill is +/// best-effort (inheriting Phase 1c's existing linear-only, non-radial/path gradient +/// boundary); a pattern or picture background fill remains deferred (inheriting +/// 's existing +/// boundary unchanged) - see pptx-document.md's "Phase 2 Follow-Up: Slide/Layout/ +/// Master Background Fill (<p:bg>)" design section for the full algorithm and +/// fidelity boundary. +/// +/// +/// Phase 2 Follow-Up: Bullets and Numbering Rendering closes the gap left +/// by Phase 1d's own "Bullets/numbering ... are explicitly deferred" boundary above: a +/// paragraph's <a:buChar> character, <a:buAutoNum> auto-number +/// marker, or explicit <a:buNone> absence is now parsed +/// (PptxDocument.Text.cs), resolved through the same four independent +/// placeholder/layout/master choice-group inheritance chains already established for +/// other paragraph properties (PptxDocument.TextInheritance.cs's +/// ), formatted into its rendered string for +/// the eleven most common Latin-numeral auto-number schemes +/// (PptxDocument.Bullets.cs's ), and measured and +/// painted at the paragraph's own gutter without ever overlapping its text +/// (PptxDocument.TextLayout.cs) - see pptx-document.md's "Phase 2 Follow-Up: +/// Bullets and Numbering Rendering" design section for the full algorithm and fidelity +/// boundary. +/// +/// +/// Phase 2 Follow-Up: Connector Shape Rendering closes the gap left by +/// Phase 1f's own "<p:cxnSp> connector shapes" deferral above: a +/// <p:cxnSp> is now parsed into a +/// alongside every other shape-tree node kind (PptxDocument.ShapeTree.cs), and +/// paints its resolved +/// straight/elbow/curved line - merging its own line style with its style-reference +/// fallback, and orienting an optional stroked or filled arrowhead at either end +/// (PptxDocument.Connectors.cs/PptxDocument.Render.cs's +/// RenderConnector) - see pptx-document.md's "Phase 2 Follow-Up: Connector +/// Shape Rendering (<p:cxnSp>)" design section for the full algorithm and fidelity +/// boundary (an unrecognized connector preset degrades to "this one connector is +/// invisible" rather than aborting the slide). +/// +/// +/// Thread safety: a instance is not +/// thread-safe. and every other +/// public member lazily populate and read shared per-instance caches (parsed slides, +/// layouts, masters, themes, relationships, table styles, and owned picture surfaces) +/// without synchronization, so calling any of them concurrently from multiple threads on +/// the same instance is unsupported and may corrupt those caches, throw, or +/// leak/double-dispose a decoded picture. This mirrors common .NET document/parser types +/// (for example or ): open +/// one instance per thread (re-opening the same file is cheap +/// relative to rendering) when concurrent access is needed, or otherwise synchronize all +/// calls to a single shared instance externally. +/// +/// +public sealed partial class PptxDocument : IDisposable +{ + /// + /// The maximum number of bytes will buffer from a caller- + /// supplied stream before rejecting it with . + /// + /// + /// buffers its entire input into memory before any ZIP + /// validation occurs (see this type's own remarks); without a bound, a very large, or + /// deliberately non-terminating, caller-supplied stream could exhaust memory or buffer + /// indefinitely before that validation ever runs. 268,435,456 bytes (256 MiB) mirrors the + /// core 's own documented "comfortably below + /// " byte-count precedent (8192 * 32768 = 268,435,456) - + /// large enough for any realistic .pptx package (including one carrying embedded + /// video/image media), while remaining small enough to keep worst-case memory use bounded + /// to a small, practical amount for an attacker-controlled input. + /// + private const long MaxPackageBytes = 268_435_456L; + + /// + /// Initializes a new instance of the class by parsing the + /// supplied, already fully-buffered package bytes. + /// + /// + /// The complete package bytes. Ownership transfers to this instance - the bytes back the + /// non-writable for the lifetime of this instance. + /// + /// + /// Thrown when is not a readable ZIP archive, or when the + /// package is missing the required [Content_Types].xml or _rels/.rels + /// parts. + /// + private PptxDocument(byte[] buffer) + { + _stream = new MemoryStream(buffer, writable: false); + _archive = OpenZipArchive(_stream); + _entriesByPath = BuildEntryLookup(_archive); + _defaultContentTypes = new Dictionary(StringComparer.OrdinalIgnoreCase); + _overrideContentTypes = new Dictionary(StringComparer.Ordinal); + _relationshipCache = new Dictionary>(StringComparer.Ordinal); + _themeCache = new Dictionary(StringComparer.Ordinal); + _tableStylesCache = new Dictionary>(StringComparer.Ordinal); + _masterCache = new Dictionary(StringComparer.Ordinal); + _layoutCache = new Dictionary(StringComparer.Ordinal); + _slideCache = new Dictionary(); + + InitializePackage(); + InitializePresentation(); + } + + /// + /// Gets the total number of slides declared by the presentation's <p:sldIdLst>. + /// + /// Thrown when this document has been disposed. + public int SlideCount + { + get + { + ObjectDisposedException.ThrowIf(_disposed, this); + return _slidePartPaths.Count; + } + } + + /// + /// Gets the presentation's slide size, in EMU (English Metric Units), as declared by + /// ppt/presentation.xml's <p:sldSz> element. + /// + /// Thrown when this document has been disposed. + public PptxSlideSize SlideSize + { + get + { + ObjectDisposedException.ThrowIf(_disposed, this); + return _slideSize; + } + } + + /// + /// Opens and parses a .pptx document from a stream. + /// + /// + /// The stream to read the package from. The entire stream is read into an in-memory + /// buffer; this method does not take ownership of, and never disposes or closes, + /// . + /// + /// A new instance representing the parsed package. + /// Thrown when is null. + /// + /// Thrown when the package cannot be parsed - see the private constructor's remarks for + /// the exact conditions - or when supplies more than + /// bytes (see 's own remarks + /// for the bound's rationale). + /// + public static PptxDocument Open(Stream stream) + { + ArgumentNullException.ThrowIfNull(stream); + + using var buffered = new MemoryStream(); + CopyBounded( + stream, + buffered, + MaxPackageBytes, + $"The package stream exceeds the maximum supported size of {MaxPackageBytes} bytes."); + return new PptxDocument(buffered.ToArray()); + } + + /// + /// Opens and parses a .pptx document from a file path. + /// + /// The path of the .pptx file to read. + /// A new instance representing the parsed package. + /// Thrown when is null. + /// + /// Thrown when is empty or consists only of white space. + /// + /// + /// Thrown when the package cannot be parsed - see 's remarks for + /// the exact conditions. + /// + /// + /// File-system exceptions (for example , + /// , , or + /// ) raised while opening propagate + /// uncaught to the caller. + /// + public static PptxDocument Open(string path) + { + ArgumentNullException.ThrowIfNull(path); + if (string.IsNullOrWhiteSpace(path)) + { + throw new ArgumentException("Path must not be empty.", nameof(path)); + } + + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read); + return Open(stream); + } + + /// + /// Copies into , reading in fixed- + /// size chunks and rejecting the input the moment the running total exceeds + /// - bounding both the worst-case memory this copy can consume + /// and the worst-case time it can spend reading a deliberately large or non-terminating + /// , rather than calling + /// unconditionally and discovering the problem only after it has already exhausted memory. + /// Shared by (bounding the whole buffered package stream) and + /// (bounding a single part's decompressed byte size). + /// + /// The stream to copy from. + /// The stream to copy into. + /// + /// The maximum number of bytes that may be read from before this + /// method throws. + /// + /// + /// The message used to construct the thrown + /// when is exceeded. + /// + /// + /// Thrown once more than bytes have been read from + /// . + /// + private static void CopyBounded(Stream source, MemoryStream destination, long maxBytes, string exceededMessage) + { + var buffer = new byte[81920]; + var total = 0L; + int read; + while ((read = source.Read(buffer, 0, buffer.Length)) > 0) + { + total += read; + if (total > maxBytes) + { + throw new InvalidDataException(exceededMessage); + } + + destination.Write(buffer, 0, read); + } + } + + /// + /// Releases the resources held by this (the underlying ZIP + /// archive, its backing buffer, and every cached table-cell picture + /// this document decoded and owns - see ). Calling this + /// method more than once has no effect beyond the first call. + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + foreach (var surface in _ownedImageSurfaces) + { + surface.Dispose(); + } + + _archive.Dispose(); + _stream.Dispose(); + _disposed = true; + } +} diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxEffectiveTextProperties.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxEffectiveTextProperties.cs new file mode 100644 index 00000000..60f79719 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxEffectiveTextProperties.cs @@ -0,0 +1,189 @@ +using DemaConsulting.CanvasNet.Canvas; + +namespace DemaConsulting.CanvasNet.Pptx; + +/// +/// A run's fully-resolved, effective character properties (Phase 1d), produced by +/// by walking the attribute-level +/// run/paragraph/placeholder/master/theme inheritance chain - see +/// PptxDocument.TextInheritance.cs's class remarks for the full, verified chain. +/// +/// The resolved typeface name (<a:latin typeface="..."/>). +/// The resolved font size, in EMU (already scaled by any applicable autofit fontScale). +/// Whether the run is resolved bold. +/// Whether the run is resolved italic. +/// The run's resolved underline style (Phase 2 Follow-Up: Underline Rendering). +/// +/// The run's resolved underline ink color (Phase 2 Follow-Up: Underline Rendering) - only +/// meaningful when is not . +/// +/// The run's resolved color. +internal sealed record PptxEffectiveRunProperties( + string FontFamily, + float SizeEmu, + bool Bold, + bool Italic, + PptxUnderlineStyle UnderlineStyle, + Rgba32 UnderlineColor, + Rgba32 Color); + +/// +/// The discriminator for a run's resolved <a:rPr u="..."/> underline style +/// (Phase 2 Follow-Up: Underline Rendering), produced by +/// . +/// +/// +/// ECMA-376 §20.1.10.84 defines many more u values than CanvasNet distinguishes for +/// painting: heavy, dotted, dottedHeavy, dash, dashHeavy, +/// dashLong, dashLongHeavy, dotDash, dotDashHeavy, +/// dotDotDash, dotDotDashHeavy, wavy, wavyHeavy, wavyDbl, +/// and any other unrecognized non-empty string, all map to and are +/// painted as a single solid line identical to - a documented +/// simplification (true dotted/dashed/wavy stroke patterns are a separable, deferred +/// refinement; see the design document's "Phase 2 Follow-Up: Underline Rendering" section). +/// +internal enum PptxUnderlineStyle +{ + /// No underline is painted (an explicit <a:rPr u="none"/>, or the OOXML schema default when the attribute is absent at every inheritance tier). + None, + + /// A single solid underline (<a:rPr u="sng"/>). + Single, + + /// A double solid underline (<a:rPr u="dbl"/>), painted as two thinner, gapped lines. + Double, + + /// Every other recognized/unrecognized non-"none" u value - see this enum's own remarks. + Other, +} + +/// +/// A paragraph's fully-resolved, effective properties (Phase 1d), produced by +/// . +/// +/// +/// The resolved horizontal alignment: "l", "ctr", or "r" - "just" +/// (full justification) is deliberately resolved to "l", a documented simplification +/// (see the design document's "Text Layout and Rendering (Phase 1d)" section). +/// +/// The resolved left margin, in EMU. +/// The resolved first-line indent, in EMU (frequently negative). +/// The resolved line spacing (already scaled by any applicable autofit lnSpcReduction). +/// +/// The paragraph's resolved bullet/numbering properties (Phase 2 Follow-Up: Bullets and +/// Numbering), produced by +/// walking the same attribute-level chain as every other paragraph property. Defaults to +/// so pre-existing call sites continue to compile unchanged; a +/// value is treated identically to +/// being +/// (no bullet painted) by every consumer. +/// +/// +/// The resolved <a:spcBef> paragraph spacing-before (Phase 2 Follow-Up: +/// Paragraph Spacing), produced by +/// walking the same attribute-level chain as every other paragraph property. Defaults to +/// (no extra spacing) so pre-existing call sites continue +/// to compile unchanged. Only applied as an additive gap between adjacent paragraphs - never +/// before a text body's first paragraph - see 's own +/// remarks for the exact additive semantics. +/// +/// +/// The resolved <a:spcAft> paragraph spacing-after (Phase 2 Follow-Up: Paragraph +/// Spacing), mirroring in every respect except which side of +/// the paragraph it applies to - never applied after a text body's last paragraph. +/// +internal sealed record PptxEffectiveParagraphProperties( + string Alignment, + float MarginLeftEmu, + float IndentEmu, + PptxLineSpacing LineSpacing, + PptxEffectiveBulletProperties? Bullet = null, + PptxLineSpacing? SpaceBefore = null, + PptxLineSpacing? SpaceAfter = null) +{ + /// The resolved spacing-before, defaulting to when not supplied. + internal PptxLineSpacing EffectiveSpaceBefore => SpaceBefore ?? PptxLineSpacing.None; + + /// The resolved spacing-after, defaulting to when not supplied. + internal PptxLineSpacing EffectiveSpaceAfter => SpaceAfter ?? PptxLineSpacing.None; +} + +/// +/// The discriminator for a paragraph's resolved bullet/numbering type, produced by resolving +/// the mutually-exclusive <a:buNone>/<a:buAutoNum>/ +/// <a:buChar> OOXML schema choice-group. +/// +internal enum PptxBulletKind +{ + /// No bullet is painted for this paragraph (an explicit <a:buNone/>, or the conservative hard-coded default). + None, + + /// A single literal bullet character/glyph string (<a:buChar char="..."/>). + Char, + + /// An auto-numbered bullet (<a:buAutoNum type="..." startAt="..."/>). + AutoNum, +} + +/// +/// A paragraph's fully-resolved, effective bullet/numbering properties (Phase 2 Follow-Up: +/// Bullets and Numbering), produced by +/// walking four independent +/// choice-group chains (type, color, font, size) - see +/// PptxDocument.TextInheritance.cs's ResolveEffectiveBulletProperties remarks for +/// the full, verified chain. +/// +/// The resolved bullet type discriminator. +/// +/// The resolved literal bullet character/glyph string, only meaningful when +/// is ; +/// otherwise. +/// +/// +/// The resolved <a:buAutoNum type="..."/> value (for example +/// "arabicPeriod"), only meaningful when is +/// ; otherwise. +/// +/// +/// The resolved <a:buAutoNum startAt="..."/> value (defaulting to 1), +/// only meaningful when is . +/// +/// The resolved bullet typeface name (never bold/italic - see the design document's documented simplification). +/// The resolved bullet font size, in EMU (not yet scaled by any applicable autofit fontScale - the layout engine applies that scale, mirroring run sizes). +/// The resolved bullet ink color. +internal sealed record PptxEffectiveBulletProperties( + PptxBulletKind Kind, + string? Character, + string? AutoNumType, + int AutoNumStartAt, + string FontFamily, + float SizeEmu, + Rgba32 Color) +{ + /// Builds a instance carrying the supplied (otherwise-unused) font/size/color context. + internal static PptxEffectiveBulletProperties CreateNone(string fontFamily, float sizeEmu, Rgba32 color) => + new(PptxBulletKind.None, null, null, 1, fontFamily, sizeEmu, color); +} + +/// +/// A paragraph's resolved line spacing: either a percentage of a single line's natural height +/// (<a:spcPct val="..."/>) or a fixed point size (<a:spcPts val="..."/>) +/// - mutually exclusive per the OOXML schema, modeled as a discriminated pair where exactly +/// one of / is non-null. +/// +/// The resolved percentage (1.0 = 100%), or when fixed spacing applies instead. +/// The resolved fixed spacing, in EMU, or when percentage spacing applies instead. +internal sealed record PptxLineSpacing(float? Percent, float? FixedEmu) +{ + /// The OOXML schema default line spacing: 100% of a single line's natural height. + internal static PptxLineSpacing Default { get; } = new(1.0f, null); + + /// + /// The "no extra spacing" sentinel (Phase 2 Follow-Up: Paragraph Spacing): 0% of a single + /// line's natural height - distinct from 's "100% of line height" + /// sentinel, which is meaningless for an unresolved <a:spcBef>/<a:spcAft> + /// (a paragraph that never declares spacing-before/after must contribute zero extra gap, + /// not a full extra line's worth). + /// + internal static PptxLineSpacing None { get; } = new(0f, null); +} diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxLineStyle.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxLineStyle.cs new file mode 100644 index 00000000..aed9426e --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxLineStyle.cs @@ -0,0 +1,33 @@ +namespace DemaConsulting.CanvasNet.Pptx; + +// cspell:ignore pptx prst + +/// +/// A resolved DrawingML line style (<a:ln>): its stroke width, resolved paint, and +/// optional dash pattern - everything needs to +/// convert a shape's outline geometry into a stroked, fillable outline (see +/// ). +/// +/// +/// The line's width (<a:ln w="..."/>), in EMU. Always a finite value greater than +/// zero - never produces a +/// for a zero/negative explicitly-declared width or an +/// explicit <a:noFill/> line (both resolve to instead, +/// meaning "no stroke"); a genuinely absent width instead defaults to +/// 's documented default stroke width - see that +/// method's remarks. +/// +/// +/// The line's resolved paint (an <a:ln>'s own <a:solidFill>/ +/// <a:gradFill> child, resolved exactly like a shape fill via +/// ). Never - that case +/// resolves to a instead (see +/// 's remarks). +/// +/// +/// The line's resolved dash lengths (in EMU, alternating on/off), or +/// for a solid line - resolved from <a:prstDash val="..."/>'s named preset dash +/// pattern (see 's remarks for the supported preset +/// names). +/// +internal sealed record PptxLineStyle(float WidthEmu, PptxPaint Paint, IReadOnlyList? DashArray); diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxPaint.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxPaint.cs new file mode 100644 index 00000000..f364a3a2 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxPaint.cs @@ -0,0 +1,238 @@ +using System.Numerics; +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Drawing; + +namespace DemaConsulting.CanvasNet.Pptx; + +// cspell:ignore pptx srgb paintable + +/// +/// A resolved DrawingML paint - the result of resolving +/// a shape's <p:spPr> fill element (or an <a:ln>'s own fill element, +/// via ) into a concrete, paintable value. +/// +/// +/// A deliberately closed type hierarchy, mirroring 's own +/// documented rationale: 's constructor is +/// , so only , +/// , , , +/// and - all declared in this same file/assembly - may derive +/// from it. A later rendering phase that consumes pattern-matches +/// exhaustively on exactly these five subtypes. +/// +internal abstract record PptxPaint +{ + /// Restricts this hierarchy to the five subtypes declared in this file - see this type's remarks. + private protected PptxPaint() + { + } +} + +/// +/// The resolved paint for an explicit <a:noFill/> element: paints nothing. +/// +internal sealed record PptxNoFill : PptxPaint +{ + /// The single shared instance - carries no state of its own. + internal static readonly PptxNoFill Instance = new(); +} + +/// +/// The resolved paint for an <a:solidFill> element: a single concrete +/// color, already resolved from whichever color-definition element +/// (<a:srgbClr>/<a:schemeClr>/<a:sysClr>) and color +/// transform chain (lumMod/lumOff/shade/tint/alpha) it +/// declared - see . +/// +/// The resolved, concrete color. +internal sealed record PptxSolidFill(Rgba32 Color) : PptxPaint; + +/// +/// The resolved paint for an <a:gradFill> element declaring a linear gradient +/// (<a:lin>) - the only gradient kind resolved this phase; a radial or path +/// gradient (<a:path>) throws - see +/// . +/// +/// +/// The resolved core (always a +/// this phase), already positioned in the owning shape's own local geometry coordinate space. +/// +internal sealed record PptxGradientFill(Gradient Gradient) : PptxPaint; + +/// +/// The resolved paint for an <a:blipFill> element used as a shape/paragraph +/// fill (as distinct from a <p:pic> picture shape, which is +/// resolved by the separate / +/// pipeline) - a decoded raster image, repeated +/// (<a:tile>) or stretched (<a:stretch>, optionally cropped by +/// <a:fillRect>) across the owning shape's own fill region. See +/// for how +/// is derived from either sub-element. +/// +/// +/// The fully decoded raster image (see , reused +/// unchanged for this fill-context <a:blipFill>, which is structurally identical +/// to a <p:pic>'s own <p:blipFill>). Not owned/disposed by this +/// record - the resolving instance remains responsible for its own +/// decoded-image lifetime, mirroring 's own documented +/// non-ownership convention. +/// +/// +/// The transform mapping 's own pixel-space coordinates (pattern space, +/// with (0,0) at its top-left corner) into the owning shape's local EMU coordinate +/// space (the same space 's own returned +/// is expressed in) - composed with a caller's own +/// shape-to-surface transform at fill time, mirroring 's own +/// composition pattern. +/// +internal sealed record PptxImageFill(Surface Image, Matrix3x2 ImageToLocalTransform) : PptxPaint; + +/// +/// The ECMA-376 ST_PresetPatternVal preset-pattern names this project resolves (as +/// distinct from throwing ) for an +/// <a:pattFill prst="..."/> element - see . +/// +/// +/// +/// Covered (30 of the 54 named ST_PresetPatternVal values): the two preset names +/// the project's own pythonpptx-dml-fill.pptx fixture requires +/// (, ); the horizontal/vertical stripe family +/// (, , , , +/// , ); the diagonal-stripe family +/// (, , , +/// , , , +/// , ); the cross-hatch family +/// (, ); and the percentage/dot-density family +/// (, , , , +/// , , , , +/// , , , ). +/// +/// +/// Explicitly deferred (the remaining 24 names), each still throwing +/// (feature token "pptx-pattern-fill") +/// from : narHorz, narVert, +/// dashHorz, dashVert, dashDnDiag, dashUpDiag, diagBrick, +/// horzBrick, plaid, sphere, weave, shingle, trellis, +/// zigZag, dotGrid, dotDmnd, openDmnd, solidDmnd, +/// smCheck, lgCheck, smGrid, lgGrid, smConfetti, +/// lgConfetti. +/// +/// +/// A separate, non-named edge case - an <a:pattFill> with no prst +/// attribute at all (non-conformant per ECMA-376, but present in the real +/// pythonpptx-dml-fill.pptx fixture) - is not a member of this enum at all: it resolves +/// to directly, never reaching +/// - see 's remarks. +/// +/// +internal enum PptxPresetPattern +{ + /// prst="horz": equal-width alternating horizontal stripes. + Horz, + + /// prst="vert": equal-width alternating vertical stripes. + Vert, + + /// prst="ltHorz": thin, sparse horizontal stripes (mostly background). + LtHorz, + + /// prst="ltVert": thin, sparse vertical stripes (mostly background). + LtVert, + + /// prst="dkHorz": thick, dense horizontal stripes (mostly foreground). + DkHorz, + + /// prst="dkVert": thick, dense vertical stripes (mostly foreground). + DkVert, + + /// prst="dnDiag": equal-width alternating diagonal stripes, falling left-to-right. + DnDiag, + + /// prst="upDiag": equal-width alternating diagonal stripes, rising left-to-right. + UpDiag, + + /// prst="ltDnDiag": thin, sparse falling diagonal stripes. + LtDnDiag, + + /// prst="ltUpDiag": thin, sparse rising diagonal stripes. + LtUpDiag, + + /// prst="dkDnDiag": thick, dense falling diagonal stripes. + DkDnDiag, + + /// prst="dkUpDiag": thick, dense rising diagonal stripes. + DkUpDiag, + + /// prst="wdDnDiag": wide falling diagonal bands. + WdDnDiag, + + /// prst="wdUpDiag": wide rising diagonal bands. + WdUpDiag, + + /// prst="cross": a horizontal/vertical cross-hatch grid. + Cross, + + /// prst="diagCross": a diagonal cross-hatch grid. + DiagCross, + + /// prst="pct5": an approximately 5%-density dot fill. + Pct5, + + /// prst="pct10": an approximately 10%-density dot fill. + Pct10, + + /// prst="pct20": an approximately 20%-density dot fill. + Pct20, + + /// prst="pct25": an approximately 25%-density dot fill. + Pct25, + + /// prst="pct30": an approximately 30%-density dot fill. + Pct30, + + /// prst="pct40": an approximately 40%-density dot fill. + Pct40, + + /// prst="pct50": an approximately 50%-density dot fill. + Pct50, + + /// prst="pct60": an approximately 60%-density dot fill. + Pct60, + + /// prst="pct70": an approximately 70%-density dot fill. + Pct70, + + /// prst="pct75": an approximately 75%-density dot fill. + Pct75, + + /// prst="pct80": an approximately 80%-density dot fill. + Pct80, + + /// prst="pct90": an approximately 90%-density dot fill. + Pct90, + + /// prst="divot": small diamond-shaped dots, fixture-mandatory (see this enum's remarks). + Divot, + + /// prst="wave": a wavy horizontal line, fixture-mandatory (see this enum's remarks). + Wave, +} + +/// +/// The resolved paint for an <a:pattFill prst="..."/> element naming a covered +/// : a procedurally synthesized repeating tile (see +/// ) alternating / +/// per the named preset's own pixel rule. +/// +/// The resolved, covered preset-pattern name. +/// +/// The pattern's resolved foreground color (the <a:fgClr> child's own +/// color-definition element, via ; black when +/// <a:fgClr> is absent - see 's remarks). +/// +/// +/// The pattern's resolved background color (the <a:bgClr> child's own +/// color-definition element, via ; white when +/// <a:bgClr> is absent - see 's remarks). +/// +internal sealed record PptxPatternFill(PptxPresetPattern Preset, Rgba32 Foreground, Rgba32 Background) : PptxPaint; diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxPatternTileRenderer.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxPatternTileRenderer.cs new file mode 100644 index 00000000..b320b6d9 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxPatternTileRenderer.cs @@ -0,0 +1,155 @@ +using DemaConsulting.CanvasNet.Canvas; + +namespace DemaConsulting.CanvasNet.Pptx; + +// cspell:ignore pptx divot dmnd + +/// +/// Procedurally synthesizes a small, repeating one-cell tile for a +/// covered , alternating a caller-supplied foreground/ +/// background color per preset's own deterministic per-pixel rule - see +/// , +/// which wraps this renderer's output in a . +/// +/// +/// +/// OOXML does not mandate a pixel-exact appearance for any ST_PresetPatternVal name - +/// only a named preset. This renderer's own per-preset rules are therefore a documented, +/// deliberately simple approximation (density/period based, not a reproduction of +/// PowerPoint's own exact rendering) - see 's own remarks for +/// the full covered/deferred preset-name boundary this renderer implements. +/// +/// +/// Every tile is exactly x pixels - a single, +/// small, fixed size shared by every covered preset, chosen (not a magic number) to render a +/// visually sane repeat density at normal slide-rendering zoom levels while staying cheap to +/// synthesize - see PptxDocument.Tables.cs's own FillPaint for how this tile's +/// pixel space is mapped into the owning shape's local EMU space (reusing the existing +/// EmuPerPixelAt96Dpi convention). +/// +/// +internal static class PptxPatternTileRenderer +{ + /// + /// The fixed pixel width/height of every synthesized tile - see this class's own remarks. + /// + internal const int TileSize = 8; + + /// + /// The classic 8x8 ordered (Bayer) dithering threshold matrix, each cell holding a + /// distinct value in [0, 63] arranged for maximally even visual dispersion - used + /// by to approximate a percentage-density dot fill + /// without clumping. + /// + private static readonly int[,] BayerMatrix8X8 = + { + { 0, 48, 12, 60, 3, 51, 15, 63 }, + { 32, 16, 44, 28, 35, 19, 47, 31 }, + { 8, 56, 4, 52, 11, 59, 7, 55 }, + { 40, 24, 36, 20, 43, 27, 39, 23 }, + { 2, 50, 14, 62, 1, 49, 13, 61 }, + { 34, 18, 46, 30, 33, 17, 45, 29 }, + { 10, 58, 6, 54, 9, 57, 5, 53 }, + { 42, 26, 38, 22, 41, 25, 37, 21 }, + }; + + /// The fixed pixel coordinates of each small diamond dot in 's tile. + private static readonly (int X, int Y)[] DivotForegroundPixels = + { + (1, 0), (0, 1), (1, 1), (2, 1), (1, 2), + (5, 4), (4, 5), (5, 5), (6, 5), (5, 6), + }; + + /// + /// The per-column row offset defining 's own wavy + /// line (a small, symmetric triangle wave spanning the tile's own 8-pixel height). + /// + private static readonly int[] WaveRowOffsets = { 0, 1, 2, 3, 3, 2, 1, 0 }; + + /// + /// Synthesizes a x + /// for , painting each pixel or + /// per that preset's own deterministic rule (see this + /// class's own remarks and each private per-family method's documentation). + /// + /// The covered preset pattern to synthesize a tile for. + /// The pattern's resolved foreground color. + /// The pattern's resolved background color. + /// A newly allocated - ownership passes to the caller. + internal static Surface RenderTile(PptxPresetPattern preset, Rgba32 foreground, Rgba32 background) + { + var surface = new Surface(TileSize, TileSize); + for (var y = 0; y < TileSize; y++) + { + for (var x = 0; x < TileSize; x++) + { + surface[x, y] = IsForeground(preset, x, y) ? foreground : background; + } + } + + return surface; + } + + /// + /// Determines whether pixel / (each in + /// [0, TileSize)) is painted the foreground color for . + /// + private static bool IsForeground(PptxPresetPattern preset, int x, int y) => + preset switch + { + // Horizontal/vertical stripe family: see this method's own per-arm comments. + PptxPresetPattern.Horz => y % 2 == 0, + PptxPresetPattern.Vert => x % 2 == 0, + PptxPresetPattern.LtHorz => y % 4 == 0, + PptxPresetPattern.LtVert => x % 4 == 0, + PptxPresetPattern.DkHorz => y % 4 != 0, + PptxPresetPattern.DkVert => x % 4 != 0, + + // Diagonal-stripe family: "falling" uses (x+y) mod period; "rising" uses (x-y) mod + // period (offset by TileSize to keep the operand non-negative before the CLR '%'). + PptxPresetPattern.DnDiag => (x + y) % 4 == 0, + PptxPresetPattern.UpDiag => (x - y + TileSize) % 4 == 0, + PptxPresetPattern.LtDnDiag => (x + y) % 8 == 0, + PptxPresetPattern.LtUpDiag => (x - y + TileSize) % 8 == 0, + PptxPresetPattern.DkDnDiag => (x + y) % 4 != 0, + PptxPresetPattern.DkUpDiag => (x - y + TileSize) % 4 != 0, + PptxPresetPattern.WdDnDiag => (x + y) % 8 < 4, + PptxPresetPattern.WdUpDiag => (x - y + TileSize) % 8 < 4, + + // Cross-hatch family: the union of the corresponding "lt" horizontal/vertical or + // diagonal stripe rules above. + PptxPresetPattern.Cross => y % 4 == 0 || x % 4 == 0, + PptxPresetPattern.DiagCross => (x + y) % 4 == 0 || (x - y + TileSize) % 4 == 0, + + // Percentage/dot-density family: see RenderPercentageTile's own remarks. + PptxPresetPattern.Pct5 => IsPercentageForeground(x, y, 5), + PptxPresetPattern.Pct10 => IsPercentageForeground(x, y, 10), + PptxPresetPattern.Pct20 => IsPercentageForeground(x, y, 20), + PptxPresetPattern.Pct25 => IsPercentageForeground(x, y, 25), + PptxPresetPattern.Pct30 => IsPercentageForeground(x, y, 30), + PptxPresetPattern.Pct40 => IsPercentageForeground(x, y, 40), + PptxPresetPattern.Pct50 => IsPercentageForeground(x, y, 50), + PptxPresetPattern.Pct60 => IsPercentageForeground(x, y, 60), + PptxPresetPattern.Pct70 => IsPercentageForeground(x, y, 70), + PptxPresetPattern.Pct75 => IsPercentageForeground(x, y, 75), + PptxPresetPattern.Pct80 => IsPercentageForeground(x, y, 80), + PptxPresetPattern.Pct90 => IsPercentageForeground(x, y, 90), + + // Fixture-mandatory named presets: see each field's own documentation. + PptxPresetPattern.Divot => Array.IndexOf(DivotForegroundPixels, (x, y)) >= 0, + PptxPresetPattern.Wave => WaveRowOffsets[x] == y || WaveRowOffsets[x] + 4 == y, + + _ => throw new ArgumentOutOfRangeException(nameof(preset), preset, "Unrecognized covered preset pattern."), + }; + + /// + /// Determines whether pixel / is foreground for a + /// pctNN preset, approximating 's density via + /// 's ordered-dithering threshold: a pixel is foreground when + /// its matrix cell value is less than percent * 64 / 100 - evenly dispersing the + /// foreground-colored cells across the tile rather than clumping them, without needing to + /// reproduce PowerPoint's own exact rendering (see this class's own remarks). + /// + private static bool IsPercentageForeground(int x, int y, int percent) => + BayerMatrix8X8[y, x] < percent * TileSize * TileSize / 100; +} diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxPicture.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxPicture.cs new file mode 100644 index 00000000..e3d2a925 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxPicture.cs @@ -0,0 +1,19 @@ +namespace DemaConsulting.CanvasNet.Pptx; + +// cspell:ignore pptx srcrect + +/// +/// A picture shape's resolved <a:srcRect> crop: the fractional border cropped +/// away from each edge of the source image before it is sampled into the shape's placement +/// rectangle, produced by and consumed by +/// . +/// +/// +/// The fraction of the source image's width cropped away from its left edge (<a:srcRect +/// l="..."/>, OOXML's own 1/100000-of-a-percent unit divided by 100000), where +/// 0 means "no crop on this edge". +/// +/// The fraction cropped away from the top edge (t), same unit as . +/// The fraction cropped away from the right edge (r), same unit as . +/// The fraction cropped away from the bottom edge (b), same unit as . +internal sealed record PptxSrcRect(float Left, float Top, float Right, float Bottom); diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxPlaceholder.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxPlaceholder.cs new file mode 100644 index 00000000..9994835d --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxPlaceholder.cs @@ -0,0 +1,282 @@ +using System.Globalization; +using System.Xml.Linq; + +namespace DemaConsulting.CanvasNet.Pptx; + +// cspell:ignore nvsppr nvpr sptree pptx + +/// +/// A single placeholder shape (a <p:sp> with a <p:ph> descendant) +/// parsed structurally from a slide master, slide layout, or slide - identical shape across +/// all three levels, matched between levels by +/// 's resolver. +/// +/// +/// The placeholder's <p:ph type="..."/> value, or "obj" (the OOXML schema +/// default) when the attribute is omitted. +/// +/// +/// The placeholder's <p:ph idx="..."/> value, or 0 (the OOXML schema +/// default) when the attribute is omitted. +/// +/// +/// The raw <p:sp> element, retained unparsed beyond this shape - later +/// phases/the Inheritance resolver extract individual property fragments (for example +/// <p:spPr>) from it directly rather than this type pre-extracting every +/// possible fragment. +/// +/// +/// The placeholder's own <p:ph type="..."/> value exactly as written, or +/// when the attribute is omitted - distinct from , +/// which collapses "omitted" and "explicitly obj" into the same schema-defaulted +/// string. consults this to resolve a +/// slide placeholder's effective type (its own declared type when present, +/// otherwise the idx-matched layout placeholder's type) for master text-style-bucket +/// selection () - an omitted slide +/// type must inherit the matched layout placeholder's type rather than being treated +/// as a genuine, schema-defaulted "obj" placeholder. Defaults to +/// so pre-existing 3-argument call sites continue to compile unchanged. +/// +internal sealed record PptxPlaceholder(string Type, uint Idx, XElement ShapeElement, string? DeclaredType = null); + +/// A parsed slide master: its own part path, its theme's part path, its placeholder shapes, and its text styles. +/// The master's own resolved part path. +/// The master's resolved /theme relationship target part path. +/// The master's immediate placeholder shapes, in document order. +/// +/// The master's own <p:txStyles> element (Phase 1d), parsed into a +/// - a direct child of <p:sldMaster>, a +/// sibling of <p:cSld>, not nested inside it. +/// +/// +/// The master's own <p:cSld>/<p:bg> element, retained raw/unparsed (the +/// same "retain raw, resolve lazily" pattern +/// already establishes), +/// or when the master declares no background - the final fallback tier +/// of the slide -> layout -> master background-fill resolution chain (see +/// ). +/// +/// +/// The master's full recursive shape tree, parsed via +/// from the same <p:cSld>/<p:spTree> element as - +/// used (Phase 2 Follow-Up) to render the master's own non-placeholder decorative shapes +/// (pictures, autoshapes, groups, freeform shapes) beneath every slide using this master; its +/// own placeholder shapes are never painted directly from this list (see +/// 's +/// skipPlaceholderShapes mechanism). Defaults to Array.Empty<PptxShapeTreeNode>() +/// so pre-existing 4-argument call sites continue to compile unchanged. +/// +/// +/// The master's own required <p:clrMap> element (a sibling of <p:cSld>), +/// parsed into a - the final fallback tier of the slide -> +/// layout -> master <p:clrMapOvr>/<p:clrMap> resolution chain +/// (see ). Defaults to +/// so pre-existing positional call sites continue to +/// compile unchanged. +/// +internal sealed record PptxMaster( + string PartPath, + string ThemePartPath, + IReadOnlyList Placeholders, + PptxMasterTextStyles TxStyles, + XElement? Background = null, + IReadOnlyList? ShapeTree = null, + PptxColorMap? ColorMap = null) +{ + /// The master's own resolved color map - see the constructor parameter's own remarks. + public PptxColorMap ColorMap { get; init; } = ColorMap ?? PptxColorMap.Default; + + /// The master's full recursive shape tree - see the constructor parameter's own remarks. + public IReadOnlyList ShapeTree { get; init; } = ShapeTree ?? Array.Empty(); +} + +/// +/// A slide master's own <p:txStyles> element (Phase 1d), carrying each of its +/// (schema-optional) title/body/other list styles unparsed - each is an <a:lstStyle> +/// -shaped element consulted, level-indexed, by the run/paragraph property-inheritance +/// resolver () after a placeholder's +/// own EffectiveTxBodyListStyle and before the theme/hard-coded default. +/// +/// The master's <p:titleStyle> element, or when absent. +/// The master's <p:bodyStyle> element, or when absent. +/// The master's <p:otherStyle> element, or when absent. +internal sealed record PptxMasterTextStyles(XElement? TitleStyle, XElement? BodyStyle, XElement? OtherStyle); + +/// A parsed slide layout: its own part path, its master's part path, and its placeholder shapes. +/// The layout's own resolved part path. +/// The layout's resolved /slideMaster relationship target part path. +/// The layout's immediate placeholder shapes, in document order. +/// +/// The layout's own <p:cSld>/<p:bg> element, retained raw/unparsed, or +/// when the layout declares no background - the middle tier of the +/// slide -> layout -> master background-fill resolution chain (see +/// ). +/// +/// +/// The layout's full recursive shape tree, parsed via +/// from the same <p:cSld>/<p:spTree> element as - +/// used (Phase 2 Follow-Up) to render the layout's own non-placeholder decorative shapes +/// (pictures, autoshapes, groups, freeform shapes) on every slide using this layout, on top of +/// its master's own equivalent shapes; its own placeholder shapes are never painted directly +/// from this list (see 's +/// skipPlaceholderShapes mechanism). Defaults to Array.Empty<PptxShapeTreeNode>() +/// so pre-existing 4-argument call sites continue to compile unchanged. +/// +/// +/// The layout's own optional <p:clrMapOvr> element (a sibling of +/// <p:cSld>), retained raw/unparsed (mirrors 's own +/// "retain raw, resolve lazily" convention) - resolution requires slide -> layout tier +/// fallback logic, not a layout-local decision, so it is deferred to render time (see +/// ). when the +/// layout declares no <p:clrMapOvr> at all (the middle tier falls through to the +/// master's own ). +/// +internal sealed record PptxLayout( + string PartPath, + string MasterPartPath, + IReadOnlyList Placeholders, + XElement? Background = null, + IReadOnlyList? ShapeTree = null, + XElement? ClrMapOvr = null) +{ + /// The layout's full recursive shape tree - see the constructor parameter's own remarks. + public IReadOnlyList ShapeTree { get; init; } = ShapeTree ?? Array.Empty(); +} + +/// +/// A parsed slide: its own part path, its layout's part path, its placeholder shapes, and (as +/// of Phase 1e) its full recursive shape tree. +/// +/// The slide's own resolved part path. +/// The slide's resolved /slideLayout relationship target part path. +/// +/// The slide's immediate placeholder shapes (direct children of <p:spTree> only - +/// Phase 1b does not recurse into groups), in document order. Still populated and unchanged by +/// Phase 1e - the run/paragraph property-inheritance resolver +/// () continues to consult this flat +/// list rather than . +/// +/// +/// The slide's full recursive shape tree (Phase 1e), parsed via +/// from the same <p:spTree> element as +/// - recurses into <p:grpSp> groups (unlike +/// ) and additionally recognizes <p:pic>/ +/// <p:graphicFrame> shapes. +/// +/// +/// The slide's own <p:cSld>/<p:bg> element, retained raw/unparsed, or +/// when the slide declares no background - the highest-priority tier +/// of the slide -> layout -> master background-fill resolution chain (see +/// ). +/// +/// +/// The slide's own optional <p:clrMapOvr> element (a sibling of +/// <p:cSld>), retained raw/unparsed - the highest-priority tier of the slide +/// -> layout -> master <p:clrMapOvr>/<p:clrMap> resolution +/// chain (see ). +/// when the slide declares no <p:clrMapOvr> at all. +/// +internal sealed record PptxSlide( + string PartPath, + string LayoutPartPath, + IReadOnlyList Placeholders, + IReadOnlyList ShapeTree, + XElement? Background = null, + XElement? ClrMapOvr = null); + + +/// +/// Shared placeholder-shape structural parser, used identically by the master/layout/slide +/// parsers (// +/// ) to avoid duplicating the same XML-shape +/// extraction three times. +/// +internal static class PptxPlaceholderParser +{ + /// + /// Walks 's immediate <p:sp> children only + /// (no group/recursive descent), filters to those with a + /// <p:nvSpPr>/<p:nvPr>/<p:ph> descendant, and builds one + /// per match, applying the type/idx schema + /// defaults ("obj"/0) when the corresponding attribute is omitted. + /// + /// The <p:spTree> element to walk. + /// The placeholder shapes found, in document order. + /// + /// Thrown when a <p:ph> element's idx attribute is present but not a + /// valid non-negative integer. + /// + internal static IReadOnlyList ParsePlaceholderShapes(XElement spTreeElement) + { + var placeholders = new List(); + + foreach (var sp in spTreeElement.Elements(PptxDocument.PresentationNamespace + "sp")) + { + if (TryParsePlaceholder(sp) is { } placeholder) + { + placeholders.Add(placeholder); + } + } + + return placeholders; + } + + /// + /// Parses a single <p:sp> element's <p:nvSpPr>/<p:nvPr>/ + /// <p:ph> descendant (if any) into a , applying the + /// type/idx schema defaults ("obj"/0) when the corresponding + /// attribute is omitted. Extracted from 's own + /// per-<p:sp> inline logic (no behavior change) so the Phase 1e shape-tree + /// walker () can reuse the exact same + /// placeholder-detection logic without duplicating Phase 1b's placeholder-type/idx + /// parsing. + /// + /// The <p:sp> element to inspect. + /// + /// The parsed , or when + /// has no <p:nvSpPr>/<p:nvPr>/<p:ph> + /// descendant (a freeform, non-placeholder shape). + /// + /// + /// Thrown when a present <p:ph> element's idx attribute is present but + /// not a valid non-negative integer. + /// + internal static PptxPlaceholder? TryParsePlaceholder(XElement spElement) + { + var ph = spElement.Element(PptxDocument.PresentationNamespace + "nvSpPr")? + .Element(PptxDocument.PresentationNamespace + "nvPr")? + .Element(PptxDocument.PresentationNamespace + "ph"); + if (ph is null) + { + return null; + } + + var declaredType = (string?)ph.Attribute("type"); + var type = declaredType ?? "obj"; + var idx = ParseIdx(ph); + + return new PptxPlaceholder(type, idx, spElement, declaredType); + } + + /// + /// Parses <p:ph>'s idx attribute, defaulting to 0 when absent. + /// + /// + /// Thrown when the idx attribute is present but not a valid non-negative integer. + /// + private static uint ParseIdx(XElement ph) + { + var idxValue = (string?)ph.Attribute("idx"); + if (idxValue is null) + { + return 0; + } + + if (!uint.TryParse(idxValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var idx)) + { + throw new InvalidDataException($"A element has an invalid 'idx' attribute value '{idxValue}'."); + } + + return idx; + } +} diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxPlaceholderProperties.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxPlaceholderProperties.cs new file mode 100644 index 00000000..b910f963 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxPlaceholderProperties.cs @@ -0,0 +1,96 @@ +using System.Xml.Linq; + +namespace DemaConsulting.CanvasNet.Pptx; + +// cspell:ignore xfrm prst cust + +/// +/// The effective, resolved property fragments for a single slide placeholder, produced by +/// . +/// +/// +/// The first non-null <p:spPr> element found walking slide -> matched layout +/// placeholder -> matched master placeholder (in that order), or if +/// none of the three declares one. +/// +/// +/// The first <p:txBody>/<a:lstStyle> element (walking the same slide -> +/// matched layout placeholder -> matched master placeholder chain, independently of +/// ) that itself declares at least one <a:lvl1pPr> +/// through <a:lvl9pPr> level-override child, or if none +/// declares one. A tier's own empty, self-closing <a:lstStyle/> (no level-override +/// child at all) does not count as "declaring one" and is walked past, for the same +/// reason walks past an empty, otherwise-"present" +/// <p:spPr/>: a real-world slide placeholder commonly declares its own empty +/// <a:lstStyle/> while deliberately relying on the layout/master for level-based +/// run/paragraph overrides (for example a large title <a:defRPr sz="..."/>) - +/// GetLevelDefRPr/GetLevelElement ('s text-inheritance +/// resolver, in PptxDocument.TextInheritance.cs) index this +/// element by level, so stopping at an empty element here would silently defeat every level +/// lookup against the layout/master's real override instead of consulting it. +/// +/// +/// The first non-null <p:spPr>/<a:xfrm> element found walking the same +/// slide -> matched layout placeholder -> matched master placeholder chain, resolved +/// independently of (a per-attribute lookup, not a +/// by-product of whichever tier's whole <p:spPr> element won): a real-world +/// placeholder commonly declares its own empty <p:spPr/> (no <a:xfrm> +/// child at all, deliberately relying on the layout/master for position/size while still +/// being "present" for 's own element-level fallback) - resolving +/// 's <a:xfrm> child directly would incorrectly stop at +/// that empty element and never consult the layout/master's own geometry, silently rendering +/// nothing. Defaults to so pre-existing call sites continue to compile +/// unchanged. +/// +/// +/// The first <p:spPr> element (walking the same slide -> matched layout +/// placeholder -> matched master placeholder chain) that itself declares an +/// <a:prstGeom> or <a:custGeom> child, resolved independently of +/// for exactly the same reason +/// is: a real-world placeholder's own (otherwise "present", element-level-winning) empty +/// <p:spPr/> commonly omits geometry too, deliberately relying on the slide +/// master's own placeholder shape (which, per ECMA-376's standard master content, always +/// declares <a:prstGeom prst="rect"/>) - unlike fill/line (see +/// 's own remarks: fill/line inheritance from +/// placeholder/layout/master is a deliberate, documented Phase 1c simplification, not +/// resolved at all), geometry has no such graceful no-op fallback - +/// throws when neither child is present, so failing to walk this chain independently turns a +/// a real, common document shape into an unhandled exception instead of a silent skip. +/// Defaults to so pre-existing call sites continue to compile +/// unchanged. +/// +/// +/// The resolved theme carried through as context (not itself selected via the fallback chain - +/// see 's remarks). +/// +/// +/// The owning slide master's own <p:txStyles> (Phase 1d), carried through +/// alongside as further context for the run/paragraph property-inheritance +/// resolver () - like , +/// it is not itself part of the slide->layout->master placeholder matching chain. +/// Defaults to (treated identically to all- +/// buckets) so Phase 1b/1c call sites that predate Phase 1d continue to compile unchanged. +/// +/// +/// The slide placeholder's effective type for master text-style-bucket selection +/// (/): +/// its own when explicitly present, otherwise the +/// idx-matched layout placeholder's (mirroring the same +/// hop-1 idx-only match already used for / +/// ), otherwise the slide placeholder's own +/// schema-defaulted - a slide <p:ph> that +/// omits type must inherit the matched layout placeholder's type rather than being +/// treated as a genuinely-declared "obj" placeholder (see the companion planning +/// report's root-cause finding for the Section Header title font-size bug). Defaults to +/// so pre-existing call sites continue to compile unchanged; a +/// value means "no placeholder-specific override available" (for +/// example a non-placeholder shape), and callers should fall back to their own raw type. +/// +internal sealed record PptxPlaceholderProperties( + XElement? EffectiveSpPr, + XElement? EffectiveTxBodyListStyle, + PptxTheme Theme, + PptxMasterTextStyles? MasterTextStyles = null, + XElement? EffectiveXfrmElement = null, + XElement? EffectiveGeometrySpPr = null, + string? EffectivePlaceholderType = null); diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxPresetGeometry.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxPresetGeometry.cs new file mode 100644 index 00000000..35f4b265 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxPresetGeometry.cs @@ -0,0 +1,601 @@ +using System.Numerics; +using DemaConsulting.CanvasNet.Geometry; +using Path = DemaConsulting.CanvasNet.Geometry.Path; + +namespace DemaConsulting.CanvasNet.Pptx; + +// cspell:ignore rtTriangle homePlate lnTo cubicBezTo quadBezTo prstGeom custGeom pptx prst fmla + +/// +/// Builds the core geometry for a DrawingML preset shape +/// (<a:prstGeom prst="..."/>), for the ~24-name subset of OOXML's full preset +/// geometry catalog supported this phase - see pptx-document.md's "Geometry and Paint +/// (Phase 1c)" design section for the exact supported list and the rationale for every +/// deferred name. +/// +/// +/// +/// Every builder below produces geometry sized to exactly (0,0)-(w,h) local +/// coordinates (the shape's own declared width/height) - a caller then applies the shape's +/// resolved (via ) +/// to place it into parent coordinates, exactly like +/// does. +/// +/// +/// Adjustment-value scope. OOXML preset shapes are parameterized by one or more named +/// "adjustment values" (<a:avLst>/<a:gd name="adj" fmla="val NNNNN"/>) a +/// document may override to reshape a preset (for example a roundRect's corner +/// radius). This phase does not parse <a:avLst> at all - every preset below is +/// built using a single, fixed, documented proportion approximating that preset's own OOXML +/// schema default adjustment value(s), deliberately not pixel-exact to every PowerPoint +/// rendering of a shape with customized adjustment handles. Parsing <a:avLst> is +/// deferred to a later phase. +/// +/// +internal static class PptxPresetGeometry +{ + /// + /// Builds the for the named preset geometry, sized to + /// (0,0)-(w,h) local coordinates. + /// + /// The preset geometry name (<a:prstGeom prst="..."/>'s value). + /// The shape's own declared width, in EMU. Must be finite and non-negative. + /// The shape's own declared height, in EMU. Must be finite and non-negative. + /// The built , or when / is non-positive. + /// + /// Thrown when is not one of the supported preset names (feature + /// token "pptx-preset-geometry"). + /// + internal static Path Build(string prst, float w, float h) + { + if (!float.IsFinite(w) || !float.IsFinite(h) || w < 0f || h < 0f) + { + return Path.Empty; + } + + // Connector line presets (Phase 2 Follow-Up: Connector Shape Rendering) are open strokes, + // not closed polygon/ellipse fills - unlike every other preset, a straight/elbow/curved + // connector is still meaningful with a zero width or height (a horizontal or vertical + // line), so they are dispatched before the "positive bounding box" guard below applies. + if (IsConnectorPreset(prst)) + { + return BuildConnectorPreset(prst, w, h); + } + + if (w <= 0f || h <= 0f) + { + return Path.Empty; + } + + return prst switch + { + "rect" => Path.Rectangle(0, 0, w, h), + "roundRect" => Path.RoundRectangle(0, 0, w, h, MathF.Min(w, h) * (1f / 6f)), + "ellipse" => Ellipse(w / 2f, h / 2f, w / 2f, h / 2f), + "triangle" => Polygon(new Vector2(w / 2f, 0), new Vector2(w, h), new Vector2(0, h)), + "rtTriangle" => Polygon(new Vector2(0, 0), new Vector2(0, h), new Vector2(w, h)), + "diamond" => Polygon(new Vector2(w / 2f, 0), new Vector2(w, h / 2f), new Vector2(w / 2f, h), new Vector2(0, h / 2f)), + "parallelogram" => Parallelogram(w, h), + "trapezoid" => Trapezoid(w, h), + "hexagon" => Hexagon(w, h), + "octagon" => Octagon(w, h), + "pentagon" => RegularPolygon(w, h, 5), + "chevron" => Chevron(w, h), + "homePlate" => HomePlate(w, h), + "pie" => Pie(w, h), + "donut" => Donut(w, h), + "plus" => Plus(w, h), + "rightArrow" => RightArrow(w, h), + "leftArrow" => Mirror(RightArrow(w, h), w), + "upArrow" => UpArrow(w, h, pointingDown: false), + "downArrow" => UpArrow(w, h, pointingDown: true), + "leftRightArrow" => LeftRightArrow(w, h), + "upDownArrow" => UpDownArrow(w, h), + "star4" => Star(w, h, 4, 0.42f), + "star5" => Star(w, h, 5, 0.42f), + "curvedRightArrow" => CurvedRightArrow(w, h), + "curvedLeftArrow" => Mirror(CurvedRightArrow(w, h), w), + "curvedUpArrow" => CurvedUpOrDownArrow(w, h, pointingDown: false), + "curvedDownArrow" => CurvedUpOrDownArrow(w, h, pointingDown: true), + _ => throw new PptxUnsupportedFeatureException( + "pptx-preset-geometry", + $"Preset geometry '{prst}' is not supported."), + }; + } + + /// + /// Determines whether is one of the connector-only preset names + /// this phase supports (Phase 2 Follow-Up: Connector Shape Rendering) - the straight, + /// elbow ("bent"), and curved connector families. + /// + private static bool IsConnectorPreset(string prst) => prst is + "line" or "straightConnector1" or + "bentConnector2" or "bentConnector3" or "bentConnector4" or "bentConnector5" or + "curvedConnector2" or "curvedConnector3" or "curvedConnector4" or "curvedConnector5"; + + /// + /// Builds the open-stroke for a connector-only preset name (see + /// ), sized to (0,0)-(w,h) local coordinates. + /// + /// + /// Thrown when is not actually one of the names + /// recognizes - unreachable from itself + /// (which only calls this method after that same check), kept only so this method remains + /// independently exhaustive. + /// + private static Path BuildConnectorPreset(string prst, float w, float h) => prst switch + { + "line" or "straightConnector1" => StraightConnectorLine(w, h), + "bentConnector2" => BentConnector(w, h, 2), + "bentConnector3" => BentConnector(w, h, 3), + "bentConnector4" => BentConnector(w, h, 4), + "bentConnector5" => BentConnector(w, h, 5), + "curvedConnector2" => CurvedConnector(w, h, 2), + "curvedConnector3" => CurvedConnector(w, h, 3), + "curvedConnector4" => CurvedConnector(w, h, 4), + "curvedConnector5" => CurvedConnector(w, h, 5), + _ => throw new PptxUnsupportedFeatureException( + "pptx-preset-geometry", + $"Preset geometry '{prst}' is not supported."), + }; + + /// + /// Builds an open, single-segment straight diagonal from (0,0) to (w,h) - + /// the line/straightConnector1 presets. Unlike every closed preset in this + /// file, the returned is never d: a + /// connector is a visible stroke, not a fillable region. + /// + private static Path StraightConnectorLine(float w, float h) => + new PathBuilder().MoveTo(Vector2.Zero).LineTo(new Vector2(w, h)).Build(); + + /// + /// Builds an open, right-angle "elbow" polyline from (0,0) to (w,h) with the + /// given number of straight (the bentConnectorN + /// preset's own N) - the bentConnector2-bentConnector5 presets. + /// + /// + /// This phase does not parse a bent/curved connector's own <a:avLst> + /// adjustment values (the actual bend-point positions PowerPoint lets a user drag) - every + /// bend is placed by a single, fixed, documented proportion (an even division of the total + /// horizontal/vertical travel across the alternating horizontal/vertical segments implied + /// by ), consistent with this file's own "fixed-proportion + /// approximation" philosophy for every other preset's adjustment values. + /// + private static Path BentConnector(float w, float h, int segments) => + BuildOpenPolyline(ComputeElbowVertices(w, h, segments)); + + /// + /// Builds the same fixed-proportion elbow route as , then + /// softens every interior bend into a tangent circular arc via + /// - the curvedConnector2-curvedConnector5 + /// presets. The connector's own two endpoints (the first and last vertex) are never + /// rounded - only interior bend points qualify, matching 's + /// own documented scope policy for an open polyline. + /// + private static Path CurvedConnector(float w, float h, int segments) + { + var polyline = BuildOpenPolyline(ComputeElbowVertices(w, h, segments)); + var radius = MathF.Min(MathF.Abs(w), MathF.Abs(h)) * 0.15f; + return CornerRoundEffect.Apply(polyline, radius); + } + + /// + /// Computes the vertices of a fixed-proportion, right-angle elbow route from (0,0) + /// to (w,h) with the given number of alternating horizontal/vertical + /// (horizontal first): the total horizontal travel + /// is divided evenly across however many of the alternating segments + /// are horizontal, and likewise across however many are vertical, so + /// the route reaches exactly (w,h) regardless of . + /// + private static Vector2[] ComputeElbowVertices(float w, float h, int segments) + { + var numHorizontal = (segments + 1) / 2; + var numVertical = segments / 2; + var stepX = numHorizontal == 0 ? 0f : w / numHorizontal; + var stepY = numVertical == 0 ? 0f : h / numVertical; + + var vertices = new Vector2[segments + 1]; + vertices[0] = Vector2.Zero; + var x = 0f; + var y = 0f; + for (var i = 0; i < segments; i++) + { + if (i % 2 == 0) + { + x += stepX; + } + else + { + y += stepY; + } + + vertices[i + 1] = new Vector2(x, y); + } + + return vertices; + } + + /// Builds an open (not d) polyline through the given vertices, in order. + private static Path BuildOpenPolyline(Vector2[] vertices) + { + var builder = new PathBuilder().MoveTo(vertices[0]); + for (var i = 1; i < vertices.Length; i++) + { + builder.LineTo(vertices[i]); + } + + return builder.Build(); + } + + /// Builds a closed polygon from the given vertices, in order. + private static Path Polygon(params Vector2[] points) + { + var builder = new PathBuilder().MoveTo(points[0]); + for (var i = 1; i < points.Length; i++) + { + builder.LineTo(points[i]); + } + + return builder.Close().Build(); + } + + /// + /// Builds an axis-aligned ellipse centered at (cx,cy) with radii (rx,ry), + /// approximated by four cubic Bezier quadrants (the same kappa=0.5522847498 constant + /// uses, generalized to independent x/y radii). + /// + /// The ellipse's center x-coordinate. + /// The ellipse's center y-coordinate. + /// The ellipse's x-radius. + /// The ellipse's y-radius. + /// + /// When , the ellipse is traversed in the opposite winding order - + /// used by to cut a hole out of an outer ellipse under the nonzero + /// fill rule. + /// + internal static Path Ellipse(float cx, float cy, float rx, float ry, bool reversed = false) + { + const float kappa = 0.5522847498f; + var ox = rx * kappa; + var oy = ry * kappa; + + var right = new Vector2(cx + rx, cy); + var bottom = new Vector2(cx, cy + ry); + var left = new Vector2(cx - rx, cy); + var top = new Vector2(cx, cy - ry); + + var builder = new PathBuilder(); + if (!reversed) + { + builder.MoveTo(right) + .CubicBezierTo(new Vector2(cx + rx, cy + oy), new Vector2(cx + ox, cy + ry), bottom) + .CubicBezierTo(new Vector2(cx - ox, cy + ry), new Vector2(cx - rx, cy + oy), left) + .CubicBezierTo(new Vector2(cx - rx, cy - oy), new Vector2(cx - ox, cy - ry), top) + .CubicBezierTo(new Vector2(cx + ox, cy - ry), new Vector2(cx + rx, cy - oy), right); + } + else + { + builder.MoveTo(right) + .CubicBezierTo(new Vector2(cx + rx, cy - oy), new Vector2(cx + ox, cy - ry), top) + .CubicBezierTo(new Vector2(cx - ox, cy - ry), new Vector2(cx - rx, cy - oy), left) + .CubicBezierTo(new Vector2(cx - rx, cy + oy), new Vector2(cx - ox, cy + ry), bottom) + .CubicBezierTo(new Vector2(cx + ox, cy + ry), new Vector2(cx + rx, cy + oy), right); + } + + return builder.Close().Build(); + } + + private static Path Parallelogram(float w, float h) + { + var slant = w * 0.25f; + return Polygon( + new Vector2(slant, 0), new Vector2(w, 0), + new Vector2(w - slant, h), new Vector2(0, h)); + } + + private static Path Trapezoid(float w, float h) + { + var inset = w * 0.25f; + return Polygon( + new Vector2(inset, 0), new Vector2(w - inset, 0), + new Vector2(w, h), new Vector2(0, h)); + } + + private static Path Hexagon(float w, float h) + { + var inset = w * 0.25f; + return Polygon( + new Vector2(inset, 0), new Vector2(w - inset, 0), new Vector2(w, h / 2f), + new Vector2(w - inset, h), new Vector2(inset, h), new Vector2(0, h / 2f)); + } + + private static Path Octagon(float w, float h) + { + var insetX = w * 0.2929f; + var insetY = h * 0.2929f; + return Polygon( + new Vector2(insetX, 0), new Vector2(w - insetX, 0), + new Vector2(w, insetY), new Vector2(w, h - insetY), + new Vector2(w - insetX, h), new Vector2(insetX, h), + new Vector2(0, h - insetY), new Vector2(0, insetY)); + } + + /// Builds a regular N-gon inscribed in the (w,h) bounding box, apex pointing up. + private static Path RegularPolygon(float w, float h, int sides) + { + var cx = w / 2f; + var cy = h / 2f; + var points = new Vector2[sides]; + for (var i = 0; i < sides; i++) + { + var angle = -MathF.PI / 2f + i * (2f * MathF.PI / sides); + points[i] = new Vector2(cx + cx * MathF.Cos(angle), cy + cy * MathF.Sin(angle)); + } + + return Polygon(points); + } + + private static Path Chevron(float w, float h) + { + var notch = w * 0.25f; + return Polygon( + new Vector2(0, 0), new Vector2(w - notch, 0), new Vector2(w, h / 2f), + new Vector2(w - notch, h), new Vector2(0, h), new Vector2(notch, h / 2f)); + } + + private static Path HomePlate(float w, float h) + { + var notch = w * 0.25f; + return Polygon( + new Vector2(0, 0), new Vector2(w - notch, 0), new Vector2(w, h / 2f), + new Vector2(w - notch, h), new Vector2(0, h)); + } + + /// + /// Builds a pie-slice sector of the (w,h) bounding ellipse, using the OOXML preset's own + /// schema default angles (start=0 degrees, sweep=270 degrees - a three-quarter pie). + /// + private static Path Pie(float w, float h) + { + var cx = w / 2f; + var cy = h / 2f; + var rx = w / 2f; + var ry = h / 2f; + + const float startDeg = 0f; + const float sweepDeg = 270f; + var start = new Vector2(cx + rx * MathF.Cos(startDeg * MathF.PI / 180f), cy + ry * MathF.Sin(startDeg * MathF.PI / 180f)); + var end = new Vector2( + cx + rx * MathF.Cos((startDeg + sweepDeg) * MathF.PI / 180f), + cy + ry * MathF.Sin((startDeg + sweepDeg) * MathF.PI / 180f)); + + return new PathBuilder() + .MoveTo(new Vector2(cx, cy)) + .LineTo(start) + .ArcTo(new Vector2(rx, ry), 0f, largeArc: sweepDeg > 180f, sweep: true, end) + .Close() + .Build(); + } + + /// + /// Builds a ring (outer ellipse with a concentric inner hole), using the OOXML preset's + /// own schema default hole-size adjustment value (25% of the outer radius). + /// + private static Path Donut(float w, float h) + { + var cx = w / 2f; + var cy = h / 2f; + var outer = Ellipse(cx, cy, w / 2f, h / 2f); + var inner = Ellipse(cx, cy, w / 2f * 0.25f, h / 2f * 0.25f, reversed: true); + return CombineSubpaths(outer, inner); + } + + private static Path Plus(float w, float h) + { + var thickness = MathF.Min(w, h) * 0.3f; + var vx0 = (w - thickness) / 2f; + var vx1 = (w + thickness) / 2f; + var vy0 = (h - thickness) / 2f; + var vy1 = (h + thickness) / 2f; + + return Polygon( + new Vector2(vx0, 0), new Vector2(vx1, 0), new Vector2(vx1, vy0), + new Vector2(w, vy0), new Vector2(w, vy1), new Vector2(vx1, vy1), + new Vector2(vx1, h), new Vector2(vx0, h), new Vector2(vx0, vy1), + new Vector2(0, vy1), new Vector2(0, vy0), new Vector2(vx0, vy0)); + } + + private static Path RightArrow(float w, float h) + { + var headLen = w * 0.4f; + var shaftY0 = h * 0.25f; + var shaftY1 = h * 0.75f; + + return Polygon( + new Vector2(0, shaftY0), new Vector2(w - headLen, shaftY0), new Vector2(w - headLen, 0), + new Vector2(w, h / 2f), + new Vector2(w - headLen, h), new Vector2(w - headLen, shaftY1), new Vector2(0, shaftY1)); + } + + private static Path LeftRightArrow(float w, float h) + { + var headLen = w * 0.3f; + var shaftY0 = h * 0.25f; + var shaftY1 = h * 0.75f; + + return Polygon( + new Vector2(0, h / 2f), + new Vector2(headLen, 0), new Vector2(headLen, shaftY0), + new Vector2(w - headLen, shaftY0), new Vector2(w - headLen, 0), + new Vector2(w, h / 2f), + new Vector2(w - headLen, h), new Vector2(w - headLen, shaftY1), + new Vector2(headLen, shaftY1), new Vector2(headLen, h)); + } + + /// Mirrors a path horizontally within the (w,0) bounding width - used to derive leftArrow from rightArrow. + private static Path Mirror(Path path, float w) => path.Transform(new Matrix3x2(-1, 0, 0, 1, w, 0)); + + /// + /// Maps a path built in a swapped-axis local box into the final (w,h) box via + /// finalX = localY, finalY = h - localX - used to derive + /// upArrow/downArrow/upDownArrow from their horizontal counterparts + /// (built pointing toward local +x) without duplicating their vertex geometry: local +x + /// (the horizontal arrow's own pointing direction) rotates to final -y ("up"). + /// + private static Path RotateQuarter(Path path, float h) + { + var transform = new Matrix3x2(0, -1, 1, 0, 0, h); + return path.Transform(transform); + } + + /// Builds upArrow/downArrow by rotating a built in a swapped-axis local box. + private static Path UpArrow(float w, float h, bool pointingDown) + { + var swappedWidth = h; + var swappedHeight = w; + var arrow = RightArrow(swappedWidth, swappedHeight); + if (pointingDown) + { + arrow = Mirror(arrow, swappedWidth); + } + + return RotateQuarter(arrow, h); + } + + /// Builds upDownArrow by rotating a built in a swapped-axis local box. + private static Path UpDownArrow(float w, float h) + { + var swappedWidth = h; + var swappedHeight = w; + return RotateQuarter(LeftRightArrow(swappedWidth, swappedHeight), h); + } + + /// + /// Builds a quarter-annulus band curving from a flat tail (at the left edge) to a + /// pointed arrowhead tip (at the top-right corner), approximating OOXML's + /// curvedRightArrow preset's own schema default adjustment values (a 90-degree + /// sweep, an inner radius at half the outer, and a tip formed by letting the band's outer + /// rim reach the full sweep while the inner rim stops short - matching this file's own + /// documented "single, fixed, documented proportion" approximation philosophy). + /// + /// + /// Built on an ellipse of radii (w,h) centered at the local origin (0,0) + /// (the box's own top-left corner): the tail sits at angle 90 degrees (the box's + /// bottom-left corner, (0,h)) and the tip sits at angle 0 degrees (the box's + /// top-right corner, (w,0)). Every vertex lies on or inside the unit ellipse + /// (cos/sin of an angle within [0,90] degrees are both within + /// [0,1]), so the whole shape stays within the declared (0,0)-(w,h) + /// bounds by construction. + /// + private static Path CurvedRightArrow(float w, float h) + { + const float innerRatio = 0.5f; + const float innerEndDeg = 20f; + + var tailOuter = EllipsePointAtOrigin(w, h, 90f, 1f); + var tip = EllipsePointAtOrigin(w, h, 0f, 1f); + var innerEnd = EllipsePointAtOrigin(w, h, innerEndDeg, innerRatio); + var tailInner = EllipsePointAtOrigin(w, h, 90f, innerRatio); + + return new PathBuilder() + .MoveTo(tailOuter) + .ArcTo(new Vector2(w, h), 0f, largeArc: false, sweep: false, tip) + .LineTo(innerEnd) + .ArcTo(new Vector2(w * innerRatio, h * innerRatio), 0f, largeArc: false, sweep: true, tailInner) + .Close() + .Build(); + } + + /// + /// Builds curvedUpArrow/curvedDownArrow by rotating a + /// built in a swapped-axis local box - the same + /// derivation precedent already uses for . + /// + private static Path CurvedUpOrDownArrow(float w, float h, bool pointingDown) + { + var swappedWidth = h; + var swappedHeight = w; + var arrow = CurvedRightArrow(swappedWidth, swappedHeight); + if (pointingDown) + { + arrow = Mirror(arrow, swappedWidth); + } + + return RotateQuarter(arrow, h); + } + + /// + /// Computes a point on the ellipse of radii (w,h) centered at the local origin + /// (0,0), at the given angle and radius scale (1.0 = the outer rim). + /// + private static Vector2 EllipsePointAtOrigin(float w, float h, float angleDegrees, float radiusScale) + { + var radians = angleDegrees * MathF.PI / 180f; + return new Vector2(w * radiusScale * MathF.Cos(radians), h * radiusScale * MathF.Sin(radians)); + } + + /// + /// Builds a 2N-point star (N outer points, N inner points, alternating), apex pointing up. + /// + /// The bounding box width. + /// The bounding box height. + /// The number of star points (N). + /// The inner vertex radius as a fraction of the outer radius. + private static Path Star(float w, float h, int points, float innerRatio) + { + var cx = w / 2f; + var cy = h / 2f; + var vertices = new Vector2[points * 2]; + for (var i = 0; i < points * 2; i++) + { + var angle = -MathF.PI / 2f + i * (MathF.PI / points); + var radius = (i % 2 == 0) ? 1f : innerRatio; + vertices[i] = new Vector2(cx + cx * radius * MathF.Cos(angle), cy + cy * radius * MathF.Sin(angle)); + } + + return Polygon(vertices); + } + + /// + /// Combines two already-built paths' subpaths into a single (used by + /// to combine its outer and inner ellipse subpaths into one + /// nonzero-fill-rule ring). + /// + private static Path CombineSubpaths(Path first, Path second) + { + var builder = new PathBuilder(); + AppendSubpaths(builder, first); + AppendSubpaths(builder, second); + return builder.Build(); + } + + /// Replays every subpath of into . + private static void AppendSubpaths(PathBuilder builder, Path path) + { + foreach (var subpath in path.Subpaths) + { + builder.MoveTo(subpath.Start); + foreach (var command in subpath.Commands) + { + switch (command.Type) + { + case PathCommandType.LineTo: + builder.LineTo(command.EndPoint); + break; + case PathCommandType.QuadraticBezierTo: + builder.QuadraticBezierTo(command.Control1, command.EndPoint); + break; + case PathCommandType.CubicBezierTo: + builder.CubicBezierTo(command.Control1, command.Control2, command.EndPoint); + break; + case PathCommandType.ArcTo: + builder.ArcTo(command.Radius, command.RotationDegrees, command.LargeArc, command.Sweep, command.EndPoint); + break; + case PathCommandType.Close: + builder.Close(); + break; + } + } + } + } +} diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxRenderOptions.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxRenderOptions.cs new file mode 100644 index 00000000..4687e0ea --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxRenderOptions.cs @@ -0,0 +1,47 @@ +using DemaConsulting.CanvasNet.Canvas; + +namespace DemaConsulting.CanvasNet.Pptx; + +/// +/// Groups slide-rendering configuration accepted by +/// and +/// . +/// +/// +/// This type exists as a single, extensible options parameter rather than a growing list of +/// individual Render arguments, so future rendering options can be added as new +/// -only properties without another breaking change to +/// 's Render signatures - mirroring +/// DemaConsulting.CanvasNet.Pdf.PdfRenderOptions's own rationale exactly. It is a +/// class with -only properties (rather than a +/// positional record) so +/// that object-initializer call sites such as +/// new PptxRenderOptions { BackgroundColor = ... } keep compiling unchanged whenever a +/// new option is added later. +/// +public sealed class PptxRenderOptions +{ + /// + /// The default instance, used by Render whenever a + /// caller passes (or omits the options parameter entirely) - + /// opaque white (see 's own default). + /// + public static readonly PptxRenderOptions Default = new(); + + /// + /// The color the rendered is cleared to before the slide's shape + /// tree is painted. Defaults to opaque white (R=255, G=255, B=255, A=255). + /// + /// + /// Set this to a fully transparent color (new Rgba32(0, 0, 0, 0)) to reproduce a + /// fully transparent background. A slide's own <p:bg> background fill (or, + /// when the slide declares none, its layout's/master's own <p:bg>) now takes + /// priority over this option when declared - painted across the full slide after this + /// clear and before the shape-tree walk (see + /// 's remarks and + /// ). This option remains the + /// fallback - and the base clear color - only when none of slide/layout/master declare a + /// <p:bg> at all. + /// + public Rgba32 BackgroundColor { get; init; } = new(255, 255, 255, 255); +} diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxShapeFrame.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxShapeFrame.cs new file mode 100644 index 00000000..46dd2419 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxShapeFrame.cs @@ -0,0 +1,32 @@ +using System.Numerics; + +namespace DemaConsulting.CanvasNet.Pptx; + +// cspell:ignore pptx unrotated unflipped + +/// +/// The resolved placement of a single shape: the transform mapping that shape's own local, +/// unrotated/unflipped geometry coordinate space ((0,0) to +/// (WidthEmu, HeightEmu)) into its parent's coordinate space (the slide itself, or - +/// for a shape nested inside a <p:grpSp> - the enclosing group's child coordinate +/// space; see 's remarks for how a +/// group's own frame composes with its children's), together with the shape's own declared +/// width/height (needed by preset/custom geometry builders, which size their output to +/// exactly this box before is applied). +/// +/// +/// The transform mapping a point in this shape's own local geometry coordinate space into its +/// parent's coordinate space, row-vector convention (matching +/// : Vector2.Transform(p, Transform)). +/// +/// +/// The shape's own declared width (<a:ext cx="..."/>), in EMU - the width a +/// preset/custom geometry builder sizes its local-space output to, before +/// is applied. +/// +/// +/// The shape's own declared height (<a:ext cy="..."/>), in EMU - the height a +/// preset/custom geometry builder sizes its local-space output to, before +/// is applied. +/// +internal sealed record PptxShapeFrame(Matrix3x2 Transform, float WidthEmu, float HeightEmu); diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxShapeTree.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxShapeTree.cs new file mode 100644 index 00000000..681072cc --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxShapeTree.cs @@ -0,0 +1,81 @@ +using System.Numerics; +using System.Xml.Linq; +using DemaConsulting.CanvasNet.Charts; + +namespace DemaConsulting.CanvasNet.Pptx; + +// cspell:ignore grpsp pptx sppr + +/// +/// A single node of a slide's full shape tree (Phase 1e, extended by the Phase 2 Follow-Up +/// connector-rendering work), produced by : a closed +/// hierarchy mirroring every shape kind this phase recognizes inside a +/// <p:spTree>/<p:grpSp> - a <p:sp> +/// (), a <p:pic> (), +/// a <p:graphicFrame> (), a nested +/// <p:grpSp> (), or a <p:cxnSp> +/// connector (). <p:contentPart>/any other +/// element kind are not represented at all - +/// silently skips them (see its own remarks). +/// +internal abstract record PptxShapeTreeNode +{ + private protected PptxShapeTreeNode() + { + } +} + +/// +/// A <p:sp> shape-tree node - either a placeholder shape (when +/// is non-null) or a freeform shape (when it is +/// ). +/// +/// The raw <p:sp> element. +/// +/// The shape's resolved (see +/// ), or for a +/// freeform shape with no <p:nvSpPr>/<p:nvPr>/<p:ph> descendant. +/// +internal sealed record PptxSpShapeNode(XElement ShapeElement, PptxPlaceholder? Placeholder) : PptxShapeTreeNode; + +/// A <p:pic> picture-shape-tree node. +/// The raw <p:pic> element. +internal sealed record PptxPictureShapeNode(XElement PicElement) : PptxShapeTreeNode; + +/// +/// A <p:graphicFrame> shape-tree node declaring either a table or a chart - +/// exactly one of / is non-null, never both and never +/// neither, by construction (see 's own +/// table-vs-chart dispatch). +/// +/// The raw <p:graphicFrame> element. +/// +/// The eagerly-parsed (see ), or +/// when this node is a chart ( is non-null +/// instead). +/// +/// +/// The eagerly-parsed (Phase 4; see +/// ), or when this node is a +/// table ( is non-null instead). +/// +internal sealed record PptxGraphicFrameShapeNode(XElement GraphicFrameElement, PptxTable? Table, Chart? Chart) : PptxShapeTreeNode; + +/// A <p:grpSp> group shape-tree node, recursively carrying its own children. +/// The raw <p:grpSp> element. +/// +/// The transform mapping this group's own child coordinate space into its parent's coordinate +/// space (see ). Each child node's own +/// full parent-relative transform is that child's own resolved frame transform composed with +/// this transform (child-local first, then this transform - row-vector convention). +/// +/// This group's immediate shape-tree children, in document order. +internal sealed record PptxGroupShapeNode(XElement GroupElement, Matrix3x2 ChildTransform, IReadOnlyList Children) : PptxShapeTreeNode; + +/// +/// A <p:cxnSp> connector shape-tree node (Phase 2 Follow-Up: Connector Shape +/// Rendering) - a straight/elbow/curved line, typically drawn between two other shapes in a +/// flowchart or diagram, with no text body and (typically) no fill of its own. +/// +/// The raw <p:cxnSp> element. +internal sealed record PptxConnectorShapeNode(XElement CxnSpElement) : PptxShapeTreeNode; diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxSlideSize.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxSlideSize.cs new file mode 100644 index 00000000..78103112 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxSlideSize.cs @@ -0,0 +1,9 @@ +namespace DemaConsulting.CanvasNet.Pptx; + +/// +/// Reports a presentation's slide size in its native OOXML unit, EMU (English Metric Units; +/// 914400 EMU per inch). +/// +/// The slide width, in EMU, from <p:sldSz cx="...">. +/// The slide height, in EMU, from <p:sldSz cy="...">. +public readonly record struct PptxSlideSize(long WidthEmu, long HeightEmu); diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxTable.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxTable.cs new file mode 100644 index 00000000..b48f46ef --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxTable.cs @@ -0,0 +1,80 @@ +namespace DemaConsulting.CanvasNet.Pptx; + +// cspell:ignore hmerge vmerge gridspan rowspan pptx + +/// +/// A parsed <a:tbl> table (Phase 1e): its column widths and ordered rows, +/// produced by . +/// +/// +/// Each <a:tblGrid>/<a:gridCol w="..."/> column's declared width, in EMU, +/// in document (left-to-right) order. +/// +/// The table's ordered <a:tr> rows. +internal sealed record PptxTable(IReadOnlyList ColumnWidthsEmu, IReadOnlyList Rows); + +/// +/// A parsed <a:tr> table row: its declared height plus its ordered cells, +/// produced by . +/// +/// +/// The row's declared <a:tr h="..."/> height, in EMU, used as-is (this phase does +/// not grow a row to fit overflowing cell content - see the design document's documented +/// deferral). +/// +/// The row's ordered <a:tc> cells. +internal sealed record PptxTableRow(float HeightEmu, IReadOnlyList Cells); + +/// +/// A parsed <a:tc> table cell: its merge span/continuation flags, resolved +/// fill/border paint, and optional text content, produced by +/// . +/// +/// +/// The cell's gridSpan attribute (the number of grid columns this cell spans, +/// including itself), defaulting to 1 when absent. +/// +/// +/// The cell's rowSpan attribute (the number of grid rows this cell spans, including +/// itself), defaulting to 1 when absent. +/// +/// +/// Whether this cell is a horizontal-merge continuation (hMerge="1") - a placeholder +/// cell beneath a preceding cell's , not independently painted. +/// +/// +/// Whether this cell is a vertical-merge continuation (vMerge="1") - a placeholder +/// cell beneath a preceding row's cell's , not independently painted. +/// +/// The cell's resolved <a:tcPr> fill (), or when absent. +/// The cell's resolved <a:tcPr>/<a:lnL> border, or for "no border". +/// The cell's resolved <a:tcPr>/<a:lnR> border, or for "no border". +/// The cell's resolved <a:tcPr>/<a:lnT> border, or for "no border". +/// The cell's resolved <a:tcPr>/<a:lnB> border, or for "no border". +/// The cell's parsed <a:txBody> content, or for an empty cell (no <a:txBody> declared). +internal sealed record PptxTableCell( + int GridSpan, + int RowSpan, + bool HMerge, + bool VMerge, + PptxPaint Fill, + PptxLineStyle? LeftBorder, + PptxLineStyle? RightBorder, + PptxLineStyle? TopBorder, + PptxLineStyle? BottomBorder, + PptxTextBody? TextBody); + +/// +/// A single resolved to its final, merge-aware, shape-local +/// rectangle, produced by . Only cells that are +/// not themselves a merge continuation (/ +/// both ) are resolved - a merge +/// continuation cell contributes no rectangle of its own (its space is already covered by the +/// governing cell's own /). +/// +/// The cell's left edge, in the table's own shape-local coordinate space, in EMU. +/// The cell's top edge, in the table's own shape-local coordinate space, in EMU. +/// The cell's full merged width (the sum of consecutive column widths), in EMU. +/// The cell's full merged height (the sum of consecutive row heights), in EMU. +/// The resolved cell this rectangle belongs to. +internal sealed record PptxResolvedTableCell(float XEmu, float YEmu, float WidthEmu, float HeightEmu, PptxTableCell Cell); diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxTextBody.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxTextBody.cs new file mode 100644 index 00000000..a0fc519f --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxTextBody.cs @@ -0,0 +1,203 @@ +using System.Linq; +using System.Xml.Linq; + +namespace DemaConsulting.CanvasNet.Pptx; + +// cspell:ignore bodypr lstyle lnspc spcpct spcpts marl + +/// +/// A parsed <p:txBody> element (Phase 1d): resolved body properties plus its +/// ordered paragraphs, produced by . +/// +/// The text body's resolved <a:bodyPr> properties. +/// The text body's ordered <a:p> paragraphs. +internal sealed record PptxTextBody(PptxBodyProperties Properties, IReadOnlyList Paragraphs); + +/// +/// A text body's resolved <a:bodyPr> properties, produced by +/// . +/// +/// The vertical text anchor (anchor="t"/"ctr"/"b", defaulting to ). +/// The word-wrap mode (wrap="square"/"none", defaulting to ). +/// The left inset (lIns), in EMU, defaulting to 91440 (0.1 inch) when absent. +/// The top inset (tIns), in EMU, defaulting to 45720 (0.05 inch) when absent. +/// The right inset (rIns), in EMU, defaulting to 91440 (0.1 inch) when absent. +/// The bottom inset (bIns), in EMU, defaulting to 45720 (0.05 inch) when absent. +/// +/// The body's raw autofit child element (one of <a:noAutofit/>, +/// <a:normAutofit/>, or <a:spAutoFit/>), retained unparsed for the +/// layout engine () to interpret, or +/// when <a:bodyPr> declares none (equivalent to +/// <a:noAutofit/>). +/// +internal sealed record PptxBodyProperties( + PptxTextAnchor Anchor, + PptxTextWrap Wrap, + float InsetLeftEmu, + float InsetTopEmu, + float InsetRightEmu, + float InsetBottomEmu, + XElement? AutofitElement); + +/// +/// A parsed <a:p> paragraph: its own raw, unresolved properties plus its ordered +/// runs, produced by . +/// +/// The paragraph's own raw, unresolved properties. +/// +/// The paragraph's content in document order: each item is either an <a:r> run +/// () or an explicit <a:br> line break +/// (). An empty paragraph is valid DrawingML - a blank line. +/// +internal sealed record PptxParagraph(PptxRawParagraphProperties RawProperties, IReadOnlyList Items) +{ + /// + /// The paragraph's ordered <a:r> runs only, with any interleaved + /// <a:br> items excluded - a convenience accessor for callers (such as the + /// run-property inheritance resolver) that only need run content/order, not break + /// positions. The returned order always matches 's run-item order. + /// + internal IReadOnlyList Runs { get; } = + Items.OfType().Select(item => item.Run).ToList(); +} + +/// +/// A single item of content, in document order: either a +/// run () or an explicit line break (). +/// +internal abstract record PptxParagraphItem +{ + private protected PptxParagraphItem() + { + } +} + +/// A paragraph content item wrapping a parsed <a:r> run. +/// The wrapped run. +internal sealed record PptxRunItem(PptxTextRun Run) : PptxParagraphItem; + +/// +/// A paragraph content item representing an explicit <a:br> line break - +/// DrawingML's explicit line-break element, which forces a new layout line at this point in +/// the paragraph regardless of word-wrap, independent of any run text. +/// +internal sealed record PptxLineBreakItem : PptxParagraphItem +{ + /// The single, stateless instance used for every parsed <a:br>. + internal static readonly PptxLineBreakItem Instance = new(); +} + +/// +/// A paragraph's own raw, unresolved properties (its <a:pPr> element's +/// attributes/children) - resolution against the inheritance chain (placeholder/layout/ +/// master/theme) happens in , +/// not here. +/// +/// The paragraph's lvl attribute (0-based, clamped to [0,8]), defaulting to 0 when absent. +/// The paragraph's own algn attribute, or when absent. +/// The paragraph's own marL (left margin, EMU) attribute, or when absent. +/// The paragraph's own indent (first-line indent, EMU, frequently negative) attribute, or when absent. +/// The paragraph's own <a:lnSpc> element, or when absent. +/// The paragraph's own <a:spcBef> element, or when absent. +/// The paragraph's own <a:spcAft> element, or when absent. +/// +/// The paragraph's own <a:pPr>/<a:defRPr> element (the paragraph-level run +/// property default every run in the paragraph without its own override falls back to, +/// ranking above the placeholder/master level-indexed style in the run-property inheritance +/// chain), or when absent. +/// +/// +/// The paragraph's own raw, unresolved bullet/numbering markup (the +/// <a:buNone>/<a:buAutoNum>/<a:buChar> choice element, +/// plus the independent <a:buClrTx>/<a:buClr>, +/// <a:buFontTx>/<a:buFont>, and <a:buSzTx>/ +/// <a:buSzPct>/<a:buSzPts> choice elements), resolved against the +/// inheritance chain in - not +/// here. Defaults to (every field +/// ) so pre-existing call sites continue to compile unchanged. +/// +internal sealed record PptxRawParagraphProperties( + int Level, + string? Algn, + float? MarLEmu, + float? IndentEmu, + XElement? LnSpcElement, + XElement? SpcBeforeElement, + XElement? SpcAfterElement, + XElement? DefRPrElement, + PptxRawBulletProperties? BulletProperties = null) +{ + /// + /// The paragraph's own raw bullet properties, defaulting to + /// when itself + /// is (every pre-existing call site that omits the parameter + /// entirely). + /// + internal PptxRawBulletProperties EffectiveBulletProperties => BulletProperties ?? PptxRawBulletProperties.Empty; +} + +/// +/// A paragraph's own raw, unresolved bullet/numbering markup, produced by +/// . Each field is one member of its own +/// independent OOXML schema choice-group (at most one of that group's members is present per +/// <a:pPr>/<a:lvl{N}pPr>); the four groups (type, color, font, size) +/// are themselves independent of each other, mirroring the per-attribute inheritance +/// granularity already used for run properties - see +/// 's remarks. +/// +/// +/// The one of <a:buNone>/<a:buAutoNum>/<a:buChar> +/// present, or when none is declared at this tier. +/// +/// +/// The <a:buClrTx> or <a:buClr> element present, or +/// when neither is declared at this tier. +/// +/// +/// The <a:buFontTx> or <a:buFont> element present, or +/// when neither is declared at this tier. +/// +/// +/// The <a:buSzTx>, <a:buSzPct>, or <a:buSzPts> element +/// present, or when none is declared at this tier. +/// +internal sealed record PptxRawBulletProperties( + XElement? TypeElement, + XElement? ColorElement, + XElement? FontElement, + XElement? SizeElement) +{ + /// The shared, all- instance for a tier that declares no bullet markup at all. + internal static PptxRawBulletProperties Empty { get; } = new(null, null, null, null); +} + +/// +/// A parsed <a:r> run: its own raw, unresolved <a:rPr> element plus +/// its <a:t> text, produced by . +/// +/// The run's own raw, unresolved <a:rPr> element, or when absent. +/// The run's <a:t> text, defaulting to when absent. +internal sealed record PptxTextRun(XElement? RawRPr, string Text); + +/// The resolved vertical text anchor of a text body (<a:bodyPr anchor="..."/>). +internal enum PptxTextAnchor +{ + /// Text anchors to the top of its shape's box (anchor="t", the OOXML schema default). + Top, + + /// Text anchors to the vertical middle of its shape's box (anchor="ctr"). + Middle, + + /// Text anchors to the bottom of its shape's box (anchor="b"). + Bottom, +} + +/// The resolved word-wrap mode of a text body (<a:bodyPr wrap="..."/>). +internal enum PptxTextWrap +{ + /// Text wraps within the shape's bounding box (wrap="square", the OOXML schema default). + Square, + + /// Text never wraps (wrap="none") - every paragraph lays out as a single, unbounded-width line. + None, +} diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxTextLayout.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxTextLayout.cs new file mode 100644 index 00000000..0d5df361 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxTextLayout.cs @@ -0,0 +1,72 @@ +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Fonts; + +namespace DemaConsulting.CanvasNet.Pptx; + +/// +/// The fully-resolved, laid-out glyph stream for a single <p:txBody>, produced by +/// and consumed by +/// . +/// +/// +/// Every non-whitespace glyph to paint, in reading order, already positioned in the owning +/// shape's own local, unrotated/unflipped geometry coordinate space (the same space +/// maps from). +/// +/// +/// The autofit font-size scale factor actually applied (1.0 = no scaling) - see the +/// design document's "Text Layout and Rendering (Phase 1d)" autofit policy for when this is +/// read verbatim from <a:normAutofit fontScale="..."/>, computed by the bounded +/// shrink loop, or left at 1.0. +/// +/// +/// Every underline segment to paint (Phase 2 Follow-Up: Underline Rendering), in reading +/// order, already positioned in the same shape-local coordinate space as +/// . Defaults to an empty list so every pre-existing direct +/// construction call site (for example in tests) keeps compiling unchanged. +/// +internal sealed record PptxTextLayout( + IReadOnlyList Glyphs, + float AppliedFontScale, + IReadOnlyList Underlines) +{ + /// Convenience constructor for direct-construction call sites that predate underline support (defaults to no underline segments). + internal PptxTextLayout(IReadOnlyList Glyphs, float AppliedFontScale) + : this(Glyphs, AppliedFontScale, Array.Empty()) + { + } +} + +/// +/// A single resolved, positioned underline segment ready for +/// to paint (Phase 2 Follow-Up: Underline +/// Rendering), spanning one contiguous run of underlined tokens on a single line. +/// +/// The segment's start X, in EMU, in the owning shape's own local coordinate space. +/// The segment's end X, in EMU, in the owning shape's own local coordinate space. +/// The owning line's baseline Y, in EMU, in the owning shape's own local coordinate space. +/// The underlined run's resolved (already autofit-scaled) font size, in EMU - used to derive the painted stroke's thickness/offset (see ). +/// The resolved underline style. +/// The resolved underline ink color. +internal readonly record struct PptxUnderlineSegment( + float StartXEmu, + float EndXEmu, + float BaselineYEmu, + float SizeEmu, + PptxUnderlineStyle Style, + Rgba32 Color); + +/// A single resolved, positioned glyph ready for to paint. +/// The resolved supplying this glyph's outline. +/// The glyph's index within (as returned by ). +/// The glyph's baseline origin X, in EMU, in the owning shape's own local coordinate space. +/// 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. +internal sealed record PptxGlyphPlacement( + TrueTypeFont Font, + int GlyphIndex, + float OriginXEmu, + float OriginYEmu, + float SizeEmu, + Rgba32 Color); diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxTheme.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxTheme.cs new file mode 100644 index 00000000..3615ceb4 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxTheme.cs @@ -0,0 +1,163 @@ +// cspell:ignore srgb hlink bgfillstylelst patt + +using System.Xml.Linq; +using DemaConsulting.CanvasNet.Canvas; + +namespace DemaConsulting.CanvasNet.Pptx; + +/// +/// A parsed OOXML theme (ppt/theme/themeN.xml): its color scheme and font scheme. This +/// is internal - carried alongside the slide/layout/master placeholder property chain +/// as context for a later rendering phase to resolve a scheme-color/font token embedded in an +/// effective property fragment; it is not itself part of the placeholder type/idx matching +/// chain (see 's remarks). +/// +/// The theme's <a:clrScheme>, resolved to concrete colors. +/// The theme's <a:fontScheme>, resolved to concrete typefaces. +/// +/// The theme's <a:fmtScheme>/<a:bgFillStyleLst> entries - each a raw, +/// unparsed fill-definition element (<a:solidFill>/<a:gradFill>/ +/// <a:pattFill>/<a:blipFill>), in document order, typically +/// parameterized with an <a:schemeClr val="phClr"/> placeholder-color token a +/// <p:bgRef>'s own color child substitutes a concrete value for (see +/// ) - mirrors +/// 's own "retain raw, unparsed" precedent for list-shaped +/// theme/master content. Defaults to an empty list when the theme declares no +/// <a:fmtScheme>/<a:bgFillStyleLst> at all - the overwhelming +/// majority of themes/slides never reference <p:bgRef>, so this content is +/// parsed lazily/defensively rather than required. +/// +/// +/// The theme's <a:fmtScheme>/<a:fillStyleLst> entries - consulted by a +/// shape's own <p:style>/<a:fillRef idx="..."/> (see +/// ). Defaults to an empty list when the +/// theme declares no <a:fmtScheme>/<a:fillStyleLst> at all. +/// +/// +/// The theme's <a:fmtScheme>/<a:lnStyleLst> entries - consulted by a +/// shape's own <p:style>/<a:lnRef idx="..."/> (see +/// ). Defaults to an empty list when +/// the theme declares no <a:fmtScheme>/<a:lnStyleLst> at all. +/// +internal sealed record PptxTheme( + PptxColorScheme ColorScheme, + PptxFontScheme FontScheme, + IReadOnlyList? BgFillStyleList = null, + IReadOnlyList? FillStyleList = null, + IReadOnlyList? LnStyleList = null) +{ + /// + /// The theme's <a:fmtScheme>/<a:bgFillStyleLst> entries, normalized to + /// an empty list (rather than ) when the constructor's own optional + /// BgFillStyleList argument is omitted - every pre-existing 2-argument call site + /// continues to compile unchanged, each now implicitly declaring "no background + /// format-scheme style list" rather than being required to pass an empty list explicitly. + /// + public IReadOnlyList BgFillStyleList { get; init; } = BgFillStyleList ?? []; + + /// + /// The theme's <a:fmtScheme>/<a:fillStyleLst> entries - each a raw, + /// unparsed fill-definition element (<a:solidFill>/<a:gradFill>/ + /// <a:pattFill>/<a:blipFill>), in document order, consulted by a + /// shape's own <p:style>/<a:fillRef idx="..."/> (see + /// ) - indexed directly, 1-based, with no + /// offset (unlike 's own <p:bgRef> 1001-offset + /// convention). Defaults to an empty list when the theme declares no + /// <a:fmtScheme>/<a:fillStyleLst> at all. + /// + public IReadOnlyList FillStyleList { get; init; } = FillStyleList ?? []; + + /// + /// The theme's <a:fmtScheme>/<a:lnStyleLst> entries - each a raw, + /// unparsed <a:ln> line-properties element, in document order, consulted by a + /// shape's own <p:style>/<a:lnRef idx="..."/> (see + /// ) - indexed directly, 1-based, + /// with no offset. Defaults to an empty list when the theme declares no + /// <a:fmtScheme>/<a:lnStyleLst> at all. + /// + public IReadOnlyList LnStyleList { get; init; } = LnStyleList ?? []; +} + +/// +/// A theme's resolved 12-slot color scheme (<a:clrScheme>), each slot already +/// resolved to a concrete value (an <a:srgbClr val="RRGGBB"/> +/// resolves directly; an <a:sysClr val="windowText" lastClr="RRGGBB"/> resolves to +/// its cached lastClr RGB equivalent - no actual OS system-color resolution is +/// performed or possible offline). +/// +/// The dk1 slot. +/// The lt1 slot. +/// The dk2 slot. +/// The lt2 slot. +/// The accent1 slot. +/// The accent2 slot. +/// The accent3 slot. +/// The accent4 slot. +/// The accent5 slot. +/// The accent6 slot. +/// The hlink slot. +/// The folHlink slot. +internal sealed record PptxColorScheme( + Rgba32 Dark1, + Rgba32 Light1, + Rgba32 Dark2, + Rgba32 Light2, + Rgba32 Accent1, + Rgba32 Accent2, + Rgba32 Accent3, + Rgba32 Accent4, + Rgba32 Accent5, + Rgba32 Accent6, + Rgba32 Hyperlink, + Rgba32 FollowedHyperlink); + +/// +/// A resolved <p:clrMap>/effective <p:clrMapOvr>'s own +/// bg1/tx1/bg2/tx2 indirection targets - each one of the theme's +/// twelve canonical slot names (in practice always one of +/// "dk1"/"lt1"/"dk2"/"lt2"). A slide master's <p:clrMap +/// bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" .../> (the overwhelming majority of +/// real-world themes) maps each alias to its own slot of the same name (no redirection) - +/// - while a non-identity map (for example a dark/inverted layout) +/// redirects bg1/tx1 to dk1/lt1 instead. See +/// for how the effective, per-slide value +/// is resolved from the slide -> layout -> master <p:clrMapOvr>/ +/// <p:clrMap> chain, and for how it +/// is consulted when resolving an <a:schemeClr val="bg1"/>-shaped token. +/// accentN/hlink/folHlink attributes are deliberately not carried here - +/// per the companion planning report's documented simplification, a real-world +/// <p:clrMap> always maps those six to themselves, so only the four +/// background/text slots are ever meaningfully redirected. +/// +/// The bg1 attribute's target slot name. +/// The tx1 attribute's target slot name. +/// The bg2 attribute's target slot name. +/// The tx2 attribute's target slot name. +internal sealed record PptxColorMap(string Bg1, string Tx1, string Bg2, string Tx2) +{ + /// + /// The identity color map (bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2") - the + /// overwhelming majority of real-world themes, and this unit's own pre-existing hardcoded + /// behavior before <p:clrMap>/<p:clrMapOvr> were consulted at + /// all. Used as every pre-existing call site's implicit default so none of them observe a + /// behavior change. + /// + public static PptxColorMap Default { get; } = new("lt1", "dk1", "lt2", "dk2"); +} + +/// +/// A theme's font scheme (<a:fontScheme>): the major (heading) and minor (body) +/// typeface collections. +/// +/// The <a:majorFont> typeface collection. +/// The <a:minorFont> typeface collection. +internal sealed record PptxFontScheme(PptxFontCollection MajorFont, PptxFontCollection MinorFont); + +/// +/// A single typeface collection (either a theme's major or minor font) naming the typeface +/// used for Latin, East Asian, and complex-script text runs. +/// +/// The <a:latin typeface="..."/> value. +/// The <a:ea typeface="..."/> value. +/// The <a:cs typeface="..."/> value. +internal sealed record PptxFontCollection(string Latin, string EastAsian, string ComplexScript); diff --git a/src/DemaConsulting.CanvasNet.Pptx/PptxUnsupportedFeatureException.cs b/src/DemaConsulting.CanvasNet.Pptx/PptxUnsupportedFeatureException.cs new file mode 100644 index 00000000..47642304 --- /dev/null +++ b/src/DemaConsulting.CanvasNet.Pptx/PptxUnsupportedFeatureException.cs @@ -0,0 +1,96 @@ +namespace DemaConsulting.CanvasNet.Pptx; + +/// +/// The exception thrown when a geometry/paint resolver refuses to +/// resolve an otherwise well-formed DrawingML construct because it declares a feature this +/// phase does not implement (for example a preset geometry name not in the supported ~24 +/// subset, a radial/path gradient fill, or a pattern/picture fill - see +/// pptx-document.md's "Geometry and Paint (Phase 1c)" design section for the exact +/// supported/deferred boundary). +/// +/// +/// This mirrors 's own rationale and +/// shape exactly, applied to this package instead of the core codec package: it is a distinct +/// type from specifically so a caller can distinguish +/// "well-formed but unsupported" from "malformed" without string-matching +/// . itself is +/// in .NET and therefore cannot be a base type here; this type +/// instead derives from - a sibling of +/// , not its base type ( +/// derives directly from , not ). A +/// caller that wants to handle both the malformed and the well-formed-but-unsupported cases +/// together cannot do so with a single catch (IOException) clause and must add two +/// explicit clauses. is a short, stable, machine-matchable token +/// identifying which unsupported feature was encountered (for example +/// "pptx-preset-geometry", "pptx-gradient-path", "pptx-pattern-fill", +/// "pptx-picture-fill"), distinct from the free-text, human-readable +/// . +/// +public sealed class PptxUnsupportedFeatureException : IOException +{ + /// + /// A short, stable, machine-matchable token identifying which unsupported feature caused + /// this exception to be thrown (for example "pptx-preset-geometry"), or + /// when this exception was constructed via one of the standard + /// parameterless/message-only/inner-exception constructors rather than the two + /// feature-carrying constructors below. + /// + public string Feature { get; } + + /// + /// Initializes a new instance of the class + /// with a default message and an empty . + /// + public PptxUnsupportedFeatureException() + : this(string.Empty, "An unsupported PPTX feature was encountered.") + { + } + + /// + /// Initializes a new instance of the class + /// with the specified message and an empty . + /// + /// The message that describes the error. + public PptxUnsupportedFeatureException(string message) + : this(string.Empty, message) + { + } + + /// + /// Initializes a new instance of the class + /// with the specified message, inner exception, and an empty . + /// + /// The message that describes the error. + /// The exception that is the cause of this exception. + public PptxUnsupportedFeatureException(string message, Exception innerException) + : this(string.Empty, message, innerException) + { + } + + /// + /// Initializes a new instance of the class + /// with the specified feature token and message. + /// + /// + /// A short, stable, machine-matchable token identifying the unsupported feature (for + /// example "pptx-preset-geometry"). + /// + /// The message that describes the error. + public PptxUnsupportedFeatureException(string feature, string message) + : base(message) + => Feature = feature; + + /// + /// Initializes a new instance of the class + /// with the specified feature token, message, and inner exception. + /// + /// + /// A short, stable, machine-matchable token identifying the unsupported feature (for + /// example "pptx-preset-geometry"). + /// + /// The message that describes the error. + /// The exception that is the cause of this exception. + public PptxUnsupportedFeatureException(string feature, string message, Exception innerException) + : base(message, innerException) + => Feature = feature; +} diff --git a/src/DemaConsulting.CanvasNet/Canvas/Rgba32.cs b/src/DemaConsulting.CanvasNet/Canvas/Rgba32.cs index 1c53d97f..db6f2287 100644 --- a/src/DemaConsulting.CanvasNet/Canvas/Rgba32.cs +++ b/src/DemaConsulting.CanvasNet/Canvas/Rgba32.cs @@ -245,4 +245,68 @@ private static bool TryParseCore(string s, out Rgba32 result, out string? reason /// boolean expression. /// private static bool IsHexDigit(char c) => char.IsAsciiHexDigit(c); + + /// + /// Computes the standard Porter-Duff "over" alpha compositing formula for a single pixel: + /// composited over , both + /// treated as straight (unassociated) alpha in and straight alpha out. + /// + /// The existing destination pixel. + /// The source pixel being painted over . + /// The alpha-blended result pixel. + /// + /// + /// This is the exact single-pixel equivalent of 's + /// own per-row formula (see that method's own XML doc remarks for the full derivation): + /// with fgA/bgA normalized to [0, 1], + /// outA = fgA + bgA * (1 - fgA), and for each color channel, + /// outC = (fgC * fgA + bgC * bgA * (1 - fgA)) / outA when outA != 0, + /// otherwise outC = 0 (the documented "alpha == 0 implies every color channel is + /// 0" degenerate case - see 's own ZeroColorWhereAlphaByteIsZero + /// remarks). Every intermediate result is rounded half-away-from-zero and clamped to + /// [0, 255] before narrowing to a , exactly matching + /// 's own NarrowRoundedClamp rounding rule. + /// + /// + /// Internal (not a public API) and exposed cross-assembly only to the two sibling + /// renderer packages that each composite a decoded source image onto a destination + /// one sampled pixel at a time - DemaConsulting.CanvasNet.Pptx's + /// PptxDocument.Images.cs's PaintPicture and + /// DemaConsulting.CanvasNet.Pdf's PdfDocument.Images.cs's + /// CompositeImageOntoSurface - via this assembly's own InternalsVisibleTo + /// grants, so the identical blending math is written and tested exactly once rather than + /// duplicated across both renderer assemblies. + /// + /// + internal static Rgba32 CompositeOver(Rgba32 background, Rgba32 foreground) + { + var bgA = background.A / 255f; + var fgA = foreground.A / 255f; + var oneMinusFgA = 1f - fgA; + var outA = fgA + (bgA * oneMinusFgA); + + if (outA == 0f) + { + return new Rgba32(0, 0, 0, 0); + } + + var outR = CompositeOverChannel(background.R, bgA, foreground.R, fgA, oneMinusFgA, outA); + var outG = CompositeOverChannel(background.G, bgA, foreground.G, fgA, oneMinusFgA, outA); + var outB = CompositeOverChannel(background.B, bgA, foreground.B, fgA, oneMinusFgA, outA); + var outAByte = (byte)Math.Clamp(MathF.Round(outA * 255f, MidpointRounding.AwayFromZero), 0f, 255f); + + return new Rgba32(outR, outG, outB, outAByte); + } + + /// + /// Computes (fgC * fgA + bgC * bgA * (1 - fgA)) / outA for one [0, 255]- + /// scaled color channel of a single pixel - the single-pixel counterpart of + /// 's own per-row CompositeOverChannel - rounded + /// half-away-from-zero and clamped to [0, 255]. + /// + private static byte CompositeOverChannel(byte bgC, float bgA, byte fgC, float fgA, float oneMinusFgA, float outA) + { + var outC = ((fgC * fgA) + (bgC * bgA * oneMinusFgA)) / outA; + return (byte)Math.Clamp(MathF.Round(outC, MidpointRounding.AwayFromZero), 0f, 255f); + } } diff --git a/src/DemaConsulting.CanvasNet/DemaConsulting.CanvasNet.csproj b/src/DemaConsulting.CanvasNet/DemaConsulting.CanvasNet.csproj index 07eb7934..b94ad84f 100644 --- a/src/DemaConsulting.CanvasNet/DemaConsulting.CanvasNet.csproj +++ b/src/DemaConsulting.CanvasNet/DemaConsulting.CanvasNet.csproj @@ -87,6 +87,11 @@ substitute-font resolution path - see PdfDocument.FontFallback.cs's ResolveSymbolicNotoFallback. --> + + diff --git a/src/DemaConsulting.CanvasNet/Drawing/StrokeOutliner.cs b/src/DemaConsulting.CanvasNet/Drawing/StrokeOutliner.cs index 8037c497..43644bb9 100644 --- a/src/DemaConsulting.CanvasNet/Drawing/StrokeOutliner.cs +++ b/src/DemaConsulting.CanvasNet/Drawing/StrokeOutliner.cs @@ -623,6 +623,46 @@ private static List BuildClosedSide( // valid inward offset - instead runs BACKWARDS, because the offset lines were pushed past // each other. A single such reversal invalidates the whole ring as a hole (see // CreateClosedStrokePolygons). + // + // The "runs backwards" test below tolerates a bounded amount of backwards projected + // length before concluding the ring has genuinely collapsed, and that tolerance must + // scale with the LOCAL tessellation density of the edge being tested, not with the + // contour's overall size. For a contour of true local radius of curvature R tessellated + // into N vertices, a forced-exact-intersection inner-ring vertex lands at radius + // R' ~= R - h (independent of N - the geometric erosion amount itself is computed + // correctly), but the projected length this check actually measures is the CHORD between + // two such adjacent vertices, whose length is approximately |R - h| * 2*sin(pi/N) - + // i.e. it shrinks proportionally to 1/N purely because finer tessellation produces more, + // shorter inner-ring edges, regardless of how far h exceeds the inradius. A single + // tolerance computed once from the whole contour's (N-independent) bounding-box span + // cannot track this density-dependent shrinkage of the very signal it is compared + // against: once N is large enough, the raw chord length falls below that tolerance for a + // growing range of genuine over-erosions, not just a "near-exact-inradius" edge case, + // silently stopping detection of real collapses (confirmed - see + // StrokeOutliner_Outline_SmallCircleRealisticTessellation_HalfWidthJustOverInradius_CollapsesToSolid). + // + // The fix is to derive the tolerance PER EDGE from that edge's own flattened + // source-segment length (Vector2.Distance(points[i], points[next])) rather than from the + // whole contour's bounding-box span. The raw chord-length signal - both genuine + // over-erosion and float32 storage-quantization noise amplified by the near-parallel- + // offset-lines division inherent to resolving each forced exact-intersection point at a + // tiny per-vertex turn angle (see CreateClosedStrokePolygons's areaNearZeroTolerance + // remarks for the analogous noise-amplification mechanism) - scales with local segment + // length/density the same way, so comparing it against a per-edge, local-segment-length- + // relative tolerance restores a stable, density-invariant comparison. This preserves both + // existing guarantees: a tiny contour (short segments, short tolerance) still correctly + // detects a genuine half-width-exceeds-inradius collapse (confirmed - see + // StrokeOutliner_Outline_ClosedSquareHalfWidthExceedsInradius_ProducesNoInvalidHole and + // StrokeOutliner_Outline_ClosedSquareHalfWidthNearButBelowInradius_ProducesValidHole), + // while a huge, finely-tessellated contour (long source segments even at high N, so a + // correspondingly generous per-edge tolerance) is not given a tolerance so tight that + // ordinary float32 noise at its own scale still triggers a false collapse (confirmed - + // see StrokeOutliner_Outline_LargeCoordinateFinelyTessellatedClosedContour_ProducesValidThinRing + // and StrokeOutliner_Outline_LargeCoordinateFinelyTessellatedSmallCircleThickStroke_CollapsesToSolid). + // NearZeroDistance remains a floor so a tiny or degenerate (near-zero-length) source + // segment still falls back to the original, already-correct absolute behavior. + const double relativeCollapseTolerance = 1e-2; + collapsed = false; for (var i = 0; i < points.Count; i++) { @@ -634,8 +674,12 @@ private static List BuildClosedSide( continue; } + var localSegmentLength = Vector2.Distance(points[i], points[next]); + var collapseTolerance = + (float)Math.Max(NearZeroDistance, localSegmentLength * relativeCollapseTolerance); + var edgeVector = ring[nextIndex] - ring[currentIndex]; - if (Vector2.Dot(edgeVector, frames[i].Tangent) <= NearZeroDistance) + if (Vector2.Dot(edgeVector, frames[i].Tangent) <= -collapseTolerance) { collapsed = true; break; diff --git a/src/DemaConsulting.CanvasNet/Fonts/SystemFontCatalog.cs b/src/DemaConsulting.CanvasNet/Fonts/SystemFontCatalog.cs index 4d4243d9..ba445690 100644 --- a/src/DemaConsulting.CanvasNet/Fonts/SystemFontCatalog.cs +++ b/src/DemaConsulting.CanvasNet/Fonts/SystemFontCatalog.cs @@ -57,6 +57,23 @@ public readonly record struct SystemFontInfo( /// corrupt face within an otherwise well-formed .ttc collection is silently /// skipped rather than aborting the whole catalog build. /// +/// +/// Thread safety: every member of this class is safe to call concurrently from +/// multiple threads, with no external synchronization required. is +/// backed by a constructed with +/// - concurrent first-time +/// callers block until a single winning thread finishes the directory scan, after which +/// every caller (concurrent or later) observes that same published result; the scan +/// itself is never run more than once per process, and no caller can observe a +/// partially-built catalog. 's process-lifetime cache +/// () is guarded by a dedicated lock +/// () around both its lookup and its populate-on-miss path, +/// so concurrent requests for the same bundled style can never race on the cache +/// dictionary or load/parse the same embedded font file twice. +/// only reads already-published, immutable data ('s own +/// and this class's read-only generic-family-name arrays) +/// and performs no mutation, so it requires no locking of its own. +/// /// public static class SystemFontCatalog { @@ -92,7 +109,10 @@ public static class SystemFontCatalog /// /// Backs - built once per process, on first access, and never - /// rescanned afterward. + /// rescanned afterward. Uses + /// so concurrent first-time accessors block on a single winning thread's scan rather than + /// each independently (and redundantly) scanning, and so no thread can ever observe a + /// partially-built catalog - see the class remarks' "Thread safety" section. /// private static readonly Lazy> LazyFonts = new(BuildCatalog, LazyThreadSafetyMode.ExecutionAndPublication); @@ -103,11 +123,18 @@ public static class SystemFontCatalog /// fallback (see 's remarks), keyed by the embedded /// resource's bundled file name (for example "LiberationSans-Bold.ttf") - at most /// 15 entries are ever created (12 Liberation Sans/Serif/Mono style variants plus 3 Noto - /// substitute fonts), each created at most once, for the lifetime of the process. + /// substitute fonts), each created at most once, for the lifetime of the process. All + /// access to this dictionary is guarded by - see the class + /// remarks' "Thread safety" section. /// private static readonly Dictionary BundledFontCache = new(StringComparer.Ordinal); - /// Guards against concurrent population. + /// + /// Guards against concurrent population - every read and + /// write of the cache (in ) holds this lock for its + /// full lookup-or-populate critical section, so two threads requesting the same not-yet- + /// cached bundled style can never both load/parse that embedded font file. + /// private static readonly object BundledFontLock = new(); /// @@ -119,7 +146,8 @@ public static class SystemFontCatalog /// Never throws: a missing or inaccessible scan directory, or a candidate file that fails /// to parse as a valid font, is silently skipped rather than aborting the scan. An empty /// result (for example on a minimal container image with no fonts installed at all) is a - /// valid, expected outcome, not an error. + /// valid, expected outcome, not an error. Safe to call concurrently from any number of + /// threads - see the class remarks' "Thread safety" section. /// public static IReadOnlyList Fonts => LazyFonts.Value; @@ -152,7 +180,8 @@ public static class SystemFontCatalog /// is empty, or genuinely has no exact family match and no /// generic-bucket match either. This method never consults /// - composing "system match, else bundled fallback" is - /// the caller's responsibility. + /// the caller's responsibility. Safe to call concurrently from any number of threads - + /// see the class remarks' "Thread safety" section. /// public static SystemFontInfo? FindBestMatch( string familyNameHint, @@ -183,6 +212,12 @@ public static class SystemFontCatalog /// Liberation Sans/Serif/Mono style-variant bundled files this method can request are /// guaranteed present at build time. /// + /// + /// Safe to call concurrently from any number of threads: the underlying cache lookup and + /// populate-on-miss path are both guarded by , so no two + /// threads can load/parse the same embedded font file twice - see the class remarks' + /// "Thread safety" section. + /// public static TrueTypeFont LoadBundledFallback(bool serif, bool fixedPitch, bool bold, bool italic) { string family; diff --git a/test/DemaConsulting.CanvasNet.Charts.Tests/ChartAxisTests.cs b/test/DemaConsulting.CanvasNet.Charts.Tests/ChartAxisTests.cs new file mode 100644 index 00000000..d62175e9 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Charts.Tests/ChartAxisTests.cs @@ -0,0 +1,135 @@ +namespace DemaConsulting.CanvasNet.Charts.Tests; + +/// +/// Tests for . +/// +public class ChartAxisTests +{ + /// Proves constructing with only labels sets labels and leaves the range unset. + [Fact] + public void Constructor_LabelsOnly_SetsLabelsAndLeavesRangeUnset() + { + var axis = new ChartAxis(["Q1", "Q2", "Q3"]); + + Assert.Equal(["Q1", "Q2", "Q3"], axis.Labels); + Assert.Null(axis.Minimum); + Assert.Null(axis.Maximum); + Assert.Null(axis.TickInterval); + Assert.Null(axis.Title); + } + + /// Proves constructing with only a value range sets the range and leaves labels unset. + [Fact] + public void Constructor_ValueRangeOnly_SetsRangeAndLeavesLabelsUnset() + { + var axis = new ChartAxis(minimum: 0f, maximum: 100f, tickInterval: 10f); + + Assert.Null(axis.Labels); + Assert.Equal(0f, axis.Minimum); + Assert.Equal(100f, axis.Maximum); + Assert.Equal(10f, axis.TickInterval); + } + + /// Proves constructing with labels, a full range, and a title sets every member. + [Fact] + public void Constructor_LabelsAndRangeAndTitle_SetsAllMembers() + { + var axis = new ChartAxis(["A", "B"], 0f, 10f, 1f, "Value"); + + Assert.Equal(["A", "B"], axis.Labels); + Assert.Equal(0f, axis.Minimum); + Assert.Equal(10f, axis.Maximum); + Assert.Equal(1f, axis.TickInterval); + Assert.Equal("Value", axis.Title); + } + + /// Proves an empty-string label entry is accepted as a legitimate blank label. + [Fact] + public void Constructor_EmptyStringLabel_IsAccepted() + { + var axis = new ChartAxis(["", "B"]); + + Assert.Equal(["", "B"], axis.Labels); + } + + /// Proves a null label entry is rejected. + [Fact] + public void Constructor_NullLabelEntry_ThrowsArgumentException() + { + var labels = new List { "A", null }; + + Assert.Throws(() => new ChartAxis(labels!)); + } + + /// Proves a non-finite minimum is rejected. + [Theory] + [InlineData(float.NaN)] + [InlineData(float.PositiveInfinity)] + [InlineData(float.NegativeInfinity)] + public void Constructor_NonFiniteMinimum_ThrowsArgumentOutOfRangeException(float minimum) + { + Assert.Throws(() => new ChartAxis(minimum: minimum)); + } + + /// Proves a non-finite maximum is rejected. + [Theory] + [InlineData(float.NaN)] + [InlineData(float.PositiveInfinity)] + [InlineData(float.NegativeInfinity)] + public void Constructor_NonFiniteMaximum_ThrowsArgumentOutOfRangeException(float maximum) + { + Assert.Throws(() => new ChartAxis(maximum: maximum)); + } + + /// Proves a minimum equal to the maximum is rejected. + [Fact] + public void Constructor_MinimumEqualToMaximum_ThrowsArgumentOutOfRangeException() + { + Assert.Throws(() => new ChartAxis(minimum: 5f, maximum: 5f)); + } + + /// Proves a minimum greater than the maximum is rejected. + [Fact] + public void Constructor_MinimumGreaterThanMaximum_ThrowsArgumentOutOfRangeException() + { + Assert.Throws(() => new ChartAxis(minimum: 10f, maximum: 5f)); + } + + /// Proves an invalid tick interval (zero, negative, or non-finite) is rejected. + [Theory] + [InlineData(0f)] + [InlineData(-1f)] + [InlineData(float.NaN)] + [InlineData(float.PositiveInfinity)] + public void Constructor_InvalidTickInterval_ThrowsArgumentOutOfRangeException(float tickInterval) + { + Assert.Throws(() => new ChartAxis(tickInterval: tickInterval)); + } + + /// Proves a whitespace-only title is rejected. + [Fact] + public void Constructor_WhitespaceTitle_ThrowsArgumentException() + { + Assert.Throws(() => new ChartAxis(title: " ")); + } + + /// Proves an empty title is rejected. + [Fact] + public void Constructor_EmptyTitle_ThrowsArgumentException() + { + Assert.Throws(() => new ChartAxis(title: "")); + } + + /// Proves mutating the caller's original labels list after construction does not affect the stored snapshot. + [Fact] + public void Constructor_MutatingOriginalLabelsList_DoesNotAffectStoredSnapshot() + { + var original = new List { "A", "B" }; + var axis = new ChartAxis(original); + + original.Add("C"); + original[0] = "Changed"; + + Assert.Equal(["A", "B"], axis.Labels); + } +} diff --git a/test/DemaConsulting.CanvasNet.Charts.Tests/ChartBuilderTests.cs b/test/DemaConsulting.CanvasNet.Charts.Tests/ChartBuilderTests.cs new file mode 100644 index 00000000..844b9670 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Charts.Tests/ChartBuilderTests.cs @@ -0,0 +1,173 @@ +using DemaConsulting.CanvasNet.Canvas; + +namespace DemaConsulting.CanvasNet.Charts.Tests; + +/// +/// Tests for . +/// +public class ChartBuilderTests +{ + /// Proves every fluent method returns the same instance, enabling chaining. + [Fact] + public void FluentMethods_EachReturnSameBuilderInstance() + { + var builder = new ChartBuilder(); + + Assert.Same(builder, builder.OfType(ChartType.Bar)); + Assert.Same(builder, builder.AddSeries("Revenue", [1.0])); + Assert.Same(builder, builder.AddSeries(new ChartSeries("Other", [2.0]))); + Assert.Same(builder, builder.WithCategoryAxis(new ChartAxis(["A", "B"]))); + Assert.Same(builder, builder.WithCategoryAxis(["A", "B"])); + Assert.Same(builder, builder.WithValueAxis(new ChartAxis(minimum: 0f, maximum: 1f))); + Assert.Same(builder, builder.WithValueAxis(0f, 1f)); + Assert.Same(builder, builder.WithLegend(new ChartLegend())); + Assert.Same(builder, builder.WithLegend(ChartLegendPosition.Top)); + Assert.Same(builder, builder.WithTitle("Title")); + Assert.Same(builder, builder.WithColorPalette([new Rgba32(1, 2, 3, 255)])); + } + + /// Proves a fully configured builder produces a reflecting every configured member. + [Fact] + public void Build_FullConfiguration_ProducesChartRoundTrippingBuilderInputs() + { + var chart = new ChartBuilder() + .OfType(ChartType.Bar) + .WithCategoryAxis(["Q1", "Q2"]) + .AddSeries("Revenue", [120.0, 150.0]) + .WithValueAxis(0f, 200f, 50f, "Dollars") + .WithLegend(ChartLegendPosition.Bottom, isVisible: true) + .WithTitle("Quarterly Revenue", 24f) + .WithColorPalette([new Rgba32(255, 0, 0, 255)]) + .Build(); + + Assert.Equal(ChartType.Bar, chart.Type); + Assert.Single(chart.Series); + Assert.Equal("Revenue", chart.Series[0].Name); + Assert.Equal([120.0, 150.0], chart.Series[0].Values); + Assert.Equal(["Q1", "Q2"], chart.CategoryAxis!.Labels); + Assert.Equal(0f, chart.ValueAxis!.Minimum); + Assert.Equal(200f, chart.ValueAxis.Maximum); + Assert.Equal(50f, chart.ValueAxis.TickInterval); + Assert.Equal("Dollars", chart.ValueAxis.Title); + Assert.Equal(ChartLegendPosition.Bottom, chart.Legend!.Position); + Assert.True(chart.Legend.IsVisible); + Assert.Equal("Quarterly Revenue", chart.Title!.Text); + Assert.Equal(24f, chart.Title.FontSize); + Assert.Equal([new Rgba32(255, 0, 0, 255)], chart.ColorPalette); + } + + /// Proves multiple series added via the pre-built overload preserve insertion order. + [Fact] + public void Build_WithMultipleSeriesAddedViaChartSeriesOverload_PreservesOrder() + { + var chart = new ChartBuilder() + .OfType(ChartType.Line) + .AddSeries(new ChartSeries("First", [1.0])) + .AddSeries(new ChartSeries("Second", [2.0])) + .Build(); + + Assert.Equal(["First", "Second"], chart.Series.Select(s => s.Name)); + } + + /// Proves calling without ever calling throws. + [Fact] + public void Build_TypeNeverSet_ThrowsInvalidOperationException() + { + var builder = new ChartBuilder().AddSeries("Revenue", [1.0]); + + Assert.Throws(() => builder.Build()); + } + + /// Proves calling without ever adding a series throws. + [Fact] + public void Build_NoSeriesAdded_ThrowsInvalidOperationException() + { + var builder = new ChartBuilder().OfType(ChartType.Bar); + + Assert.Throws(() => builder.Build()); + } + + /// Proves an invalid series name propagates the same exception 's constructor would throw. + [Fact] + public void AddSeries_InvalidName_PropagatesChartSeriesConstructorException() + { + var builder = new ChartBuilder().OfType(ChartType.Bar); + + Assert.Throws(() => builder.AddSeries("", [1.0])); + } + + /// Proves a null pre-built series is rejected. + [Fact] + public void AddSeries_NullChartSeries_PropagatesArgumentNullException() + { + var builder = new ChartBuilder().OfType(ChartType.Bar); + + Assert.Throws(() => builder.AddSeries((ChartSeries)null!)); + } + + /// Proves a null pre-built category axis is rejected. + [Fact] + public void WithCategoryAxis_NullChartAxis_PropagatesArgumentNullException() + { + var builder = new ChartBuilder(); + + Assert.Throws(() => builder.WithCategoryAxis((ChartAxis)null!)); + } + + /// Proves a null pre-built value axis is rejected. + [Fact] + public void WithValueAxis_NullChartAxis_PropagatesArgumentNullException() + { + var builder = new ChartBuilder(); + + Assert.Throws(() => builder.WithValueAxis((ChartAxis)null!)); + } + + /// Proves an invalid value-axis range propagates the same exception 's constructor would throw. + [Fact] + public void WithValueAxis_InvalidRange_PropagatesChartAxisConstructorException() + { + var builder = new ChartBuilder(); + + Assert.Throws(() => builder.WithValueAxis(10f, 5f)); + } + + /// Proves a null pre-built legend is rejected. + [Fact] + public void WithLegend_NullChartLegend_PropagatesArgumentNullException() + { + var builder = new ChartBuilder(); + + Assert.Throws(() => builder.WithLegend((ChartLegend)null!)); + } + + /// Proves whitespace-only title text propagates the same exception 's constructor would throw. + [Fact] + public void WithTitle_WhitespaceText_PropagatesChartTitleConstructorException() + { + var builder = new ChartBuilder(); + + Assert.Throws(() => builder.WithTitle(" ")); + } + + /// Proves a null color palette is rejected. + [Fact] + public void WithColorPalette_Null_ThrowsArgumentNullException() + { + var builder = new ChartBuilder(); + + Assert.Throws(() => builder.WithColorPalette(null!)); + } + + /// Proves a series/category-axis length mismatch surfaces at , from 's own constructor. + [Fact] + public void Build_SeriesLengthMismatchedWithCategoryAxis_PropagatesChartConstructorException() + { + var builder = new ChartBuilder() + .OfType(ChartType.Bar) + .WithCategoryAxis(["Q1", "Q2"]) + .AddSeries("Revenue", [1.0, 2.0, 3.0]); + + Assert.Throws(() => builder.Build()); + } +} diff --git a/test/DemaConsulting.CanvasNet.Charts.Tests/ChartLegendTests.cs b/test/DemaConsulting.CanvasNet.Charts.Tests/ChartLegendTests.cs new file mode 100644 index 00000000..9e3aefe3 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Charts.Tests/ChartLegendTests.cs @@ -0,0 +1,49 @@ +namespace DemaConsulting.CanvasNet.Charts.Tests; + +/// +/// Tests for and . +/// +public class ChartLegendTests +{ + /// Proves the parameterless constructor defaults to a visible, right-positioned legend. + [Fact] + public void Constructor_Defaults_IsVisibleAndRightPositioned() + { + var legend = new ChartLegend(); + + Assert.Equal(ChartLegendPosition.Right, legend.Position); + Assert.True(legend.IsVisible); + } + + /// Proves every defined value is accepted. + [Theory] + [InlineData(ChartLegendPosition.Top)] + [InlineData(ChartLegendPosition.Bottom)] + [InlineData(ChartLegendPosition.Left)] + [InlineData(ChartLegendPosition.Right)] + [InlineData(ChartLegendPosition.None)] + public void Constructor_EachDefinedPosition_IsAccepted(ChartLegendPosition position) + { + var legend = new ChartLegend(position); + + Assert.Equal(position, legend.Position); + } + + /// Proves isVisible is accepted and stored. + [Fact] + public void Constructor_IsVisibleFalse_IsStored() + { + var legend = new ChartLegend(isVisible: false); + + Assert.False(legend.IsVisible); + } + + /// Proves an undefined value is rejected. + [Fact] + public void Constructor_UndefinedPosition_ThrowsArgumentOutOfRangeException() + { + var undefined = (ChartLegendPosition)99; + + Assert.Throws(() => new ChartLegend(undefined)); + } +} diff --git a/test/DemaConsulting.CanvasNet.Charts.Tests/ChartRenderOptionsTests.cs b/test/DemaConsulting.CanvasNet.Charts.Tests/ChartRenderOptionsTests.cs new file mode 100644 index 00000000..70ee25d4 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Charts.Tests/ChartRenderOptionsTests.cs @@ -0,0 +1,29 @@ +using DemaConsulting.CanvasNet.Canvas; + +namespace DemaConsulting.CanvasNet.Charts.Tests; + +/// +/// Tests for 's immutability guarantees. +/// +public class ChartRenderOptionsTests +{ + /// + /// Proves defensively copies the caller's + /// list at construction time, so a later mutation of the original, caller-owned array + /// cannot retroactively change an already-constructed, supposedly-immutable + /// instance's own resolved palette. + /// + [Fact] + public void ColorPalette_MutatedAfterConstruction_DoesNotAffectOptionsInstance() + { + // Arrange + var original = new Rgba32[] { new(1, 2, 3, 255) }; + var options = new ChartRenderOptions { ColorPalette = original }; + + // Act + original[0] = new Rgba32(9, 9, 9, 255); + + // Assert + Assert.Equal(new Rgba32(1, 2, 3, 255), options.ColorPalette[0]); + } +} diff --git a/test/DemaConsulting.CanvasNet.Charts.Tests/ChartRendererAxisLegendTitleTests.cs b/test/DemaConsulting.CanvasNet.Charts.Tests/ChartRendererAxisLegendTitleTests.cs new file mode 100644 index 00000000..2d8cf576 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Charts.Tests/ChartRendererAxisLegendTitleTests.cs @@ -0,0 +1,353 @@ +using DemaConsulting.CanvasNet.Canvas; + +namespace DemaConsulting.CanvasNet.Charts.Tests; + +/// +/// Tests for 's title pass, legend pass (every +/// ), axis painting, and per-point data labels. +/// +public class ChartRendererAxisLegendTitleTests +{ + /// Opaque white - 's own documented default background. + private static readonly Rgba32 White = new(255, 255, 255, 255); + + /// Opaque green, used as an explicit series color distinct from any legend swatch confusion. + private static readonly Rgba32 Green = new(0, 255, 0, 255); + + /// Opaque purple, used as a second explicit series color. + private static readonly Rgba32 Purple = new(128, 0, 128, 255); + + /// Scans a rectangular pixel region for any pixel exactly equal to . See 's own identical helper. + /// The surface to scan. + /// The exact color to look for. + /// The region's inclusive left x-coordinate. + /// The region's inclusive top y-coordinate. + /// The region's inclusive right x-coordinate. + /// The region's inclusive bottom y-coordinate. + /// when at least one pixel in the region exactly equals . + private static bool RegionContainsColor(Surface surface, Rgba32 color, int x0, int y0, int x1, int y1) + { + for (var y = y0; y <= y1; y++) + { + for (var x = x0; x <= x1; x++) + { + if (surface[x, y].Equals(color)) + { + return true; + } + } + } + + return false; + } + + /// Scans a rectangular pixel region for any pixel that differs from (proving something painted there, without needing to know its exact color). + /// The surface to scan. + /// The known cleared background color. + /// The region's inclusive left x-coordinate. + /// The region's inclusive top y-coordinate. + /// The region's inclusive right x-coordinate. + /// The region's inclusive bottom y-coordinate. + /// when at least one pixel in the region differs from . + private static bool RegionHasNonBackgroundPixel(Surface surface, Rgba32 background, int x0, int y0, int x1, int y1) + { + for (var y = y0; y <= y1; y++) + { + for (var x = x0; x <= x1; x++) + { + if (!surface[x, y].Equals(background)) + { + return true; + } + } + } + + return false; + } + + /// Finds the smallest (topmost) row containing anywhere in , or -1 if none. + /// The surface to scan. + /// The exact color to look for. + /// The topmost row index containing , or -1 when not found. + private static int FindTopmostColorRow(Surface surface, Rgba32 color) + { + for (var y = 0; y < surface.Height; y++) + { + for (var x = 0; x < surface.Width; x++) + { + if (surface[x, y].Equals(color)) + { + return y; + } + } + } + + return -1; + } + + /// Builds a full-scale, single-category Column chart (bar spanning the full value range) with the given optional title/legend. + /// An optional title. + /// An optional legend. + /// An optional per-point label for the series' single value. + /// The built chart. + private static Chart BuildFullScaleColumnChart(ChartTitle? title = null, ChartLegend? legend = null, IReadOnlyList? pointLabels = null) + { + var seriesA = new ChartSeries("Alpha", [10.0], pointLabels: pointLabels, color: Green); + var seriesB = new ChartSeries("Beta", [10.0], color: Purple); + var categoryAxis = new ChartAxis(["Only"]); + var valueAxis = new ChartAxis(minimum: 0f, maximum: 10f); + return new Chart(ChartType.Column, [seriesA, seriesB], categoryAxis, valueAxis, legend, title); + } + + /// Proves a chart title paints visible content within the reserved title band. + [Fact] + public void Render_WithTitle_PaintsContentInTitleBand() + { + // Arrange + var chart = BuildFullScaleColumnChart(title: new ChartTitle("Revenue")); + + // Act + using var surface = ChartRenderer.Render(chart, 400, 300); + + // Assert + // The title band's height is a small fraction of the full render target; a generous top + // fifth comfortably contains it regardless of the exact bundled-font text metrics. + Assert.True(RegionHasNonBackgroundPixel(surface, White, 0, 0, 399, 59)); + } + + /// Proves a chart title pushes the plot area's content down, compared to the same chart without a title. + [Fact] + public void Render_WithTitle_PushesPlotContentDownComparedToNoTitle() + { + // Arrange + var withoutTitle = BuildFullScaleColumnChart(); + var withTitle = BuildFullScaleColumnChart(title: new ChartTitle("Revenue")); + + // Act + using var surfaceWithoutTitle = ChartRenderer.Render(withoutTitle, 400, 300); + using var surfaceWithTitle = ChartRenderer.Render(withTitle, 400, 300); + + var topRowWithoutTitle = FindTopmostColorRow(surfaceWithoutTitle, Green); + var topRowWithTitle = FindTopmostColorRow(surfaceWithTitle, Green); + + // Assert + Assert.True(topRowWithoutTitle >= 0); + Assert.True(topRowWithTitle >= 0); + Assert.True(topRowWithTitle > topRowWithoutTitle); + } + + /// Proves a Top-positioned legend paints its swatches within the top band. + [Fact] + public void Render_LegendTop_PaintsSwatchesInTopBand() + { + // Arrange + var chart = BuildFullScaleColumnChart(legend: new ChartLegend(ChartLegendPosition.Top)); + + // Act + using var surface = ChartRenderer.Render(chart, 400, 300); + + // Assert + Assert.True(RegionContainsColor(surface, Green, 0, 0, 399, 59)); + Assert.True(RegionContainsColor(surface, Purple, 0, 0, 399, 59)); + } + + /// Proves a Bottom-positioned legend paints its swatches within the bottom band. + [Fact] + public void Render_LegendBottom_PaintsSwatchesInBottomBand() + { + // Arrange + var chart = BuildFullScaleColumnChart(legend: new ChartLegend(ChartLegendPosition.Bottom)); + + // Act + using var surface = ChartRenderer.Render(chart, 400, 300); + + // Assert + Assert.True(RegionContainsColor(surface, Green, 0, 240, 399, 299)); + Assert.True(RegionContainsColor(surface, Purple, 0, 240, 399, 299)); + } + + /// Proves a Left-positioned legend paints its swatches within the left band. + [Fact] + public void Render_LegendLeft_PaintsSwatchesInLeftBand() + { + // Arrange + var chart = BuildFullScaleColumnChart(legend: new ChartLegend(ChartLegendPosition.Left)); + + // Act + using var surface = ChartRenderer.Render(chart, 400, 300); + + // Assert + Assert.True(RegionContainsColor(surface, Green, 0, 0, 159, 299)); + Assert.True(RegionContainsColor(surface, Purple, 0, 0, 159, 299)); + } + + /// Proves a Right-positioned legend paints its swatches within the right band. + [Fact] + public void Render_LegendRight_PaintsSwatchesInRightBand() + { + // Arrange + var chart = BuildFullScaleColumnChart(legend: new ChartLegend(ChartLegendPosition.Right)); + + // Act + using var surface = ChartRenderer.Render(chart, 400, 300); + + // Assert + Assert.True(RegionContainsColor(surface, Green, 240, 0, 399, 299)); + Assert.True(RegionContainsColor(surface, Purple, 240, 0, 399, 299)); + } + + /// Proves a Right-positioned legend reserves plot-area space, narrowing where series content can reach, compared to no legend at all. + [Fact] + public void Render_LegendRight_NarrowsPlotAreaComparedToNoLegend() + { + // Arrange + var withoutLegend = BuildFullScaleColumnChart(); + var withRightLegend = BuildFullScaleColumnChart(legend: new ChartLegend(ChartLegendPosition.Right)); + + // Act + using var surfaceWithoutLegend = ChartRenderer.Render(withoutLegend, 400, 300); + using var surfaceWithLegend = ChartRenderer.Render(withRightLegend, 400, 300); + + // Assert + // Without a legend, the rightmost (second) series' bar reaches almost to the render + // target's right edge; with a Right legend reserving space, it cannot reach nearly as + // far right. + var rightmostWithoutLegend = FindRightmostColorColumn(surfaceWithoutLegend, Purple); + var rightmostWithLegend = FindRightmostColorColumn(surfaceWithLegend, Purple); + + Assert.True(rightmostWithoutLegend >= 0); + Assert.True(rightmostWithLegend >= 0); + Assert.True(rightmostWithLegend < rightmostWithoutLegend); + } + + /// Finds the largest (rightmost) column containing anywhere in , or -1 if none. + /// The surface to scan. + /// The exact color to look for. + /// The rightmost column index containing , or -1 when not found. + private static int FindRightmostColorColumn(Surface surface, Rgba32 color) + { + for (var x = surface.Width - 1; x >= 0; x--) + { + for (var y = 0; y < surface.Height; y++) + { + if (surface[x, y].Equals(color)) + { + return x; + } + } + } + + return -1; + } + + /// Proves a legend with reserves no band, rendering successfully with no legend pass. + [Fact] + public void Render_LegendPositionNone_RendersSuccessfullyWithoutLegendBand() + { + // Arrange + var chart = BuildFullScaleColumnChart(legend: new ChartLegend(ChartLegendPosition.None)); + + // Act + using var surface = ChartRenderer.Render(chart, 400, 300); + + // Assert + Assert.Equal(400, surface.Width); + Assert.Equal(300, surface.Height); + } + + /// Proves an invisible legend ( = ) reserves no band. + [Fact] + public void Render_LegendNotVisible_RendersSuccessfullyWithoutLegendBand() + { + // Arrange + var chart = BuildFullScaleColumnChart(legend: new ChartLegend(ChartLegendPosition.Right, isVisible: false)); + + // Act + using var surface = ChartRenderer.Render(chart, 400, 300); + + var withoutLegend = BuildFullScaleColumnChart(); + using var surfaceWithoutLegend = ChartRenderer.Render(withoutLegend, 400, 300); + + // Assert + // An invisible legend reserves exactly as little space as no legend at all: both renders + // reach the same rightmost extent for the second series' bar. + Assert.Equal(FindRightmostColorColumn(surfaceWithoutLegend, Purple), FindRightmostColorColumn(surface, Purple)); + } + + /// Proves a value axis and category axis paint non-background content in their reserved margins. + [Fact] + public void Render_WithAxes_PaintsNonBackgroundContentInReservedMargins() + { + // Arrange + var chart = BuildFullScaleColumnChart(); + + // Act + using var surface = ChartRenderer.Render(chart, 400, 300); + + // Assert + // The left margin (reserved for the vertical value axis' tick labels/marks) and the + // bottom margin (reserved for the horizontal category axis) both contain painted content. + Assert.True(RegionHasNonBackgroundPixel(surface, White, 0, 20, 45, 270)); + Assert.True(RegionHasNonBackgroundPixel(surface, White, 60, 280, 340, 299)); + } + + /// Counts how many pixels in differ from . + /// The surface to scan. + /// The known cleared background color. + /// The count of non-background pixels. + private static int CountNonBackgroundPixels(Surface surface, Rgba32 background) + { + var count = 0; + for (var y = 0; y < surface.Height; y++) + { + for (var x = 0; x < surface.Width; x++) + { + if (!surface[x, y].Equals(background)) + { + count++; + } + } + } + + return count; + } + + /// Proves a per-point data label (an explicit, opt-in entry) paints additional, visible content compared to the same chart without one. + [Fact] + public void Render_PointWithDataLabel_PaintsVisibleTextNearPoint() + { + // Arrange + // A half-scale bar (value 5 of a 0..10 range) leaves headroom above its top edge for the + // data label text to actually land on-surface (a full-scale bar's label would be pushed + // above y=0 and clipped away entirely). + var withLabel = new Chart(ChartType.Column, [new ChartSeries("Alpha", [5.0], pointLabels: ["5.0"], color: Green)], new ChartAxis(["Only"]), new ChartAxis(minimum: 0f, maximum: 10f)); + var withoutLabel = new Chart(ChartType.Column, [new ChartSeries("Alpha", [5.0], color: Green)], new ChartAxis(["Only"]), new ChartAxis(minimum: 0f, maximum: 10f)); + + // Act + using var surfaceWithLabel = ChartRenderer.Render(withLabel, 400, 300); + using var surfaceWithoutLabel = ChartRenderer.Render(withoutLabel, 400, 300); + + // Assert + // Both charts share identical axes/bars/gridlines; the only difference a data label can + // introduce is additional painted (non-background) pixels for its text glyphs. + Assert.True(CountNonBackgroundPixels(surfaceWithLabel, White) > CountNonBackgroundPixels(surfaceWithoutLabel, White)); + } + + /// Proves a series with no renders fully deterministically (no stray data-label artifacts appear from one render to the next). + [Fact] + public void Render_PointWithoutDataLabel_RendersDeterministicallyWithNoLabelArtifacts() + { + // Arrange + var chart = BuildFullScaleColumnChart(); + + // Act + using var surfaceA = ChartRenderer.Render(chart, 400, 300); + using var surfaceB = ChartRenderer.Render(chart, 400, 300); + + // Assert + // Rendering the identical chart (with no PointLabels on any series) twice must be fully + // deterministic: no data-label text (or anything else) can appear in one render but not + // the other. + Assert.Equal(CountNonBackgroundPixels(surfaceA, White), CountNonBackgroundPixels(surfaceB, White)); + } +} diff --git a/test/DemaConsulting.CanvasNet.Charts.Tests/ChartRendererEdgeCaseTests.cs b/test/DemaConsulting.CanvasNet.Charts.Tests/ChartRendererEdgeCaseTests.cs new file mode 100644 index 00000000..a0234286 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Charts.Tests/ChartRendererEdgeCaseTests.cs @@ -0,0 +1,392 @@ +using DemaConsulting.CanvasNet.Canvas; + +namespace DemaConsulting.CanvasNet.Charts.Tests; + +/// +/// Edge-case tests for : single-data-point series, single-value +/// Pie/Doughnut, negative values, many-series legend layout, long labels, and very +/// small/large render targets. +/// +public class ChartRendererEdgeCaseTests +{ + /// Opaque white - 's own documented default background. + private static readonly Rgba32 White = new(255, 255, 255, 255); + + /// Opaque red, used as an explicit series/point color. + private static readonly Rgba32 Red = new(255, 0, 0, 255); + + /// Proves a single-data-point Bar series renders without error, painting a single full-width band. + [Fact] + public void Render_Bar_SingleDataPoint_RendersSuccessfully() + { + // Arrange + var series = new ChartSeries("S1", [5.0], color: Red); + var chart = new Chart(ChartType.Bar, [series], new ChartAxis(["Only"]), new ChartAxis(minimum: 0f, maximum: 10f)); + + // Act + using var surface = ChartRenderer.Render(chart, 300, 200); + + // Assert + Assert.Equal(Red, surface[150, 100]); + } + + /// Proves a single-data-point Column series renders without error. + [Fact] + public void Render_Column_SingleDataPoint_RendersSuccessfully() + { + // Arrange + var series = new ChartSeries("S1", [5.0], color: Red); + var chart = new Chart(ChartType.Column, [series], new ChartAxis(["Only"]), new ChartAxis(minimum: 0f, maximum: 10f)); + + // Act + using var surface = ChartRenderer.Render(chart, 300, 200); + + // Assert + Assert.Equal(Red, surface[150, 100]); + } + + /// Proves a single-data-point Line series renders without error (a lone marker, no segment to stroke). + [Fact] + public void Render_Line_SingleDataPoint_RendersSuccessfully() + { + // Arrange + var series = new ChartSeries("S1", [5.0], color: Red); + var chart = new Chart(ChartType.Line, [series], new ChartAxis(["Only"]), new ChartAxis(minimum: 0f, maximum: 10f)); + + // Act + using var surface = ChartRenderer.Render(chart, 300, 200); + + // Assert + Assert.Equal(300, surface.Width); + Assert.Equal(200, surface.Height); + } + + /// Proves a single-data-point Area series renders without error (a degenerate, zero-width fill region). + [Fact] + public void Render_Area_SingleDataPoint_RendersSuccessfully() + { + // Arrange + var series = new ChartSeries("S1", [5.0], color: Red); + var chart = new Chart(ChartType.Area, [series], new ChartAxis(["Only"]), new ChartAxis(minimum: 0f, maximum: 10f)); + + // Act + using var surface = ChartRenderer.Render(chart, 300, 200); + + // Assert + Assert.Equal(300, surface.Width); + Assert.Equal(200, surface.Height); + } + + /// Proves a single-value (100% share) Pie renders as one full-circle wedge, not an invisible/degenerate arc. + [Fact] + public void Render_Pie_SingleValue_RendersFullCircleWedge() + { + // Arrange + var series = new ChartSeries("S1", [1.0], pointColors: [Red]); + var chart = new Chart(ChartType.Pie, [series]); + + // Act + using var surface = ChartRenderer.Render(chart, 200, 200); + + // Assert + Assert.Equal(Red, surface[100, 30]); + Assert.Equal(Red, surface[100, 170]); + } + + /// Proves a single-value (100% share) Doughnut renders as one full-circle ring wedge, not an invisible/degenerate arc. + [Fact] + public void Render_Doughnut_SingleValue_RendersFullCircleWedge() + { + // Arrange + var series = new ChartSeries("S1", [1.0], pointColors: [Red]); + var chart = new Chart(ChartType.Doughnut, [series]); + + // Act + using var surface = ChartRenderer.Render(chart, 200, 200); + + // Assert + Assert.Equal(Red, surface[100, 30]); + Assert.Equal(Red, surface[100, 170]); + Assert.Equal(White, surface[100, 100]); + } + + /// Proves a Column series spanning both negative and positive values paints bars extending both above and below the zero baseline. + [Fact] + public void Render_Column_NegativeAndPositiveValues_PaintsBothAboveAndBelowZeroBaseline() + { + // Arrange + var series = new ChartSeries("S1", [-5.0, 5.0], color: Red); + var chart = new Chart(ChartType.Column, [series], new ChartAxis(["Neg", "Pos"]), new ChartAxis(minimum: -5f, maximum: 5f)); + + // Act + using var surface = ChartRenderer.Render(chart, 400, 300); + + // Assert + // The zero baseline falls at the vertical midpoint of the inner plot rect; the negative + // bar (first category) occupies a band below it, the positive bar (second category) an + // equally sized band above it. + Assert.Equal(Red, surface[100, 220]); + Assert.Equal(Red, surface[300, 80]); + } + + /// Proves a Bar series spanning both negative and positive values paints bars extending both left and right of the zero baseline. + [Fact] + public void Render_Bar_NegativeAndPositiveValues_PaintsBothLeftAndRightOfZeroBaseline() + { + // Arrange + var series = new ChartSeries("S1", [-5.0, 5.0], color: Red); + var chart = new Chart(ChartType.Bar, [series], new ChartAxis(["Neg", "Pos"]), new ChartAxis(minimum: -5f, maximum: 5f)); + + // Act + using var surface = ChartRenderer.Render(chart, 400, 300); + + // Assert + Assert.Equal(Red, surface[80, 75]); + Assert.Equal(Red, surface[320, 225]); + } + + /// Proves a Line series spanning both negative and positive values renders successfully, crossing the zero baseline. + [Fact] + public void Render_Line_NegativeAndPositiveValues_RendersSuccessfully() + { + // Arrange + var series = new ChartSeries("S1", [-5.0, 5.0], color: Red); + var chart = new Chart(ChartType.Line, [series], new ChartAxis(["Neg", "Pos"]), new ChartAxis(minimum: -5f, maximum: 5f)); + + // Act + using var surface = ChartRenderer.Render(chart, 400, 300); + + // Assert + Assert.Equal(400, surface.Width); + Assert.Equal(300, surface.Height); + } + + /// Proves an Area series spanning both negative and positive values renders successfully, filling both above and below the zero baseline. + [Fact] + public void Render_Area_NegativeAndPositiveValues_RendersSuccessfully() + { + // Arrange + var series = new ChartSeries("S1", [-5.0, 5.0], color: Red); + var chart = new Chart(ChartType.Area, [series], new ChartAxis(["Neg", "Pos"]), new ChartAxis(minimum: -5f, maximum: 5f)); + + // Act + using var surface = ChartRenderer.Render(chart, 400, 300); + + // Assert + Assert.Equal(400, surface.Width); + Assert.Equal(300, surface.Height); + } + + /// + /// Proves a value-axis range auto-derived from extreme-but-finite series values (for + /// example, [-double.MaxValue, double.MaxValue]) renders successfully instead of the + /// range-padding arithmetic overflowing to infinite/NaN bounds (which previously produced + /// meaningless coordinates and caused the renderer to silently fail to draw), and the + /// plotted data remains visible (non-background pixels are actually painted) rather than + /// collapsing to NaN coordinates that paint nothing. + /// + [Fact] + public void Render_Column_ExtremeFiniteValues_RendersSuccessfullyWithoutOverflow() + { + // Arrange + var series = new ChartSeries("S1", [-double.MaxValue, double.MaxValue], color: Red); + var chart = new Chart(ChartType.Column, [series], new ChartAxis(["Neg", "Pos"])); + + // Act + using var surface = ChartRenderer.Render(chart, 400, 300); + + // Assert + Assert.Equal(400, surface.Width); + Assert.Equal(300, surface.Height); + Assert.True(CountNonBackgroundPixels(surface, White) > 0); + } + + /// Counts how many pixels in differ from . + /// The surface to scan. + /// The known cleared background color. + /// The count of non-background pixels. + private static int CountNonBackgroundPixels(Surface surface, Rgba32 background) + { + var count = 0; + for (var y = 0; y < surface.Height; y++) + { + for (var x = 0; x < surface.Width; x++) + { + if (!surface[x, y].Equals(background)) + { + count++; + } + } + } + + return count; + } + + /// Proves many series (enough to overflow a legend band) renders without error, the legend degrading by omission rather than throwing or overlapping the plot area. + [Fact] + public void Render_ManySeries_LegendDegradesByOmissionWithoutError() + { + // Arrange + var series = new List(); + for (var i = 0; i < 40; i++) + { + series.Add(new ChartSeries($"Series {i}", [1.0])); + } + + var chart = new Chart( + ChartType.Column, + series, + new ChartAxis(["Only"]), + new ChartAxis(minimum: 0f, maximum: 1f), + new ChartLegend(ChartLegendPosition.Right)); + + // Act + using var surface = ChartRenderer.Render(chart, 400, 300); + + // Assert + Assert.Equal(400, surface.Width); + Assert.Equal(300, surface.Height); + } + + /// Proves a very long category label is truncated to fit rather than overflowing its allotted axis band or crashing. + [Fact] + public void Render_VeryLongCategoryLabel_RendersSuccessfullyWithoutOverflow() + { + // Arrange + var longLabel = new string('X', 200); + var series = new ChartSeries("S1", [5.0], color: Red); + var chart = new Chart(ChartType.Column, [series], new ChartAxis([longLabel]), new ChartAxis(minimum: 0f, maximum: 10f)); + + // Act + using var surface = ChartRenderer.Render(chart, 400, 300); + + // Assert + Assert.Equal(400, surface.Width); + Assert.Equal(300, surface.Height); + } + + /// Proves a very long legend label is truncated to fit rather than overflowing its allotted legend band or crashing. + [Fact] + public void Render_VeryLongLegendLabel_RendersSuccessfullyWithoutOverflow() + { + // Arrange + var longName = new string('Y', 200); + var series = new ChartSeries(longName, [5.0], color: Red); + var chart = new Chart( + ChartType.Column, + [series], + new ChartAxis(["Only"]), + new ChartAxis(minimum: 0f, maximum: 10f), + new ChartLegend(ChartLegendPosition.Bottom)); + + // Act + using var surface = ChartRenderer.Render(chart, 400, 300); + + // Assert + Assert.Equal(400, surface.Width); + Assert.Equal(300, surface.Height); + } + + /// Proves every renders successfully at the smallest possible 1x1 render target. + [Theory] + [InlineData(ChartType.Bar)] + [InlineData(ChartType.Column)] + [InlineData(ChartType.Line)] + [InlineData(ChartType.Area)] + [InlineData(ChartType.Pie)] + [InlineData(ChartType.Doughnut)] + public void Render_OnePixelByOnePixelTarget_RendersSuccessfullyForEveryChartType(ChartType type) + { + // Arrange + var series = new ChartSeries("S1", [1.0, 2.0], pointColors: type is ChartType.Pie or ChartType.Doughnut ? [Red, Red] : null); + var categoryAxis = type is ChartType.Pie or ChartType.Doughnut ? null : new ChartAxis(["A", "B"]); + var chart = new Chart( + type, + [series], + categoryAxis, + categoryAxis is null ? null : new ChartAxis(minimum: 0f, maximum: 2f), + new ChartLegend(ChartLegendPosition.Right), + new ChartTitle("T")); + + // Act + using var surface = ChartRenderer.Render(chart, 1, 1); + + // Assert + Assert.Equal(1, surface.Width); + Assert.Equal(1, surface.Height); + } + + /// Proves rendering succeeds across a handful of minimal sizes up to roughly 10x10, with title and legend bands present (and defensively skipped once too small). + [Theory] + [InlineData(2, 2)] + [InlineData(5, 5)] + [InlineData(10, 10)] + [InlineData(10, 3)] + [InlineData(3, 10)] + public void Render_VerySmallRenderTargets_RenderSuccessfully(int width, int height) + { + // Arrange + var series = new ChartSeries("S1", [1.0, 2.0]); + var chart = new Chart( + ChartType.Column, + [series], + new ChartAxis(["A", "B"]), + new ChartAxis(minimum: 0f, maximum: 2f), + new ChartLegend(ChartLegendPosition.Top), + new ChartTitle("T")); + + // Act + using var surface = ChartRenderer.Render(chart, width, height); + + // Assert + Assert.Equal(width, surface.Width); + Assert.Equal(height, surface.Height); + } + + /// Proves rendering succeeds at the maximum dimension in a reasonable time. + [Fact] + public void Render_MaximumDimensionRenderTarget_RendersSuccessfully() + { + // Arrange + var series = new ChartSeries("S1", [1.0, 2.0], color: Red); + var chart = new Chart( + ChartType.Column, + [series], + new ChartAxis(["A", "B"]), + new ChartAxis(minimum: 0f, maximum: 2f), + new ChartLegend(ChartLegendPosition.Right), + new ChartTitle("Large")); + + // Act + using var surface = ChartRenderer.Render(chart, 8192, 8192); + + // Assert + Assert.Equal(8192, surface.Width); + Assert.Equal(8192, surface.Height); + } + + /// Proves a grouped Column chart with a large practical series count narrows each series' bar within its category band rather than overlapping or crashing. + [Fact] + public void Render_ManySeriesGroupedColumn_NarrowsBarsRatherThanOverlapping() + { + // Arrange + var series = new List(); + for (var i = 0; i < 20; i++) + { + series.Add(new ChartSeries($"S{i}", [1.0])); + } + + var chart = new Chart( + ChartType.Column, + series, + new ChartAxis(["Only"]), + new ChartAxis(minimum: 0f, maximum: 1f)); + + // Act + using var surface = ChartRenderer.Render(chart, 400, 300); + + // Assert + Assert.Equal(400, surface.Width); + Assert.Equal(300, surface.Height); + } +} diff --git a/test/DemaConsulting.CanvasNet.Charts.Tests/ChartRendererTests.cs b/test/DemaConsulting.CanvasNet.Charts.Tests/ChartRendererTests.cs new file mode 100644 index 00000000..576c148b --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Charts.Tests/ChartRendererTests.cs @@ -0,0 +1,399 @@ +using DemaConsulting.CanvasNet.Canvas; + +namespace DemaConsulting.CanvasNet.Charts.Tests; + +/// +/// Tests for 's public entry points: argument validation, the DPI +/// overload, and a per- success path. +/// +public class ChartRendererTests +{ + /// Opaque white - 's own documented default background. + private static readonly Rgba32 White = new(255, 255, 255, 255); + + /// Opaque red, used throughout as an explicit, unambiguous series/point color. + private static readonly Rgba32 Red = new(255, 0, 0, 255); + + /// Opaque blue, used as a second explicit series/point color. + private static readonly Rgba32 Blue = new(0, 0, 255, 255); + + /// + /// Scans the inclusive pixel rectangle [, ] x + /// [, ] of for any + /// pixel exactly equal to . + /// + /// The surface to scan. + /// The exact color to look for. + /// The region's inclusive left x-coordinate. + /// The region's inclusive top y-coordinate. + /// The region's inclusive right x-coordinate. + /// The region's inclusive bottom y-coordinate. + /// when at least one pixel in the region exactly equals . + private static bool RegionContainsColor(Surface surface, Rgba32 color, int x0, int y0, int x1, int y1) + { + for (var y = y0; y <= y1; y++) + { + for (var x = x0; x <= x1; x++) + { + if (surface[x, y].Equals(color)) + { + return true; + } + } + } + + return false; + } + + /// Proves a null chart is rejected by the pixel-size overload. + [Fact] + public void Render_PixelOverloadNullChart_ThrowsArgumentNullException() + { + // Act / Assert + Assert.Throws(() => ChartRenderer.Render(null!, 100, 100)); + } + + /// Proves a null chart is rejected by the DPI overload. + [Fact] + public void Render_DpiOverloadNullChart_ThrowsArgumentNullException() + { + // Act / Assert + Assert.Throws(() => ChartRenderer.Render(null!, 2f, 1f, 100f)); + } + + /// Proves an invalid pixel width/height propagates 's own validation directly. + [Theory] + [InlineData(0, 100)] + [InlineData(100, 0)] + [InlineData(-1, 100)] + [InlineData(8193, 100)] + public void Render_PixelOverloadInvalidSize_ThrowsArgumentOutOfRangeException(int width, int height) + { + // Arrange + var chart = new Chart(ChartType.Bar, [new ChartSeries("S1", [1.0])]); + + // Act / Assert + Assert.Throws(() => ChartRenderer.Render(chart, width, height)); + } + + /// Proves a non-finite or non-positive widthInches/heightInches/dpi is rejected. + [Theory] + [InlineData(0f, 1f, 100f)] + [InlineData(-1f, 1f, 100f)] + [InlineData(float.NaN, 1f, 100f)] + [InlineData(float.PositiveInfinity, 1f, 100f)] + [InlineData(1f, 0f, 100f)] + [InlineData(1f, -1f, 100f)] + [InlineData(1f, float.NaN, 100f)] + [InlineData(1f, 1f, 0f)] + [InlineData(1f, 1f, -1f)] + [InlineData(1f, 1f, float.NaN)] + public void Render_DpiOverloadInvalidInputs_ThrowsArgumentOutOfRangeException(float widthInches, float heightInches, float dpi) + { + // Arrange + var chart = new Chart(ChartType.Bar, [new ChartSeries("S1", [1.0])]); + + // Act / Assert + Assert.Throws(() => ChartRenderer.Render(chart, widthInches, heightInches, dpi)); + } + + /// Proves computed pixel dimensions exceeding are rejected. + [Fact] + public void Render_DpiOverloadExceedsMaxDimension_ThrowsArgumentOutOfRangeException() + { + // Arrange + var chart = new Chart(ChartType.Bar, [new ChartSeries("S1", [1.0])]); + + // Act / Assert + Assert.Throws(() => ChartRenderer.Render(chart, 1000f, 1f, 100f)); + } + + /// Proves the DPI overload computes pixel dimensions as round(inches * dpi). + [Fact] + public void Render_DpiOverload_ComputesExpectedPixelDimensions() + { + // Arrange + var chart = new Chart(ChartType.Bar, [new ChartSeries("S1", [1.0])]); + + // Act + using var surface = ChartRenderer.Render(chart, 2f, 1.5f, 100f); + + // Assert + Assert.Equal(200, surface.Width); + Assert.Equal(150, surface.Height); + } + + /// Proves every defined renders successfully at a typical size. + [Theory] + [InlineData(ChartType.Bar)] + [InlineData(ChartType.Column)] + [InlineData(ChartType.Line)] + [InlineData(ChartType.Area)] + [InlineData(ChartType.Pie)] + [InlineData(ChartType.Doughnut)] + public void Render_EveryChartType_ProducesCorrectlySizedSurface(ChartType type) + { + // Arrange + var series = new ChartSeries("S1", [3.0, 7.0], color: Red); + var categoryAxis = new ChartAxis(["A", "B"]); + var chart = new Chart(type, [series], type == ChartType.Pie || type == ChartType.Doughnut ? null : categoryAxis); + + // Act + using var surface = ChartRenderer.Render(chart, 300, 200); + + // Assert + Assert.Equal(300, surface.Width); + Assert.Equal(200, surface.Height); + + // The far corner is always outside every chart type's painted content for this chart + // (no title/legend reserved, and no painter ever reaches a render target's absolute + // top-left corner) - a deliberately type-agnostic smoke assertion that something sane + // (the cleared background) survives untouched there. + Assert.Equal(White, surface[1, 1]); + } + + /// Proves a Bar (horizontal) chart's full-scale bar fills the plot area's interior. + [Fact] + public void Render_Bar_FullScaleBarPaintsInteriorPixel() + { + // Arrange + var series = new ChartSeries("S1", [10.0], color: Red); + var valueAxis = new ChartAxis(minimum: 0f, maximum: 10f); + var chart = new Chart(ChartType.Bar, [series], new ChartAxis(["A"]), valueAxis); + + // Act + using var surface = ChartRenderer.Render(chart, 400, 300); + + // Assert + Assert.Equal(Red, surface[200, 150]); + + // The bar's vertical extent never reaches the render target's top edge (it is inset by + // 10% of its category band on each side), so the top-right corner remains background + // regardless of how wide the (small, single-letter-label) left category-axis margin is. + Assert.Equal(White, surface[398, 2]); + } + + /// Proves a Column (vertical) chart's full-scale bar fills the plot area's interior. + [Fact] + public void Render_Column_FullScaleBarPaintsInteriorPixel() + { + // Arrange + var series = new ChartSeries("S1", [10.0], color: Red); + var valueAxis = new ChartAxis(minimum: 0f, maximum: 10f); + var chart = new Chart(ChartType.Column, [series], new ChartAxis(["A"]), valueAxis); + + // Act + using var surface = ChartRenderer.Render(chart, 400, 300); + + // Assert + Assert.Equal(Red, surface[200, 150]); + + // The bar's horizontal extent never reaches the render target's left edge (inset by 10% + // of its category band, in addition to the reserved left value-axis margin), so the + // top-left corner remains background. + Assert.Equal(White, surface[2, 2]); + } + + /// Proves a Line chart strokes a visible segment between two category points of differing value. + [Fact] + public void Render_Line_StrokesVisibleSegmentBetweenPoints() + { + // Arrange + var series = new ChartSeries("S1", [0.0, 10.0], color: Blue); + var valueAxis = new ChartAxis(minimum: 0f, maximum: 10f); + var chart = new Chart(ChartType.Line, [series], new ChartAxis(["A", "B"]), valueAxis); + + // Act + using var surface = ChartRenderer.Render(chart, 400, 300); + + // Assert + // The ascending diagonal (bottom-left to top-right) necessarily crosses the central + // region of the plot; scanning a generous central window (rather than one exact pixel) + // tolerates the left/bottom axis-margin sizes, which depend on bundled-font tick-label + // metrics not replicated here. + Assert.True(RegionContainsColor(surface, Blue, 120, 60, 280, 220)); + + // Far corners the ascending line never reaches, regardless of margin size. + Assert.Equal(White, surface[2, 2]); + Assert.Equal(White, surface[398, 298]); + } + + /// Proves an Area chart fills a translucent region beneath its line and strokes an opaque top edge. + [Fact] + public void Render_Area_FillsRegionAndStrokesTopEdge() + { + // Arrange + var series = new ChartSeries("S1", [0.0, 10.0], color: new Rgba32(0, 255, 0, 255)); + var valueAxis = new ChartAxis(minimum: 0f, maximum: 10f); + var chart = new Chart(ChartType.Area, [series], new ChartAxis(["A", "B"]), valueAxis); + + // Act + using var surface = ChartRenderer.Render(chart, 400, 300); + + // Assert + // The filled region blends translucent green over white, landing on neither endpoint; + // assert only that it differs from the untouched background, without depending on the + // exact alpha-blended byte values. + var belowDiagonalMidpoint = surface[260, 260]; + Assert.NotEqual(White, belowDiagonalMidpoint); + + // The opaque top-edge stroke is pure, full-alpha green and is found somewhere along the + // ascending diagonal's generous central window. + Assert.True(RegionContainsColor(surface, new Rgba32(0, 255, 0, 255), 120, 60, 280, 220)); + } + + /// Proves a single-category Pie fills (almost) the entire wedge circle with that category's color. + /// + /// This is the key regression test for the full-circle degenerate-arc fix: a naive, + /// unsplit 360-degree ArcTo whose start and end points coincide would silently + /// vanish (see ChartRenderer.PieChart.cs's own AppendForwardArc remarks), so + /// a single-category Pie would otherwise paint nothing at all. + /// + [Fact] + public void Render_Pie_SingleCategory_FillsEntireCircleWithoutDegenerateArc() + { + // Arrange + var series = new ChartSeries("S1", [5.0], pointColors: [Red]); + var chart = new Chart(ChartType.Pie, [series]); + + // Act + using var surface = ChartRenderer.Render(chart, 300, 300); + + // Assert + // Directly above, below, left of, and right of center - every quadrant of the single + // wedge - all resolve to the one category's color. + Assert.Equal(Red, surface[150, 80]); + Assert.Equal(Red, surface[150, 220]); + Assert.Equal(Red, surface[80, 150]); + Assert.Equal(Red, surface[220, 150]); + + // The far corner, outside the wedge circle's outer radius, remains background. + Assert.Equal(White, surface[5, 5]); + } + + /// Proves a two-category Pie splits into a right wedge and a left wedge, in category order. + [Fact] + public void Render_Pie_TwoEqualCategories_SplitsIntoRightAndLeftWedges() + { + // Arrange + var series = new ChartSeries("S1", [1.0, 1.0], pointColors: [Red, Blue]); + var chart = new Chart(ChartType.Pie, [series]); + + // Act + using var surface = ChartRenderer.Render(chart, 300, 300); + + // Assert + // Wedge 0 sweeps from 12 o'clock to 6 o'clock clockwise through 3 o'clock (the right + // half); wedge 1 continues from 6 o'clock back to 12 o'clock through 9 o'clock (the left + // half) - see ChartRenderer.PieChart.cs's own angle-convention remarks. + Assert.Equal(Red, surface[220, 150]); + Assert.Equal(Blue, surface[80, 150]); + } + + /// Proves a single-category Doughnut fills its ring but leaves the central hole as background. + [Fact] + public void Render_Doughnut_SingleCategory_FillsRingAndLeavesHoleUnpainted() + { + // Arrange + var series = new ChartSeries("S1", [5.0], pointColors: [Red]); + var chart = new Chart(ChartType.Doughnut, [series]); + + // Act + using var surface = ChartRenderer.Render(chart, 300, 300); + + // Assert + // The ring, roughly midway between the inner and outer radius (71 and 142 for a 300x300 + // target), is painted; the exact center (the hole) is not. + Assert.Equal(Red, surface[150, 44]); + Assert.Equal(White, surface[150, 150]); + } + + /// Proves a custom is used to clear the surface. + [Fact] + public void Render_CustomBackgroundColor_ClearsToThatColor() + { + // Arrange + var chart = new Chart(ChartType.Bar, [new ChartSeries("S1", [1.0])]); + var background = new Rgba32(10, 20, 30, 255); + var options = new ChartRenderOptions { BackgroundColor = background }; + + // Act + using var surface = ChartRenderer.Render(chart, 100, 100, options); + + // Assert + Assert.Equal(background, surface[1, 1]); + } + + /// Proves a fully transparent clears to transparent. + [Fact] + public void Render_TransparentBackgroundColor_ClearsToTransparent() + { + // Arrange + var chart = new Chart(ChartType.Bar, [new ChartSeries("S1", [1.0])]); + var options = new ChartRenderOptions { BackgroundColor = new Rgba32(0, 0, 0, 0) }; + + // Act + using var surface = ChartRenderer.Render(chart, 100, 100, options); + + // Assert + Assert.Equal(new Rgba32(0, 0, 0, 0), surface[1, 1]); + } + + /// Proves series without an explicit color resolve, in series order, to . + [Fact] + public void Render_SeriesWithoutExplicitColor_ResolvesToDefaultPaletteInOrder() + { + // Arrange + var seriesA = new ChartSeries("A", [10.0]); + var seriesB = new ChartSeries("B", [10.0]); + var categoryAxis = new ChartAxis(["Only"]); + var valueAxis = new ChartAxis(minimum: 0f, maximum: 10f); + var chart = new Chart(ChartType.Column, [seriesA, seriesB], categoryAxis, valueAxis); + + // Act + using var surface = ChartRenderer.Render(chart, 400, 300); + + // Assert + // Two series sharing one category are painted as two side-by-side bars within the same + // category band; scan each half of the plot's central row for the corresponding default + // palette entry. + Assert.True(RegionContainsColor(surface, ChartColorPalette.Default[0], 40, 100, 195, 270)); + Assert.True(RegionContainsColor(surface, ChartColorPalette.Default[1], 205, 100, 395, 270)); + } + + /// Proves a chart-level color palette overrides . + [Fact] + public void Render_ChartColorPaletteOverride_TakesPriorityOverDefaultPalette() + { + // Arrange + var series = new ChartSeries("S1", [10.0]); + var categoryAxis = new ChartAxis(["A"]); + var valueAxis = new ChartAxis(minimum: 0f, maximum: 10f); + var palette = new[] { new Rgba32(1, 2, 3, 255) }; + var chart = new Chart(ChartType.Column, [series], categoryAxis, valueAxis, colorPalette: palette); + + // Act + using var surface = ChartRenderer.Render(chart, 400, 300); + + // Assert + Assert.Equal(new Rgba32(1, 2, 3, 255), surface[200, 150]); + } + + /// Proves an options-level color palette is used when the chart itself supplies none. + [Fact] + public void Render_OptionsColorPaletteOverride_UsedWhenChartSuppliesNone() + { + // Arrange + var series = new ChartSeries("S1", [10.0]); + var categoryAxis = new ChartAxis(["A"]); + var valueAxis = new ChartAxis(minimum: 0f, maximum: 10f); + var chart = new Chart(ChartType.Column, [series], categoryAxis, valueAxis); + var options = new ChartRenderOptions { ColorPalette = [new Rgba32(4, 5, 6, 255)] }; + + // Act + using var surface = ChartRenderer.Render(chart, 400, 300, options); + + // Assert + Assert.Equal(new Rgba32(4, 5, 6, 255), surface[200, 150]); + } +} diff --git a/test/DemaConsulting.CanvasNet.Charts.Tests/ChartSeriesTests.cs b/test/DemaConsulting.CanvasNet.Charts.Tests/ChartSeriesTests.cs new file mode 100644 index 00000000..f4b7c44a --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Charts.Tests/ChartSeriesTests.cs @@ -0,0 +1,145 @@ +using DemaConsulting.CanvasNet.Canvas; + +namespace DemaConsulting.CanvasNet.Charts.Tests; + +/// +/// Tests for . +/// +public class ChartSeriesTests +{ + /// Proves constructing with only a name and values sets those members and leaves the rest unset. + [Fact] + public void Constructor_NameAndValuesOnly_SetsMembersAndLeavesOptionalsUnset() + { + var series = new ChartSeries("Revenue", [1.0, 2.0, 3.0]); + + Assert.Equal("Revenue", series.Name); + Assert.Equal([1.0, 2.0, 3.0], series.Values); + Assert.Null(series.PointColors); + Assert.Null(series.PointLabels); + Assert.Null(series.Color); + } + + /// Proves constructing with every optional member sets all of them. + [Fact] + public void Constructor_AllMembers_SetsAllMembers() + { + var red = new Rgba32(255, 0, 0, 255); + var blue = new Rgba32(0, 0, 255, 255); + + var series = new ChartSeries( + "Revenue", + [1.0, 2.0], + [red, blue], + ["First", "Second"], + red); + + Assert.Equal([red, blue], series.PointColors); + Assert.Equal(["First", "Second"], series.PointLabels); + Assert.Equal(red, series.Color); + } + + /// Proves a null name is rejected. + [Fact] + public void Constructor_NullName_ThrowsArgumentNullException() + { + Assert.Throws(() => new ChartSeries(null!, [1.0])); + } + + /// Proves an empty or whitespace-only name is rejected. + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Constructor_EmptyOrWhitespaceName_ThrowsArgumentException(string name) + { + Assert.Throws(() => new ChartSeries(name, [1.0])); + } + + /// Proves a null values argument is rejected. + [Fact] + public void Constructor_NullValues_ThrowsArgumentNullException() + { + Assert.Throws(() => new ChartSeries("Revenue", null!)); + } + + /// Proves an empty values argument is rejected. + [Fact] + public void Constructor_EmptyValues_ThrowsArgumentException() + { + Assert.Throws(() => new ChartSeries("Revenue", [])); + } + + /// Proves a non-finite entry in values is rejected. + [Theory] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + [InlineData(double.NegativeInfinity)] + public void Constructor_NonFiniteValueEntry_ThrowsArgumentException(double value) + { + Assert.Throws(() => new ChartSeries("Revenue", [1.0, value])); + } + + /// Proves a point colors count mismatched with the values count is rejected. + [Fact] + public void Constructor_PointColorsCountMismatch_ThrowsArgumentException() + { + var red = new Rgba32(255, 0, 0, 255); + + Assert.Throws(() => new ChartSeries("Revenue", [1.0, 2.0], [red])); + } + + /// Proves a point labels count mismatched with the values count is rejected. + [Fact] + public void Constructor_PointLabelsCountMismatch_ThrowsArgumentException() + { + Assert.Throws(() => new ChartSeries("Revenue", [1.0, 2.0], pointLabels: ["Only one"])); + } + + /// Proves a null entry in point labels is rejected. + [Fact] + public void Constructor_NullPointLabelEntry_ThrowsArgumentException() + { + var labels = new List { "First", null }; + + Assert.Throws(() => new ChartSeries("Revenue", [1.0, 2.0], pointLabels: labels!)); + } + + /// Proves mutating the caller's original values list after construction does not affect the stored snapshot. + [Fact] + public void Constructor_MutatingOriginalValuesList_DoesNotAffectStoredSnapshot() + { + var original = new List { 1.0, 2.0 }; + var series = new ChartSeries("Revenue", original); + + original.Add(3.0); + original[0] = 99.0; + + Assert.Equal([1.0, 2.0], series.Values); + } + + /// Proves mutating the caller's original point colors list after construction does not affect the stored snapshot. + [Fact] + public void Constructor_MutatingOriginalPointColorsList_DoesNotAffectStoredSnapshot() + { + var red = new Rgba32(255, 0, 0, 255); + var blue = new Rgba32(0, 0, 255, 255); + var original = new List { red, blue }; + var series = new ChartSeries("Revenue", [1.0, 2.0], original); + + original[0] = blue; + + Assert.Equal([red, blue], series.PointColors); + } + + /// Proves mutating the caller's original point labels list after construction does not affect the stored snapshot. + [Fact] + public void Constructor_MutatingOriginalPointLabelsList_DoesNotAffectStoredSnapshot() + { + var original = new List { "First", "Second" }; + var series = new ChartSeries("Revenue", [1.0, 2.0], pointLabels: original); + + original[0] = "Changed"; + + Assert.Equal(["First", "Second"], series.PointLabels); + } +} diff --git a/test/DemaConsulting.CanvasNet.Charts.Tests/ChartTests.cs b/test/DemaConsulting.CanvasNet.Charts.Tests/ChartTests.cs new file mode 100644 index 00000000..d4835dd0 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Charts.Tests/ChartTests.cs @@ -0,0 +1,171 @@ +using DemaConsulting.CanvasNet.Canvas; + +namespace DemaConsulting.CanvasNet.Charts.Tests; + +/// +/// Tests for . +/// +public class ChartTests +{ + /// Proves constructing with only the required members sets them and leaves the rest unset. + [Fact] + public void Constructor_MinimalMembers_SetsRequiredAndLeavesOptionalsUnset() + { + var series = new ChartSeries("Revenue", [1.0, 2.0, 3.0]); + var chart = new Chart(ChartType.Bar, [series]); + + Assert.Equal(ChartType.Bar, chart.Type); + Assert.Equal([series], chart.Series); + Assert.Null(chart.CategoryAxis); + Assert.Null(chart.ValueAxis); + Assert.Null(chart.Legend); + Assert.Null(chart.Title); + Assert.Null(chart.ColorPalette); + } + + /// Proves constructing with every optional member sets all of them. + [Fact] + public void Constructor_AllMembers_SetsAllMembers() + { + var series = new ChartSeries("Revenue", [1.0, 2.0]); + var categoryAxis = new ChartAxis(["Q1", "Q2"]); + var valueAxis = new ChartAxis(minimum: 0f, maximum: 10f); + var legend = new ChartLegend(); + var title = new ChartTitle("Revenue Chart"); + var palette = new Rgba32[] { new(255, 0, 0, 255) }; + + var chart = new Chart(ChartType.Bar, [series], categoryAxis, valueAxis, legend, title, palette); + + Assert.Same(categoryAxis, chart.CategoryAxis); + Assert.Same(valueAxis, chart.ValueAxis); + Assert.Same(legend, chart.Legend); + Assert.Same(title, chart.Title); + Assert.Equal(palette, chart.ColorPalette); + } + + /// Proves an undefined value is rejected. + [Fact] + public void Constructor_UndefinedType_ThrowsArgumentOutOfRangeException() + { + var series = new ChartSeries("Revenue", [1.0]); + + Assert.Throws(() => new Chart((ChartType)99, [series])); + } + + /// Proves a null series argument is rejected. + [Fact] + public void Constructor_NullSeries_ThrowsArgumentNullException() + { + Assert.Throws(() => new Chart(ChartType.Bar, null!)); + } + + /// Proves an empty series argument is rejected. + [Fact] + public void Constructor_EmptySeries_ThrowsArgumentException() + { + Assert.Throws(() => new Chart(ChartType.Bar, [])); + } + + /// Proves a null entry in series is rejected. + [Fact] + public void Constructor_NullSeriesEntry_ThrowsArgumentException() + { + var series = new List { new ChartSeries("Revenue", [1.0]), null }; + + Assert.Throws(() => new Chart(ChartType.Bar, series!)); + } + + /// Proves category-based types accept a series whose values count matches the category axis label count. + [Theory] + [InlineData(ChartType.Bar)] + [InlineData(ChartType.Column)] + [InlineData(ChartType.Line)] + [InlineData(ChartType.Area)] + public void Constructor_CategoryBasedTypeWithMatchingLength_Succeeds(ChartType type) + { + var series = new ChartSeries("Revenue", [1.0, 2.0]); + var categoryAxis = new ChartAxis(["Q1", "Q2"]); + + var chart = new Chart(type, [series], categoryAxis); + + Assert.Equal(type, chart.Type); + } + + /// Proves category-based types reject a series whose values count mismatches the category axis label count. + [Theory] + [InlineData(ChartType.Bar)] + [InlineData(ChartType.Column)] + [InlineData(ChartType.Line)] + [InlineData(ChartType.Area)] + public void Constructor_CategoryBasedTypeWithMismatchedLength_ThrowsArgumentException(ChartType type) + { + var series = new ChartSeries("Revenue", [1.0, 2.0, 3.0]); + var categoryAxis = new ChartAxis(["Q1", "Q2"]); + + Assert.Throws(() => new Chart(type, [series], categoryAxis)); + } + + /// Proves proportional-wedge types (pie/doughnut) are exempt from the series/category-axis length check. + [Theory] + [InlineData(ChartType.Pie)] + [InlineData(ChartType.Doughnut)] + public void Constructor_ProportionalType_IsExemptFromLengthCheck(ChartType type) + { + var series = new ChartSeries("Revenue", [1.0, 2.0, 3.0]); + var categoryAxis = new ChartAxis(["Q1", "Q2"]); + + var chart = new Chart(type, [series], categoryAxis); + + Assert.Equal(type, chart.Type); + } + + /// Proves a category axis with no labels (a value-only axis) does not trigger the length check. + [Fact] + public void Constructor_CategoryAxisWithNoLabels_DoesNotTriggerLengthCheck() + { + var series = new ChartSeries("Revenue", [1.0, 2.0, 3.0]); + var categoryAxis = new ChartAxis(minimum: 0f, maximum: 10f); + + var chart = new Chart(ChartType.Bar, [series], categoryAxis); + + Assert.Equal(ChartType.Bar, chart.Type); + } + + /// Proves an empty color palette is rejected. + [Fact] + public void Constructor_EmptyColorPalette_ThrowsArgumentException() + { + var series = new ChartSeries("Revenue", [1.0]); + + Assert.Throws(() => new Chart(ChartType.Bar, [series], colorPalette: [])); + } + + /// Proves mutating the caller's original series list after construction does not affect the stored snapshot. + [Fact] + public void Constructor_MutatingOriginalSeriesList_DoesNotAffectStoredSnapshot() + { + var first = new ChartSeries("First", [1.0]); + var second = new ChartSeries("Second", [2.0]); + var original = new List { first }; + + var chart = new Chart(ChartType.Bar, original); + original.Add(second); + + Assert.Equal([first], chart.Series); + } + + /// Proves mutating the caller's original color palette list after construction does not affect the stored snapshot. + [Fact] + public void Constructor_MutatingOriginalColorPaletteList_DoesNotAffectStoredSnapshot() + { + var series = new ChartSeries("Revenue", [1.0]); + var red = new Rgba32(255, 0, 0, 255); + var blue = new Rgba32(0, 0, 255, 255); + var original = new List { red }; + + var chart = new Chart(ChartType.Bar, [series], colorPalette: original); + original.Add(blue); + + Assert.Equal([red], chart.ColorPalette); + } +} diff --git a/test/DemaConsulting.CanvasNet.Charts.Tests/ChartTitleTests.cs b/test/DemaConsulting.CanvasNet.Charts.Tests/ChartTitleTests.cs new file mode 100644 index 00000000..3145ae4c --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Charts.Tests/ChartTitleTests.cs @@ -0,0 +1,60 @@ +namespace DemaConsulting.CanvasNet.Charts.Tests; + +/// +/// Tests for . +/// +public class ChartTitleTests +{ + /// Proves constructing with only text sets the text and leaves the font size unset. + [Fact] + public void Constructor_TextOnly_SetsTextAndLeavesFontSizeUnset() + { + var title = new ChartTitle("Revenue"); + + Assert.Equal("Revenue", title.Text); + Assert.Null(title.FontSize); + } + + /// Proves constructing with both text and a font size sets both members. + [Fact] + public void Constructor_TextAndFontSize_SetsBothMembers() + { + var title = new ChartTitle("Revenue", 18f); + + Assert.Equal("Revenue", title.Text); + Assert.Equal(18f, title.FontSize); + } + + /// Proves a null text argument is rejected. + [Fact] + public void Constructor_NullText_ThrowsArgumentNullException() + { + Assert.Throws(() => new ChartTitle(null!)); + } + + /// Proves an empty text argument is rejected. + [Fact] + public void Constructor_EmptyText_ThrowsArgumentException() + { + Assert.Throws(() => new ChartTitle("")); + } + + /// Proves a whitespace-only text argument is rejected. + [Fact] + public void Constructor_WhitespaceText_ThrowsArgumentException() + { + Assert.Throws(() => new ChartTitle(" ")); + } + + /// Proves a non-finite or non-positive font size is rejected. + [Theory] + [InlineData(0f)] + [InlineData(-1f)] + [InlineData(float.NaN)] + [InlineData(float.PositiveInfinity)] + [InlineData(float.NegativeInfinity)] + public void Constructor_InvalidFontSize_ThrowsArgumentOutOfRangeException(float fontSize) + { + Assert.Throws(() => new ChartTitle("Revenue", fontSize)); + } +} diff --git a/test/DemaConsulting.CanvasNet.Charts.Tests/ChartTypeTests.cs b/test/DemaConsulting.CanvasNet.Charts.Tests/ChartTypeTests.cs new file mode 100644 index 00000000..455ce79b --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Charts.Tests/ChartTypeTests.cs @@ -0,0 +1,39 @@ +namespace DemaConsulting.CanvasNet.Charts.Tests; + +/// +/// Tests for the enum. +/// +public class ChartTypeTests +{ + /// Proves every v1 member is a defined enum value. + [Theory] + [InlineData(ChartType.Bar)] + [InlineData(ChartType.Column)] + [InlineData(ChartType.Line)] + [InlineData(ChartType.Pie)] + [InlineData(ChartType.Doughnut)] + [InlineData(ChartType.Area)] + public void AllV1Members_AreDefined(ChartType type) + { + Assert.True(Enum.IsDefined(type)); + } + + /// Proves an out-of-range cast value is not a defined value. + [Fact] + public void UndefinedValue_IsNotDefined() + { + var undefined = (ChartType)99; + + Assert.False(Enum.IsDefined(undefined)); + } + + /// Proves 's constructor rejects an undefined value. + [Fact] + public void UndefinedValue_RejectedByChartConstructor() + { + var undefined = (ChartType)99; + + Assert.Throws(() => + new Chart(undefined, [new ChartSeries("Series 1", [1.0])])); + } +} diff --git a/test/DemaConsulting.CanvasNet.Charts.Tests/DemaConsulting.CanvasNet.Charts.Tests.csproj b/test/DemaConsulting.CanvasNet.Charts.Tests/DemaConsulting.CanvasNet.Charts.Tests.csproj new file mode 100644 index 00000000..8f94495e --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Charts.Tests/DemaConsulting.CanvasNet.Charts.Tests.csproj @@ -0,0 +1,78 @@ + + + + + net8.0;net9.0;net10.0 + latest + enable + enable + + Exe + false + true + true + + + true + true + true + latest + + + + + + + + + + + + all + compile; runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/test/DemaConsulting.CanvasNet.Charts.Tests/OpenXmlChartFixtures/README.md b/test/DemaConsulting.CanvasNet.Charts.Tests/OpenXmlChartFixtures/README.md new file mode 100644 index 00000000..6a3ce586 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Charts.Tests/OpenXmlChartFixtures/README.md @@ -0,0 +1,56 @@ +# OpenXML Chart Test Fixtures + + + + +`OpenXmlChartParserTests.cs` builds every one of its test fixtures as an in-memory +`System.Xml.Linq.XElement` tree directly in the test file itself (the idiomatic pattern already +used by this project's own `.Pptx.Tests`/`.Svg.Tests` suites for fine-grained, single-construct +unit tests - small hand-authored element fragments, not embedded resource files), rather than +loading external `.xml` fixture files from this folder. This folder exists to document the +provenance of the one fixture shape that *is* derived from a real, third-party file: the +stacked-column bar-chart excerpt used by `Parse_RealFixtureBarChartExcerpt_ParsesExpectedShape` +and `Parse_ThenRender_RealFixtureBarChartExcerpt_ProducesCorrectlySizedSurface`. + +## Real-World Fixture Provenance + +Both tests above hand-reconstruct a trimmed, styling-stripped excerpt of the real +`ppt/charts/chart1.xml` part found inside +`test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/aiden0z-1-chart-and-complex.pptx` - itself +taken unmodified from the [aiden0z/pptx-renderer](https://github.com/aiden0z/pptx-renderer) +project's own `docs/example/1-chart-and-complex/source.pptx` (Apache License 2.0; see that +folder's own `README.md` and `Aiden0zPptxRenderer.LICENSE` for the full provenance and license +text - this folder does not re-host either, to avoid two divergent copies of the same license +notice). + +The real `chart1.xml` part was inspected directly (extracted locally via `Expand-Archive` after +copying the `.pptx` to a `.zip` extension, then pretty-printed) to confirm the exact shape this +parser's test excerpt reconstructs: + +- A single `` with `` and `` - a + stacked column chart, classified by this parser as `ChartType.Column`. +- Three `` entries ("Series 1", "Series 2", "Series 3"), each with a cached + `//` name, a cached `//` four-entry + category list ("Category 1".."Category 4"), and a cached `//` + four-entry value list. +- A `` element that is present (carries real styling/``/`` content) but + has **no** `` child at all - a styled-but-textless PowerPoint auto-title placeholder, + which this parser maps to a `null` `Chart.Title` (see `OpenXmlChartParser.ParseTitle`'s own + documented rule) - confirmed directly against the real file, not assumed. +- `` and `` siblings of `` (not nested inside it), neither carrying + a `` child. +- A `` with exactly `` - a bottom legend. + +The test excerpt reproduces every one of these structurally significant shapes (chart kind, +`barDir`/`grouping`, series count, per-series name/category/value cache shape and count, the +textless title, the title-less axes, and the bottom legend) while deliberately dropping every +real file's purely cosmetic styling element (``, ``, ``, theme color +references, and so on) that this format-agnostic, cached-values-only parser does not read at +all - keeping the fixture small, readable, and focused on what this phase actually parses, while +still being traceable back to genuine real-world output rather than an arbitrary invention. + +No real-world fixture exists anywhere in this project's corpus for `c:lineChart`, `c:pieChart`, +`c:doughnutChart`, or `c:areaChart` (confirmed during this phase's planning): every other chart +type's test fixture is therefore a minimal, hand-authored fragment following the ECMA-376 +DrawingML-Charts schema shapes documented in `OpenXmlChartParser`'s own XML doc comments, not a +real-file excerpt. diff --git a/test/DemaConsulting.CanvasNet.Charts.Tests/OpenXmlChartParserTests.cs b/test/DemaConsulting.CanvasNet.Charts.Tests/OpenXmlChartParserTests.cs new file mode 100644 index 00000000..5860f221 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Charts.Tests/OpenXmlChartParserTests.cs @@ -0,0 +1,1029 @@ +using System.Xml.Linq; +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Charts.OpenXml; + +namespace DemaConsulting.CanvasNet.Charts.Tests; + +/// +/// Tests for : argument validation, per- +/// success parsing, series/category/value/title/legend/axis extraction, cached-values-only +/// enforcement, unsupported-chart-type/combo-chart/empty-plot-area rejection, a real-fixture- +/// derived end-to-end test, and parse-then-render round-trip pixel-sanity tests. +/// +/// +/// Every fixture in this file is either a minimal hand-authored XML fragment following the +/// ECMA-376 DrawingML-Charts schema shapes this parser documents, or - for the bar/column +/// cases - a trimmed, adapted excerpt of a real chart1.xml part; see +/// OpenXmlChartFixtures/README.md for exact provenance. +/// +public class OpenXmlChartParserTests +{ + /// The DrawingML-Charts XML namespace, used throughout to build fragment elements. + private static readonly XNamespace C = "http://schemas.openxmlformats.org/drawingml/2006/chart"; + + /// The DrawingML-Main XML namespace, used only for a rich-text title's a:t runs. + private static readonly XNamespace A = "http://schemas.openxmlformats.org/drawingml/2006/main"; + + /// Opaque white - 's own documented default background. + private static readonly Rgba32 White = new(255, 255, 255, 255); + + #region Fragment builders + + /// Builds a c:numCache element for the given dense values. + private static XElement NumCache(params double[] values) => + new(C + "numCache", + new XElement(C + "formatCode", "General"), + new XElement(C + "ptCount", new XAttribute("val", values.Length)), + values.Select((v, i) => new XElement(C + "pt", new XAttribute("idx", i), new XElement(C + "v", v)))); + + /// Builds a c:strCache element for the given dense labels. + private static XElement StrCache(params string[] values) => + new(C + "strCache", + new XElement(C + "ptCount", new XAttribute("val", values.Length)), + values.Select((v, i) => new XElement(C + "pt", new XAttribute("idx", i), new XElement(C + "v", v)))); + + /// Builds a c:tx element with a cached string name. + private static XElement Tx(string name) => + new(C + "tx", new XElement(C + "strRef", new XElement(C + "f", "Sheet1!$B$1"), StrCache(name))); + + /// Builds a c:val element with cached numeric values. + private static XElement Val(params double[] values) => + new(C + "val", new XElement(C + "numRef", new XElement(C + "f", "Sheet1!$B$2:$B$5"), NumCache(values))); + + /// Builds a c:cat element with cached string labels. + private static XElement Cat(params string[] labels) => + new(C + "cat", new XElement(C + "strRef", new XElement(C + "f", "Sheet1!$A$2:$A$5"), StrCache(labels))); + + /// Builds a c:ser element from the given name/values/categories. + private static XElement Ser(string name, double[] values, string[]? categories = null) + { + var ser = new XElement(C + "ser", + new XElement(C + "idx", new XAttribute("val", 0)), + new XElement(C + "order", new XAttribute("val", 0)), + Tx(name)); + if (categories is not null) + { + ser.Add(Cat(categories)); + } + + ser.Add(Val(values)); + return ser; + } + + /// Builds a minimal c:chart element wrapping a single chart-type element. + private static XElement Chart(XElement chartTypeElement, XElement? title = null, XElement? legend = null) => + new(C + "chart", + title is null ? null : new XElement(C + "title", title), + new XElement(C + "autoTitleDeleted", new XAttribute("val", 0)), + new XElement(C + "plotArea", new XElement(C + "layout"), chartTypeElement), + legend is null ? null : new XElement(C + "legend", legend)); + + #endregion + + #region Argument validation + + /// Proves a null is rejected. + [Fact] + public void Parse_NullDocument_ThrowsArgumentNullException() + { + // Act / Assert + Assert.Throws(() => OpenXmlChartParser.Parse((XDocument)null!)); + } + + /// Proves a null is rejected. + [Fact] + public void Parse_NullElement_ThrowsArgumentNullException() + { + // Act / Assert + Assert.Throws(() => OpenXmlChartParser.Parse((XElement)null!)); + } + + /// Proves a document with no root element is rejected. + [Fact] + public void Parse_DocumentWithNoRoot_ThrowsArgumentException() + { + // Arrange + var document = new XDocument(); + + // Act / Assert + Assert.Throws(() => OpenXmlChartParser.Parse(document)); + } + + /// Proves an element that is neither a c:chart nor a c:chartSpace containing one is rejected. + [Fact] + public void Parse_ElementWithNoChartChild_ThrowsArgumentException() + { + // Arrange + var element = new XElement(C + "notAChartSpace"); + + // Act / Assert + Assert.Throws(() => OpenXmlChartParser.Parse(element)); + } + + /// Proves a c:chart element with no c:plotArea child is rejected. + [Fact] + public void Parse_ChartWithNoPlotArea_ThrowsArgumentException() + { + // Arrange + var element = new XElement(C + "chart"); + + // Act / Assert + Assert.Throws(() => OpenXmlChartParser.Parse(element)); + } + + /// Proves a full c:chartSpace wrapper around c:chart is accepted identically to a bare c:chart. + [Fact] + public void Parse_ChartSpaceWrapper_ParsesSameAsBareChart() + { + // Arrange + var barChart = new XElement(C + "barChart", + new XElement(C + "barDir", new XAttribute("val", "col")), + Ser("S1", [1.0, 2.0], ["A", "B"])); + var bareChart = Chart(barChart); + var chartSpace = new XElement(C + "chartSpace", bareChart); + + // Act + var fromBare = OpenXmlChartParser.Parse(bareChart); + var fromWrapped = OpenXmlChartParser.Parse(chartSpace); + + // Assert + Assert.Equal(fromBare.Type, fromWrapped.Type); + Assert.Equal(fromBare.Series[0].Name, fromWrapped.Series[0].Name); + } + + /// Proves delegates correctly to the element overload. + [Fact] + public void Parse_Document_ParsesSameAsElement() + { + // Arrange + var barChart = new XElement(C + "barChart", + new XElement(C + "barDir", new XAttribute("val", "col")), + Ser("S1", [1.0, 2.0], ["A", "B"])); + var chartElement = Chart(barChart); + var document = new XDocument(new XElement(C + "chartSpace", chartElement)); + + // Act + var chart = OpenXmlChartParser.Parse(document); + + // Assert + Assert.Equal(ChartType.Column, chart.Type); + } + + #endregion + + #region Per-chart-type success parsing + + /// Proves a c:barChart with barDir val="bar" classifies as . + [Fact] + public void Parse_BarChartWithBarDirection_ClassifiesAsBar() + { + // Arrange + var barChart = new XElement(C + "barChart", + new XElement(C + "barDir", new XAttribute("val", "bar")), + Ser("S1", [1.0, 2.0], ["A", "B"])); + var chartElement = Chart(barChart); + + // Act + var chart = OpenXmlChartParser.Parse(chartElement); + + // Assert + Assert.Equal(ChartType.Bar, chart.Type); + } + + /// Proves a c:barChart with barDir val="col" classifies as . + [Fact] + public void Parse_BarChartWithColumnDirection_ClassifiesAsColumn() + { + // Arrange + var barChart = new XElement(C + "barChart", + new XElement(C + "barDir", new XAttribute("val", "col")), + Ser("S1", [1.0, 2.0], ["A", "B"])); + var chartElement = Chart(barChart); + + // Act + var chart = OpenXmlChartParser.Parse(chartElement); + + // Assert + Assert.Equal(ChartType.Column, chart.Type); + } + + /// Proves a c:barChart with no c:barDir element defaults to . + [Fact] + public void Parse_BarChartWithNoBarDirection_DefaultsToColumn() + { + // Arrange + var barChart = new XElement(C + "barChart", Ser("S1", [1.0, 2.0], ["A", "B"])); + var chartElement = Chart(barChart); + + // Act + var chart = OpenXmlChartParser.Parse(chartElement); + + // Assert + Assert.Equal(ChartType.Column, chart.Type); + } + + /// Proves a c:lineChart classifies as . + [Fact] + public void Parse_LineChart_ClassifiesAsLine() + { + // Arrange + var lineChart = new XElement(C + "lineChart", Ser("S1", [1.0, 2.0], ["A", "B"])); + var chartElement = Chart(lineChart); + + // Act + var chart = OpenXmlChartParser.Parse(chartElement); + + // Assert + Assert.Equal(ChartType.Line, chart.Type); + } + + /// Proves a c:areaChart classifies as . + [Fact] + public void Parse_AreaChart_ClassifiesAsArea() + { + // Arrange + var areaChart = new XElement(C + "areaChart", Ser("S1", [1.0, 2.0], ["A", "B"])); + var chartElement = Chart(areaChart); + + // Act + var chart = OpenXmlChartParser.Parse(chartElement); + + // Assert + Assert.Equal(ChartType.Area, chart.Type); + } + + /// Proves a c:pieChart classifies as and maps categories to point labels. + [Fact] + public void Parse_PieChart_ClassifiesAsPieWithPointLabels() + { + // Arrange + var pieChart = new XElement(C + "pieChart", Ser("S1", [30.0, 70.0], ["Red", "Blue"])); + var chartElement = Chart(pieChart); + + // Act + var chart = OpenXmlChartParser.Parse(chartElement); + + // Assert + Assert.Equal(ChartType.Pie, chart.Type); + Assert.Null(chart.CategoryAxis); + Assert.Equal(["Red", "Blue"], chart.Series[0].PointLabels); + } + + /// Proves a c:doughnutChart classifies as . + [Fact] + public void Parse_DoughnutChart_ClassifiesAsDoughnut() + { + // Arrange + var doughnutChart = new XElement(C + "doughnutChart", Ser("S1", [30.0, 70.0], ["Red", "Blue"])); + var chartElement = Chart(doughnutChart); + + // Act + var chart = OpenXmlChartParser.Parse(chartElement); + + // Assert + Assert.Equal(ChartType.Doughnut, chart.Type); + } + + #endregion + + #region Series/category/value extraction + + /// Proves a series' cached name is extracted from its c:tx/c:strRef/c:strCache. + [Fact] + public void Parse_SeriesWithCachedName_ExtractsName() + { + // Arrange + var barChart = new XElement(C + "barChart", Ser("Revenue", [1.0, 2.0], ["A", "B"])); + var chartElement = Chart(barChart); + + // Act + var chart = OpenXmlChartParser.Parse(chartElement); + + // Assert + Assert.Equal("Revenue", chart.Series[0].Name); + } + + /// Proves a series with no c:tx falls back to a default 1-based "Series N" name. + [Fact] + public void Parse_SeriesWithNoTx_DefaultsToSeriesIndexName() + { + // Arrange + var ser = new XElement(C + "ser", + new XElement(C + "idx", new XAttribute("val", 0)), + new XElement(C + "order", new XAttribute("val", 0)), + Cat("A", "B"), + Val(1.0, 2.0)); + var barChart = new XElement(C + "barChart", ser); + var chartElement = Chart(barChart); + + // Act + var chart = OpenXmlChartParser.Parse(chartElement); + + // Assert + Assert.Equal("Series 1", chart.Series[0].Name); + } + + /// Proves a series' literal c:tx/c:v name (no c:strRef) is extracted. + [Fact] + public void Parse_SeriesWithLiteralTxValue_ExtractsName() + { + // Arrange + var ser = new XElement(C + "ser", + new XElement(C + "idx", new XAttribute("val", 0)), + new XElement(C + "order", new XAttribute("val", 0)), + new XElement(C + "tx", new XElement(C + "v", "Literal Name")), + Cat("A", "B"), + Val(1.0, 2.0)); + var barChart = new XElement(C + "barChart", ser); + var chartElement = Chart(barChart); + + // Act + var chart = OpenXmlChartParser.Parse(chartElement); + + // Assert + Assert.Equal("Literal Name", chart.Series[0].Name); + } + + /// Proves categories backed by c:numRef/c:numCache are converted to invariant-culture strings. + [Fact] + public void Parse_NumericCategories_ConvertsToInvariantCultureStrings() + { + // Arrange + var ser = new XElement(C + "ser", + new XElement(C + "idx", new XAttribute("val", 0)), + new XElement(C + "order", new XAttribute("val", 0)), + Tx("S1"), + new XElement(C + "cat", new XElement(C + "numRef", new XElement(C + "f", "Sheet1!$A$2:$A$3"), NumCache(2000, 2001))), + Val(1.0, 2.0)); + var barChart = new XElement(C + "barChart", ser); + var chartElement = Chart(barChart); + + // Act + var chart = OpenXmlChartParser.Parse(chartElement); + + // Assert + Assert.Equal(["2000", "2001"], chart.CategoryAxis!.Labels); + } + + /// Proves sparse c:pt gaps in a c:numCache default to 0.0. + [Fact] + public void Parse_SparseNumCache_MissingIndexDefaultsToZero() + { + // Arrange + var numCache = new XElement(C + "numCache", + new XElement(C + "formatCode", "General"), + new XElement(C + "ptCount", new XAttribute("val", 3)), + new XElement(C + "pt", new XAttribute("idx", 0), new XElement(C + "v", 1.0)), + new XElement(C + "pt", new XAttribute("idx", 2), new XElement(C + "v", 3.0))); + var ser = new XElement(C + "ser", + new XElement(C + "idx", new XAttribute("val", 0)), + new XElement(C + "order", new XAttribute("val", 0)), + Tx("S1"), + Cat("A", "B", "C"), + new XElement(C + "val", new XElement(C + "numRef", new XElement(C + "f", "Sheet1!$B$2:$B$4"), numCache))); + var barChart = new XElement(C + "barChart", ser); + var chartElement = Chart(barChart); + + // Act + var chart = OpenXmlChartParser.Parse(chartElement); + + // Assert + Assert.Equal([1.0, 0.0, 3.0], chart.Series[0].Values); + } + + /// Proves multiple series under one chart-type element are all parsed, in order. + [Fact] + public void Parse_MultipleSeries_ParsesAllInOrder() + { + // Arrange + var barChart = new XElement(C + "barChart", + Ser("First", [1.0, 2.0], ["A", "B"]), + Ser("Second", [3.0, 4.0], ["A", "B"])); + var chartElement = Chart(barChart); + + // Act + var chart = OpenXmlChartParser.Parse(chartElement); + + // Assert + Assert.Equal(2, chart.Series.Count); + Assert.Equal("First", chart.Series[0].Name); + Assert.Equal("Second", chart.Series[1].Name); + } + + #endregion + + #region Title extraction + + /// Proves a chart title's cached c:strCache text is extracted. + [Fact] + public void Parse_TitleWithCachedText_ExtractsTitle() + { + // Arrange + var titleContent = new XElement(C + "tx", new XElement(C + "strRef", new XElement(C + "f", "Sheet1!$A$1"), StrCache("Quarterly Revenue"))); + var barChart = new XElement(C + "barChart", Ser("S1", [1.0, 2.0], ["A", "B"])); + var chartElement = Chart(barChart, title: titleContent); + + // Act + var chart = OpenXmlChartParser.Parse(chartElement); + + // Assert + Assert.Equal("Quarterly Revenue", chart.Title?.Text); + } + + /// Proves a chart title's rich-text c:rich body's concatenated a:t runs are extracted. + [Fact] + public void Parse_TitleWithRichText_ConcatenatesRuns() + { + // Arrange + var rich = new XElement(C + "rich", + new XElement(A + "p", new XElement(A + "r", new XElement(A + "t", "Hello ")), new XElement(A + "r", new XElement(A + "t", "World")))); + var titleContent = new XElement(C + "tx", rich); + var barChart = new XElement(C + "barChart", Ser("S1", [1.0, 2.0], ["A", "B"])); + var chartElement = Chart(barChart, title: titleContent); + + // Act + var chart = OpenXmlChartParser.Parse(chartElement); + + // Assert + Assert.Equal("Hello World", chart.Title?.Text); + } + + /// Proves a c:title present with no c:tx (a styled-but-textless auto-title placeholder) maps to a null title. + [Fact] + public void Parse_TitlePresentWithNoTx_MapsToNullTitle() + { + // Arrange + var barChart = new XElement(C + "barChart", Ser("S1", [1.0, 2.0], ["A", "B"])); + var chartElement = new XElement(C + "chart", + new XElement(C + "title", new XElement(C + "overlay", new XAttribute("val", 0))), + new XElement(C + "autoTitleDeleted", new XAttribute("val", 0)), + new XElement(C + "plotArea", new XElement(C + "layout"), barChart)); + + // Act + var chart = OpenXmlChartParser.Parse(chartElement); + + // Assert + Assert.Null(chart.Title); + } + + /// Proves an absent c:title element maps to a null title. + [Fact] + public void Parse_NoTitleElement_MapsToNullTitle() + { + // Arrange + var barChart = new XElement(C + "barChart", Ser("S1", [1.0, 2.0], ["A", "B"])); + var chartElement = Chart(barChart); + + // Act + var chart = OpenXmlChartParser.Parse(chartElement); + + // Assert + Assert.Null(chart.Title); + } + + #endregion + + #region Legend extraction + + /// Proves an absent c:legend element maps to a null legend. + [Fact] + public void Parse_NoLegendElement_MapsToNullLegend() + { + // Arrange + var barChart = new XElement(C + "barChart", Ser("S1", [1.0, 2.0], ["A", "B"])); + var chartElement = Chart(barChart); + + // Act + var chart = OpenXmlChartParser.Parse(chartElement); + + // Assert + Assert.Null(chart.Legend); + } + + /// Proves each defined c:legendPos value maps to the expected . + [Theory] + [InlineData("t", ChartLegendPosition.Top)] + [InlineData("b", ChartLegendPosition.Bottom)] + [InlineData("l", ChartLegendPosition.Left)] + [InlineData("r", ChartLegendPosition.Right)] + [InlineData("tr", ChartLegendPosition.Right)] + public void Parse_LegendPosition_MapsToExpectedChartLegendPosition(string legendPos, ChartLegendPosition expected) + { + // Arrange + var legendContent = new XElement(C + "legendPos", new XAttribute("val", legendPos)); + var barChart = new XElement(C + "barChart", Ser("S1", [1.0, 2.0], ["A", "B"])); + var chartElement = Chart(barChart, legend: legendContent); + + // Act + var chart = OpenXmlChartParser.Parse(chartElement); + + // Assert + Assert.Equal(expected, chart.Legend?.Position); + } + + /// Proves a c:legend with no c:legendPos defaults to . + [Fact] + public void Parse_LegendWithNoPosition_DefaultsToRight() + { + // Arrange + var barChart = new XElement(C + "barChart", Ser("S1", [1.0, 2.0], ["A", "B"])); + var chartElement = Chart(barChart, legend: new XElement(C + "overlay", new XAttribute("val", 0))); + + // Act + var chart = OpenXmlChartParser.Parse(chartElement); + + // Assert + Assert.Equal(ChartLegendPosition.Right, chart.Legend?.Position); + } + + #endregion + + #region Axis extraction + + /// Proves a category axis's cached labels are extracted from the first series' c:cat. + [Fact] + public void Parse_CategoryBasedChart_ExtractsCategoryAxisLabels() + { + // Arrange + var barChart = new XElement(C + "barChart", Ser("S1", [1.0, 2.0, 3.0], ["Jan", "Feb", "Mar"])); + var chartElement = Chart(barChart); + + // Act + var chart = OpenXmlChartParser.Parse(chartElement); + + // Assert + Assert.Equal(["Jan", "Feb", "Mar"], chart.CategoryAxis!.Labels); + } + + /// Proves a c:catAx/c:title's cached text becomes the category axis title. + [Fact] + public void Parse_CatAxTitle_ExtractsCategoryAxisTitle() + { + // Arrange + var barChart = new XElement(C + "barChart", Ser("S1", [1.0, 2.0], ["A", "B"])); + var catAx = new XElement(C + "catAx", new XElement(C + "title", Tx("Month"))); + var chartElement = new XElement(C + "chart", + new XElement(C + "autoTitleDeleted", new XAttribute("val", 1)), + new XElement(C + "plotArea", new XElement(C + "layout"), barChart, catAx)); + + // Act + var chart = OpenXmlChartParser.Parse(chartElement); + + // Assert + Assert.Equal("Month", chart.CategoryAxis!.Title); + } + + /// Proves a c:valAx's c:scaling/c:min, c:max, c:majorUnit, and title are all extracted. + [Fact] + public void Parse_ValAx_ExtractsRangeAndTitle() + { + // Arrange + var barChart = new XElement(C + "barChart", Ser("S1", [1.0, 2.0], ["A", "B"])); + var valAx = new XElement(C + "valAx", + new XElement(C + "scaling", + new XElement(C + "min", new XAttribute("val", 0.0)), + new XElement(C + "max", new XAttribute("val", 100.0))), + new XElement(C + "majorUnit", new XAttribute("val", 10.0)), + new XElement(C + "title", Tx("Units Sold"))); + var chartElement = new XElement(C + "chart", + new XElement(C + "autoTitleDeleted", new XAttribute("val", 1)), + new XElement(C + "plotArea", new XElement(C + "layout"), barChart, valAx)); + + // Act + var chart = OpenXmlChartParser.Parse(chartElement); + + // Assert + var valueAxis = chart.ValueAxis; + Assert.NotNull(valueAxis); + Assert.Equal(0.0f, valueAxis.Minimum); + Assert.Equal(100.0f, valueAxis.Maximum); + Assert.Equal(10.0f, valueAxis.TickInterval); + Assert.Equal("Units Sold", valueAxis.Title); + } + + /// Proves an absent c:valAx element maps to a null value axis. + [Fact] + public void Parse_NoValAx_MapsToNullValueAxis() + { + // Arrange + var barChart = new XElement(C + "barChart", Ser("S1", [1.0, 2.0], ["A", "B"])); + var chartElement = Chart(barChart); + + // Act + var chart = OpenXmlChartParser.Parse(chartElement); + + // Assert + Assert.Null(chart.ValueAxis); + } + + #endregion + + #region Cached-values-only enforcement + + /// Proves a series whose c:val has a formula but no cached c:numCache is rejected. + [Fact] + public void Parse_SeriesWithUncachedValues_ThrowsWithUncachedValuesFeatureToken() + { + // Arrange + var ser = new XElement(C + "ser", + new XElement(C + "idx", new XAttribute("val", 0)), + new XElement(C + "order", new XAttribute("val", 0)), + Tx("S1"), + Cat("A", "B"), + new XElement(C + "val", new XElement(C + "numRef", new XElement(C + "f", "Sheet1!$B$2:$B$3")))); + var barChart = new XElement(C + "barChart", ser); + var chartElement = Chart(barChart); + + // Act + var exception = Assert.Throws(() => OpenXmlChartParser.Parse(chartElement)); + + // Assert + Assert.Equal("charts-openxml-uncached-values", exception.Feature); + } + + #endregion + + #region Oversized-c:ptCount rejection + + /// + /// Proves a c:numCache declaring a c:ptCount far larger than any genuine + /// worksheet-backed chart could have is rejected before a dense array is allocated, + /// rather than attempting a multi-gigabyte allocation / crashing with + /// . + /// + [Fact] + public void Parse_NumCacheWithExcessivePtCount_ThrowsWithPointCountTooLargeFeatureToken() + { + // Arrange + var val = new XElement(C + "val", + new XElement(C + "numRef", + new XElement(C + "f", "Sheet1!$B$2:$B$5"), + new XElement(C + "numCache", + new XElement(C + "formatCode", "General"), + new XElement(C + "ptCount", new XAttribute("val", 2_000_000_000))))); + var ser = new XElement(C + "ser", + new XElement(C + "idx", new XAttribute("val", 0)), + new XElement(C + "order", new XAttribute("val", 0)), + Tx("S1"), + Cat("A", "B"), + val); + var barChart = new XElement(C + "barChart", ser); + var chartElement = Chart(barChart); + + // Act + var exception = Assert.Throws(() => OpenXmlChartParser.Parse(chartElement)); + + // Assert + Assert.Equal("charts-openxml-point-count-too-large", exception.Feature); + } + + /// + /// Proves the same oversized-c:ptCount rejection applies to a c:strCache + /// (for example a string-labeled category axis), not just c:numCache. + /// + [Fact] + public void Parse_CatStrCacheWithExcessivePtCount_ThrowsWithPointCountTooLargeFeatureToken() + { + // Arrange + var cat = new XElement(C + "cat", + new XElement(C + "strRef", + new XElement(C + "f", "Sheet1!$A$2:$A$5"), + new XElement(C + "strCache", + new XElement(C + "ptCount", new XAttribute("val", 2_000_000_000))))); + var ser = new XElement(C + "ser", + new XElement(C + "idx", new XAttribute("val", 0)), + new XElement(C + "order", new XAttribute("val", 0)), + Tx("S1"), + cat, + Val(1.0, 2.0)); + var barChart = new XElement(C + "barChart", ser); + var chartElement = Chart(barChart); + + // Act + var exception = Assert.Throws(() => OpenXmlChartParser.Parse(chartElement)); + + // Assert + Assert.Equal("charts-openxml-point-count-too-large", exception.Feature); + } + + #endregion + + #region Malformed-numeric-value rejection + + /// + /// Proves a c:numCache declaring a non-numeric c:ptCount is rejected with + /// rather than letting the explicit + /// (int?) cast's raw propagate uncaught. + /// + [Fact] + public void Parse_NumCacheWithNonNumericPtCount_ThrowsWithMalformedNumericValueFeatureToken() + { + // Arrange + var val = new XElement(C + "val", + new XElement(C + "numRef", + new XElement(C + "f", "Sheet1!$B$2:$B$5"), + new XElement(C + "numCache", + new XElement(C + "formatCode", "General"), + new XElement(C + "ptCount", new XAttribute("val", "not-a-number"))))); + var ser = new XElement(C + "ser", + new XElement(C + "idx", new XAttribute("val", 0)), + new XElement(C + "order", new XAttribute("val", 0)), + Tx("S1"), + Cat("A", "B"), + val); + var barChart = new XElement(C + "barChart", ser); + var chartElement = Chart(barChart); + + // Act + var exception = Assert.Throws(() => OpenXmlChartParser.Parse(chartElement)); + + // Assert + Assert.Equal("charts-openxml-malformed-numeric-value", exception.Feature); + } + + /// + /// Proves a c:pt's overflowing (out-of--range) idx + /// attribute is rejected with rather than + /// letting the explicit (int?) cast's raw + /// propagate uncaught. + /// + [Fact] + public void Parse_PtWithOverflowingIdx_ThrowsWithMalformedNumericValueFeatureToken() + { + // Arrange + var numCache = new XElement(C + "numCache", + new XElement(C + "formatCode", "General"), + new XElement(C + "ptCount", new XAttribute("val", 2)), + new XElement(C + "pt", new XAttribute("idx", "99999999999"), new XElement(C + "v", 1.0))); + var val = new XElement(C + "val", + new XElement(C + "numRef", new XElement(C + "f", "Sheet1!$B$2:$B$5"), numCache)); + var ser = new XElement(C + "ser", + new XElement(C + "idx", new XAttribute("val", 0)), + new XElement(C + "order", new XAttribute("val", 0)), + Tx("S1"), + Cat("A", "B"), + val); + var barChart = new XElement(C + "barChart", ser); + var chartElement = Chart(barChart); + + // Act + var exception = Assert.Throws(() => OpenXmlChartParser.Parse(chartElement)); + + // Assert + Assert.Equal("charts-openxml-malformed-numeric-value", exception.Feature); + } + + /// + /// Proves a c:numCache's c:pt/c:v cached value being non-numeric is rejected + /// with rather than letting the explicit + /// (double?) cast's raw propagate uncaught. + /// + [Fact] + public void Parse_NumCacheWithNonNumericV_ThrowsWithMalformedNumericValueFeatureToken() + { + // Arrange + var numCache = new XElement(C + "numCache", + new XElement(C + "formatCode", "General"), + new XElement(C + "ptCount", new XAttribute("val", 1)), + new XElement(C + "pt", new XAttribute("idx", 0), new XElement(C + "v", "not-a-number"))); + var val = new XElement(C + "val", + new XElement(C + "numRef", new XElement(C + "f", "Sheet1!$B$2:$B$5"), numCache)); + var ser = new XElement(C + "ser", + new XElement(C + "idx", new XAttribute("val", 0)), + new XElement(C + "order", new XAttribute("val", 0)), + Tx("S1"), + Cat("A"), + val); + var barChart = new XElement(C + "barChart", ser); + var chartElement = Chart(barChart); + + // Act + var exception = Assert.Throws(() => OpenXmlChartParser.Parse(chartElement)); + + // Assert + Assert.Equal("charts-openxml-malformed-numeric-value", exception.Feature); + } + + /// + /// Proves a c:valAx/c:scaling/c:min's non-numeric val attribute is rejected + /// with rather than letting the explicit + /// (float?) cast's raw propagate uncaught. + /// + [Fact] + public void Parse_ValAxScalingWithNonNumericMin_ThrowsWithMalformedNumericValueFeatureToken() + { + // Arrange + var barChart = new XElement(C + "barChart", Ser("S1", [1.0, 2.0], ["A", "B"])); + var valAx = new XElement(C + "valAx", + new XElement(C + "scaling", new XElement(C + "min", new XAttribute("val", "not-a-number")))); + var chartElement = new XElement(C + "chart", + new XElement(C + "autoTitleDeleted", new XAttribute("val", 1)), + new XElement(C + "plotArea", new XElement(C + "layout"), barChart, valAx)); + + // Act + var exception = Assert.Throws(() => OpenXmlChartParser.Parse(chartElement)); + + // Assert + Assert.Equal("charts-openxml-malformed-numeric-value", exception.Feature); + } + + #endregion + + #region Unsupported-type / combo / empty-plot-area rejection + + /// Proves every recognized-but-unimplemented chart-type element is rejected with its documented feature token. + [Theory] + [InlineData("radarChart", "charts-openxml-radar-chart")] + [InlineData("scatterChart", "charts-openxml-scatter-chart")] + [InlineData("bubbleChart", "charts-openxml-bubble-chart")] + [InlineData("stockChart", "charts-openxml-stock-chart")] + [InlineData("surfaceChart", "charts-openxml-surface-chart")] + [InlineData("surface3DChart", "charts-openxml-surface-chart")] + [InlineData("bar3DChart", "charts-openxml-3d-chart")] + [InlineData("line3DChart", "charts-openxml-3d-chart")] + [InlineData("pie3DChart", "charts-openxml-3d-chart")] + [InlineData("area3DChart", "charts-openxml-3d-chart")] + [InlineData("ofPieChart", "charts-openxml-of-pie-chart")] + public void Parse_UnsupportedChartType_ThrowsWithExpectedFeatureToken(string localName, string expectedFeature) + { + // Arrange + var unsupported = new XElement(C + localName, Ser("S1", [1.0, 2.0], ["A", "B"])); + var chartElement = Chart(unsupported); + + // Act + var exception = Assert.Throws(() => OpenXmlChartParser.Parse(chartElement)); + + // Assert + Assert.Equal(expectedFeature, exception.Feature); + } + + /// Proves a combo chart (two chart-type elements sharing one plot area) is rejected. + [Fact] + public void Parse_ComboChart_ThrowsWithComboChartFeatureToken() + { + // Arrange + var barChart = new XElement(C + "barChart", Ser("S1", [1.0, 2.0], ["A", "B"])); + var lineChart = new XElement(C + "lineChart", Ser("S2", [3.0, 4.0], ["A", "B"])); + var chartElement = new XElement(C + "chart", + new XElement(C + "autoTitleDeleted", new XAttribute("val", 1)), + new XElement(C + "plotArea", new XElement(C + "layout"), barChart, lineChart)); + + // Act + var exception = Assert.Throws(() => OpenXmlChartParser.Parse(chartElement)); + + // Assert + Assert.Equal("charts-openxml-combo-chart", exception.Feature); + } + + /// Proves a plot area with no recognized chart-type element is rejected. + [Fact] + public void Parse_EmptyPlotArea_ThrowsWithNoChartTypeFeatureToken() + { + // Arrange + var chartElement = new XElement(C + "chart", + new XElement(C + "autoTitleDeleted", new XAttribute("val", 1)), + new XElement(C + "plotArea", new XElement(C + "layout"))); + + // Act + var exception = Assert.Throws(() => OpenXmlChartParser.Parse(chartElement)); + + // Assert + Assert.Equal("charts-openxml-no-chart-type", exception.Feature); + } + + /// + /// Proves a chart-type-named element (for example barChart) in a foreign namespace + /// is not misclassified as a c:barChart merely because its local name matches - + /// it must be rejected as unrecognized, even when it carries c:ser children. + /// + [Fact] + public void Parse_ForeignNamespaceChartTypeElement_ThrowsWithNoChartTypeFeatureToken() + { + // Arrange + XNamespace foreign = "urn:example:not-a-chart-namespace"; + var foreignBarChart = new XElement(foreign + "barChart", Ser("S1", [1.0, 2.0], ["A", "B"])); + var chartElement = new XElement(C + "chart", + new XElement(C + "autoTitleDeleted", new XAttribute("val", 1)), + new XElement(C + "plotArea", new XElement(C + "layout"), foreignBarChart)); + + // Act + var exception = Assert.Throws(() => OpenXmlChartParser.Parse(chartElement)); + + // Assert + Assert.Equal("charts-openxml-no-chart-type", exception.Feature); + } + + #endregion + + #region Real-fixture-derived end-to-end test + + /// + /// Proves a trimmed, adapted excerpt of the real chart1.xml part found in + /// aiden0z-1-chart-and-complex.pptx (see OpenXmlChartFixtures/README.md for + /// exact provenance) parses into the expected shape: a stacked column + /// chart (barDir val="col"), three series, four categories, a styled-but-textless + /// title (so is ), no axis titles, and a + /// bottom legend. + /// + [Fact] + public void Parse_RealFixtureBarChartExcerpt_ParsesExpectedShape() + { + // Arrange - a trimmed, styling-stripped excerpt of the real chart1.xml's c:chart element + // (the real file's likewise has no child - only styling - confirming the + // "present but textless" rule; its has ). + var barChart = new XElement(C + "barChart", + new XElement(C + "barDir", new XAttribute("val", "col")), + new XElement(C + "grouping", new XAttribute("val", "stacked")), + Ser("Series 1", [4.3, 2.5, 3.5, 4.5], ["Category 1", "Category 2", "Category 3", "Category 4"]), + Ser("Series 2", [2.4, 4.4, 1.8, 2.8], ["Category 1", "Category 2", "Category 3", "Category 4"]), + Ser("Series 3", [2.0, 2.0, 3.0, 5.0], ["Category 1", "Category 2", "Category 3", "Category 4"])); + var chartElement = new XElement(C + "chart", + new XElement(C + "title", new XElement(C + "overlay", new XAttribute("val", 0))), + new XElement(C + "autoTitleDeleted", new XAttribute("val", 0)), + new XElement(C + "plotArea", new XElement(C + "layout"), barChart, + new XElement(C + "catAx"), new XElement(C + "valAx")), + new XElement(C + "legend", new XElement(C + "legendPos", new XAttribute("val", "b")))); + + // Act + var chart = OpenXmlChartParser.Parse(chartElement); + + // Assert + Assert.Equal(ChartType.Column, chart.Type); + Assert.Equal(3, chart.Series.Count); + Assert.Equal("Series 1", chart.Series[0].Name); + Assert.Equal([4.3, 2.5, 3.5, 4.5], chart.Series[0].Values); + var categoryAxis = chart.CategoryAxis; + Assert.NotNull(categoryAxis); + Assert.Equal(["Category 1", "Category 2", "Category 3", "Category 4"], categoryAxis.Labels); + Assert.Null(chart.Title); + Assert.Null(categoryAxis.Title); + Assert.Null(chart.ValueAxis); + Assert.Equal(ChartLegendPosition.Bottom, chart.Legend?.Position); + } + + #endregion + + #region Parse-then-render round-trip pixel-sanity tests + + /// + /// Proves a parsed bar/column/line/area/pie/doughnut chart renders onto a correctly + /// sized surface without throwing, for every supported . + /// + [Theory] + [InlineData("barChart", "bar", ChartType.Bar)] + [InlineData("barChart", "col", ChartType.Column)] + [InlineData("lineChart", null, ChartType.Line)] + [InlineData("areaChart", null, ChartType.Area)] + [InlineData("pieChart", null, ChartType.Pie)] + [InlineData("doughnutChart", null, ChartType.Doughnut)] + public void Parse_ThenRender_EveryChartType_ProducesCorrectlySizedSurface(string localName, string? barDir, ChartType expectedType) + { + // Arrange + var chartTypeElement = new XElement(C + localName); + if (barDir is not null) + { + chartTypeElement.Add(new XElement(C + "barDir", new XAttribute("val", barDir))); + } + + chartTypeElement.Add(Ser("S1", [3.0, 7.0], ["A", "B"])); + var chartElement = Chart(chartTypeElement); + + // Act + var chart = OpenXmlChartParser.Parse(chartElement); + using var surface = ChartRenderer.Render(chart, 300, 200); + + // Assert + Assert.Equal(expectedType, chart.Type); + Assert.Equal(300, surface.Width); + Assert.Equal(200, surface.Height); + + // The far top-left corner is always outside every chart type's painted content for this + // chart (no title/legend reserved) - a deliberately type-agnostic smoke assertion that the + // cleared background survives untouched there. + Assert.Equal(White, surface[1, 1]); + } + + /// Proves the real-fixture-derived excerpt parses and then renders onto a correctly sized surface. + [Fact] + public void Parse_ThenRender_RealFixtureBarChartExcerpt_ProducesCorrectlySizedSurface() + { + // Arrange + var barChart = new XElement(C + "barChart", + new XElement(C + "barDir", new XAttribute("val", "col")), + new XElement(C + "grouping", new XAttribute("val", "stacked")), + Ser("Series 1", [4.3, 2.5, 3.5, 4.5], ["Category 1", "Category 2", "Category 3", "Category 4"]), + Ser("Series 2", [2.4, 4.4, 1.8, 2.8], ["Category 1", "Category 2", "Category 3", "Category 4"]), + Ser("Series 3", [2.0, 2.0, 3.0, 5.0], ["Category 1", "Category 2", "Category 3", "Category 4"])); + var chartElement = Chart(barChart, legend: new XElement(C + "legendPos", new XAttribute("val", "b"))); + + // Act + var chart = OpenXmlChartParser.Parse(chartElement); + using var surface = ChartRenderer.Render(chart, 400, 300); + + // Assert + Assert.Equal(400, surface.Width); + Assert.Equal(300, surface.Height); + } + + #endregion +} diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentImageCompositingTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentImageCompositingTests.cs new file mode 100644 index 00000000..6a06b941 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentImageCompositingTests.cs @@ -0,0 +1,90 @@ +using System.Numerics; +using System.Reflection; +using System.Runtime.CompilerServices; +using DemaConsulting.CanvasNet.Canvas; + +namespace DemaConsulting.CanvasNet.Pdf.Tests; + +/// +/// Regression test for the raw-overwrite alpha-compositing bug in PdfDocument.Images.cs's +/// private CompositeImageOntoSurface method: proves it now alpha-blends each sampled +/// source pixel "over" the existing destination pixel (standard Porter-Duff "over" +/// compositing, via the shared internal Rgba32.CompositeOver helper) rather than +/// overwriting it outright. +/// +/// +/// CompositeImageOntoSurface is private, and every decoded PDF image XObject +/// this package currently produces is always fully opaque (/SMask//Mask are not +/// consulted - see that method's own remarks), so there is no public, end-to-end way to drive +/// a non-opaque source pixel through it. This test instead invokes +/// CompositeImageOntoSurface directly via reflection against a synthetic source +/// with a genuine alpha channel, following the same reflection-based +/// internals-testing approach already used elsewhere in this test suite (for example +/// PdfDocumentSymbolicEncodingTests's reflection-based private static field access) - +/// widening the method's accessibility purely for a test is avoided, consistent with that +/// precedent. The owning instance is created via +/// (bypassing its private constructor, +/// which requires a real, parseable PDF byte buffer) since CompositeImageOntoSurface +/// only reads/writes the instance's own _surface field, which this test sets directly. +/// +public class PdfDocumentImageCompositingTests +{ + /// + /// Invokes the private instance method PdfDocument.CompositeImageOntoSurface(Surface, Matrix3x2) + /// against a instance whose private _surface field has + /// been set to , via reflection. + /// + private static void InvokeCompositeImageOntoSurface(Surface destination, Surface image, Matrix3x2 ctm) + { + var document = (PdfDocument)RuntimeHelpers.GetUninitializedObject(typeof(PdfDocument)); + + var surfaceField = typeof(PdfDocument).GetField("_surface", BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new MissingFieldException(nameof(PdfDocument), "_surface"); + surfaceField.SetValue(document, destination); + + var method = typeof(PdfDocument).GetMethod( + "CompositeImageOntoSurface", + BindingFlags.NonPublic | BindingFlags.Instance, + [typeof(Surface), typeof(Matrix3x2)]) + ?? throw new MissingMethodException(nameof(PdfDocument), "CompositeImageOntoSurface"); + + method.Invoke(document, [image, ctm]); + } + + /// + /// Proves CompositeImageOntoSurface alpha-blends each sampled source pixel "over" + /// the existing destination pixel: a fully transparent source pixel with a non-matching + /// (near-white) stored RGB leaves the background completely unchanged (the exact + /// real-world scenario this bug was confirmed against - a logo image with an + /// unassociated-alpha white matte), a fully opaque source pixel exactly replaces the + /// background, and a partially transparent (anti-aliased) source pixel blends to the + /// exact expected bytes per the documented Porter-Duff "over" formula. + /// + [Fact] + public void CompositeImageOntoSurface_SourceImageWithAlphaChannel_AlphaBlendsOntoExistingBackground() + { + using var image = new Surface(3, 1); + image[0, 0] = new Rgba32(255, 255, 255, 0); // fully transparent, non-matching near-white RGB + image[1, 0] = new Rgba32(10, 20, 30, 255); // fully opaque + image[2, 0] = new Rgba32(200, 100, 50, 128); // partially transparent (anti-aliased) + + using var surface = new Surface(3, 1); + var background = new Rgba32(0, 255, 0, 255); // known solid green background + surface[0, 0] = background; + surface[1, 0] = background; + surface[2, 0] = background; + + // Maps the image's unit square [0,1]x[0,1] directly onto the 3x1 destination surface: + // column 0 -> u in [0, 1/3), column 1 -> u in [1/3, 2/3), column 2 -> u in [2/3, 1). + var ctm = Matrix3x2.CreateScale(3f, 1f); + + InvokeCompositeImageOntoSurface(surface, image, ctm); + + // Fully transparent source pixel: background must be completely unchanged. + Assert.Equal(background, surface[0, 0]); + // Fully opaque source pixel: exact replacement. + Assert.Equal(new Rgba32(10, 20, 30, 255), surface[1, 0]); + // Partially transparent source pixel: exact Porter-Duff "over" blended bytes. + Assert.Equal(new Rgba32(100, 177, 25, 255), surface[2, 0]); + } +} diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs index 2539df3d..5f1644b8 100644 --- a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs @@ -734,6 +734,25 @@ private static byte[] BuildEmbeddedFontBytes( .Build(); } + /// + /// Reads the raw bytes of a bundled embedded-resource font file (for example + /// "LiberationSans-Regular.ttf") straight from DemaConsulting.CanvasNet's own + /// assembly, for use as a test fixture's own real /FontFile2 payload - letting a + /// test embed the exact same font bytes + /// would otherwise load, so the test's expectations never depend on which fonts happen to + /// be installed on the host running it. + /// + /// The bundled font file's name, for example "LiberationSans-Regular.ttf". + private static byte[] ReadBundledFontBytes(string bundledFileName) + { + var logicalName = $"DemaConsulting.CanvasNet.Fonts.BundledFonts.{bundledFileName}"; + using var stream = typeof(SystemFontCatalog).Assembly.GetManifestResourceStream(logicalName) + ?? throw new InvalidOperationException($"Embedded resource '{logicalName}' not found."); + using var memory = new MemoryStream(); + stream.CopyTo(memory); + return memory.ToArray(); + } + /// /// Builds a single glyph's Type 2 charstring bytecode: a filled square outline spanning /// font-design-space (100, 100)-(500, 500) (matching @@ -4567,6 +4586,53 @@ public void PdfDocument_PathOps_MoveLineRectCurve_BuildExpectedGeometry() } } + /// + /// Regression guard for the stroke-outliner false inner-ring collapse fix shared with + /// DemaConsulting.CanvasNet.Pptx (see the companion planning report): a closed, + /// stroke-only (no fill) circular path built from four cubic Bezier c curves must + /// render as a thin ring - its own center must remain unpainted (the Transparent + /// background), not the stroke color - rather than collapsing to a solid-filled disc. + /// + /// + /// PdfDocument.PaintStroke strokes an already device-space-baked path (per its own + /// XML doc remarks), so unlike PptxDocument.ResolveStrokeOutline (which + /// strokes shape geometry in native, large-magnitude EMU space before any device + /// transform), a typical PDF content stream's device-space coordinates stay modest - the + /// investigation behind this fix found this makes the shared StrokeOutliner defect + /// unlikely to trigger in practice for PDF. This test still exercises the identical, + /// shared StrokeOutliner.BuildClosedSide code path with a genuinely closed, + /// curved (not merely straight-edged) contour, guarding against any future regression of + /// the underlying fix. + /// + [Fact] + public void PdfDocument_PathOps_StrokeOnlyClosedBezierCircle_RendersThinRingNotSolidDisc() + { + // A closed circle approximated by four cubic Bezier arcs (center 50,50; radius 35; the + // standard kappa = 0.5522847498 control-point offset), stroked only (no fill) with a 6 + // unit-wide line - thick enough to sample reliably, thin enough to leave the circle's own + // center well clear of the ring. + const string content = + "6 w 85 50 m " + + "85 69.33 69.33 85 50 85 c " + + "30.67 85 15 69.33 15 50 c " + + "15 30.67 30.67 15 50 15 c " + + "69.33 15 85 30.67 85 50 c " + + "h S"; + + using var surface = RenderContent(content); + + // The circle's own center (PDF-space (50,50) -> pixel row 100-50=50) remains the + // Transparent background - proving the ring's own interior is not filled solid. + Assert.Equal(default, surface[50, 50]); + + // The circle's own top boundary (PDF-space (50,85) -> pixel row 100-85=15) is painted + // the stroke color - proving the ring itself still paints. + Assert.Equal(Black, surface[50, 15]); + + // A corner of the canvas, well outside the circle's own bounding box, remains unpainted. + Assert.Equal(default, surface[5, 5]); + } + /// Proves that a malformed operand count for a path-construction operator throws . [Theory] [InlineData("m")] @@ -5006,6 +5072,36 @@ public void PdfDocument_Color_PatternColorSpace_TooManyArrayElements_ThrowsUnsup Assert.Equal("pdf-colorspace-Pattern", exception.Feature); } + /// + /// Proves that /CS0 cs naming a nested [/Pattern /Pattern] array (a + /// /Pattern color space whose own declared base is itself another /Pattern + /// color space) declared in /Resources/ColorSpace throws + /// with feature + /// pdf-colorspace-Pattern-nested - not a raw/undocumented + /// (the previously documented "unreachable" + /// fail-closed backstop in ComponentCount's own Family.Pattern arm). + /// Rejecting this nesting proactively at cs-operator time (inside + /// ResolvePatternColorSpace), rather than only when ComponentCount is later + /// reached for an uncolored tiling pattern, both fails closed earlier and follows this + /// unit's established "well-formed but unsupported PDF feature" + /// contract consistently. + /// + [Fact] + public void PdfDocument_Color_PatternColorSpace_NestedPatternBase_ThrowsUnsupportedImageFeatureException() + { + // Arrange: a /Pattern color space array whose own 2nd element is itself a /Pattern array. + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/CS0 cs", + "/ColorSpace << /CS0 [/Pattern /Pattern] >>", + []); + + // Act & Assert + var exception = Assert.Throws(() => RenderPdfBytes(bytes)); + Assert.Equal("pdf-colorspace-Pattern-nested", exception.Feature); + } + /// Proves that /P1 scn (colored pattern operand, name alone, no base space) resolves a declared pattern successfully. [Fact] public void PdfDocument_Color_Scn_ColoredPatternOperand_NameAlone_ResolvesPattern() @@ -5488,6 +5584,37 @@ public void PdfDocument_Images_FlateDecodeTiffPredictor_DecodesExpectedPixels() Assert.Equal(new Canvas.Rgba32(200, 200, 200, 255), surface[75, 75]); } + /// + /// Proves that a FlateDecode+TIFF-predictor (/Predictor 2) stream whose + /// decoded length is not an exact multiple of the row stride (rowBytes) throws + /// rather than silently truncating the incomplete + /// trailing row via integer-division - the same validation + /// + /// already proves for the sibling PNG predictor path. + /// + [Fact] + public void PdfDocument_Images_FlateDecodeTiffPredictor_LengthNotMultipleOfRowStride_ThrowsInvalidDataException() + { + // Arrange: rowBytes is 2 (1 colors * 8 bits * 2 columns / 8), but only 3 bytes of decoded + // payload are supplied for a 2x2 image (needs 4) - not a multiple of the stride. + var compressed = ZlibCompress([10, 20, 30]); + + var imageStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 " + + "/Filter /FlateDecode /DecodeParms << /Predictor 2 /Colors 1 /BitsPerComponent 8 /Columns 2 >>", + compressed); + + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "100 0 0 100 0 0 cm /Im0 Do", + "/XObject << /Im0 5 0 R >>", + [imageStream]); + + // Act & Assert + Assert.Throws(() => RenderPdfBytes(bytes)); + } + /// /// Proves that a /DecodeParms /Colors 0 predictor declaration throws /// instead of being silently accepted (defense-in-depth: @@ -7338,6 +7465,92 @@ public void PdfDocument_BuildResolvedFont_NonStandard14FlagsOnlyUnmatchedFamily_ Assert.NotNull(surface); } + /// + /// Proves PdfDocument.ResolveFallbackFont's new per-character + /// glyph-coverage fallback for an ordinary (non-Symbol/ZapfDingbats) + /// non-embedded font: an unmatched, fixed-pitch /BaseFont family's primary + /// candidate (the host's installed monospace substitute, or the bundled + /// LiberationMono-Regular font when none is installed) does not cover U+0237 + /// (LATIN SMALL LETTER DOTLESS J, confirmed absent from both + /// LiberationMono-Regular.ttf's and the host's own Courier New's cmap + /// via a direct fontTools check performed while authoring this test) - but the + /// always-appended second candidate, plain bundled LiberationSans-Regular, does + /// cover it. A /Differences entry (/uni0237, the Adobe-Glyph-List generic + /// hex-codepoint naming convention) maps character code 1 directly to U+0237 + /// without needing a Standard-14 name. Mirrors PptxDocument.TextLayout.cs's own + /// per-character fallback regression tests for the parallel PPTX text-rendering path. + /// + /// Rather than merely asserting "some pixel was painted" (which a mismatched primary + /// font's own .notdef glyph could also satisfy, since real-world .notdef + /// glyphs are often a non-empty box outline, not literally empty), this test renders a + /// second, reference fixture that embeds the bundled LiberationSans-Regular.ttf's + /// own bytes directly as its /FontFile2 (bypassing ResolveFallbackFont + /// entirely - embedded fonts always take priority, per + /// PdfDocument_BuildResolvedFont_EmbeddedFontFileTakesPriorityOverFallback above) + /// and asserts the two renders are pixel-identical - proving the fixed-pitch fixture + /// painted U+0237 via the exact same bundled LiberationSans-Regular glyph outline, + /// not its mismatched primary candidate's differently-shaped .notdef box. + /// + /// + [Fact] + public void PdfDocument_BuildResolvedFont_PrimaryMatchMissesCodepoint_FallsBackToBundledLiberationSans() + { + // Arrange: an unmatched, fixed-pitch family (/Flags 1 -> FixedPitch bit set, Serif unset) + // with /Differences mapping code 1 to U+0237 - a codepoint the resolved primary candidate + // lacks but the always-appended generic bundled LiberationSans-Regular candidate covers. + var descriptorObj = "<< /Type /FontDescriptor /Flags 1 >>"u8.ToArray(); + var fontDictObj = + "<< /Type /Font /Subtype /TrueType /BaseFont /TotallyUnlikelyFontFamilyXyzzyMono /FirstChar 1 /LastChar 1 /Widths [600] /FontDescriptor 6 0 R /Encoding << /Differences [1 /uni0237] >> >>"u8.ToArray(); + + var bytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 40 Tf 10 30 Td <01> Tj ET", "/Font << /F1 5 0 R >>", + [fontDictObj, descriptorObj]); + + // Arrange a reference fixture: identical content/position/width, but with the bundled + // LiberationSans-Regular.ttf's own bytes embedded directly as /FontFile2, so its render is + // guaranteed (independent of ResolveFallbackFont/the host's installed fonts) to be exactly + // bundled LiberationSans-Regular's own U+0237 glyph outline. + var liberationSansBytes = ReadBundledFontBytes("LiberationSans-Regular.ttf"); + var (referenceResourcesBody, referenceExtraObjects) = BuildSimpleTrueTypeFontResources( + liberationSansBytes, + fontDictExtra: "/FirstChar 1 /LastChar 1 /Widths [600] /Encoding << /Differences [1 /uni0237] >>"); + + var referenceBytes = BuildSinglePagePdfWithResources( + 100, 100, "BT /F1 40 Tf 10 30 Td <01> Tj ET", referenceResourcesBody, referenceExtraObjects); + + // Act + using var surface = RenderPdfBytes(bytes); + using var referenceSurface = RenderPdfBytes(referenceBytes); + + // Assert: some glyph ink was actually painted... + var paintedAnyPixel = false; + for (var y = 0; y < surface.Height && !paintedAnyPixel; y++) + { + for (var x = 0; x < surface.Width; x++) + { + if (surface[x, y].A > 0) + { + paintedAnyPixel = true; + break; + } + } + } + + Assert.True(paintedAnyPixel, "Expected U+0237 to paint via the bundled Liberation Sans coverage fallback, not .notdef."); + + // ...and, crucially, it is pixel-identical to the embedded-LiberationSans reference render - + // proving the painted ink is U+0237's actual LiberationSans-Regular glyph outline, not the + // mismatched primary candidate's own (possibly also non-empty) .notdef box shape. + Assert.Equal(referenceSurface.Width, surface.Width); + Assert.Equal(referenceSurface.Height, surface.Height); + for (var y = 0; y < surface.Height; y++) + { + Assert.True( + surface.GetRowSpanBytes(y).SequenceEqual(referenceSurface.GetRowSpanBytes(y)), + $"Row {y} differs from the LiberationSans-direct reference render."); + } + } + /// /// Proves that an embedded /FontFile2 always takes priority over fallback /// substitution: even though the /BaseFont name is entirely unmatched, the embedded @@ -10267,6 +10480,44 @@ public void PdfDocument_Patterns_ShadingPattern_RadialFunctionType2_FillsVisibly Assert.True(surface[50, 50].R < surface[95, 50].R); } + /// + /// Proves that reads the shading dictionary's own /Domain + /// entry (PDF 32000-1 §8.7.4.5.3) when present, instead of always assuming the default + /// [0, 1] - a non-default /Domain must shift which portion of the axial + /// gradient's own color ramp is sampled at a given position along /Coords. + /// + [Fact] + public void PdfDocument_Patterns_ShadingPattern_NonDefaultDomain_RespectsDomainBoundsWhenSampling() + { + // Arrange: axis from (0,0) to (100,0), /Domain [0.2 0.8], linear black -> white function. + // With the correct (non-default) /Domain, t=0.2 (near x=2) must map to domain value 0.2 + // (not 0.0) and t=0.8 (near x=97) must map to domain value 0.8 (not 1.0) - i.e. the + // sampled color at both ends is noticeably less extreme than it would be under the + // (buggy) always-[0,1] behavior. + var patternDict = "<< /PatternType 2 /Shading 6 0 R >>"u8.ToArray(); + var shadingDict = "<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Domain [0.2 0.8] /Function 7 0 R >>"u8.ToArray(); + var functionStream = BuildStreamObjectBody( + "/FunctionType 2 /Domain [0 1] /C0 [0 0 0] /C1 [1 1 1] /N 1", []); + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/Pattern cs /P1 scn 0 0 100 100 re f", + "/Pattern << /P1 5 0 R >>", + [patternDict, shadingDict, functionStream]); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert: near x=2 (t~0.02 along /Coords), the correct sampled domain value is + // ~0.2 + 0.02*(0.8-0.2) ~= 0.212 -> R ~= 54; under the old always-[0,1] bug it would be + // near-black (R close to 0). Near x=97 (t~0.97), correct domain value is + // ~0.2 + 0.97*0.6 ~= 0.782 -> R ~= 199; under the old bug it would be near-white (R close + // to 255). Generous ranges absorb anti-aliasing/rounding while still distinguishing + // correct-domain sampling from the old hardcoded-[0,1] behavior. + Assert.InRange(surface[2, 50].R, 40, 70); + Assert.InRange(surface[97, 50].R, 185, 215); + } + /// Proves that an unsupported /ShadingType (1 or 4) throws with feature pdf-shading-type-{n}. [Theory] [InlineData(1)] @@ -10442,6 +10693,42 @@ public void PdfDocument_Patterns_TilingPattern_ZeroXStep_ThrowsInvalidDataExcept Assert.Throws(() => RenderPdfBytes(bytes)); } + /// + /// Proves that when a tiling pattern cell's own content stream execution throws partway + /// through (here, an undeclared /XObject reference inside the cell content), the + /// exception still propagates cleanly (the already-allocated tile surface + /// is disposed along that path inside RenderTilingPatternCell, rather than leaked). + /// + /// + /// DemaConsulting.CanvasNet.Canvas.Surface currently holds only managed memory (no finalizer, no + /// unmanaged handle) - it is forward-compatible scaffolding per + /// its own doc comments, so there is presently no CLR-observable native/unmanaged + /// resource for a dedicated leak-detection test to assert against; the GC would reclaim + /// an undisposed instance regardless. This test instead confirms there is no regression + /// in the exception-propagation behavior itself (no secondary exception, no hang, no + /// corrupted shared state) when the fix's new try/catch/Dispose path + /// is exercised. + /// + [Fact] + public void PdfDocument_Patterns_TilingPattern_CellExecutionThrows_PropagatesCleanly() + { + // Arrange: pattern /P1's own content stream references an undeclared XObject, which + // throws partway through RenderTilingPatternCell's nested ExecuteOperators call. + var patternStream = BuildStreamObjectBody( + "/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10", + "/NoSuchXObj Do"u8.ToArray()); + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "/Pattern cs /P1 scn 0 0 100 100 re f", + "/Pattern << /P1 5 0 R >>", + [patternStream]); + + // Act & Assert: propagates cleanly as InvalidDataException, with no secondary/different + // exception obscuring it (which an unhandled Dispose-path failure could otherwise cause). + Assert.Throws(() => RenderPdfBytes(bytes)); + } + /// Proves that a tiling pattern whose own content stream selects/paints with another tiling pattern, nested deeply enough, still triggers the existing MaxFormNestingDepth guard (shared, not a new counter) - confirming the guard fires through this new call path, without any dedicated correctness test of deep nested rendering itself (out of scope). [Fact] public void PdfDocument_Patterns_TilingPattern_NestingDepthExceeded_ThrowsInvalidDataException() diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/DemaConsulting.CanvasNet.Pptx.Tests.csproj b/test/DemaConsulting.CanvasNet.Pptx.Tests/DemaConsulting.CanvasNet.Pptx.Tests.csproj new file mode 100644 index 00000000..e2cc7ffa --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Pptx.Tests/DemaConsulting.CanvasNet.Pptx.Tests.csproj @@ -0,0 +1,87 @@ + + + + + net8.0;net9.0;net10.0 + latest + enable + enable + + Exe + false + true + true + + + true + true + true + latest + + + + + + + + + + + + all + compile; runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxBackgroundTests.cs b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxBackgroundTests.cs new file mode 100644 index 00000000..6c74a0ea --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxBackgroundTests.cs @@ -0,0 +1,527 @@ +using System.Xml.Linq; +using DemaConsulting.CanvasNet.Canvas; + +namespace DemaConsulting.CanvasNet.Pptx.Tests; + +// cspell:ignore srgbclr schemeclr pptx bgpr bgref phclr srgb hlink Hlink Calibri reparents + +/// +/// Unit-level tests for the Phase 2 Follow-Up slide/layout/master background-fill resolver +/// (PptxDocument.Background.cs's ) +/// and the theme's new <a:fmtScheme>/<a:bgFillStyleLst> parsing +/// (PptxDocument.Theme.cs's , exercised indirectly +/// here via directly-constructed instances). Every resolver under test +/// is a plain static method operating on directly-constructed +/// fragments, mirroring 's own established pattern - pixel-level, +/// end-to-end coverage (including real .pptx fixtures) lives in +/// and instead. +/// +public class PptxBackgroundTests +{ + private static readonly XNamespace P = "http://schemas.openxmlformats.org/presentationml/2006/main"; + private static readonly XNamespace A = "http://schemas.openxmlformats.org/drawingml/2006/main"; + + /// Builds an arbitrary, fully-populated test , optionally with a <a:bgFillStyleLst>. + private static PptxTheme BuildTestTheme(IReadOnlyList? bgFillStyleList = null) => + new( + new PptxColorScheme( + Dark1: new Rgba32(0x10, 0x10, 0x10, 255), + Light1: new Rgba32(0xF0, 0xF0, 0xF0, 255), + Dark2: new Rgba32(0x20, 0x20, 0x20, 255), + Light2: new Rgba32(0xE0, 0xE0, 0xE0, 255), + Accent1: new Rgba32(0x11, 0x22, 0x33, 255), + Accent2: new Rgba32(0x44, 0x55, 0x66, 255), + Accent3: new Rgba32(0x77, 0x88, 0x99, 255), + Accent4: new Rgba32(0xAA, 0xBB, 0xCC, 255), + Accent5: new Rgba32(0x01, 0x02, 0x03, 255), + Accent6: new Rgba32(0x04, 0x05, 0x06, 255), + Hyperlink: new Rgba32(0x07, 0x08, 0x09, 255), + FollowedHyperlink: new Rgba32(0x0A, 0x0B, 0x0C, 255)), + new PptxFontScheme( + new PptxFontCollection("MajorLatin", "MajorEA", "MajorCS"), + new PptxFontCollection("MinorLatin", "MinorEA", "MinorCS")), + bgFillStyleList); + + /// Builds a <p:bg><p:bgPr><a:solidFill> element with the given solid color. + private static XElement SolidBg(string hex) => + new(P + "bg", new XElement(P + "bgPr", new XElement(A + "solidFill", new XElement(A + "srgbClr", new XAttribute("val", hex))))); + + // --- ResolveSlideBackgroundFill: slide -> layout -> master precedence ----------------------- + + /// Resolve Slide Background Fill - All Three Tiers Null - Returns Null. + [Fact] + public void ResolveSlideBackgroundFill_AllThreeTiersNull_ReturnsNull() + { + var paint = PptxDocument.ResolveSlideBackgroundFill(null, null, null, BuildTestTheme(), 9144000, 6858000); + + Assert.Null(paint); + } + + /// Resolve Slide Background Fill - Slide Declares One - Resolves Slide's Own Background. + [Fact] + public void ResolveSlideBackgroundFill_SlideDeclaresOne_ResolvesSlideOwnBackground() + { + var paint = PptxDocument.ResolveSlideBackgroundFill( + SolidBg("FF0000"), SolidBg("00FF00"), SolidBg("0000FF"), BuildTestTheme(), 9144000, 6858000); + + var solidFill = Assert.IsType(paint); + Assert.Equal(new Rgba32(0xFF, 0x00, 0x00, 255), solidFill.Color); + } + + /// Resolve Slide Background Fill - Slide Declares None Layout Does - Resolves Layout Own Background. + [Fact] + public void ResolveSlideBackgroundFill_SlideDeclaresNoneLayoutDoes_ResolvesLayoutOwnBackground() + { + var paint = PptxDocument.ResolveSlideBackgroundFill( + null, SolidBg("00FF00"), SolidBg("0000FF"), BuildTestTheme(), 9144000, 6858000); + + var solidFill = Assert.IsType(paint); + Assert.Equal(new Rgba32(0x00, 0xFF, 0x00, 255), solidFill.Color); + } + + /// Resolve Slide Background Fill - Slide And Layout Declare None Master Does - Resolves Master Own Background. + [Fact] + public void ResolveSlideBackgroundFill_SlideAndLayoutDeclareNoneMasterDoes_ResolvesMasterOwnBackground() + { + var paint = PptxDocument.ResolveSlideBackgroundFill( + null, null, SolidBg("0000FF"), BuildTestTheme(), 9144000, 6858000); + + var solidFill = Assert.IsType(paint); + Assert.Equal(new Rgba32(0x00, 0x00, 0xFF, 255), solidFill.Color); + } + + /// Resolve Slide Background Fill - Empty Slide Background Still Wins Over Layout - Matches Placeholder Inheritance Precedent. + [Fact] + public void ResolveSlideBackgroundFill_EmptySlideBackgroundStillWinsOverLayout_MatchesPlaceholderInheritancePrecedent() + { + // An empty-but-present at a higher tier still "wins" (and, having neither + // nor , throws) over a lower tier's own, well-formed - mirroring + // the "first element present at all wins" placeholder-inheritance precedent exactly. + var emptySlideBackground = new XElement(P + "bg"); + + Assert.Throws(() => + PptxDocument.ResolveSlideBackgroundFill( + emptySlideBackground, SolidBg("00FF00"), null, BuildTestTheme(), 9144000, 6858000)); + } + + /// Resolve Slide Background Fill - Bg Element With Neither Bg Pr Nor Bg Ref - Throws Invalid Data Exception. + [Fact] + public void ResolveSlideBackgroundFill_BgElementWithNeitherBgPrNorBgRef_ThrowsInvalidDataException() + { + var malformedBackground = new XElement(P + "bg", new XElement(P + "unknownChild")); + + Assert.Throws(() => + PptxDocument.ResolveSlideBackgroundFill(malformedBackground, null, null, BuildTestTheme(), 9144000, 6858000)); + } + + // --- ResolveSlideBackgroundFill: theme-indexed references ------------------------- + + /// Resolve Slide Background Fill - Bg Ref Idx Zero - Returns No Fill. + [Fact] + public void ResolveSlideBackgroundFill_BgRefIdxZero_ReturnsNoFill() + { + var background = new XElement(P + "bg", new XElement(P + "bgRef", new XAttribute("idx", "0"), new XElement(A + "srgbClr", new XAttribute("val", "FF00FF")))); + + var paint = PptxDocument.ResolveSlideBackgroundFill(background, null, null, BuildTestTheme(), 9144000, 6858000); + + Assert.Same(PptxNoFill.Instance, paint); + } + + /// Resolve Slide Background Fill - Bg Ref Idx One Thousand - Returns No Fill. + [Fact] + public void ResolveSlideBackgroundFill_BgRefIdxOneThousand_ReturnsNoFill() + { + var background = new XElement(P + "bg", new XElement(P + "bgRef", new XAttribute("idx", "1000"), new XElement(A + "srgbClr", new XAttribute("val", "FF00FF")))); + + var paint = PptxDocument.ResolveSlideBackgroundFill(background, null, null, BuildTestTheme(), 9144000, 6858000); + + Assert.Same(PptxNoFill.Instance, paint); + } + + /// Resolve Slide Background Fill - Bg Ref Idx In Fill Style List Range - Throws Pptx Unsupported Feature Exception. + [Theory] + [InlineData("1")] + [InlineData("500")] + [InlineData("999")] + public void ResolveSlideBackgroundFill_BgRefIdxInFillStyleListRange_ThrowsPptxUnsupportedFeatureException(string idx) + { + var background = new XElement(P + "bg", new XElement(P + "bgRef", new XAttribute("idx", idx), new XElement(A + "srgbClr", new XAttribute("val", "FF00FF")))); + + var ex = Assert.Throws(() => + PptxDocument.ResolveSlideBackgroundFill(background, null, null, BuildTestTheme(), 9144000, 6858000)); + Assert.Equal("pptx-bg-fill-style-ref", ex.Feature); + } + + /// Resolve Slide Background Fill - Bg Ref Idx Resolves Bg Fill Style List Entry With Ph Clr Substitution. + [Fact] + public void ResolveSlideBackgroundFill_BgRefIdxResolvesBgFillStyleListEntryWithPhClrSubstitution() + { + // idx 1001 is the bgFillStyleLst's first (0-based) entry. + var bgFillStyleList = new List + { + new(A + "solidFill", new XElement(A + "schemeClr", new XAttribute("val", "phClr"))), + }; + var theme = BuildTestTheme(bgFillStyleList); + var background = new XElement( + P + "bg", + new XElement(P + "bgRef", new XAttribute("idx", "1001"), new XElement(A + "srgbClr", new XAttribute("val", "FF00FF")))); + + var paint = PptxDocument.ResolveSlideBackgroundFill(background, null, null, theme, 9144000, 6858000); + + var solidFill = Assert.IsType(paint); + Assert.Equal(new Rgba32(0xFF, 0x00, 0xFF, 255), solidFill.Color); + } + + /// Resolve Slide Background Fill - Bg Ref Idx Second Entry - Resolves Second Bg Fill Style List Entry. + [Fact] + public void ResolveSlideBackgroundFill_BgRefIdxSecondEntry_ResolvesSecondBgFillStyleListEntry() + { + // idx 1002 is the bgFillStyleLst's second (0-based index 1) entry. + var bgFillStyleList = new List + { + new(A + "solidFill", new XElement(A + "srgbClr", new XAttribute("val", "111111"))), + new(A + "solidFill", new XElement(A + "schemeClr", new XAttribute("val", "phClr"))), + }; + var theme = BuildTestTheme(bgFillStyleList); + var background = new XElement( + P + "bg", + new XElement(P + "bgRef", new XAttribute("idx", "1002"), new XElement(A + "srgbClr", new XAttribute("val", "00FF00")))); + + var paint = PptxDocument.ResolveSlideBackgroundFill(background, null, null, theme, 9144000, 6858000); + + var solidFill = Assert.IsType(paint); + Assert.Equal(new Rgba32(0x00, 0xFF, 0x00, 255), solidFill.Color); + } + + /// + /// Resolving a <p:bgRef>'s matched <a:bgFillStyleLst> entry must + /// not mutate the theme's own, cached tree (the + /// theme is reused across every slide in a document) - this is a regression test for the + /// "wrap the matched entry in a synthetic parent, which clones rather than reparents it" + /// approach 's own remarks document. + /// Mirrors 's own production parsing exactly: the + /// matched entry must still have its original <a:bgFillStyleLst> parent (not + /// , and not the synthetic wrapper) for this regression to be + /// meaningful. + /// + [Fact] + public void ResolveSlideBackgroundFill_BgRefResolution_DoesNotMutateThemeBgFillStyleList() + { + var originalBgFillStyleLst = new XElement( + A + "bgFillStyleLst", + new XElement(A + "solidFill", new XElement(A + "schemeClr", new XAttribute("val", "phClr")))); + var bgFillStyleList = originalBgFillStyleLst.Elements().ToList(); + var bgFillStyleListEntry = bgFillStyleList[0]; + var theme = BuildTestTheme(bgFillStyleList); + var background = new XElement( + P + "bg", + new XElement(P + "bgRef", new XAttribute("idx", "1001"), new XElement(A + "srgbClr", new XAttribute("val", "FF00FF")))); + + _ = PptxDocument.ResolveSlideBackgroundFill(background, null, null, theme, 9144000, 6858000); + + Assert.Same(originalBgFillStyleLst, bgFillStyleListEntry.Parent); + Assert.Single(theme.BgFillStyleList); + Assert.Same(bgFillStyleListEntry, theme.BgFillStyleList[0]); + } + + /// Resolve Slide Background Fill - Bg Ref Idx Past End Of Bg Fill Style List - Throws Invalid Data Exception. + [Fact] + public void ResolveSlideBackgroundFill_BgRefIdxPastEndOfBgFillStyleList_ThrowsInvalidDataException() + { + var background = new XElement( + P + "bg", + new XElement(P + "bgRef", new XAttribute("idx", "1001"), new XElement(A + "srgbClr", new XAttribute("val", "FF00FF")))); + + Assert.Throws(() => + PptxDocument.ResolveSlideBackgroundFill(background, null, null, BuildTestTheme(), 9144000, 6858000)); + } + + /// Resolve Slide Background Fill - Bg Ref With No Idx Attribute - Throws Invalid Data Exception. + [Fact] + public void ResolveSlideBackgroundFill_BgRefWithNoIdxAttribute_ThrowsInvalidDataException() + { + var background = new XElement(P + "bg", new XElement(P + "bgRef", new XElement(A + "srgbClr", new XAttribute("val", "FF00FF")))); + + Assert.Throws(() => + PptxDocument.ResolveSlideBackgroundFill(background, null, null, BuildTestTheme(), 9144000, 6858000)); + } + + /// Resolve Slide Background Fill - Bg Ref With Non Numeric Idx Attribute - Throws Invalid Data Exception. + [Fact] + public void ResolveSlideBackgroundFill_BgRefWithNonNumericIdxAttribute_ThrowsInvalidDataException() + { + var background = new XElement(P + "bg", new XElement(P + "bgRef", new XAttribute("idx", "not-a-number"), new XElement(A + "srgbClr", new XAttribute("val", "FF00FF")))); + + Assert.Throws(() => + PptxDocument.ResolveSlideBackgroundFill(background, null, null, BuildTestTheme(), 9144000, 6858000)); + } + + /// Resolve Slide Background Fill - Bg Ref With No Color Child - Resolves Entry With Default Ph Clr. + [Fact] + public void ResolveSlideBackgroundFill_BgRefWithNoColorChild_ResolvesEntryWithDefaultPhClr() + { + // A with no color child at all is schema-atypical but tolerated: phClr then + // falls back to ResolveColor's own pre-existing default (Dark1) rather than throwing. + var bgFillStyleList = new List + { + new(A + "solidFill", new XElement(A + "schemeClr", new XAttribute("val", "phClr"))), + }; + var theme = BuildTestTheme(bgFillStyleList); + var background = new XElement(P + "bg", new XElement(P + "bgRef", new XAttribute("idx", "1001"))); + + var paint = PptxDocument.ResolveSlideBackgroundFill(background, null, null, theme, 9144000, 6858000); + + var solidFill = Assert.IsType(paint); + Assert.Equal(new Rgba32(0x10, 0x10, 0x10, 255), solidFill.Color); + } + + // --- GetTheme: / parsing -------------------------------------- + + /// Get Theme - No Fmt Scheme - Bg Fill Style List Is Empty. + [Fact] + public void GetTheme_NoFmtScheme_BgFillStyleListIsEmpty() + { + using var stream = BuildThemeOnlyPackage(fmtSchemeXml: string.Empty); + using var document = PptxDocument.Open(stream); + + var theme = document.GetTheme("ppt/theme/theme1.xml"); + + Assert.Empty(theme.BgFillStyleList); + } + + /// Get Theme - Fmt Scheme With No Bg Fill Style Lst - Bg Fill Style List Is Empty. + [Fact] + public void GetTheme_FmtSchemeWithNoBgFillStyleLst_BgFillStyleListIsEmpty() + { + using var stream = BuildThemeOnlyPackage(fmtSchemeXml: """"""); + using var document = PptxDocument.Open(stream); + + var theme = document.GetTheme("ppt/theme/theme1.xml"); + + Assert.Empty(theme.BgFillStyleList); + } + + /// Get Theme - Fmt Scheme With Bg Fill Style Lst - Parses Entries In Document Order. + [Fact] + public void GetTheme_FmtSchemeWithBgFillStyleLst_ParsesEntriesInDocumentOrder() + { + const string fmtSchemeXml = + """ + + + + + + + """; + using var stream = BuildThemeOnlyPackage(fmtSchemeXml); + using var document = PptxDocument.Open(stream); + + var theme = document.GetTheme("ppt/theme/theme1.xml"); + + Assert.Equal(2, theme.BgFillStyleList.Count); + Assert.Equal(A + "solidFill", theme.BgFillStyleList[0].Name); + Assert.Equal(A + "solidFill", theme.BgFillStyleList[1].Name); + } + + // --- GetTheme: / and parsing (Phase 2 Follow-Up) -- + + /// Get Theme - No Fmt Scheme - Fill Style List And Ln Style List Default To Empty. + [Fact] + public void GetTheme_NoFmtScheme_FillStyleListAndLnStyleListDefaultToEmpty() + { + using var stream = BuildThemeOnlyPackage(fmtSchemeXml: string.Empty); + using var document = PptxDocument.Open(stream); + + var theme = document.GetTheme("ppt/theme/theme1.xml"); + + Assert.Empty(theme.FillStyleList); + Assert.Empty(theme.LnStyleList); + } + + /// Get Theme - Fmt Scheme With No Fill Style Lst Or Ln Style Lst - Both Default To Empty. + [Fact] + public void GetTheme_FmtSchemeWithNoFillStyleLstOrLnStyleLst_BothDefaultToEmpty() + { + using var stream = BuildThemeOnlyPackage(fmtSchemeXml: """"""); + using var document = PptxDocument.Open(stream); + + var theme = document.GetTheme("ppt/theme/theme1.xml"); + + Assert.Empty(theme.FillStyleList); + Assert.Empty(theme.LnStyleList); + } + + /// Get Theme - Fmt Scheme With Fill Style Lst And Ln Style Lst - Parses Entries In Document Order. + [Fact] + public void GetTheme_FmtSchemeWithFillStyleLstAndLnStyleLst_ParsesEntriesInDocumentOrder() + { + const string fmtSchemeXml = + """ + + + + + + + + + + + + + """; + using var stream = BuildThemeOnlyPackage(fmtSchemeXml); + using var document = PptxDocument.Open(stream); + + var theme = document.GetTheme("ppt/theme/theme1.xml"); + + Assert.Equal(3, theme.FillStyleList.Count); + Assert.Equal(A + "solidFill", theme.FillStyleList[0].Name); + Assert.Equal(A + "gradFill", theme.FillStyleList[1].Name); + Assert.Equal(A + "gradFill", theme.FillStyleList[2].Name); + + Assert.Equal(3, theme.LnStyleList.Count); + Assert.Equal("6350", (string?)theme.LnStyleList[0].Attribute("w")); + Assert.Equal("12700", (string?)theme.LnStyleList[1].Attribute("w")); + Assert.Equal("19050", (string?)theme.LnStyleList[2].Attribute("w")); + } + + /// Builds a minimal, navigable presentation package whose single slide master's theme declares the given <a:fmtScheme> inner XML (or none, when empty). + private static Stream BuildThemeOnlyPackage(string fmtSchemeXml) + { + const string contentTypesXml = + """ + + + + + + """; + + const string packageRelsXml = + """ + + + + + """; + + const string presentationXml = + """ + + + + + """; + + const string presentationRelsXml = + """ + + + + + """; + + const string slideXml = + """ + + + + """; + + const string slideRelsXml = + """ + + + + + """; + + const string layoutXml = + """ + + + + """; + + const string layoutRelsXml = + """ + + + + + """; + + const string masterXml = + """ + + + + + + """; + + const string masterRelsXml = + """ + + + + + """; + + var themeXml = + $""" + + + + + + + + + + + + + + + + + + + + + {fmtSchemeXml} + + + """; + + var stream = new MemoryStream(); + using (var archive = new System.IO.Compression.ZipArchive(stream, System.IO.Compression.ZipArchiveMode.Create, leaveOpen: true)) + { + WriteTextEntry(archive, "[Content_Types].xml", contentTypesXml); + WriteTextEntry(archive, "_rels/.rels", packageRelsXml); + WriteTextEntry(archive, "ppt/presentation.xml", presentationXml); + WriteTextEntry(archive, "ppt/_rels/presentation.xml.rels", presentationRelsXml); + WriteTextEntry(archive, "ppt/slides/slide1.xml", slideXml); + WriteTextEntry(archive, "ppt/slides/_rels/slide1.xml.rels", slideRelsXml); + WriteTextEntry(archive, "ppt/slideLayouts/slideLayout1.xml", layoutXml); + WriteTextEntry(archive, "ppt/slideLayouts/_rels/slideLayout1.xml.rels", layoutRelsXml); + WriteTextEntry(archive, "ppt/slideMasters/slideMaster1.xml", masterXml); + WriteTextEntry(archive, "ppt/slideMasters/_rels/slideMaster1.xml.rels", masterRelsXml); + WriteTextEntry(archive, "ppt/theme/theme1.xml", themeXml); + } + + stream.Position = 0; + return stream; + } + + private static void WriteTextEntry(System.IO.Compression.ZipArchive archive, string name, string content) + { + var entry = archive.CreateEntry(name); + using var entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream, System.Text.Encoding.UTF8); + writer.Write(content); + } +} diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxBulletTests.cs b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxBulletTests.cs new file mode 100644 index 00000000..6d109442 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxBulletTests.cs @@ -0,0 +1,1265 @@ +using System.Xml.Linq; +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Fonts; +using DemaConsulting.CanvasNet.Tests.TestSupport; + +namespace DemaConsulting.CanvasNet.Pptx.Tests; + +// cspell:ignore pptx buchar buautonum bunone buclrtx buclr bufonttx bufont busztx buszpct buszpts +// cspell:ignore arabicparenr arabicplain alphalc alphauc alphalcparenr alphaucparenr romanlc romanuc +// cspell:ignore romanlcparenr romanucparenr unitsperem marl + +/// +/// Unit-level tests for bullet/numbering rendering (Phase 2 Follow-Up: Bullets and +/// Numbering): raw parsing (PptxDocument.Text.cs's ParseBulletProperties), +/// attribute-level inheritance across the four independent choice-group chains +/// (PptxDocument.TextInheritance.cs's ResolveEffectiveBulletProperties), +/// auto-number string formatting (PptxDocument.Bullets.cs's FormatAutoNumber), +/// and the layout engine's bullet-glyph positioning/hanging-indent fix/auto-number +/// sequencing (PptxDocument.TextLayout.cs). Complements +/// (run/paragraph parsing-inheritance conventions) and +/// (word-wrap/alignment/anchor/autofit, including this file's own synthetic-font convention). +/// +public class PptxBulletTests +{ + private static readonly XNamespace DrawingNs = "http://schemas.openxmlformats.org/drawingml/2006/main"; + private static readonly XNamespace PresentationNs = "http://schemas.openxmlformats.org/presentationml/2006/main"; + + #region Synthetic font/theme/placeholder test fixtures + + // Synthetic font: UnitsPerEm 1000, ascender 800, descender -200, lineGap 0 (natural line + // height == size, mirroring PptxTextLayoutTests' own NewFont() convention). Maps every + // character this file's bullet strings/run text ever needs: 'A'/' ' (run text), the arabic + // digits '1'-'3' plus '.'/')' (arabicPeriod/arabicParenR/arabicPlain), 'a'/'b' plus 'A'/'B' + // (alpha schemes - reuses 'A'), and 'i'/'v'/'I'/'V' (roman schemes). + // .notdef (index 0), advance 0, zero contours. + // 'A' (index 1, advance 500) - the only glyph with a visible (non-empty) outline, so pixel + // painting tests have real ink to sample. + // every other mapped character (indices 2-15), zero contours, each its own distinct advance + // width so glyph-origin-X math stays simple and unambiguous per character. + private static TrueTypeFont NewFont() + { + var notdef = SyntheticFontBuilder.SimpleGlyph(); + var glyphA = SyntheticFontBuilder.SimpleGlyph([(0, 0, true), (500, 0, true), (250, 800, true)]); + var blank = SyntheticFontBuilder.SimpleGlyph(); + + // Every mapped character other than 'A' (glyph index 1, built/measured above) shares the + // same zero-contour "blank" outline, at its own distinct advance width. + var mappings = new (int Codepoint, int GlyphIndex, int Advance)[] + { + (' ', 2, 200), + ('1', 3, 300), + ('2', 4, 300), + ('3', 5, 300), + ('.', 6, 100), + (')', 7, 150), + ('a', 8, 300), + ('b', 9, 300), + ('B', 10, 300), + ('i', 11, 100), + ('v', 12, 300), + ('I', 13, 100), + ('V', 14, 300), + }; + + var advanceWidths = new List { 0, 500 }; + var glyphLengths = new List { notdef.Length, glyphA.Length }; + var glyphBytes = new List { }; + glyphBytes.AddRange(notdef); + glyphBytes.AddRange(glyphA); + var cmapMappings = new List<(int Codepoint, int GlyphId)> { (65, 1) }; + + foreach (var (codepoint, glyphIndex, advance) in mappings) + { + advanceWidths.Add(advance); + glyphLengths.Add(blank.Length); + glyphBytes.AddRange(blank); + cmapMappings.Add((codepoint, glyphIndex)); + } + + var cmap = SyntheticFontBuilder.CmapFormat4(3, 1, cmapMappings); + + var data = new SyntheticFontBuilder() + .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) + .AddTable("maxp", SyntheticFontBuilder.Maxp(advanceWidths.Count)) + .AddTable("hhea", SyntheticFontBuilder.Hhea(800, -200, 0, advanceWidths.Count)) + .AddTable("hmtx", SyntheticFontBuilder.Hmtx(advanceWidths)) + .AddTable("loca", SyntheticFontBuilder.Loca(glyphLengths, longFormat: false)) + .AddTable("glyf", glyphBytes.ToArray()) + .AddTable("cmap", cmap) + .Build(); + + using var stream = new MemoryStream(data); + return TrueTypeFont.Load(stream); + } + + private static readonly Func ConstantFontResolver = (_, _, _) => NewFont(); + + private static PptxTheme BuildTestTheme() => + new( + new PptxColorScheme( + new Rgba32(10, 10, 10, 255), new Rgba32(20, 20, 20, 255), new Rgba32(30, 30, 30, 255), new Rgba32(40, 40, 40, 255), + new Rgba32(50, 50, 50, 255), new Rgba32(60, 60, 60, 255), new Rgba32(70, 70, 70, 255), new Rgba32(80, 80, 80, 255), + new Rgba32(90, 90, 90, 255), new Rgba32(100, 100, 100, 255), new Rgba32(110, 110, 110, 255), new Rgba32(120, 120, 120, 255)), + new PptxFontScheme( + new PptxFontCollection("ThemeMajorLatin", "MajorEA", "MajorCS"), + new PptxFontCollection("ThemeMinorLatin", "MinorEA", "MinorCS"))); + + private static PptxPlaceholderProperties EmptyPlaceholderProperties(PptxTheme theme, PptxMasterTextStyles? masterTextStyles = null) => + new(null, null, theme, masterTextStyles); + + private static PptxTextRun Run(XElement? rPr, string text = "A") => new(rPr, text); + + private static PptxParagraph Paragraph(XElement? pPr, params PptxTextRun[] runs) => + new(PptxDocument.ParseParagraphProperties(pPr), runs.Select(run => (PptxParagraphItem)new PptxRunItem(run)).ToList()); + + /// + /// Resolves 's own first run's effective properties (or + /// for a run-less paragraph) - the same value a real caller + /// (ResolveTextLayout) computes and threads into + /// 's own + /// firstRunProperties parameter for the "follow text" bullet-modifier fallback. + /// + private static PptxEffectiveRunProperties? FirstRunProps(PptxParagraph paragraph, PptxPlaceholderProperties placeholderProperties, PptxTheme theme, string placeholderType) => + paragraph.Items.OfType().FirstOrDefault() is { } firstRunItem + ? PptxDocument.ResolveEffectiveRunProperties(firstRunItem.Run, paragraph, placeholderProperties, theme, placeholderType) + : null; + + /// + /// Builds a one-paragraph-per-call <p:txBody> (a no-inset + /// <a:bodyPr>, for exact EMU math) with a single run per paragraph, each + /// paragraph's own <a:pPr> fragment supplied verbatim - mirroring + /// 's own BuildSingleRunTextBody convention, but + /// supporting multiple paragraphs (needed for auto-number sequencing/reset tests). + /// + private static PptxTextBody BuildTextBody(params (string ParagraphPPr, string RunText)[] paragraphs) + { + var bodyPr = new XElement( + DrawingNs + "bodyPr", + new XAttribute("lIns", "0"), new XAttribute("tIns", "0"), new XAttribute("rIns", "0"), new XAttribute("bIns", "0")); + + var pElements = paragraphs.Select(p => + new XElement( + DrawingNs + "p", + XElement.Parse(p.ParagraphPPr), + new XElement( + DrawingNs + "r", + new XElement(DrawingNs + "rPr", new XAttribute("sz", "100")), + new XElement(DrawingNs + "t", p.RunText)))); + + var txBody = new XElement(PresentationNs + "txBody", bodyPr, pElements); + return PptxDocument.ParseTextBody(txBody); + } + + private static PptxTextLayout Layout(PptxTextBody textBody, float widthEmu = 500000f, float heightEmu = 500000f) => + PptxDocument.ResolveTextLayout(textBody, new PptxPlaceholderProperties(null, null, BuildTestTheme()), BuildTestTheme(), "body", widthEmu, heightEmu, ConstantFontResolver); + + /// + /// Builds a from raw <a:pPr> markup strings, each + /// paragraph either with a single run of its own text (when non-null) or with + /// NO <a:r> child at all (when - only a synthetic + /// <a:endParaRPr>, mirroring a real "blank/spacer" paragraph such as + /// PowerPoint's own click-to-add-a-blank-line behavior) - used by the run-less-paragraph + /// bullet-suppression tests (Phase 2 Follow-Up: Bullets and Numbering's empty-paragraph fix). + /// + private static PptxTextBody BuildTextBodyRunless(params (string ParagraphPPr, string? RunText)[] paragraphs) + { + var bodyPr = new XElement( + DrawingNs + "bodyPr", + new XAttribute("lIns", "0"), new XAttribute("tIns", "0"), new XAttribute("rIns", "0"), new XAttribute("bIns", "0")); + + var pElements = paragraphs.Select(p => + new XElement( + DrawingNs + "p", + XElement.Parse(p.ParagraphPPr), + p.RunText is null + ? new XElement(DrawingNs + "endParaRPr", new XAttribute("sz", "100")) + : new XElement( + DrawingNs + "r", + new XElement(DrawingNs + "rPr", new XAttribute("sz", "100")), + new XElement(DrawingNs + "t", p.RunText)))); + + var txBody = new XElement(PresentationNs + "txBody", bodyPr, pElements); + return PptxDocument.ParseTextBody(txBody); + } + + #endregion + + #region Raw parsing (PptxDocument.Text.cs) + + /// Proves an absent <a:pPr> resolves . + [Fact] + public void ParseParagraphProperties_Absent_ResolvesEmptyBulletProperties() + { + var result = PptxDocument.ParseParagraphProperties(null); + + Assert.Same(PptxRawBulletProperties.Empty, result.EffectiveBulletProperties); + } + + /// Proves <a:buNone/> is captured as the type choice-group element. + [Fact] + public void ParseBulletProperties_BuNone_CapturesTypeElement() + { + var pPr = new XElement(DrawingNs + "pPr", new XElement(DrawingNs + "buNone")); + + var result = PptxDocument.ParseParagraphProperties(pPr); + + Assert.NotNull(result.EffectiveBulletProperties.TypeElement); + Assert.Equal(DrawingNs + "buNone", result.EffectiveBulletProperties.TypeElement.Name); + } + + /// Proves <a:buChar char="•"/> is captured as the type choice-group element. + [Fact] + public void ParseBulletProperties_BuChar_CapturesTypeElement() + { + var pPr = new XElement(DrawingNs + "pPr", new XElement(DrawingNs + "buChar", new XAttribute("char", "\u2022"))); + + var result = PptxDocument.ParseParagraphProperties(pPr); + + Assert.NotNull(result.EffectiveBulletProperties.TypeElement); + Assert.Equal(DrawingNs + "buChar", result.EffectiveBulletProperties.TypeElement.Name); + Assert.Equal("\u2022", (string?)result.EffectiveBulletProperties.TypeElement.Attribute("char")); + } + + /// Proves <a:buAutoNum type="arabicPeriod" startAt="3"/> is captured as the type choice-group element. + [Fact] + public void ParseBulletProperties_BuAutoNum_CapturesTypeElement() + { + var pPr = new XElement(DrawingNs + "pPr", new XElement(DrawingNs + "buAutoNum", new XAttribute("type", "arabicPeriod"), new XAttribute("startAt", "3"))); + + var result = PptxDocument.ParseParagraphProperties(pPr); + + Assert.NotNull(result.EffectiveBulletProperties.TypeElement); + Assert.Equal(DrawingNs + "buAutoNum", result.EffectiveBulletProperties.TypeElement.Name); + } + + /// Proves the color/font/size choice-groups each independently capture their own present member. + [Fact] + public void ParseBulletProperties_ColorFontSizeModifiers_CapturesEachIndependently() + { + var pPr = new XElement( + DrawingNs + "pPr", + new XElement(DrawingNs + "buClr", new XElement(DrawingNs + "srgbClr", new XAttribute("val", "FF0000"))), + new XElement(DrawingNs + "buFont", new XAttribute("typeface", "Arial")), + new XElement(DrawingNs + "buSzPct", new XAttribute("val", "150000"))); + + var result = PptxDocument.ParseParagraphProperties(pPr); + + Assert.Equal(DrawingNs + "buClr", result.EffectiveBulletProperties.ColorElement!.Name); + Assert.Equal(DrawingNs + "buFont", result.EffectiveBulletProperties.FontElement!.Name); + Assert.Equal(DrawingNs + "buSzPct", result.EffectiveBulletProperties.SizeElement!.Name); + } + + /// Proves <a:buClrTx/>/<a:buFontTx/>/<a:buSzTx/> ("follow text") are each captured as their own choice-group's element. + [Fact] + public void ParseBulletProperties_FollowTextModifiers_CapturesEachIndependently() + { + var pPr = new XElement( + DrawingNs + "pPr", + new XElement(DrawingNs + "buClrTx"), + new XElement(DrawingNs + "buFontTx"), + new XElement(DrawingNs + "buSzTx")); + + var result = PptxDocument.ParseParagraphProperties(pPr); + + Assert.Equal(DrawingNs + "buClrTx", result.EffectiveBulletProperties.ColorElement!.Name); + Assert.Equal(DrawingNs + "buFontTx", result.EffectiveBulletProperties.FontElement!.Name); + Assert.Equal(DrawingNs + "buSzTx", result.EffectiveBulletProperties.SizeElement!.Name); + } + + #endregion + + #region Inheritance - type choice-group (ResolveEffectiveBulletProperties) + + /// Proves a paragraph with no bullet markup anywhere in its own chain resolves the conservative default. + [Fact] + public void ResolveEffectiveParagraphProperties_NoBulletMarkupAnywhere_ResolvesNone() + { + var theme = BuildTestTheme(); + var paragraph = Paragraph(null, Run(null)); + var placeholderProperties = EmptyPlaceholderProperties(theme); + + var result = PptxDocument.ResolveEffectiveParagraphProperties(paragraph, placeholderProperties, "body"); + + Assert.Equal(PptxBulletKind.None, result.Bullet!.Kind); + } + + /// Proves the paragraph's own <a:buChar/> wins over every other tier. + [Fact] + public void ResolveEffectiveParagraphProperties_OwnBuChar_WinsOverEveryOtherTier() + { + var theme = BuildTestTheme(); + var pPr = new XElement(DrawingNs + "pPr", new XElement(DrawingNs + "buChar", new XAttribute("char", "\u2022"))); + var paragraph = Paragraph(pPr, Run(null)); + var lstStyle = new XElement(DrawingNs + "lstStyle", new XElement(DrawingNs + "lvl1pPr", new XElement(DrawingNs + "buNone"))); + var placeholderProperties = new PptxPlaceholderProperties(null, lstStyle, theme); + + var result = PptxDocument.ResolveEffectiveParagraphProperties(paragraph, placeholderProperties, "body"); + + Assert.Equal(PptxBulletKind.Char, result.Bullet!.Kind); + Assert.Equal("\u2022", result.Bullet.Character); + } + + /// Proves an explicit <a:buNone/> at the paragraph's own tier suppresses an inherited placeholder-level bullet. + [Fact] + public void ResolveEffectiveParagraphProperties_OwnBuNone_SuppressesInheritedBullet() + { + var theme = BuildTestTheme(); + var pPr = new XElement(DrawingNs + "pPr", new XElement(DrawingNs + "buNone")); + var paragraph = Paragraph(pPr, Run(null)); + var lstStyle = new XElement(DrawingNs + "lstStyle", new XElement(DrawingNs + "lvl1pPr", new XElement(DrawingNs + "buChar", new XAttribute("char", "\u2022")))); + var placeholderProperties = new PptxPlaceholderProperties(null, lstStyle, theme); + + var result = PptxDocument.ResolveEffectiveParagraphProperties(paragraph, placeholderProperties, "body"); + + Assert.Equal(PptxBulletKind.None, result.Bullet!.Kind); + } + + /// Proves a placeholder level-indexed bullet wins when the paragraph itself declares no bullet markup. + [Fact] + public void ResolveEffectiveParagraphProperties_PlaceholderLevelBuAutoNum_WinsWhenParagraphDeclaresNothing() + { + var theme = BuildTestTheme(); + var paragraph = Paragraph(null, Run(null)); + var lstStyle = new XElement( + DrawingNs + "lstStyle", + new XElement(DrawingNs + "lvl1pPr", new XElement(DrawingNs + "buAutoNum", new XAttribute("type", "arabicParenR")))); + var placeholderProperties = new PptxPlaceholderProperties(null, lstStyle, theme); + + var result = PptxDocument.ResolveEffectiveParagraphProperties(paragraph, placeholderProperties, "body"); + + Assert.Equal(PptxBulletKind.AutoNum, result.Bullet!.Kind); + Assert.Equal("arabicParenR", result.Bullet.AutoNumType); + Assert.Equal(1, result.Bullet.AutoNumStartAt); + } + + /// Proves a master <p:bodyStyle> level-indexed bullet wins when everything above is absent. + [Fact] + public void ResolveEffectiveParagraphProperties_MasterBodyStyleBuChar_WinsWhenEverythingAboveIsAbsent() + { + var theme = BuildTestTheme(); + var paragraph = Paragraph(null, Run(null)); + var bodyStyle = new XElement( + DrawingNs + "bodyStyle", + new XElement(DrawingNs + "lvl1pPr", new XElement(DrawingNs + "buChar", new XAttribute("char", "-")))); + var masterTextStyles = new PptxMasterTextStyles(null, bodyStyle, null); + var placeholderProperties = EmptyPlaceholderProperties(theme, masterTextStyles); + + var result = PptxDocument.ResolveEffectiveParagraphProperties(paragraph, placeholderProperties, "body"); + + Assert.Equal(PptxBulletKind.Char, result.Bullet!.Kind); + Assert.Equal("-", result.Bullet.Character); + } + + /// + /// Proves "sldNum"/"dt"/"ftr" field placeholder types never resolve a + /// bullet, even when the master's own <p:bodyStyle> declares one for the same + /// level that the preceding "body"-placeholder-type test proves *does* win a bullet + /// - this is the regression guard for the stray-bullet-on-slide-number defect found during + /// visual QA (SelectMasterTextStyle routes these types to bodyStyle rather + /// than the bullet-free otherStyle). + /// + [Theory] + [InlineData("sldNum")] + [InlineData("dt")] + [InlineData("ftr")] + public void ResolveEffectiveParagraphProperties_SldNumDtFtrPlaceholderType_SuppressesMasterBodyStyleBullet(string placeholderType) + { + var theme = BuildTestTheme(); + var paragraph = Paragraph(null, Run(null)); + var bodyStyle = new XElement( + DrawingNs + "bodyStyle", + new XElement(DrawingNs + "lvl1pPr", new XElement(DrawingNs + "buChar", new XAttribute("char", "-")))); + var masterTextStyles = new PptxMasterTextStyles(null, bodyStyle, null); + var placeholderProperties = EmptyPlaceholderProperties(theme, masterTextStyles); + + var result = PptxDocument.ResolveEffectiveParagraphProperties(paragraph, placeholderProperties, placeholderType); + + Assert.Equal(PptxBulletKind.None, result.Bullet!.Kind); + } + + /// + /// Proves "sldNum"/"dt"/"ftr" field placeholder types still resolve + /// their own, explicitly-declared <a:buChar> override, even when the same + /// master <p:bodyStyle> bucket used by the preceding suppression test also + /// declares a bullet for the same level - this is the regression guard for the retry-1 + /// fix's own over-broad unconditional early-return (an own-paragraph explicit bullet + /// choice must always win over any style-bucket default, regardless of placeholder type, + /// per real OOXML/PowerPoint semantics). The two tests together isolate the corrected + /// behavior: master-tier type exclusion for these three placeholder types, without + /// disturbing the paragraph's own explicit override. + /// + [Theory] + [InlineData("sldNum")] + [InlineData("dt")] + [InlineData("ftr")] + public void ResolveEffectiveParagraphProperties_SldNumDtFtrPlaceholderTypeWithOwnBuChar_StillResolvesOwnBullet(string placeholderType) + { + var theme = BuildTestTheme(); + var pPr = new XElement(DrawingNs + "pPr", new XElement(DrawingNs + "buChar", new XAttribute("char", "*"))); + var paragraph = Paragraph(pPr, Run(null)); + var bodyStyle = new XElement( + DrawingNs + "bodyStyle", + new XElement(DrawingNs + "lvl1pPr", new XElement(DrawingNs + "buChar", new XAttribute("char", "-")))); + var masterTextStyles = new PptxMasterTextStyles(null, bodyStyle, null); + var placeholderProperties = EmptyPlaceholderProperties(theme, masterTextStyles); + + var result = PptxDocument.ResolveEffectiveParagraphProperties(paragraph, placeholderProperties, placeholderType); + + Assert.Equal(PptxBulletKind.Char, result.Bullet!.Kind); + Assert.Equal("*", result.Bullet.Character); + } + + /// Proves an absent type/startAt on <a:buAutoNum/> resolves the OOXML schema's own documented defaults. + [Fact] + public void ResolveEffectiveParagraphProperties_BuAutoNumNoAttributes_ResolvesSchemaDefaults() + { + var theme = BuildTestTheme(); + var pPr = new XElement(DrawingNs + "pPr", new XElement(DrawingNs + "buAutoNum")); + var paragraph = Paragraph(pPr, Run(null)); + var placeholderProperties = EmptyPlaceholderProperties(theme); + + var result = PptxDocument.ResolveEffectiveParagraphProperties(paragraph, placeholderProperties, "body"); + + Assert.Equal("arabicPeriod", result.Bullet!.AutoNumType); + Assert.Equal(1, result.Bullet.AutoNumStartAt); + } + + /// + /// Proves a non-numeric <a:buAutoNum startAt="..."/> is rejected with + /// rather than letting the explicit (int?) + /// cast's raw propagate uncaught. + /// + [Fact] + public void ResolveEffectiveParagraphProperties_BuAutoNumNonNumericStartAt_ThrowsInvalidDataException() + { + var theme = BuildTestTheme(); + var pPr = new XElement( + DrawingNs + "pPr", + new XElement(DrawingNs + "buAutoNum", new XAttribute("startAt", "not-a-number"))); + var paragraph = Paragraph(pPr, Run(null)); + var placeholderProperties = EmptyPlaceholderProperties(theme); + + Assert.Throws( + () => PptxDocument.ResolveEffectiveParagraphProperties(paragraph, placeholderProperties, "body")); + } + + /// + /// Proves a non-numeric <a:buSzPct val="..."/> is rejected with + /// rather than letting the explicit (float?) + /// cast's raw propagate uncaught. + /// + [Fact] + public void ResolveEffectiveParagraphProperties_BuSzPctNonNumericVal_ThrowsInvalidDataException() + { + var theme = BuildTestTheme(); + var pPr = new XElement( + DrawingNs + "pPr", + new XElement(DrawingNs + "buChar", new XAttribute("char", "*")), + new XElement(DrawingNs + "buSzPct", new XAttribute("val", "not-a-number"))); + var paragraph = Paragraph(pPr, Run(null)); + var placeholderProperties = EmptyPlaceholderProperties(theme); + + Assert.Throws( + () => PptxDocument.ResolveEffectiveParagraphProperties(paragraph, placeholderProperties, "body")); + } + + /// + /// Proves a non-numeric <a:buSzPts val="..."/> is rejected with + /// rather than letting the explicit (float?) + /// cast's raw propagate uncaught. + /// + [Fact] + public void ResolveEffectiveParagraphProperties_BuSzPtsNonNumericVal_ThrowsInvalidDataException() + { + var theme = BuildTestTheme(); + var pPr = new XElement( + DrawingNs + "pPr", + new XElement(DrawingNs + "buChar", new XAttribute("char", "*")), + new XElement(DrawingNs + "buSzPts", new XAttribute("val", "not-a-number"))); + var paragraph = Paragraph(pPr, Run(null)); + var placeholderProperties = EmptyPlaceholderProperties(theme); + + Assert.Throws( + () => PptxDocument.ResolveEffectiveParagraphProperties(paragraph, placeholderProperties, "body")); + } + + #endregion + + #region Inheritance - color/font/size choice-groups are independent of each other and of the type choice-group + + /// + /// Proves a paragraph may declare only its own bullet character while separately + /// inheriting its color/font/size from the placeholder level style - the four + /// choice-groups resolve independently, not as one monolithic tier-wins-everything object. + /// + [Fact] + public void ResolveEffectiveParagraphProperties_OwnTypeOnly_InheritsColorFontSizeIndependently() + { + var theme = BuildTestTheme(); + var pPr = new XElement(DrawingNs + "pPr", new XElement(DrawingNs + "buChar", new XAttribute("char", "\u2022"))); + var paragraph = Paragraph(pPr, Run(null)); + var lstStyle = new XElement( + DrawingNs + "lstStyle", + new XElement( + DrawingNs + "lvl1pPr", + new XElement(DrawingNs + "buClr", new XElement(DrawingNs + "srgbClr", new XAttribute("val", "FF0000"))), + new XElement(DrawingNs + "buFont", new XAttribute("typeface", "Courier New")), + new XElement(DrawingNs + "buSzPts", new XAttribute("val", "2400")))); + var placeholderProperties = new PptxPlaceholderProperties(null, lstStyle, theme); + + var result = PptxDocument.ResolveEffectiveParagraphProperties(paragraph, placeholderProperties, "body"); + + Assert.Equal(PptxBulletKind.Char, result.Bullet!.Kind); + Assert.Equal("\u2022", result.Bullet.Character); + Assert.Equal(new Rgba32(255, 0, 0, 255), result.Bullet.Color); + Assert.Equal("Courier New", result.Bullet.FontFamily); + Assert.Equal(2400f * 127f, result.Bullet.SizeEmu); + } + + /// Proves <a:buClrTx/> (or no color markup at all) resolves to the paragraph's own first run's effective color. + [Fact] + public void ResolveEffectiveParagraphProperties_BuClrTxOrAbsent_FollowsFirstRunColor() + { + var theme = BuildTestTheme(); + var pPr = new XElement(DrawingNs + "pPr", new XElement(DrawingNs + "buChar", new XAttribute("char", "\u2022"))); + var runRPr = new XElement(DrawingNs + "rPr", new XElement(DrawingNs + "solidFill", new XElement(DrawingNs + "srgbClr", new XAttribute("val", "00FF00")))); + var paragraph = Paragraph(pPr, Run(runRPr)); + var placeholderProperties = EmptyPlaceholderProperties(theme); + var firstRunProperties = FirstRunProps(paragraph, placeholderProperties, theme, "body"); + + var result = PptxDocument.ResolveEffectiveParagraphProperties(paragraph, placeholderProperties, "body", firstRunProperties); + + Assert.Equal(new Rgba32(0, 255, 0, 255), result.Bullet!.Color); + } + + /// Proves a run-less paragraph's "follow text" color falls back to . + [Fact] + public void ResolveEffectiveParagraphProperties_BuClrTxRunLessParagraph_FallsBackToDark1() + { + var theme = BuildTestTheme(); + var pPr = new XElement(DrawingNs + "pPr", new XElement(DrawingNs + "buChar", new XAttribute("char", "\u2022"))); + var paragraph = Paragraph(pPr); + var placeholderProperties = EmptyPlaceholderProperties(theme); + + var result = PptxDocument.ResolveEffectiveParagraphProperties(paragraph, placeholderProperties, "body"); + + Assert.Equal(theme.ColorScheme.Dark1, result.Bullet!.Color); + } + + /// Proves <a:buFontTx/> (or no font markup at all) resolves to the paragraph's own first run's effective typeface. + [Fact] + public void ResolveEffectiveParagraphProperties_BuFontTxOrAbsent_FollowsFirstRunFont() + { + var theme = BuildTestTheme(); + var pPr = new XElement(DrawingNs + "pPr", new XElement(DrawingNs + "buChar", new XAttribute("char", "\u2022"))); + var runRPr = new XElement(DrawingNs + "rPr", new XElement(DrawingNs + "latin", new XAttribute("typeface", "Georgia"))); + var paragraph = Paragraph(pPr, Run(runRPr)); + var placeholderProperties = EmptyPlaceholderProperties(theme); + var firstRunProperties = FirstRunProps(paragraph, placeholderProperties, theme, "body"); + + var result = PptxDocument.ResolveEffectiveParagraphProperties(paragraph, placeholderProperties, "body", firstRunProperties); + + Assert.Equal("Georgia", result.Bullet!.FontFamily); + } + + /// Proves <a:buSzPct val="50000"/> resolves to half of the paragraph's own first run's effective size. + [Fact] + public void ResolveEffectiveParagraphProperties_BuSzPct_ResolvesFractionOfFirstRunSize() + { + var theme = BuildTestTheme(); + var pPr = new XElement( + DrawingNs + "pPr", + new XElement(DrawingNs + "buChar", new XAttribute("char", "\u2022")), + new XElement(DrawingNs + "buSzPct", new XAttribute("val", "50000"))); + var runRPr = new XElement(DrawingNs + "rPr", new XAttribute("sz", "2000")); + var paragraph = Paragraph(pPr, Run(runRPr)); + var placeholderProperties = EmptyPlaceholderProperties(theme); + var firstRunProperties = FirstRunProps(paragraph, placeholderProperties, theme, "body"); + + var result = PptxDocument.ResolveEffectiveParagraphProperties(paragraph, placeholderProperties, "body", firstRunProperties); + + Assert.Equal(2000f * 127f * 0.5f, result.Bullet!.SizeEmu); + } + + /// Proves <a:buSzPts val="3600"/> resolves to an absolute size, independent of the run's own size entirely. + [Fact] + public void ResolveEffectiveParagraphProperties_BuSzPts_ResolvesAbsoluteSizeIndependentOfRunSize() + { + var theme = BuildTestTheme(); + var pPr = new XElement( + DrawingNs + "pPr", + new XElement(DrawingNs + "buChar", new XAttribute("char", "\u2022")), + new XElement(DrawingNs + "buSzPts", new XAttribute("val", "3600"))); + var runRPr = new XElement(DrawingNs + "rPr", new XAttribute("sz", "2000")); + var paragraph = Paragraph(pPr, Run(runRPr)); + var placeholderProperties = EmptyPlaceholderProperties(theme); + + var result = PptxDocument.ResolveEffectiveParagraphProperties(paragraph, placeholderProperties, "body"); + + Assert.Equal(3600f * 127f, result.Bullet!.SizeEmu); + } + + #endregion + + #region FormatAutoNumber (PptxDocument.Bullets.cs) + + /// Format Auto Number Supported Types Formats Expected String. + [Theory] + [InlineData(1, "arabicPeriod", "1.")] + [InlineData(1, "arabicParenR", "1)")] + [InlineData(1, "arabicPlain", "1")] + [InlineData(1, "alphaLcPeriod", "a.")] + [InlineData(2, "alphaLcPeriod", "b.")] + [InlineData(27, "alphaLcPeriod", "aa.")] + [InlineData(1, "alphaUcPeriod", "A.")] + [InlineData(1, "alphaLcParenR", "a)")] + [InlineData(1, "alphaUcParenR", "A)")] + [InlineData(4, "romanLcPeriod", "iv.")] + [InlineData(9, "romanLcPeriod", "ix.")] + [InlineData(1994, "romanUcPeriod", "MCMXCIV.")] + [InlineData(1, "romanLcParenR", "i)")] + [InlineData(1, "romanUcParenR", "I)")] + public void FormatAutoNumber_SupportedTypes_FormatsExpectedString(int value, string type, string expected) + { + var result = PptxDocument.FormatAutoNumber(value, type); + + Assert.Equal(expected, result); + } + + /// Proves an unrecognized <a:buAutoNum type="..."/> scheme fails gracefully - returning , not throwing. + [Fact] + public void FormatAutoNumber_UnsupportedType_ReturnsNull() + { + var result = PptxDocument.FormatAutoNumber(1, "circleNumDbPlain"); + + Assert.Null(result); + } + + #endregion + + #region Hanging-indent fix and bullet glyph geometry (ResolveTextLayout/PositionLines) + + /// + /// Proves a non-bulleted paragraph's first-line indent still applies to the text's own + /// X exactly as before this phase (regression guard for the hanging-indent fix, which is + /// strictly gated on "this line has a resolved bullet"). + /// + [Fact] + public void ResolveTextLayout_NonBulletedParagraph_FirstLineIndentStillAppliesToTextX() + { + var textBody = BuildTextBody(("""""", "A")); + + var layout = Layout(textBody); + + // marL + indent = 30000 (no bullet is painted, so the pre-existing first-line-indent + // behavior applies unchanged). + Assert.Equal(30000f, layout.Glyphs[0].OriginXEmu, 2); + } + + /// + /// Proves a bulleted paragraph's own text starts flush at marL (dropping + /// indent entirely), while the bullet glyph itself is painted at the + /// marL+indent gutter - the hanging-indent fix this phase introduces alongside the + /// bullet glyph itself. + /// + [Fact] + public void ResolveTextLayout_BulletedParagraph_TextStartsAtMarLGutterHoldsBullet() + { + var textBody = BuildTextBody( + ("""""", "A")); + + var layout = Layout(textBody); + + // Two glyphs: the run's own "A" at marL (50000, not marL+indent, proving the fix), then + // the bullet's own "A" at the gutter (marL+indent=30000) - PositionLines paints each + // line's own text glyphs before appending that line's bullet glyphs. + Assert.Equal(2, layout.Glyphs.Count); + Assert.Equal(50000f, layout.Glyphs[0].OriginXEmu, 2); + Assert.Equal(30000f, layout.Glyphs[1].OriginXEmu, 2); + } + + /// Proves an explicit <a:buNone/> paints no bullet glyph at all - the glyph stream contains only the paragraph's own run text. + [Fact] + public void ResolveTextLayout_BuNone_PaintsNoGlyphBeyondRunText() + { + var textBody = BuildTextBody( + ("""""", "A")); + + var layout = Layout(textBody); + + Assert.Single(layout.Glyphs); + // Explicit buNone paragraphs are bullet-less, so the hanging-indent fix does NOT apply - + // text renders at the pre-existing marL+indent first-line position. + Assert.Equal(30000f, layout.Glyphs[0].OriginXEmu, 2); + } + + /// + /// Proves consecutive same-level <a:buAutoNum type="arabicPlain"/> paragraphs + /// render "1", "2", "3" in sequence - the counter increments once per paragraph at that + /// level. + /// + [Fact] + public void ResolveTextLayout_ConsecutiveAutoNumParagraphs_SequencesCounterAcrossParagraphs() + { + var pPr = """"""; + var textBody = BuildTextBody((pPr, "A"), (pPr, "A"), (pPr, "A")); + + var layout = Layout(textBody); + + // Each paragraph contributes 2 glyphs (run "A", then bullet digit) - 6 total. + Assert.Equal(6, layout.Glyphs.Count); + + // Bullet digit glyph indices: '1'=3 (300 wide), '2'=4 (300 wide), '3'=5 (300 wide) - see + // NewFont()'s own cmap/advance table. Comparing GlyphIndex (not rendered text, which this + // layer does not expose directly) proves "1"/"2"/"3" were each individually resolved, not + // the same digit repeated. + Assert.Equal(3, layout.Glyphs[1].GlyphIndex); + Assert.Equal(4, layout.Glyphs[3].GlyphIndex); + Assert.Equal(5, layout.Glyphs[5].GlyphIndex); + } + + /// + /// Proves returning to a shallower level after a deeper nested auto-numbered paragraph + /// resumes the shallower level's own counter (unaffected by the deeper level's own + /// sequence), while the deeper level itself restarts from its own startAt the next + /// time it is used - the full per-level reset/resume state machine + /// AdvanceBulletCounters implements. + /// + [Fact] + public void ResolveTextLayout_NestedThenReturnToShallowerLevel_ResumesShallowerCounterRestartsDeeperLevel() + { + const string ns = "http://schemas.openxmlformats.org/drawingml/2006/main"; + var level0 = $""""""; + var level1 = $""""""; + + // level0 (-> "1"), level1 (-> "1"), level1 (-> "2"), level0 (-> "2", resuming its own + // counter, not "3" and not restarting at "1"), level1 (-> "1" again, restarted since the + // intervening level0 paragraph closed out the nested list). + var textBody = BuildTextBody((level0, "A"), (level1, "A"), (level1, "A"), (level0, "A"), (level1, "A")); + + var layout = Layout(textBody); + + // 5 paragraphs * 2 glyphs (run + bullet) = 10. + Assert.Equal(10, layout.Glyphs.Count); + + // Bullet glyph indices, one per paragraph, at stream positions 1, 3, 5, 7, 9: expect + // "1", "1", "2", "2", "1" -> glyph indices 3, 3, 4, 4, 3. + Assert.Equal(3, layout.Glyphs[1].GlyphIndex); + Assert.Equal(3, layout.Glyphs[3].GlyphIndex); + Assert.Equal(4, layout.Glyphs[5].GlyphIndex); + Assert.Equal(4, layout.Glyphs[7].GlyphIndex); + Assert.Equal(3, layout.Glyphs[9].GlyphIndex); + } + + /// + /// Proves a run-less (blank/spacer) paragraph with resolved <a:buChar/> bullet + /// properties paints NO bullet glyph - the Phase 2 Follow-Up empty-paragraph fix + /// (BuildLines's hasRuns gate in PptxDocument.TextLayout.cs). + /// + [Fact] + public void BuildLines_ParagraphWithBulletPropertiesAndNoRuns_PaintsNoBulletGlyph() + { + var pPr = """"""; + var textBody = BuildTextBodyRunless((pPr, null)); + + var layout = Layout(textBody); + + // No run glyphs and no bullet glyph - a run-less paragraph still occupies a blank line, + // but paints nothing. + Assert.Empty(layout.Glyphs); + } + + /// + /// Differential counterpart to + /// : + /// the otherwise-identical paragraph with one run still paints its bullet glyph. + /// + [Fact] + public void BuildLines_OtherwiseIdenticalParagraphWithOneRun_StillPaintsBulletGlyph() + { + var pPr = """"""; + var textBody = BuildTextBodyRunless((pPr, "A")); + + var layout = Layout(textBody); + + // Run's own "A" glyph plus the bullet's own "A" glyph. + Assert.Equal(2, layout.Glyphs.Count); + } + + /// + /// Proves a run-less auto-numbered paragraph's suppressed bullet glyph does NOT disturb + /// the auto-number counter state machine - the counter still advances for it, so a + /// subsequent paragraph at the same level correctly continues the sequence (not restarting + /// at "1") - per the bug report's explicit requirement that only the glyph is suppressed, + /// not the counter advancement. + /// + [Fact] + public void BuildLines_RunlessAutoNumberParagraph_CounterStillAdvancesForSubsequentParagraph() + { + var pPr = """"""; + var textBody = BuildTextBodyRunless((pPr, null), (pPr, "A")); + + var layout = Layout(textBody); + + // First paragraph is run-less: no glyphs painted for it at all (counter still advances to + // "1" internally, but nothing is painted). Second paragraph: its own run "A" plus a bullet + // glyph for "2" (glyph index 4, see NewFont()'s cmap/advance table), proving the counter + // advanced past "1" rather than restarting. + Assert.Equal(2, layout.Glyphs.Count); + Assert.Equal(4, layout.Glyphs[1].GlyphIndex); + } + + /// + /// Proves a paragraph whose <a:buAutoNum> declares an unrecognized/unsupported + /// type skips painting a bullet for that one paragraph only (its own run text still + /// renders normally) - the documented per-bullet graceful-degradation policy, not a + /// whole-render failure. + /// + [Fact] + public void ResolveTextLayout_UnsupportedAutoNumType_SkipsOnlyThatBulletGracefully() + { + var pPr = """"""; + var textBody = BuildTextBody((pPr, "A")); + + var layout = Layout(textBody); + + // Only the run's own "A" glyph - no bullet glyph painted, and no exception thrown. + Assert.Single(layout.Glyphs); + } + + /// + /// Regression test for the bullet-gutter/text-start-X collision defect (confirmed on a + /// real-world slide: marL="320040", lvl="1", no indent attribute, + /// <a:buAutoNum type="arabicPeriod"/>, no placeholder/master tier + /// contributing an indent - see the design document's "Bullet/text gutter clearance" + /// note). With IndentEmu resolving to 0, the bullet gutter + /// (marL+indent=320040) and the paragraph's own unclamped text-start-X + /// (also 320040, since a bulleted first line drops indent entirely) would, + /// before the fix, collide exactly. Proves the bullet glyph's own X is unchanged (still + /// the unclamped gutter), while the first run-text glyph's X is now clamped to clear the + /// bullet string's own measured width plus the buAutoNum-only minimum trailing gap + /// - an exact numeric value derived from this file's own synthetic font metrics (bullet + /// string "1.": '1' advance 300/1000*12700 EMU + '.' advance 100/1000*12700 EMU = + /// 5080 EMU, plus a minimum trailing gap of one space character's own advance width, + /// 200/1000*12700 EMU = 2540 EMU, reserved beyond the marker's own width for + /// buAutoNum bullets specifically, so the marker reads as visibly separated from + /// the paragraph's own text rather than merely touching it - see the design document's + /// "buAutoNum gutter-clearance minimum gap" note). + /// + [Fact] + public void ResolveTextLayout_BulletedParagraphWithZeroIndent_TextClearsBulletWidth() + { + var textBody = BuildTextBody( + ("""""", "A")); + + var layout = Layout(textBody); + + // Run "A", then bullet "1" and ".". + Assert.Equal(3, layout.Glyphs.Count); + + // Bullet glyph X is unchanged: still the unclamped gutter (marL+indent = 320040+0). + Assert.Equal(320040f, layout.Glyphs[1].OriginXEmu, 2); + Assert.Equal(323850f, layout.Glyphs[2].OriginXEmu, 2); // 320040 + '1' advance (3810). + + // The run's own text glyph is clamped to clear the bullet's own measured width plus the + // buAutoNum-only minimum trailing gap (320040 + 5080 + 2540 = 327660), rather than + // sitting at the unclamped marL (320040), which would collide with the bullet, or merely + // touching the bullet's own width (325120), which (per the bug report) still reads as too + // tight for an auto-numbered marker. + Assert.Equal(327660f, layout.Glyphs[0].OriginXEmu, 2); + } + + /// + /// Proves the bullet-gutter clamp (Finding 3) also fires for algn="ctr", not just + /// the default left alignment - previously the clamp was only consulted by the + /// alignment switch's default ("l") arm, so a centered bulleted paragraph's text + /// could overlap its own bullet. + /// + /// + /// Same marL="320040" lvl="1" + buAutoNum type="arabicPeriod" setup as + /// + /// (minTextStartX = 327660), plus algn="ctr", laid out at a shape width + /// (326390 = marginLeft + lineWidth(6350)) chosen so the unclamped centered startX + /// collapses to exactly marginLeft (320040) - below minTextStartX - proving + /// the clamp now raises it to 327660 instead of leaving it at the unclamped, overlapping + /// 320040. + /// + [Fact] + public void ResolveTextLayout_BulletedParagraphCenterAligned_TextClearsBulletWidth() + { + var textBody = BuildTextBody( + ("""""", "A")); + + var layout = Layout(textBody, 326390f, 500000f); + + Assert.Equal(3, layout.Glyphs.Count); + + // Bullet glyph X is unchanged: still the unclamped gutter (marL+indent = 320040+0). + Assert.Equal(320040f, layout.Glyphs[1].OriginXEmu, 2); + + // The run's own text glyph is clamped to the same minTextStartX as the left-aligned + // test (327660), not the unclamped centered position (320040), which would overlap the + // bullet. + Assert.Equal(327660f, layout.Glyphs[0].OriginXEmu, 2); + } + + /// + /// Mirrors + /// + /// for algn="r": the unclamped right-aligned startX also collapses to + /// exactly marginLeft (320040) under the same chosen width, below + /// minTextStartX (327660) - proving the clamp fires for right alignment too. + /// + [Fact] + public void ResolveTextLayout_BulletedParagraphRightAligned_TextClearsBulletWidth() + { + var textBody = BuildTextBody( + ("""""", "A")); + + var layout = Layout(textBody, 326390f, 500000f); + + Assert.Equal(3, layout.Glyphs.Count); + Assert.Equal(320040f, layout.Glyphs[1].OriginXEmu, 2); + Assert.Equal(327660f, layout.Glyphs[0].OriginXEmu, 2); + } + + /// + /// Pixel-level counterpart to the center-aligned gutter-clamp fix: proves the bullet's own + /// ink and the paragraph's own (now correctly clamped) text ink occupy disjoint X ranges + /// once actually painted to a surface, mirroring + /// + /// but for algn="ctr". + /// + [Fact] + public void PaintTextLayout_BulletedParagraphCenterAligned_BulletAndTextInkDoNotOverlap() + { + // Shape width 335000 is chosen so the unclamped centered startX (320040 + (335000 - + // 320040 - 6350) / 2 = 324345) falls below minTextStartX (326390), so the clamp fires, + // while still leaving the clamped text's own full glyph width (326390 + 6350 = 332740) + // inside the shape's own bounds (not clipped at the right edge). + var textBody = BuildTextBody( + ("""""", "A")); + var layout = Layout(textBody, 335000f, 100000f); + + const float bulletGutterX = 320040f; + const float bulletWidthEmu = 6350f; // 'A' glyph advance at this bullet's resolved font size. + var textStartX = bulletGutterX + bulletWidthEmu; + Assert.Equal(bulletGutterX, layout.Glyphs[1].OriginXEmu, 2); + Assert.Equal(textStartX, layout.Glyphs[0].OriginXEmu, 2); + + using var surface = new Surface(128, 16); + var scale = 128f / 335000f; + var transform = System.Numerics.Matrix3x2.CreateScale(scale, scale); + PptxDocument.PaintTextLayout(surface, layout, transform); + + var bulletColumnMaxPx = (int)MathF.Floor(textStartX * scale); + var textColumnMinPx = (int)MathF.Ceiling(textStartX * scale); + + bool AnyInkInColumnRange(int minPxInclusive, int maxPxExclusive) + { + for (var y = 0; y < surface.Height; y++) + { + for (var x = Math.Max(0, minPxInclusive); x < Math.Min(surface.Width, maxPxExclusive); x++) + { + if (surface[x, y].A > 0) + { + return true; + } + } + } + + return false; + } + + Assert.True(AnyInkInColumnRange(0, bulletColumnMaxPx), "Expected ink in the bullet's own column."); + Assert.True(AnyInkInColumnRange(textColumnMinPx, surface.Width), "Expected ink in the text's own (clamped) column."); + } + + /// + /// Proves a supplementary-plane bullet character (Finding 4: surrogate pairs) - a single + /// Unicode scalar value above U+FFFF, requiring a UTF-16 surrogate pair to + /// represent - resolves to exactly one bullet glyph via <a:buChar char="..."/>, + /// not two mis-measured surrogate halves. + /// + [Fact] + public void BuildBulletGlyphs_SupplementaryPlaneBulletCharacter_EmitsExactlyOneGlyph() + { + var notdef = SyntheticFontBuilder.SimpleGlyph(); + var glyphA = SyntheticFontBuilder.SimpleGlyph([(0, 0, true), (500, 0, true), (250, 800, true)]); + var glyphEmoji = SyntheticFontBuilder.SimpleGlyph([(0, 0, true), (600, 0, true), (300, 800, true)]); + var cmap = SyntheticFontBuilder.CmapFormat12(3, 10, [(0x41, 0x41, 1), (0x1F600, 0x1F600, 2)]); + + var data = new SyntheticFontBuilder() + .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) + .AddTable("maxp", SyntheticFontBuilder.Maxp(3)) + .AddTable("hhea", SyntheticFontBuilder.Hhea(800, -200, 0, 3)) + .AddTable("hmtx", SyntheticFontBuilder.Hmtx([0, 500, 600])) + .AddTable("loca", SyntheticFontBuilder.Loca([notdef.Length, glyphA.Length, glyphEmoji.Length], longFormat: false)) + .AddTable("glyf", [.. notdef, .. glyphA, .. glyphEmoji]) + .AddTable("cmap", cmap) + .Build(); + + using var stream = new MemoryStream(data); + var font = TrueTypeFont.Load(stream); + var emoji = char.ConvertFromUtf32(0x1F600); + + var textBody = BuildTextBody( + ($"""""", "A")); + + var layout = PptxDocument.ResolveTextLayout( + textBody, new PptxPlaceholderProperties(null, null, BuildTestTheme()), BuildTestTheme(), "body", + 500000f, 500000f, (_, _, _) => font); + + // Run "A" plus exactly one bullet glyph for the supplementary-plane buChar - not two + // (which the pre-fix per-char enumeration would have emitted for the surrogate pair). + Assert.Equal(2, layout.Glyphs.Count); + Assert.Equal(font.GetGlyphIndex(0x1F600), layout.Glyphs[1].GlyphIndex); + Assert.NotEqual(0, layout.Glyphs[1].GlyphIndex); + } + + /// + /// Pixel-level counterpart to + /// : + /// proves the bullet's own ink and the paragraph's own (now correctly offset) text ink + /// occupy disjoint X ranges once actually painted to a surface - not merely resolved. Uses + /// <a:buChar char="A"/> rather than the exact buAutoNum slide-23 + /// fixture so the bullet glyph has real (non-blank) ink to sample in this file's synthetic + /// font (see 's own remarks: only glyph index 1, mapped to 'A', has a + /// visible outline - the auto-number digits/period are zero-contour placeholders) - the + /// underlying gutter-clearance defect is identical for buChar and buAutoNum + /// bullets alike (it is purely a function of resolved indent versus bullet width, never + /// the bullet's type - see the design document), so this remains a faithful pixel-level + /// proof of the same fix. + /// + [Fact] + public void PaintTextLayout_BulletedParagraphWithZeroIndent_BulletAndTextInkDoNotOverlap() + { + var textBody = BuildTextBody( + ("""""", "A")); + var layout = Layout(textBody, 400000f, 100000f); + + // Bullet "A" gutter X is the resolved marL+indent (320040 EMU); its measured width is the + // 'A' glyph's advance scaled to the bullet's font size (6350 EMU); the clamped text start + // X is their sum (326390 EMU) - touching, not overlapping, the bullet's own span. This is + // the geometric proof the two glyphs' spans never share an X range. + const float bulletGutterX = 320040f; + const float bulletWidthEmu = 6350f; + const float textStartX = bulletGutterX + bulletWidthEmu; + Assert.Equal(bulletGutterX, layout.Glyphs[1].OriginXEmu, 2); + Assert.Equal(textStartX, layout.Glyphs[0].OriginXEmu, 2); + + using var surface = new Surface(64, 16); + var scale = 64f / 400000f; + var transform = System.Numerics.Matrix3x2.CreateScale(scale, scale); + PptxDocument.PaintTextLayout(surface, layout, transform); + + var bulletColumnMaxPx = (int)MathF.Floor(textStartX * scale); + var textColumnMinPx = (int)MathF.Ceiling(textStartX * scale); + + bool AnyInkInColumnRange(int minPxInclusive, int maxPxExclusive) + { + for (var y = 0; y < surface.Height; y++) + { + for (var x = Math.Max(0, minPxInclusive); x < Math.Min(surface.Width, maxPxExclusive); x++) + { + if (surface[x, y].A > 0) + { + return true; + } + } + } + + return false; + } + + Assert.True(AnyInkInColumnRange(0, bulletColumnMaxPx), "Expected ink in the bullet's own column."); + Assert.True(AnyInkInColumnRange(textColumnMinPx, surface.Width), "Expected ink in the text's own (clamped) column."); + } + + /// + /// Regression test for the real-world buAutoNum "touching, zero extra gap" defect + /// (the user-reported "1.3 custom tip geometries"/"2.QO Reagent durable probe + /// (J18604)" symptom): proves the fix's added minimum trailing gap scales additively + /// with a genuinely multi-character auto-number marker's own full measured width (not a + /// fixed/single-glyph constant), and that it clears a digit-first paragraph first word + /// (reproducing the exact real-world ambiguous "marker digit immediately followed by the + /// paragraph's own digit" pattern) with a real, visible gap rather than merely touching. + /// Uses startAt="12" so the rendered marker is "12." (three glyphs: '1', '2', + /// '.'), wider than the single-digit "1."/"2." markers used elsewhere in this + /// file, and run text "3" (a digit-first word, matching the real-world fixture). + /// + [Fact] + public void ResolveTextLayout_BulletedParagraphWithZeroIndent_AutoNumTwoDigitMarker_TextClearsBulletWidthPlusGap() + { + var textBody = BuildTextBody( + ("""""", "3")); + + var layout = Layout(textBody); + + // Run "3", then bullet "1", "2", and ".". + Assert.Equal(4, layout.Glyphs.Count); + + // Bullet glyphs are unchanged: still the unclamped gutter (marL+indent = 320040+0), + // positioned by the marker's own per-character advances ('1' then '2' then '.'). + Assert.Equal(320040f, layout.Glyphs[1].OriginXEmu, 2); + Assert.Equal(323850f, layout.Glyphs[2].OriginXEmu, 2); // 320040 + '1' advance (3810). + Assert.Equal(327660f, layout.Glyphs[3].OriginXEmu, 2); // 320040 + '1'+'2' advances (7620). + + // The marker's own full measured width ('1'+'2'+'.' advances: (300+300+100)/1000*12700 = + // 8890 EMU) plus the buAutoNum-only minimum trailing gap (one space character's own + // advance, 200/1000*12700 = 2540 EMU) together clamp the run's own text-start-X to + // 320040 + 8890 + 2540 = 331470 - proving the gap is additive to the full multi-character + // marker's own width, not a fixed constant that would ignore the extra digit. + Assert.Equal(331470f, layout.Glyphs[0].OriginXEmu, 2); + } + + /// + /// Pixel-level proof that the buAutoNum-only trailing gap renders as a genuine + /// blank pixel-column range strictly between the marker's own measured-width boundary + /// (where pre-fix behavior would have started the paragraph's own text, "touching" the + /// marker) and the new, further-right clamped text-start-X - a stronger proof than mere + /// non-overlap (see + /// , + /// which remains unchanged and continues to assert zero extra gap for buChar + /// bullets specifically). + /// + [Fact] + public void PaintTextLayout_BulletedParagraphWithZeroIndent_AutoNumMarker_BulletAndTextHaveVisibleGap() + { + var textBody = BuildTextBody( + ("""""", "A")); + var layout = Layout(textBody, 400000f, 100000f); + + // Marker "1.": '1' advance 300/1000*12700 + '.' advance 100/1000*12700 = 5080 EMU. The + // pre-fix "touching" boundary would have been bulletGutterX + 5080; the fix instead adds + // the buAutoNum-only minimum trailing gap (2540 EMU) before the text may start. + const float bulletGutterX = 320040f; + const float bulletWidthEmu = 5080f; + const float trailingGapEmu = 2540f; + const float preFixTouchBoundaryX = bulletGutterX + bulletWidthEmu; + const float textStartX = preFixTouchBoundaryX + trailingGapEmu; + Assert.Equal(bulletGutterX, layout.Glyphs[1].OriginXEmu, 2); + Assert.Equal(textStartX, layout.Glyphs[0].OriginXEmu, 2); + + using var surface = new Surface(800, 200); + var scale = 800f / 400000f; + var transform = System.Numerics.Matrix3x2.CreateScale(scale, scale); + PptxDocument.PaintTextLayout(surface, layout, transform); + + // Mirrors PaintTextLayout_BulletedParagraphWithZeroIndent_BulletAndTextInkDoNotOverlap's + // own floor/ceil convention: the single pixel column straddling an exact EMU boundary can + // carry genuine partial-coverage anti-aliased ink from the glyph that starts there, so + // each region below is checked strictly on its own side of that pixel, never across it. + var preFixTouchBoundaryPx = (int)MathF.Floor(preFixTouchBoundaryX * scale); + var gapColumnMaxPx = (int)MathF.Floor(textStartX * scale); + var textColumnMinPx = (int)MathF.Ceiling(textStartX * scale); + + // Sanity: the gap must be wide enough, at this test's chosen scale, to span at least one + // whole pixel column - otherwise the pixel-level assertions below would be vacuous. + Assert.True(gapColumnMaxPx > preFixTouchBoundaryPx, "Expected the trailing gap to span at least one pixel column at this scale."); + + bool AnyInkInColumnRange(int minPxInclusive, int maxPxExclusive) + { + for (var y = 0; y < surface.Height; y++) + { + for (var x = Math.Max(0, minPxInclusive); x < Math.Min(surface.Width, maxPxExclusive); x++) + { + if (surface[x, y].A > 0) + { + return true; + } + } + } + + return false; + } + + // The marker itself (digits/period) is blank ink in this file's synthetic font (see + // NewFont's own remarks), so the proof is: no ink anywhere at or before the pre-fix + // touching boundary, no ink in the new gap region itself, and real ink once comfortably + // past the clamped text-start-X - a genuine blank gap, not merely "no overlap". + Assert.False(AnyInkInColumnRange(0, preFixTouchBoundaryPx), "Expected no ink at or before the pre-fix touching boundary."); + Assert.False(AnyInkInColumnRange(preFixTouchBoundaryPx, gapColumnMaxPx), "Expected a genuinely blank gap between the marker's own width and the clamped text start."); + Assert.True(AnyInkInColumnRange(textColumnMinPx, surface.Width), "Expected ink in the text's own (clamped) column."); + } + + /// + /// Permanent regression test (promoted from a planning-pass investigation probe) for the + /// task's explicit "layout/master declares a default buChar at a list level; the + /// slide's own paragraph at that level declares buAutoNum; assert only the + /// auto-number marker renders" scenario. Proves the master <p:bodyStyle> + /// level's own <a:buChar/> default is fully superseded (not merged) by the + /// paragraph's own <a:buAutoNum/>, and that the full + /// pipeline emits exactly one run-glyph set + /// plus exactly one bullet-glyph set - never two overlapping markers. + /// + [Fact] + public void ResolveTextLayout_MasterBuCharDefault_OwnBuAutoNumOverride_PaintsOnlyAutoNumberMarker() + { + var theme = BuildTestTheme(); + var bodyStyle = new XElement( + DrawingNs + "bodyStyle", + new XElement(DrawingNs + "lvl1pPr", new XElement(DrawingNs + "buChar", new XAttribute("char", "\u2022")))); + var masterTextStyles = new PptxMasterTextStyles(null, bodyStyle, null); + var placeholderProperties = new PptxPlaceholderProperties(null, null, theme, masterTextStyles); + + var pPrXml = """"""; + var pPrElement = XElement.Parse(pPrXml); + + // Resolved bullet properties: the master's own buChar default is fully superseded, not + // merged, by the paragraph's own buAutoNum. + var paragraph = Paragraph(pPrElement, Run(null, "A")); + var resolvedBullet = PptxDocument.ResolveEffectiveParagraphProperties(paragraph, placeholderProperties, "body"); + Assert.Equal(PptxBulletKind.AutoNum, resolvedBullet.Bullet!.Kind); + Assert.Null(resolvedBullet.Bullet.Character); + + // Full pipeline: exactly one run glyph ("A") plus exactly one bullet-glyph set (the + // auto-number "1." - two glyphs, '1' then '.') - never a second, master-buChar marker. + var textBody = BuildTextBody((pPrXml, "A")); + var layout = PptxDocument.ResolveTextLayout(textBody, placeholderProperties, theme, "body", 500000f, 500000f, ConstantFontResolver); + + Assert.Equal(3, layout.Glyphs.Count); + Assert.Equal(3, layout.Glyphs[1].GlyphIndex); // '1' (see NewFont()'s cmap/advance table). + Assert.Equal(6, layout.Glyphs[2].GlyphIndex); // '.' (see NewFont()'s cmap/advance table). + } + + #endregion + + #region End-to-end pixel painting + + /// + /// Proves a <a:buChar/> bullet is actually painted to the pixel surface via + /// - not merely resolved/positioned - at the + /// expected gutter X, with the surrounding surface left untouched. + /// + [Fact] + public void PaintTextLayout_BulletedParagraph_PaintsBulletGlyphToSurface() + { + var textBody = BuildTextBody( + ("""""", "A")); + var layout = Layout(textBody, 200000f, 200000f); + + using var surface = new Surface(64, 64); + var scale = 64f / 200000f; + var transform = System.Numerics.Matrix3x2.CreateScale(scale, scale); + PptxDocument.PaintTextLayout(surface, layout, transform); + + var paintedAny = false; + for (var y = 0; y < surface.Height && !paintedAny; y++) + { + for (var x = 0; x < surface.Width; x++) + { + if (surface[x, y].A > 0) + { + paintedAny = true; + break; + } + } + } + + Assert.True(paintedAny, "Expected the bullet glyph (gutter X=0) to paint at least one visible pixel."); + } + + #endregion +} diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxChartsTests.cs b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxChartsTests.cs new file mode 100644 index 00000000..e232cccb --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxChartsTests.cs @@ -0,0 +1,422 @@ +using System.IO.Compression; +using System.Text; +using System.Xml.Linq; +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Charts; +using DemaConsulting.CanvasNet.Charts.OpenXml; + +namespace DemaConsulting.CanvasNet.Pptx.Tests; + +// cspell:ignore pptx graphicframe chartspace numcache strcache sppr nvpr nvgraphicframepr cnvgraphicframepr +// cspell:ignore barchart linechart piechart areachart radarchart + +/// +/// Unit-level tests for Phase 4's chart-graphic-frame integration: +/// 's table-vs-chart dispatch (bar/line/pie/area), +/// 's deferred-feature wrap-to- +/// and malformed-chart rejection, and a +/// synthetic, fully in-memory chart-only-slide end-to-end render proving visible pixels - +/// mirroring 's own "directly-constructed +/// fragments, no full on-disk package needed" style for the dispatch/wrap/malformed tests, and +/// 's own in-memory-package style for the end-to-end test. +/// Complements (does not duplicate) the real-world-fixture-driven proofs in +/// . +/// +public class PptxChartsTests +{ + /// The PresentationML namespace. + private static readonly XNamespace P = "http://schemas.openxmlformats.org/presentationml/2006/main"; + + /// The DrawingML-Main namespace. + private static readonly XNamespace A = "http://schemas.openxmlformats.org/drawingml/2006/main"; + + /// The DrawingML-Charts namespace. + private static readonly XNamespace C = "http://schemas.openxmlformats.org/drawingml/2006/chart"; + + /// The OfficeDocument-Relationships namespace, used for a <c:chart> element's own r:id attribute. + private static readonly XNamespace R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + + #region Chart XML fragment builders (mirroring DemaConsulting.CanvasNet.Charts.Tests.OpenXmlChartParserTests) + + /// Builds a c:numCache element for the given dense values. + private static XElement NumCache(params double[] values) => + new(C + "numCache", + new XElement(C + "formatCode", "General"), + new XElement(C + "ptCount", new XAttribute("val", values.Length)), + values.Select((v, i) => new XElement(C + "pt", new XAttribute("idx", i), new XElement(C + "v", v)))); + + /// Builds a c:strCache element for the given dense labels. + private static XElement StrCache(params string[] values) => + new(C + "strCache", + new XElement(C + "ptCount", new XAttribute("val", values.Length)), + values.Select((v, i) => new XElement(C + "pt", new XAttribute("idx", i), new XElement(C + "v", v)))); + + /// Builds a c:tx element with a cached string name. + private static XElement Tx(string name) => + new(C + "tx", new XElement(C + "strRef", new XElement(C + "f", "Sheet1!$B$1"), StrCache(name))); + + /// Builds a c:val element with cached numeric values. + private static XElement Val(params double[] values) => + new(C + "val", new XElement(C + "numRef", new XElement(C + "f", "Sheet1!$B$2:$B$5"), NumCache(values))); + + /// Builds a c:cat element with cached string labels. + private static XElement Cat(params string[] labels) => + new(C + "cat", new XElement(C + "strRef", new XElement(C + "f", "Sheet1!$A$2:$A$5"), StrCache(labels))); + + /// Builds a c:ser element from the given name/values/categories. + private static XElement Ser(string name, double[] values, string[] categories) => + new(C + "ser", + new XElement(C + "idx", new XAttribute("val", 0)), + new XElement(C + "order", new XAttribute("val", 0)), + Tx(name), + Cat(categories), + Val(values)); + + /// Builds a minimal c:chart element wrapping a single chart-type element. + private static XElement ChartElement(XElement chartTypeElement) => + new(C + "chart", + new XElement(C + "autoTitleDeleted", new XAttribute("val", 0)), + new XElement(C + "plotArea", new XElement(C + "layout"), chartTypeElement)); + + #endregion + + /// Builds a <p:graphicFrame> wrapping a chart's <a:graphicData>, referencing the given relationship id. + private static XElement BuildChartGraphicFrame(string relationshipId) => + new(P + "graphicFrame", + new XElement(P + "nvGraphicFramePr", + new XElement(P + "cNvPr", new XAttribute("id", 2), new XAttribute("name", "Chart 1")), + new XElement(P + "cNvGraphicFramePr"), + new XElement(P + "nvPr")), + new XElement(P + "xfrm", + new XElement(A + "off", new XAttribute("x", 0), new XAttribute("y", 0)), + new XElement(A + "ext", new XAttribute("cx", 4572000), new XAttribute("cy", 3429000))), + new XElement(A + "graphic", + new XElement(A + "graphicData", + new XAttribute("uri", "http://schemas.openxmlformats.org/drawingml/2006/chart"), + new XElement(C + "chart", new XAttribute(R + "id", relationshipId))))); + + // --- Dispatch: bar/line/pie/area ------------------------------------------------------------ + + /// Proves a <p:graphicFrame> declaring each of bar/line/pie/area chart kinds is dispatched by to a non-null (and a null ) of the expected , rather than to . + [Theory] + [InlineData("barChart", "bar", ChartType.Bar)] + [InlineData("lineChart", null, ChartType.Line)] + [InlineData("pieChart", null, ChartType.Pie)] + [InlineData("areaChart", null, ChartType.Area)] + public void ParseShapeTree_GraphicFrameWithSupportedChart_DispatchesToChartNode(string chartTypeLocalName, string? barDir, ChartType expectedType) + { + // Arrange + var ser = Ser("Series 1", [1.0, 2.0, 3.0], ["A", "B", "C"]); + var chartTypeElement = barDir is null + ? new XElement(C + chartTypeLocalName, ser) + : new XElement(C + chartTypeLocalName, new XElement(C + "barDir", new XAttribute("val", barDir)), ser); + var chartSpace = ChartElement(chartTypeElement); + var graphicFrame = BuildChartGraphicFrame("rId1"); + var spTree = new XElement(P + "spTree", graphicFrame); + + // Act + var nodes = PptxDocument.ParseShapeTree( + spTree, + themeResolver: () => throw new InvalidOperationException("A chart graphic frame must never resolve a theme."), + resolveChartPart: _ => chartSpace); + + // Assert + var node = Assert.IsType(Assert.Single(nodes)); + Assert.Null(node.Table); + Assert.NotNull(node.Chart); + Assert.Equal(expectedType, node.Chart.Type); + } + + /// Proves a <p:graphicFrame> declaring a table (not a chart) is still dispatched to even when resolveChartPart is supplied, since its own <a:graphicData> uri does not end in "/chart". + [Fact] + public void ParseShapeTree_GraphicFrameWithTable_StillDispatchesToTableNode() + { + // Arrange + var tbl = new XElement(A + "tbl", + new XElement(A + "tblGrid", new XElement(A + "gridCol", new XAttribute("w", 100))), + new XElement(A + "tr", new XAttribute("h", 100), new XElement(A + "tc", new XElement(A + "tcPr")))); + var graphicFrame = new XElement(P + "graphicFrame", + new XElement(A + "graphic", + new XElement(A + "graphicData", new XAttribute("uri", "http://schemas.openxmlformats.org/drawingml/2006/table"), tbl))); + var spTree = new XElement(P + "spTree", graphicFrame); + var theme = new PptxTheme( + new PptxColorScheme( + new Rgba32(0, 0, 0, 255), new Rgba32(0, 0, 0, 255), new Rgba32(0, 0, 0, 255), new Rgba32(0, 0, 0, 255), + new Rgba32(0, 0, 0, 255), new Rgba32(0, 0, 0, 255), new Rgba32(0, 0, 0, 255), new Rgba32(0, 0, 0, 255), + new Rgba32(0, 0, 0, 255), new Rgba32(0, 0, 0, 255), new Rgba32(0, 0, 0, 255), new Rgba32(0, 0, 0, 255)), + new PptxFontScheme( + new PptxFontCollection("Latin", "EA", "CS"), + new PptxFontCollection("Latin", "EA", "CS"))); + + // Act + var nodes = PptxDocument.ParseShapeTree( + spTree, + themeResolver: () => theme, + resolveChartPart: _ => throw new InvalidOperationException("A table graphic frame must never resolve a chart part.")); + + // Assert + var node = Assert.IsType(Assert.Single(nodes)); + Assert.NotNull(node.Table); + Assert.Null(node.Chart); + } + + // --- Deferred feature: radar ----------------------------------------------------------------- + + /// Proves a <p:graphicFrame> declaring a recognized-but-unsupported chart kind (a radar chart) is wrapped by into a with feature token "pptx-chart-" + ex.Feature (here "pptx-chart-charts-openxml-radar-chart"), rather than the underlying escaping unwrapped. + [Fact] + public void ParseShapeTree_GraphicFrameWithRadarChart_ThrowsPptxUnsupportedFeatureExceptionWithWrappedFeatureToken() + { + // Arrange + var radarChart = new XElement(C + "radarChart", Ser("Series 1", [1.0, 2.0, 3.0], ["A", "B", "C"])); + var chartSpace = ChartElement(radarChart); + var graphicFrame = BuildChartGraphicFrame("rId1"); + var spTree = new XElement(P + "spTree", graphicFrame); + + // Act + var exception = Assert.Throws(() => PptxDocument.ParseShapeTree( + spTree, + themeResolver: () => throw new InvalidOperationException("A chart graphic frame must never resolve a theme."), + resolveChartPart: _ => chartSpace)); + + // Assert + Assert.Equal("pptx-chart-charts-openxml-radar-chart", exception.Feature); + Assert.IsType(exception.InnerException); + } + + // --- Malformed chart --------------------------------------------------------------------------- + + /// Proves an <a:graphicData> whose <c:chart> child has no r:id attribute throws (a malformed chart graphic frame, distinct from a recognized-but-unsupported chart kind). + [Fact] + public void ParseChart_ChartElementWithNoRelationshipId_ThrowsInvalidDataException() + { + // Arrange + var graphicData = new XElement(A + "graphicData", + new XAttribute("uri", "http://schemas.openxmlformats.org/drawingml/2006/chart"), + new XElement(C + "chart")); + + // Act & Assert + Assert.Throws(() => PptxDocument.ParseChart( + graphicData, _ => throw new InvalidOperationException("No relationship id to resolve - the exception must be thrown before this is called."))); + } + + /// Proves an <a:graphicData> with no <c:chart> child at all throws . + [Fact] + public void ParseChart_GraphicDataWithNoChartChild_ThrowsInvalidDataException() + { + // Arrange + var graphicData = new XElement(A + "graphicData", new XAttribute("uri", "http://schemas.openxmlformats.org/drawingml/2006/chart")); + + // Act & Assert + Assert.Throws(() => PptxDocument.ParseChart( + graphicData, _ => throw new InvalidOperationException("No relationship id to resolve - the exception must be thrown before this is called."))); + } + + // --- Synthetic end-to-end render ------------------------------------------------------------- + + /// + /// Proves a synthetic, fully in-memory chart-only slide (no tables, pictures, or other + /// shapes) opens and renders successfully, painting visible pixels - an end-to-end proof + /// that a chart graphic frame's own result reaches + /// 's compositing via + /// PptxDocument.Render.cs's chart branch (ChartRenderer.Render then + /// PaintPicture), not merely that the shape tree parses correctly. + /// + [Fact] + public void Render_ChartOnlySlide_PaintsVisibleContent() + { + // Arrange + var barChart = new XElement(C + "barChart", + new XElement(C + "barDir", new XAttribute("val", "col")), + Ser("Revenue", [10.0, 20.0, 30.0], ["Q1", "Q2", "Q3"])); + var chartSpace = new XElement(C + "chartSpace", ChartElement(barChart)); + using var stream = BuildChartOnlySlidePackage(chartSpace); + using var document = PptxDocument.Open(stream); + + // Act + Assert.Equal(1, document.SlideCount); + using var surface = document.Render(0, dpi: 96f, new PptxRenderOptions { BackgroundColor = new Rgba32(0, 0, 0, 0) }); + + // Assert + var paintedAnyPixel = false; + for (var y = 0; y < surface.Height && !paintedAnyPixel; y++) + { + for (var x = 0; x < surface.Width; x++) + { + if (surface[x, y].A != 0) + { + paintedAnyPixel = true; + break; + } + } + } + + Assert.True(paintedAnyPixel, "Expected the rendered chart-only slide to paint at least one non-transparent pixel."); + } + + /// + /// Builds a minimal, fully navigable, in-memory .pptx-shaped package (presentation + /// -> slide -> layout -> master -> theme, each with its own relationships part, + /// mirroring 's own BuildRenderPackage helper) whose + /// one slide's <p:spTree> declares a single chart <p:graphicFrame> + /// referencing as ppt/charts/chart1.xml. + /// + private static Stream BuildChartOnlySlidePackage(XElement chartSpace) + { + const string contentTypesXml = + """ + + + + + + + """; + + const string packageRelsXml = + """ + + + + + """; + + const string presentationXml = + """ + + + + + """; + + const string presentationRelsXml = + """ + + + + + """; + + var slideXml = + $""" + + + + + + + + + + + + + + + + + + + + + + + + + """; + + const string slideRelsXml = + """ + + + + + + """; + + const string layoutXml = + """ + + + + """; + + const string layoutRelsXml = + """ + + + + + """; + + const string masterXml = + """ + + + + + + """; + + const string masterRelsXml = + """ + + + + + """; + + const string themeXml = + """ + + + + + + + + + + + + + + + + + + + + + + + """; + + var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + WriteTextEntry(archive, "[Content_Types].xml", contentTypesXml); + WriteTextEntry(archive, "_rels/.rels", packageRelsXml); + WriteTextEntry(archive, "ppt/presentation.xml", presentationXml); + WriteTextEntry(archive, "ppt/_rels/presentation.xml.rels", presentationRelsXml); + WriteTextEntry(archive, "ppt/slides/slide1.xml", slideXml); + WriteTextEntry(archive, "ppt/slides/_rels/slide1.xml.rels", slideRelsXml); + WriteTextEntry(archive, "ppt/slideLayouts/slideLayout1.xml", layoutXml); + WriteTextEntry(archive, "ppt/slideLayouts/_rels/slideLayout1.xml.rels", layoutRelsXml); + WriteTextEntry(archive, "ppt/slideMasters/slideMaster1.xml", masterXml); + WriteTextEntry(archive, "ppt/slideMasters/_rels/slideMaster1.xml.rels", masterRelsXml); + WriteTextEntry(archive, "ppt/theme/theme1.xml", themeXml); + WriteTextEntry(archive, "ppt/charts/chart1.xml", chartSpace.ToString()); + } + + stream.Position = 0; + return stream; + } + + private static void WriteTextEntry(ZipArchive archive, string name, string content) + { + var entry = archive.CreateEntry(name); + using var entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream, Encoding.UTF8); + writer.Write(content); + } +} diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxColorMapTests.cs b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxColorMapTests.cs new file mode 100644 index 00000000..b2d89cea --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxColorMapTests.cs @@ -0,0 +1,427 @@ +using System.IO.Compression; +using System.Text; +using System.Xml.Linq; +using DemaConsulting.CanvasNet.Canvas; + +namespace DemaConsulting.CanvasNet.Pptx.Tests; + +// cspell:ignore pptx clrmap clrmapovr sldmaster sldlayout csld sppr nvpr nvsppr + +/// +/// Unit-level tests for the <p:clrMap>/<p:clrMapOvr> color-map fix: +/// 's required <p:clrMap> parsing, +/// /'s optional +/// <p:clrMapOvr> parsing, and 's +/// slide > layout > master fallback chain. Complements 's +/// own ResolveSchemeColor/ResolveColor indirection tests and +/// 's end-to-end rendered-pixel assertion. +/// +public class PptxColorMapTests +{ + private static readonly XNamespace A = "http://schemas.openxmlformats.org/drawingml/2006/main"; + private static readonly XNamespace P = "http://schemas.openxmlformats.org/presentationml/2006/main"; + + private const string DefaultContentTypesXml = + """ + + + + + + """; + + private const string DefaultPackageRelsXml = + """ + + + + + """; + + private const string DefaultPresentationXml = + """ + + + + + """; + + private const string DefaultPresentationRelsXml = + """ + + + + + """; + + private const string MasterRelsXml = + """ + + + + + """; + + private const string LayoutRelsXml = + """ + + + + + """; + + private const string SlideRelsXml = + """ + + + + + """; + + private const string ThemeXml = + """ + + + + + + + + + + + + + + + + + + + + + + + """; + + /// Builds an in-memory, OPC-shaped .pptx ZIP package from name/content entry pairs. + private static Stream BuildPackage(params (string Name, string Content)[] entries) + { + var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var (name, content) in entries) + { + var entry = archive.CreateEntry(name); + using var entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream, Encoding.UTF8); + writer.Write(content); + } + } + + stream.Position = 0; + return stream; + } + + /// Builds the minimal set of entries needed for to succeed as a navigable, single-slide presentation. + private static (string Name, string Content)[] MinimalOpenableEntries() => + [ + ("[Content_Types].xml", DefaultContentTypesXml), + ("_rels/.rels", DefaultPackageRelsXml), + ("ppt/presentation.xml", DefaultPresentationXml), + ("ppt/_rels/presentation.xml.rels", DefaultPresentationRelsXml), + ]; + + /// Builds a <p:sldMaster> part's content with the given required <p:clrMap> attributes. + private static string BuildMasterXml(string bg1 = "lt1", string tx1 = "dk1", string bg2 = "lt2", string tx2 = "dk2") => + new XElement( + P + "sldMaster", + new XElement(P + "cSld", new XElement(P + "spTree")), + new XElement(P + "clrMap", new XAttribute("bg1", bg1), new XAttribute("tx1", tx1), new XAttribute("bg2", bg2), new XAttribute("tx2", tx2))).ToString(); + + /// Builds a <p:sldMaster> part's content with no <p:clrMap> element at all. + private static string BuildMasterXmlMissingClrMap() => + new XElement(P + "sldMaster", new XElement(P + "cSld", new XElement(P + "spTree"))).ToString(); + + /// Builds a <p:sldLayout> part's content, with an optional raw <p:clrMapOvr> child. + private static string BuildLayoutXml(XElement? clrMapOvr = null) + { + var root = new XElement(P + "sldLayout", new XElement(P + "cSld", new XElement(P + "spTree"))); + if (clrMapOvr is not null) + { + root.Add(clrMapOvr); + } + + return root.ToString(); + } + + /// Builds a <p:sld> part's content, with an optional raw <p:clrMapOvr> child. + private static string BuildSlideXml(XElement? clrMapOvr = null) + { + var root = new XElement(P + "sld", new XElement(P + "cSld", new XElement(P + "spTree"))); + if (clrMapOvr is not null) + { + root.Add(clrMapOvr); + } + + return root.ToString(); + } + + /// Builds a <p:clrMapOvr> element wrapping <a:overrideClrMapping> with the given attributes. + private static XElement OverrideClrMapOvr(string bg1, string tx1, string bg2, string tx2) => + new( + P + "clrMapOvr", + new XElement(A + "overrideClrMapping", new XAttribute("bg1", bg1), new XAttribute("tx1", tx1), new XAttribute("bg2", bg2), new XAttribute("tx2", tx2))); + + /// Builds a <p:clrMapOvr> element wrapping <a:masterClrMapping/> (the "no override at this tier" sentinel). + private static XElement MasterClrMapOvr() => new(P + "clrMapOvr", new XElement(A + "masterClrMapping")); + + // --- GetMaster: required parsing ---------------------------------------------- + + /// Proves parses a well-formed <p:clrMap> into . + [Fact] + public void GetMaster_ParsesRequiredClrMap() + { + using var stream = BuildPackage( + [ + .. MinimalOpenableEntries(), + ("ppt/slideMasters/slideMaster1.xml", BuildMasterXml(bg1: "dk1", tx1: "lt1", bg2: "dk2", tx2: "lt2")), + ("ppt/slideMasters/_rels/slideMaster1.xml.rels", MasterRelsXml), + ("ppt/theme/theme1.xml", ThemeXml), + ]); + using var document = PptxDocument.Open(stream); + + var master = document.GetMaster("ppt/slideMasters/slideMaster1.xml"); + + Assert.Equal("dk1", master.ColorMap.Bg1); + Assert.Equal("lt1", master.ColorMap.Tx1); + Assert.Equal("dk2", master.ColorMap.Bg2); + Assert.Equal("lt2", master.ColorMap.Tx2); + } + + /// Proves throws when <p:clrMap> is missing entirely. + [Fact] + public void GetMaster_MissingClrMap_ThrowsInvalidDataException() + { + using var stream = BuildPackage( + [ + .. MinimalOpenableEntries(), + ("ppt/slideMasters/slideMaster1.xml", BuildMasterXmlMissingClrMap()), + ("ppt/slideMasters/_rels/slideMaster1.xml.rels", MasterRelsXml), + ("ppt/theme/theme1.xml", ThemeXml), + ]); + using var document = PptxDocument.Open(stream); + + Assert.Throws(() => document.GetMaster("ppt/slideMasters/slideMaster1.xml")); + } + + // --- GetLayout/GetSlide: optional parsing ----------------------------------- + + /// Proves leaves null when the layout declares no <p:clrMapOvr>. + [Fact] + public void GetLayout_ParsesOptionalClrMapOvr_DefaultsToNullWhenAbsent() + { + using var stream = BuildPackage( + [ + .. MinimalOpenableEntries(), + ("ppt/slideLayouts/slideLayout1.xml", BuildLayoutXml()), + ("ppt/slideLayouts/_rels/slideLayout1.xml.rels", LayoutRelsXml), + ("ppt/slideMasters/slideMaster1.xml", BuildMasterXml()), + ("ppt/slideMasters/_rels/slideMaster1.xml.rels", MasterRelsXml), + ("ppt/theme/theme1.xml", ThemeXml), + ]); + using var document = PptxDocument.Open(stream); + + var layout = document.GetLayout("ppt/slideLayouts/slideLayout1.xml"); + + Assert.Null(layout.ClrMapOvr); + } + + /// Proves retains a present <p:clrMapOvr> element, unparsed, on . + [Fact] + public void GetLayout_ParsesOptionalClrMapOvr_RetainsElementWhenPresent() + { + using var stream = BuildPackage( + [ + .. MinimalOpenableEntries(), + ("ppt/slideLayouts/slideLayout1.xml", BuildLayoutXml(OverrideClrMapOvr("dk1", "lt1", "dk2", "lt2"))), + ("ppt/slideLayouts/_rels/slideLayout1.xml.rels", LayoutRelsXml), + ("ppt/slideMasters/slideMaster1.xml", BuildMasterXml()), + ("ppt/slideMasters/_rels/slideMaster1.xml.rels", MasterRelsXml), + ("ppt/theme/theme1.xml", ThemeXml), + ]); + using var document = PptxDocument.Open(stream); + + var layout = document.GetLayout("ppt/slideLayouts/slideLayout1.xml"); + + Assert.NotNull(layout.ClrMapOvr); + Assert.Equal(P + "clrMapOvr", layout.ClrMapOvr.Name); + } + + /// Proves leaves null when the slide declares no <p:clrMapOvr>. + [Fact] + public void GetSlide_ParsesOptionalClrMapOvr_DefaultsToNullWhenAbsent() + { + using var stream = BuildPackage( + [ + .. MinimalOpenableEntries(), + ("ppt/slides/slide1.xml", BuildSlideXml()), + ("ppt/slides/_rels/slide1.xml.rels", SlideRelsXml), + ("ppt/slideLayouts/slideLayout1.xml", BuildLayoutXml()), + ("ppt/slideLayouts/_rels/slideLayout1.xml.rels", LayoutRelsXml), + ("ppt/slideMasters/slideMaster1.xml", BuildMasterXml()), + ("ppt/slideMasters/_rels/slideMaster1.xml.rels", MasterRelsXml), + ("ppt/theme/theme1.xml", ThemeXml), + ]); + using var document = PptxDocument.Open(stream); + + var slide = document.GetSlide(0); + + Assert.Null(slide.ClrMapOvr); + } + + // --- ResolveEffectiveColorMap: slide > layout > master fallback --------------------------- + + /// Resolve Effective Color Map - No Overrides Anywhere - Returns Master Color Map. + [Fact] + public void ResolveEffectiveColorMap_NoOverridesAnywhere_ReturnsMasterColorMap() + { + var masterColorMap = new PptxColorMap("dk1", "lt1", "dk2", "lt2"); + + var effective = PptxDocument.ResolveEffectiveColorMap(null, null, masterColorMap); + + Assert.Equal(masterColorMap, effective); + } + + /// Resolve Effective Color Map - Layout Override Clr Mapping - Returns Layout Override. + [Fact] + public void ResolveEffectiveColorMap_LayoutOverrideClrMapping_ReturnsLayoutOverride() + { + var masterColorMap = PptxColorMap.Default; + var layoutClrMapOvr = OverrideClrMapOvr("dk1", "lt1", "dk2", "lt2"); + + var effective = PptxDocument.ResolveEffectiveColorMap(null, layoutClrMapOvr, masterColorMap); + + Assert.Equal(new PptxColorMap("dk1", "lt1", "dk2", "lt2"), effective); + } + + /// Resolve Effective Color Map - Slide Override Clr Mapping - Returns Slide Override Even When Layout Also Overrides. + [Fact] + public void ResolveEffectiveColorMap_SlideOverrideClrMapping_ReturnsSlideOverrideEvenWhenLayoutAlsoOverrides() + { + var masterColorMap = PptxColorMap.Default; + var layoutClrMapOvr = OverrideClrMapOvr("accent1", "accent2", "accent3", "accent4"); + var slideClrMapOvr = OverrideClrMapOvr("dk1", "lt1", "dk2", "lt2"); + + var effective = PptxDocument.ResolveEffectiveColorMap(slideClrMapOvr, layoutClrMapOvr, masterColorMap); + + Assert.Equal(new PptxColorMap("dk1", "lt1", "dk2", "lt2"), effective); + } + + /// Resolve Effective Color Map - Slide Master Clr Mapping - Falls Through To Layout Override. + [Fact] + public void ResolveEffectiveColorMap_SlideMasterClrMapping_FallsThroughToLayoutOverride() + { + var masterColorMap = PptxColorMap.Default; + var layoutClrMapOvr = OverrideClrMapOvr("dk1", "lt1", "dk2", "lt2"); + var slideClrMapOvr = MasterClrMapOvr(); + + var effective = PptxDocument.ResolveEffectiveColorMap(slideClrMapOvr, layoutClrMapOvr, masterColorMap); + + Assert.Equal(new PptxColorMap("dk1", "lt1", "dk2", "lt2"), effective); + } + + /// Resolve Effective Color Map - Slide And Layout Master Clr Mapping - Falls Through To Master Color Map. + [Fact] + public void ResolveEffectiveColorMap_SlideAndLayoutMasterClrMapping_FallsThroughToMasterColorMap() + { + var masterColorMap = new PptxColorMap("dk1", "lt1", "dk2", "lt2"); + var layoutClrMapOvr = MasterClrMapOvr(); + var slideClrMapOvr = MasterClrMapOvr(); + + var effective = PptxDocument.ResolveEffectiveColorMap(slideClrMapOvr, layoutClrMapOvr, masterColorMap); + + Assert.Equal(masterColorMap, effective); + } + + /// Resolve Effective Color Map - Override Clr Mapping Missing Required Attribute - Throws Invalid Data Exception. + [Fact] + public void ResolveEffectiveColorMap_OverrideClrMappingMissingRequiredAttribute_ThrowsInvalidDataException() + { + var malformed = new XElement(P + "clrMapOvr", new XElement(A + "overrideClrMapping", new XAttribute("bg1", "dk1"))); + + Assert.Throws(() => PptxDocument.ResolveEffectiveColorMap(malformed, null, PptxColorMap.Default)); + } + + // --- GetSlide: colorMapResolver threading into table cell fill resolution ----------------- + + /// Builds a single-cell <p:graphicFrame> table whose cell's own fill is <a:solidFill><a:schemeClr val="bg1"/></a:solidFill>. + private static XElement BuildSchemeColorTableGraphicFrame() => + new( + P + "graphicFrame", + new XElement( + A + "graphic", + new XElement( + A + "graphicData", + new XAttribute("uri", "http://schemas.openxmlformats.org/drawingml/2006/table"), + new XElement( + A + "tbl", + new XElement(A + "tblGrid", new XElement(A + "gridCol", new XAttribute("w", 1000))), + new XElement( + A + "tr", + new XAttribute("h", 1000), + new XElement( + A + "tc", + new XElement( + A + "tcPr", + new XElement(A + "solidFill", new XElement(A + "schemeClr", new XAttribute("val", "bg1")))))))))); + + /// Builds a <p:sld> part's content whose <p:spTree> contains a single scheme-color-filled table, with an optional raw <p:clrMapOvr> child. + private static string BuildSlideXmlWithSchemeColorTable(XElement? clrMapOvr = null) + { + var root = new XElement( + P + "sld", + new XElement(P + "cSld", new XElement(P + "spTree", BuildSchemeColorTableGraphicFrame()))); + if (clrMapOvr is not null) + { + root.Add(clrMapOvr); + } + + return root.ToString(); + } + + /// + /// Proves threads this slide's own real effective + /// color map (see ) into + /// , instead of silently defaulting to + /// as before this fix: a table cell's own + /// <a:schemeClr val="bg1"/> fill resolves against the theme color the + /// slide's own <p:clrMapOvr> override maps bg1 to (dk1, the + /// theme's 101010 color), not the one 's + /// unoverridden bg1->lt1 mapping would have produced (the theme's + /// F0F0F0 color). + /// + [Fact] + public void GetSlide_TableCellSchemeColorFillWithSlideClrMapOvr_UsesEffectiveColorMapNotDefault() + { + using var stream = BuildPackage( + [ + .. MinimalOpenableEntries(), + ("ppt/slides/slide1.xml", BuildSlideXmlWithSchemeColorTable(OverrideClrMapOvr("dk1", "lt1", "dk2", "lt2"))), + ("ppt/slides/_rels/slide1.xml.rels", SlideRelsXml), + ("ppt/slideLayouts/slideLayout1.xml", BuildLayoutXml()), + ("ppt/slideLayouts/_rels/slideLayout1.xml.rels", LayoutRelsXml), + ("ppt/slideMasters/slideMaster1.xml", BuildMasterXml()), + ("ppt/slideMasters/_rels/slideMaster1.xml.rels", MasterRelsXml), + ("ppt/theme/theme1.xml", ThemeXml), + ]); + using var document = PptxDocument.Open(stream); + + var slide = document.GetSlide(0); + + var graphicFrameNode = Assert.IsType(Assert.Single(slide.ShapeTree)); + Assert.NotNull(graphicFrameNode.Table); + var cellFill = Assert.IsType(graphicFrameNode.Table.Rows[0].Cells[0].Fill); + Assert.Equal(new Rgba32(0x10, 0x10, 0x10, 255), cellFill.Color); + } +} diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxConnectorTests.cs b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxConnectorTests.cs new file mode 100644 index 00000000..7a52d05a --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxConnectorTests.cs @@ -0,0 +1,489 @@ +using System.Numerics; +using System.Xml.Linq; +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Geometry; +using Path = DemaConsulting.CanvasNet.Geometry.Path; + +namespace DemaConsulting.CanvasNet.Pptx.Tests; + +// cspell:ignore xfrm flipH flipV prstGeom pptx prst lnref prstdash headend tailend cxnsp stealth sysdash sysdot pythonpptx + +/// +/// Unit-level tests for the Phase 2 Follow-Up connector-shape-rendering building blocks: +/// 's connector-only preset builders (line/ +/// straightConnector1/bentConnectorN/curvedConnectorN), the connector +/// line-style merge resolver (), arrowhead +/// resolution (), arrowhead geometry +/// (), and the endpoint/tangent helper +/// (). End-to-end, render-based proof +/// that a connector actually paints visible pixels lives in and +/// . +/// +public class PptxConnectorTests +{ + private static readonly XNamespace A = "http://schemas.openxmlformats.org/drawingml/2006/main"; + private static readonly XNamespace P = "http://schemas.openxmlformats.org/presentationml/2006/main"; + + /// Asserts two values are equal within a small tolerance. + private static void AssertVectorsClose(Vector2 expected, Vector2 actual, float tolerance = 0.01f) + { + Assert.True( + MathF.Abs(expected.X - actual.X) <= tolerance && MathF.Abs(expected.Y - actual.Y) <= tolerance, + $"Expected {expected} but was {actual}."); + } + + // --- PptxPresetGeometry: straight connector ------------------------------------------------- + + /// Straight connector preset builds an open diagonal from (0,0) to (w,h). + [Theory] + [InlineData("line")] + [InlineData("straightConnector1")] + public void PptxPresetGeometry_Build_StraightConnectorPresets_ProducesOpenDiagonal(string prst) + { + var path = PptxPresetGeometry.Build(prst, 100, 50); + + var subpath = Assert.Single(path.Subpaths); + Assert.False(subpath.IsClosed); + AssertVectorsClose(Vector2.Zero, subpath.Start); + var onlyCommand = Assert.Single(subpath.Commands); + Assert.Equal(PathCommandType.LineTo, onlyCommand.Type); + AssertVectorsClose(new Vector2(100, 50), onlyCommand.EndPoint); + } + + /// + /// Proves the critical zero-height/zero-width connector bug fix: unlike every ordinary + /// (non-connector) preset, a connector preset still builds a real, non-empty path when + /// its own width or height is zero - confirmed necessary by the real + /// pythonpptx-shp-connector-props.pptx fixture's own second slide connector + /// (<a:ext cx="1440160" cy="0"/>). + /// + [Theory] + [InlineData(100f, 0f)] + [InlineData(0f, 100f)] + [InlineData(0f, 0f)] + public void PptxPresetGeometry_Build_StraightConnectorWithZeroWidthOrHeight_StillProducesLine(float w, float h) + { + var path = PptxPresetGeometry.Build("straightConnector1", w, h); + + var subpath = Assert.Single(path.Subpaths); + AssertVectorsClose(new Vector2(w, h), subpath.Commands[0].EndPoint); + } + + /// Confirms an ordinary (non-connector) preset is unaffected by the connector zero-size bypass - it still degrades to . + [Fact] + public void PptxPresetGeometry_Build_OrdinaryPresetWithZeroHeight_StillDegradesToEmpty() + { + Assert.Same(Path.Empty, PptxPresetGeometry.Build("rect", 100, 0)); + } + + // --- PptxPresetGeometry: bent/curved connectors --------------------------------------------- + + /// Every supported bent-connector preset reaches exactly (w,h) as its own final vertex, as an open polyline. + [Theory] + [InlineData("bentConnector2")] + [InlineData("bentConnector3")] + [InlineData("bentConnector4")] + [InlineData("bentConnector5")] + public void PptxPresetGeometry_Build_BentConnectorPresets_ReachesEndpointAsOpenPolyline(string prst) + { + var path = PptxPresetGeometry.Build(prst, 200, 120); + + var subpath = Assert.Single(path.Subpaths); + Assert.False(subpath.IsClosed); + Assert.NotEmpty(subpath.Commands); + AssertVectorsClose(new Vector2(200, 120), subpath.Commands[^1].EndPoint); + } + + /// Every supported curved-connector preset reaches exactly (w,h) as its own final vertex (corner-rounding never moves the true endpoints). + [Theory] + [InlineData("curvedConnector2")] + [InlineData("curvedConnector3")] + [InlineData("curvedConnector4")] + [InlineData("curvedConnector5")] + public void PptxPresetGeometry_Build_CurvedConnectorPresets_ReachesEndpoint(string prst) + { + var path = PptxPresetGeometry.Build(prst, 200, 120); + + var subpath = Assert.Single(path.Subpaths); + Assert.False(subpath.IsClosed); + Assert.NotEmpty(subpath.Commands); + AssertVectorsClose(new Vector2(0, 0), subpath.Start); + AssertVectorsClose(new Vector2(200, 120), subpath.Commands[^1].EndPoint); + } + + // --- Flip combinations (straight connector + ResolveShapeFrame) ----------------------------- + + /// + /// A straight connector's own absolute endpoints, after composing + /// 's own transform (which bakes in + /// flipH/flipV) with the connector's local geometry, land where PowerPoint's + /// own documented flip-about-center semantics place them - covering every flip combination + /// (neither/flipH only/flipV only/both). + /// + [Theory] + [InlineData(false, false, 0, 0, 100, 50)] + [InlineData(true, false, 100, 0, 0, 50)] + [InlineData(false, true, 0, 50, 100, 0)] + [InlineData(true, true, 100, 50, 0, 0)] + public void ResolveShapeFrame_StraightConnectorWithFlipCombination_MapsEndpointsCorrectly( + bool flipH, bool flipV, float expectedStartX, float expectedStartY, float expectedEndX, float expectedEndY) + { + var xfrm = new XElement( + A + "xfrm", + new XAttribute("flipH", flipH ? "1" : "0"), + new XAttribute("flipV", flipV ? "1" : "0"), + new XElement(A + "off", new XAttribute("x", 0), new XAttribute("y", 0)), + new XElement(A + "ext", new XAttribute("cx", 100), new XAttribute("cy", 50))); + + var frame = PptxDocument.ResolveShapeFrame(xfrm); + var localPath = PptxPresetGeometry.Build("straightConnector1", frame.WidthEmu, frame.HeightEmu); + var transformed = localPath.Transform(frame.Transform); + + var subpath = Assert.Single(transformed.Subpaths); + AssertVectorsClose(new Vector2(expectedStartX, expectedStartY), subpath.Start); + AssertVectorsClose(new Vector2(expectedEndX, expectedEndY), subpath.Commands[0].EndPoint); + } + + // --- ResolveConnectorLineStyle: merge-resolver precedence ----------------------------------- + + /// Builds a minimal <p:spPr> element with the given raw <a:ln> inner XML (or none). + private static XElement BuildSpPr(string lnInnerXml = "") + { + var spPr = XElement.Parse( + $"""{lnInnerXml}"""); + return spPr; + } + + /// Builds an arbitrary, fully-populated test with the given <a:ln> entries as its own <a:lnStyleLst> (see for the matching, pre-existing pattern this mirrors). + private static PptxTheme BuildThemeWithLnStyleList(IReadOnlyList lnStyleList) => + new( + new PptxColorScheme( + Dark1: new Rgba32(0x10, 0x10, 0x10, 255), + Light1: new Rgba32(0xF0, 0xF0, 0xF0, 255), + Dark2: new Rgba32(0x20, 0x20, 0x20, 255), + Light2: new Rgba32(0xE0, 0xE0, 0xE0, 255), + Accent1: new Rgba32(0x4F, 0x81, 0xBD, 255), + Accent2: new Rgba32(0xC0, 0x50, 0x4D, 255), + Accent3: new Rgba32(0x9B, 0xBB, 0x59, 255), + Accent4: new Rgba32(0x80, 0x64, 0xA2, 255), + Accent5: new Rgba32(0x4B, 0xAC, 0xC6, 255), + Accent6: new Rgba32(0xF7, 0x96, 0x46, 255), + Hyperlink: new Rgba32(0x00, 0x00, 0xFF, 255), + FollowedHyperlink: new Rgba32(0x80, 0x00, 0x80, 255)), + new PptxFontScheme( + new PptxFontCollection("MajorLatin", "MajorEA", "MajorCS"), + new PptxFontCollection("MinorLatin", "MinorEA", "MinorCS")), + LnStyleList: lnStyleList); + + private static readonly IReadOnlyList ThreeEntryLnStyleList = + [ + new(A + "ln", new XAttribute("w", 9525), new XElement(A + "solidFill", new XElement(A + "schemeClr", new XAttribute("val", "phClr")))), + new(A + "ln", new XAttribute("w", 25400), new XElement(A + "solidFill", new XElement(A + "schemeClr", new XAttribute("val", "accent1")))), + new(A + "ln", new XAttribute("w", 38100), new XElement(A + "solidFill", new XElement(A + "schemeClr", new XAttribute("val", "accent2")))), + ]; + + /// An explicit <a:noFill/> directly on the connector's own <a:ln> always wins, even over a style that would otherwise resolve to a visible stroke. + [Fact] + public void ResolveConnectorLineStyle_OwnLnExplicitNoFill_AlwaysWinsOverStyle() + { + var spPr = BuildSpPr(""""""); + var styleElement = XElement.Parse( + $""""""); + var theme = BuildThemeWithLnStyleList(ThreeEntryLnStyleList); + + var lineStyle = PptxDocument.ResolveConnectorLineStyle(spPr, styleElement, theme, PptxColorMap.Default); + + Assert.Null(lineStyle); + } + + /// When the connector's own <a:ln> declares no w, the merged width falls back to the style's own resolved width. + [Fact] + public void ResolveConnectorLineStyle_OwnLnHasNoWidth_FallsBackToStyleWidth() + { + var spPr = BuildSpPr(""""""); + var styleElement = XElement.Parse( + $""""""); + var theme = BuildThemeWithLnStyleList(ThreeEntryLnStyleList); + + var lineStyle = PptxDocument.ResolveConnectorLineStyle(spPr, styleElement, theme, PptxColorMap.Default); + + Assert.NotNull(lineStyle); + Assert.Equal(25400f, lineStyle.WidthEmu); + var solid = Assert.IsType(lineStyle.Paint); + Assert.Equal(new Rgba32(0x4F, 0x81, 0xBD, 255), solid.Color); + } + + /// When the connector's own <a:ln> declares a width but no fill child, the merged paint falls back to the style's own resolved paint. + [Fact] + public void ResolveConnectorLineStyle_OwnLnHasWidthButNoFill_FallsBackToStylePaint() + { + var spPr = BuildSpPr(""""""); + var styleElement = XElement.Parse( + $""""""); + var theme = BuildThemeWithLnStyleList(ThreeEntryLnStyleList); + + var lineStyle = PptxDocument.ResolveConnectorLineStyle(spPr, styleElement, theme, PptxColorMap.Default); + + Assert.NotNull(lineStyle); + Assert.Equal(100000f, lineStyle.WidthEmu); + var solid = Assert.IsType(lineStyle.Paint); + Assert.Equal(new Rgba32(0xC0, 0x50, 0x4D, 255), solid.Color); + } + + /// When the connector's own <a:ln> explicitly declares both width and fill, those own values win outright over the style. + [Fact] + public void ResolveConnectorLineStyle_OwnLnHasExplicitWidthAndFill_OwnValuesWin() + { + var spPr = BuildSpPr(""""""); + var styleElement = XElement.Parse( + $""""""); + var theme = BuildThemeWithLnStyleList(ThreeEntryLnStyleList); + + var lineStyle = PptxDocument.ResolveConnectorLineStyle(spPr, styleElement, theme, PptxColorMap.Default); + + Assert.NotNull(lineStyle); + Assert.Equal(12700f, lineStyle.WidthEmu); + var solid = Assert.IsType(lineStyle.Paint); + Assert.Equal(new Rgba32(0x00, 0xFF, 0x00, 255), solid.Color); + } + + /// With neither an own <a:ln> width/fill nor any <p:style> at all, the connector resolves to "no stroke". + [Fact] + public void ResolveConnectorLineStyle_NoOwnWidthAndNoStyle_ResolvesToNull() + { + var spPr = BuildSpPr(""""""); + var theme = BuildThemeWithLnStyleList(ThreeEntryLnStyleList); + + var lineStyle = PptxDocument.ResolveConnectorLineStyle(spPr, null, theme, PptxColorMap.Default); + + Assert.Null(lineStyle); + } + + /// The connector's own explicit <a:prstDash> is used over the style's own dash pattern. + [Fact] + public void ResolveConnectorLineStyle_OwnLnHasExplicitDash_UsesOwnDashArray() + { + var spPr = BuildSpPr( + """"""); + + var lineStyle = PptxDocument.ResolveConnectorLineStyle(spPr, null, BuildThemeWithLnStyleList(ThreeEntryLnStyleList), PptxColorMap.Default); + + Assert.NotNull(lineStyle); + Assert.NotNull(lineStyle.DashArray); + Assert.Equal([12700f * 4f, 12700f * 3f], lineStyle.DashArray); + } + + /// Proves a non-numeric w attribute on the connector's own <a:ln> throws rather than letting a raw escape uncaught. + [Fact] + public void ResolveConnectorLineStyle_OwnLnNonNumericWidth_ThrowsInvalidDataException() + { + var spPr = BuildSpPr(""""""); + var theme = BuildThemeWithLnStyleList(ThreeEntryLnStyleList); + + Assert.Throws( + () => PptxDocument.ResolveConnectorLineStyle(spPr, null, theme, PptxColorMap.Default)); + } + + /// Proves a non-finite (NaN/Infinity/-Infinity) w attribute on the connector's own <a:ln> throws . + [Theory] + [InlineData("NaN")] + [InlineData("Infinity")] + [InlineData("-Infinity")] + public void ResolveConnectorLineStyle_OwnLnNonFiniteWidth_ThrowsInvalidDataException(string nonFiniteValue) + { + var spPr = BuildSpPr($""""""); + var theme = BuildThemeWithLnStyleList(ThreeEntryLnStyleList); + + Assert.Throws( + () => PptxDocument.ResolveConnectorLineStyle(spPr, null, theme, PptxColorMap.Default)); + } + + // --- ResolveArrowhead -------------------------------------------------------------------- + + /// A recognized type attribute value on a <a:tailEnd> element resolves to the matching , carrying through its w/len size keys. + [Theory] + [InlineData("triangle", "Triangle")] + [InlineData("stealth", "Stealth")] + [InlineData("diamond", "Diamond")] + [InlineData("oval", "Oval")] + [InlineData("arrow", "Arrow")] + public void ResolveArrowhead_RecognizedTypeValue_ResolvesExpectedKind(string typeValue, string expectedKindName) + { + var ln = XElement.Parse( + $""""""); + + var style = PptxDocument.ResolveArrowhead(ln, "tailEnd"); + + Assert.NotNull(style); + Assert.Equal(expectedKindName, style.Kind.ToString()); + Assert.Equal("lg", style.WidthKey); + Assert.Equal("sm", style.LengthKey); + } + + /// A <a:ln> element (no line properties at all) has no arrowhead to resolve. + [Fact] + public void ResolveArrowhead_NoLnElement_ReturnsNull() + { + Assert.Null(PptxDocument.ResolveArrowhead(null, "tailEnd")); + } + + /// Resolving the tailEnd element name on an <a:ln> that only declares a <a:headEnd> yields no arrowhead. + [Fact] + public void ResolveArrowhead_NoMatchingEndElement_ReturnsNull() + { + var ln = XElement.Parse($""""""); + + Assert.Null(PptxDocument.ResolveArrowhead(ln, "tailEnd")); + } + + /// Both the explicit none type value, and any unrecognized/exotic type value, resolve to no arrowhead at all. + [Theory] + [InlineData("none")] + [InlineData("unknownExoticType")] + public void ResolveArrowhead_NoneOrUnrecognizedType_ReturnsNull(string typeValue) + { + var ln = XElement.Parse($""""""); + + Assert.Null(PptxDocument.ResolveArrowhead(ln, "tailEnd")); + } + + /// When an arrowhead's w/len attributes are both absent, both size keys default to "med". + [Fact] + public void ResolveArrowhead_NoWOrLenAttributes_DefaultsToMed() + { + var ln = XElement.Parse($""""""); + + var style = PptxDocument.ResolveArrowhead(ln, "tailEnd"); + + Assert.NotNull(style); + Assert.Equal("med", style.WidthKey); + Assert.Equal("med", style.LengthKey); + } + + // --- PptxArrowheadGeometry: shape/sizing ---------------------------------------------------- + + /// Every filled arrowhead kind's tip sits at the local origin, with its body extending toward local -x (the caller orients this toward the connector's own direction later). + [Theory] + [InlineData("Triangle")] + [InlineData("Stealth")] + [InlineData("Diamond")] + public void PptxArrowheadGeometry_Build_FilledKinds_TipAtOriginBodyExtendsBackward(string kindName) + { + var kind = Enum.Parse(kindName); + var style = new PptxArrowheadStyle(kind, "med", "med"); + + var path = PptxArrowheadGeometry.Build(style, 100000f); + + var subpath = Assert.Single(path.Subpaths); + Assert.True(subpath.IsClosed); + var bounds = path.GetBounds(); + Assert.True(bounds.X <= 0.01f, $"{kind}: left bound should be <= 0, was {bounds.X}"); + Assert.True(bounds.X + bounds.Width >= -0.01f, $"{kind}: right bound should reach back to ~0, was {bounds.X + bounds.Width}"); + } + + /// The "oval" arrowhead kind is also closed and centered around the origin. + [Fact] + public void PptxArrowheadGeometry_Build_Oval_IsClosedAndCenteredNearOrigin() + { + var style = new PptxArrowheadStyle(PptxArrowheadKind.Oval, "med", "med"); + + var path = PptxArrowheadGeometry.Build(style, 100000f); + + var subpath = Assert.Single(path.Subpaths); + Assert.True(subpath.IsClosed); + } + + /// The "arrow" open-chevron kind is an open (unclosed) two-segment path, meant to be stroked rather than filled. + [Fact] + public void PptxArrowheadGeometry_Build_Arrow_IsOpenChevron() + { + var style = new PptxArrowheadStyle(PptxArrowheadKind.Arrow, "med", "med"); + + var path = PptxArrowheadGeometry.Build(style, 100000f); + + var subpath = Assert.Single(path.Subpaths); + Assert.False(subpath.IsClosed); + Assert.Equal(2, subpath.Commands.Count); + AssertVectorsClose(Vector2.Zero, subpath.Commands[0].EndPoint); + } + + /// A non-positive line width yields an empty arrowhead (nothing meaningful to scale from). + [Fact] + public void PptxArrowheadGeometry_Build_NonPositiveLineWidth_ReturnsEmpty() + { + var style = new PptxArrowheadStyle(PptxArrowheadKind.Triangle, "med", "med"); + + Assert.Same(Path.Empty, PptxArrowheadGeometry.Build(style, 0f)); + Assert.Same(Path.Empty, PptxArrowheadGeometry.Build(style, -5f)); + } + + /// A "lg" arrowhead is strictly larger (wider bounding box) than a "sm" one built from the same line width. + [Fact] + public void PptxArrowheadGeometry_Build_SizeKeys_LargeIsBiggerThanSmall() + { + var small = PptxArrowheadGeometry.Build(new PptxArrowheadStyle(PptxArrowheadKind.Triangle, "sm", "sm"), 50000f); + var large = PptxArrowheadGeometry.Build(new PptxArrowheadStyle(PptxArrowheadKind.Triangle, "lg", "lg"), 50000f); + + Assert.True(large.GetBounds().Width > small.GetBounds().Width); + Assert.True(large.GetBounds().Height > small.GetBounds().Height); + } + + // --- ComputeEndpointsAndTangents -------------------------------------------------------------- + + /// A horizontal straight connector's start/end points sit at its local corners, and both tangents point along local +X. + [Fact] + public void ComputeEndpointsAndTangents_HorizontalStraightConnector_TangentsPointAlongPositiveX() + { + var path = PptxPresetGeometry.Build("straightConnector1", 100f, 0f); + + var (startPoint, startTangent, endPoint, endTangent) = PptxDocument.ComputeEndpointsAndTangents(path); + + AssertVectorsClose(Vector2.Zero, startPoint); + AssertVectorsClose(new Vector2(100, 0), endPoint); + AssertVectorsClose(Vector2.UnitX, startTangent); + AssertVectorsClose(Vector2.UnitX, endTangent); + } + + /// A vertical straight connector's start/end points sit at its local corners, and both tangents point along local +Y. + [Fact] + public void ComputeEndpointsAndTangents_VerticalStraightConnector_TangentsPointAlongPositiveY() + { + var path = PptxPresetGeometry.Build("straightConnector1", 0f, 80f); + + var (startPoint, startTangent, endPoint, endTangent) = PptxDocument.ComputeEndpointsAndTangents(path); + + AssertVectorsClose(Vector2.Zero, startPoint); + AssertVectorsClose(new Vector2(0, 80), endPoint); + AssertVectorsClose(Vector2.UnitY, startTangent); + AssertVectorsClose(Vector2.UnitY, endTangent); + } + + /// A square diagonal straight connector's tangents point at exactly 45 degrees (normalized (1,1)) at both ends. + [Fact] + public void ComputeEndpointsAndTangents_DiagonalStraightConnector_TangentsPointAtFortyFiveDegrees() + { + var path = PptxPresetGeometry.Build("straightConnector1", 100f, 100f); + + var (startPoint, startTangent, endPoint, endTangent) = PptxDocument.ComputeEndpointsAndTangents(path); + + AssertVectorsClose(Vector2.Zero, startPoint); + AssertVectorsClose(new Vector2(100, 100), endPoint); + var expected = Vector2.Normalize(new Vector2(1, 1)); + AssertVectorsClose(expected, startTangent); + AssertVectorsClose(expected, endTangent); + } + + /// A two-segment bent connector's final segment is vertical, so its end tangent must point along +Y rather than the overall diagonal direction. + [Fact] + public void ComputeEndpointsAndTangents_BentConnector_FinalSegmentTangentPointsAlongLastDirection() + { + // bentConnector2 is a single horizontal-then-vertical staircase (2 segments): the final + // segment is vertical (downward), so the end tangent should point along +Y. + var path = PptxPresetGeometry.Build("bentConnector2", 100f, 100f); + + var (_, _, endPoint, endTangent) = PptxDocument.ComputeEndpointsAndTangents(path); + + AssertVectorsClose(new Vector2(100, 100), endPoint); + AssertVectorsClose(Vector2.UnitY, endTangent); + } +} diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxDocumentTests.cs b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxDocumentTests.cs new file mode 100644 index 00000000..f5f4b503 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxDocumentTests.cs @@ -0,0 +1,1571 @@ +using System.IO.Compression; +using System.Text; +using System.Xml.Linq; +using DemaConsulting.CanvasNet.Canvas; + +namespace DemaConsulting.CanvasNet.Pptx.Tests; + +// cspell:ignore pptx ooxml sppr txbody lststyle sldsz sldidlst sldid nvsppr nvpr ctrtitle srgb hlink calibri + +/// +/// Unit-level tests for the OOXML package layer (Phase 1a: ZIP +/// opening, [Content_Types].xml resolution, package/part relationship resolution) and +/// the Phase 1b presentation/theme/master/layout/slide model and placeholder-inheritance +/// resolver. Complements , which proves the same +/// behaviors end-to-end through the public / +/// API only. +/// +public class PptxDocumentTests +{ + private const string DefaultContentTypesXml = + """ + + + + + + """; + + private const string DefaultPackageRelsXml = + """ + + + + + """; + + /// + /// A minimal, valid ppt/presentation.xml content (one slide, a valid + /// <p:sldSz>) reused by Phase 1a tests that need + /// to succeed but are not themselves testing presentation parsing. + /// + private const string DefaultPresentationXml = + """ + + + + + """; + + /// + /// ppt/_rels/presentation.xml.rels content resolving 's + /// one declared slide ID (rId2) to ppt/slides/slide1.xml. + /// + private const string DefaultPresentationRelsXml = + """ + + + + + """; + + /// + /// Builds an in-memory, OPC-shaped .pptx ZIP package via , + /// with each supplied name/content pair written verbatim as a + /// ZIP entry. Used by every test in this class so each test only needs to describe the + /// handful of entries relevant to the scenario under test, rather than a complete, + /// feature-complete presentation package. + /// + private static Stream BuildPackage(params (string Name, string Content)[] entries) + { + var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var (name, content) in entries) + { + var entry = archive.CreateEntry(name); + using var entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream, Encoding.UTF8); + writer.Write(content); + } + } + + stream.Position = 0; + return stream; + } + + /// Builds a minimal, valid package: content types, package-level rels, and a minimal navigable presentation part. + private static Stream BuildMinimalValidPackage() => + BuildPackage( + ("[Content_Types].xml", DefaultContentTypesXml), + ("_rels/.rels", DefaultPackageRelsXml), + ("ppt/presentation.xml", DefaultPresentationXml), + ("ppt/_rels/presentation.xml.rels", DefaultPresentationRelsXml)); + + // --- Phase 1b fixtures: presentation/theme/master/layout/slide model ------------------- + + private const string PresentationNamespaceUri = "http://schemas.openxmlformats.org/presentationml/2006/main"; + private const string RelationshipRefNamespaceUri = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + + private static readonly XNamespace PresentationNs = PresentationNamespaceUri; + private static readonly XNamespace DrawingNs = "http://schemas.openxmlformats.org/drawingml/2006/main"; + + private const string ValidSldSz = """"""; + + /// A package-level rels part whose single relationship's Type ends "/officeDocument". + private const string OfficeDocumentPackageRelsXml = + """ + + + + + """; + + private const string ThemeXmlAllSrgb = + """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """; + + private const string ThemeXmlWithSysClr = + """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """; + + private const string MasterXml = + """ + + + + + + + + + + + + + + + + + + + + + + + + """; + + private const string MasterRelsXml = + """ + + + + + """; + + private const string LayoutXmlDefaultPlaceholder = + """ + + + + + + + + + + + + + + """; + + private const string LayoutRelsXml = + """ + + + + + """; + + private const string SlideXmlWithPlaceholderAndFreeform = + """ + + + + + + + + + + + + + + + + + + + + + """; + + private const string SlideRelsXml = + """ + + + + + """; + + /// + /// Builds ppt/presentation.xml's content declaring + /// (or no <p:sldSz> element at all, when null) and an ordered + /// <p:sldIdLst> of slides referencing + /// rId2, rId3, ... in order (rId1 is reserved, matching real + /// PowerPoint convention, for the master/other non-slide relationship slot). + /// + private static string BuildPresentationXml(string? sldSzElement, int slideCount) + { + var sb = new StringBuilder(); + sb.Append($""""""); + if (sldSzElement is not null) + { + sb.Append(sldSzElement); + } + + sb.Append(""); + for (var i = 0; i < slideCount; i++) + { + sb.Append($""""""); + } + + sb.Append(""); + return sb.ToString(); + } + + /// + /// Builds ppt/presentation.xml's content with and an + /// empty <p:sldIdLst> (used by the "empty slide list" scenario). + /// + private static string BuildPresentationXmlEmptySlideList(string sldSzElement) => + $"""{sldSzElement}"""; + + /// + /// Builds ppt/_rels/presentation.xml.rels's content mapping rId2, rId3, + /// ... (one per slide) to slides/slideN.xml targets, matching + /// 's slide ID numbering. + /// + private static string BuildPresentationRelsXml(int slideCount) + { + var sb = new StringBuilder(); + sb.Append(""""""); + for (var i = 0; i < slideCount; i++) + { + sb.Append($""""""); + } + + sb.Append(""); + return sb.ToString(); + } + + /// + /// Builds the minimal set of entries needed for to + /// succeed as a navigable, single-slide presentation (content types, package-root + /// /officeDocument relationship, a valid ppt/presentation.xml, and its own + /// rels resolving its one declared slide ID) - the actual slide part itself is + /// deliberately NOT included, since most Phase 1b tests only need Open to succeed, + /// not a fully navigable slide/layout/master/theme chain (each test adds only the specific + /// additional parts its own scenario needs). + /// + private static (string Name, string Content)[] MinimalOpenableEntries() => + [ + ("[Content_Types].xml", DefaultContentTypesXml), + ("_rels/.rels", OfficeDocumentPackageRelsXml), + ("ppt/presentation.xml", BuildPresentationXml(ValidSldSz, 1)), + ("ppt/_rels/presentation.xml.rels", BuildPresentationRelsXml(1)), + ]; + + /// + /// Builds a test placeholder shape (<p:sp>) with optional marker-tagged + /// <p:spPr>/<a:lstStyle> children. + /// + /// When non-null, adds a <p:spPr marker="..."/> child. + /// When non-null, adds a <p:txBody>/<a:lstStyle marker="..."/> child. + /// + /// When (the default) and is + /// non-null, the added <a:lstStyle> also gets a concrete + /// <a:lvl1pPr>/<a:defRPr sz="6000"/> child, so it is selected by + /// 's level-override-aware + /// GetTxBodyListStyleWithLevelOverride resolution - preserving every pre-existing + /// call site's "present and selected" semantics unchanged. Pass + /// to build the real-world "empty, self-closing <a:lstStyle/>" shape (marker + /// only, no level child at all) that new regression tests need to reproduce. + /// + private static XElement BuildShapeElement(string? spPrMarker = null, string? lstStyleMarker = null, bool lstStyleHasLevelOverride = true) + { + var sp = new XElement(PresentationNs + "sp"); + if (spPrMarker is not null) + { + sp.Add(new XElement(PresentationNs + "spPr", new XAttribute("marker", spPrMarker))); + } + + if (lstStyleMarker is not null) + { + var lstStyle = new XElement(DrawingNs + "lstStyle", new XAttribute("marker", lstStyleMarker)); + if (lstStyleHasLevelOverride) + { + lstStyle.Add(new XElement(DrawingNs + "lvl1pPr", new XElement(DrawingNs + "defRPr", new XAttribute("sz", "6000")))); + } + + sp.Add(new XElement(PresentationNs + "txBody", lstStyle)); + } + + return sp; + } + + /// Builds an arbitrary, fully-populated test for inheritance-resolver tests. + private static PptxTheme BuildTestTheme() => + new( + new PptxColorScheme( + new Rgba32(1, 1, 1, 255), new Rgba32(2, 2, 2, 255), new Rgba32(3, 3, 3, 255), new Rgba32(4, 4, 4, 255), + new Rgba32(5, 5, 5, 255), new Rgba32(6, 6, 6, 255), new Rgba32(7, 7, 7, 255), new Rgba32(8, 8, 8, 255), + new Rgba32(9, 9, 9, 255), new Rgba32(10, 10, 10, 255), new Rgba32(11, 11, 11, 255), new Rgba32(12, 12, 12, 255)), + new PptxFontScheme( + new PptxFontCollection("MajorLatin", "MajorEA", "MajorCS"), + new PptxFontCollection("MinorLatin", "MinorEA", "MinorCS"))); + + /// Proves a minimal, well-formed OPC package opens successfully. + [Fact] + public void PptxDocument_Open_WellFormedMinimalPackage_Succeeds() + { + // Arrange + using var stream = BuildMinimalValidPackage(); + + // Act + using var document = PptxDocument.Open(stream); + + // Assert: construction completed without throwing, and the instance resolves the one + // part declared in the package-level rels. + Assert.Equal("ppt/presentation.xml", document.ResolveRelationship(string.Empty, "rId1")); + } + + /// Proves a package missing [Content_Types].xml throws . + [Fact] + public void PptxDocument_Open_MissingContentTypes_ThrowsInvalidDataException() + { + // Arrange + using var stream = BuildPackage(("_rels/.rels", DefaultPackageRelsXml)); + + // Act / Assert + Assert.Throws(() => PptxDocument.Open(stream)); + } + + /// Proves a package missing the package-level _rels/.rels throws . + [Fact] + public void PptxDocument_Open_MissingPackageRelationships_ThrowsInvalidDataException() + { + // Arrange + using var stream = BuildPackage(("[Content_Types].xml", DefaultContentTypesXml)); + + // Act / Assert + Assert.Throws(() => PptxDocument.Open(stream)); + } + + /// Proves a corrupt/non-ZIP stream throws . + [Fact] + public void PptxDocument_Open_CorruptNonZipStream_ThrowsInvalidDataException() + { + // Arrange: a stream of arbitrary bytes, not a ZIP local-file-header signature. + using var stream = new MemoryStream([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]); + + // Act / Assert + Assert.Throws(() => PptxDocument.Open(stream)); + } + + /// + /// A stream that never ends, always reporting its requested buffer as fully read with + /// zero bytes - simulates a deliberately non-terminating (or simply enormous) + /// caller-supplied input to , proving the buffering + /// step bounds both the memory and time it can spend reading such a stream rather than + /// looping/allocating without limit. + /// + private sealed class InfiniteZeroStream : Stream + { + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() { } + public override int Read(byte[] buffer, int offset, int count) + { + Array.Clear(buffer, offset, count); + return count; + } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } + + /// + /// Proves rejects a stream exceeding its documented + /// maximum buffered-package size with , rather than + /// buffering a non-terminating (or simply oversized) stream without limit until memory is + /// exhausted. + /// + [Fact] + public void PptxDocument_Open_NonTerminatingOversizedStream_ThrowsInvalidDataException() + { + // Arrange: a stream that never signals end-of-stream. + using var stream = new InfiniteZeroStream(); + + // Act / Assert + Assert.Throws(() => PptxDocument.Open(stream)); + } + + /// + /// Proves follows a relative target path + /// including a "../" traversal segment, resolved relative to the source part's own + /// directory (not the package root). + /// + [Fact] + public void PptxDocument_ResolveRelationship_RelativeTargetWithParentTraversal_ResolvesCorrectTarget() + { + // Arrange: ppt/slides/slide1.xml has a relationship to "../slideLayouts/slideLayout1.xml", + // which must resolve to "ppt/slideLayouts/slideLayout1.xml" (not "ppt/slides/slideLayouts/..."). + const string slideRelsXml = + """ + + + + + """; + + using var stream = BuildPackage( + ("[Content_Types].xml", DefaultContentTypesXml), + ("_rels/.rels", DefaultPackageRelsXml), + ("ppt/presentation.xml", DefaultPresentationXml), + ("ppt/_rels/presentation.xml.rels", DefaultPresentationRelsXml), + ("ppt/slides/_rels/slide1.xml.rels", slideRelsXml)); + using var document = PptxDocument.Open(stream); + + // Act + var resolved = document.ResolveRelationship("ppt/slides/slide1.xml", "rId1"); + + // Assert + Assert.Equal("ppt/slideLayouts/slideLayout1.xml", resolved); + } + + /// + /// Proves a relationship that would traverse past the package root (more ".." + /// segments than preceding directories) throws . + /// + [Fact] + public void PptxDocument_ResolveRelationship_TargetEscapesPackageRoot_ThrowsInvalidDataException() + { + // Arrange + const string slideRelsXml = + """ + + + + + """; + + using var stream = BuildPackage( + ("[Content_Types].xml", DefaultContentTypesXml), + ("_rels/.rels", DefaultPackageRelsXml), + ("ppt/presentation.xml", DefaultPresentationXml), + ("ppt/_rels/presentation.xml.rels", DefaultPresentationRelsXml), + ("ppt/_rels/slide1.xml.rels", slideRelsXml)); + using var document = PptxDocument.Open(stream); + + // Act / Assert + Assert.Throws(() => document.ResolveRelationship("ppt/slide1.xml", "rId1")); + } + + /// + /// Proves throws + /// when the requested relationship ID is not present. + /// + [Fact] + public void PptxDocument_ResolveRelationship_UnknownRelationshipId_ThrowsInvalidDataException() + { + // Arrange + using var stream = BuildMinimalValidPackage(); + using var document = PptxDocument.Open(stream); + + // Act / Assert + Assert.Throws(() => document.ResolveRelationship(string.Empty, "rIdMissing")); + } + + /// + /// Proves a part-specific <Override> content-type mapping in + /// [Content_Types].xml takes precedence over the default extension-based mapping + /// for the same part. + /// + [Fact] + public void PptxDocument_ResolvePart_OverrideContentType_TakesPrecedenceOverDefaultExtension() + { + // Arrange: every ".xml" part defaults to "application/xml", but this package declares a + // specific override for "ppt/presentation.xml" naming its real OOXML content type. + const string contentTypesWithOverrideXml = + """ + + + + + + + """; + + using var stream = BuildPackage( + ("[Content_Types].xml", contentTypesWithOverrideXml), + ("_rels/.rels", DefaultPackageRelsXml), + ("ppt/presentation.xml", DefaultPresentationXml), + ("ppt/_rels/presentation.xml.rels", DefaultPresentationRelsXml)); + using var document = PptxDocument.Open(stream); + + // Act + var presentationContentType = document.ResolvePart("ppt/presentation.xml"); + + // Assert + Assert.Equal( + "application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml", + presentationContentType); + } + + /// Proves a plain, non-overridden part resolves via the default extension mapping. + [Fact] + public void PptxDocument_ResolvePart_NoOverride_ResolvesDefaultExtensionContentType() + { + // Arrange + using var stream = BuildPackage( + ("[Content_Types].xml", DefaultContentTypesXml), + ("_rels/.rels", DefaultPackageRelsXml), + ("ppt/presentation.xml", DefaultPresentationXml), + ("ppt/_rels/presentation.xml.rels", DefaultPresentationRelsXml)); + using var document = PptxDocument.Open(stream); + + // Act + var contentType = document.ResolvePart("ppt/presentation.xml"); + + // Assert + Assert.Equal("application/xml", contentType); + } + + /// Proves resolving a part not present in the package throws . + [Fact] + public void PptxDocument_ResolvePart_UnknownPart_ThrowsInvalidDataException() + { + // Arrange + using var stream = BuildMinimalValidPackage(); + using var document = PptxDocument.Open(stream); + + // Act / Assert + Assert.Throws(() => document.ResolvePart("ppt/missing.xml")); + } + + // --- Package-layer hardening: case-sensitive part names, bounded XML, root/attribute validation --- + + /// + /// Proves two part names differing only by case (Slide1.xml vs slide1.xml) + /// are treated as two distinct parts, each independently resolvable - per OPC's own + /// ordinal (case-sensitive) part-name comparison rule. + /// + [Fact] + public void PptxDocument_ResolvePart_PartNamesDifferingOnlyByCase_ResolveAsDistinctParts() + { + // Arrange + const string contentTypesXml = + """ + + + + + + + + """; + using var stream = BuildPackage( + ("[Content_Types].xml", contentTypesXml), + ("_rels/.rels", DefaultPackageRelsXml), + ("ppt/presentation.xml", DefaultPresentationXml), + ("ppt/_rels/presentation.xml.rels", DefaultPresentationRelsXml), + ("ppt/slides/Slide1.xml", ""), + ("ppt/slides/slide1.xml", "")); + using var document = PptxDocument.Open(stream); + + // Act + var upperContentType = document.ResolvePart("ppt/slides/Slide1.xml"); + var lowerContentType = document.ResolvePart("ppt/slides/slide1.xml"); + + // Assert + Assert.Equal("application/vnd.example.uppercase+xml", upperContentType); + Assert.Equal("application/vnd.example.lowercase+xml", lowerContentType); + } + + /// + /// Proves an incorrectly-cased reserved part name ([content_types].xml, not the + /// exact [Content_Types].xml) is never accepted as the reserved content-types part, + /// since OPC part-name comparison is ordinal (case-sensitive). + /// + [Fact] + public void PptxDocument_Open_WrongCaseContentTypesPartName_ThrowsInvalidDataException() + { + // Arrange + using var stream = BuildPackage( + ("[content_types].xml", DefaultContentTypesXml), + ("_rels/.rels", DefaultPackageRelsXml)); + + // Act / Assert + Assert.Throws(() => PptxDocument.Open(stream)); + } + + /// + /// Proves a package containing two ZIP entries with exactly identical names throws + /// (a malformed/corrupt ZIP, never legitimate for a + /// well-formed OPC package). + /// + [Fact] + public void PptxDocument_Open_DuplicateEntryNames_ThrowsInvalidDataException() + { + // Arrange: ZipArchive itself allows creating two entries with the same name. + var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var _ in new[] { 0, 1 }) + { + var entry = archive.CreateEntry("[Content_Types].xml"); + using var entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream, Encoding.UTF8); + writer.Write(DefaultContentTypesXml); + } + } + + stream.Position = 0; + + // Act / Assert + Assert.Throws(() => PptxDocument.Open(stream)); + } + + /// + /// Proves an oversized [Content_Types].xml part (exceeding the bounded XML parser's + /// character budget - an "XML bomb"/zip-bomb-style attack) throws + /// rather than exhausting memory while parsing. + /// + [Fact] + public void PptxDocument_Open_OversizedContentTypesPart_ThrowsInvalidDataException() + { + // Arrange: pad with an oversized comment so the part exceeds the parser's character + // budget well before any genuine content-types declaration is reached. + var padding = new string('x', 2_100_000); + var oversizedContentTypesXml = + $""" + + + + + + + """; + using var stream = BuildPackage( + ("[Content_Types].xml", oversizedContentTypesXml), + ("_rels/.rels", DefaultPackageRelsXml)); + + // Act / Assert + Assert.Throws(() => PptxDocument.Open(stream)); + } + + /// + /// Proves [Content_Types].xml with a malformed root element (neither named + /// Types nor in the content-types namespace) throws + /// , rather than silently finding zero + /// Default/Override children and proceeding. + /// + [Fact] + public void PptxDocument_Open_ContentTypesWrongRoot_ThrowsInvalidDataException() + { + // Arrange + const string wrongRootContentTypesXml = + """"""; + using var stream = BuildPackage( + ("[Content_Types].xml", wrongRootContentTypesXml), + ("_rels/.rels", DefaultPackageRelsXml)); + + // Act / Assert + Assert.Throws(() => PptxDocument.Open(stream)); + } + + /// + /// Proves a <Default> element missing its required Extension or + /// ContentType attribute throws rather than + /// being silently discarded. + /// + [Fact] + public void PptxDocument_Open_ContentTypesDefaultMissingRequiredAttribute_ThrowsInvalidDataException() + { + // Arrange + const string contentTypesWithBadDefaultXml = + """ + + + + + + """; + using var stream = BuildPackage( + ("[Content_Types].xml", contentTypesWithBadDefaultXml), + ("_rels/.rels", DefaultPackageRelsXml)); + + // Act / Assert + Assert.Throws(() => PptxDocument.Open(stream)); + } + + /// + /// Proves an <Override> element missing its required PartName or + /// ContentType attribute throws rather than + /// being silently discarded. + /// + [Fact] + public void PptxDocument_Open_ContentTypesOverrideMissingRequiredAttribute_ThrowsInvalidDataException() + { + // Arrange + const string contentTypesWithBadOverrideXml = + """ + + + + + + + """; + using var stream = BuildPackage( + ("[Content_Types].xml", contentTypesWithBadOverrideXml), + ("_rels/.rels", DefaultPackageRelsXml)); + + // Act / Assert + Assert.Throws(() => PptxDocument.Open(stream)); + } + + /// + /// Writes of all-zero content to a new ZIP entry named + /// within , writing in fixed 64 KiB + /// chunks rather than allocating one large array. All-zero content compresses extremely + /// well, so this produces a genuine zip-bomb shape (a tiny compressed entry that + /// decompresses to many times its own size) suitable for proving + /// bounds a part's decompressed size rather than + /// trusting its compressed size or declared . + /// + private static void WriteOversizedBinaryEntry(ZipArchive archive, string name, long totalBytes) + { + var entry = archive.CreateEntry(name, CompressionLevel.Optimal); + using var entryStream = entry.Open(); + var chunk = new byte[65536]; + var remaining = totalBytes; + while (remaining > 0) + { + var writeSize = (int)Math.Min(chunk.Length, remaining); + entryStream.Write(chunk, 0, writeSize); + remaining -= writeSize; + } + } + + /// + /// Proves rejects a part whose actual decompressed + /// content exceeds the documented maximum decompressed part size (256 MiB) with + /// , rather than trusting the ZIP entry's declared + /// length or materializing an unbounded byte array for an attacker-controlled, + /// highly-compressible media part (a "zip bomb"). + /// + [Fact] + public void PptxDocument_GetPartBytes_OversizedDecompressedEntry_ThrowsInvalidDataException() + { + // Arrange: a package whose one media part decompresses to one byte over the documented + // 268,435,456-byte (256 MiB) bound, despite compressing down to a tiny ZIP entry. + var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var (name, content) in new[] + { + ("[Content_Types].xml", DefaultContentTypesXml), + ("_rels/.rels", DefaultPackageRelsXml), + ("ppt/presentation.xml", DefaultPresentationXml), + ("ppt/_rels/presentation.xml.rels", DefaultPresentationRelsXml) + }) + { + var entry = archive.CreateEntry(name); + using var entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream, Encoding.UTF8); + writer.Write(content); + } + + WriteOversizedBinaryEntry(archive, "ppt/media/bomb.bin", 268_435_457L); + } + + stream.Position = 0; + using var document = PptxDocument.Open(stream); + + // Act / Assert + Assert.Throws(() => document.GetPartBytes("ppt/media/bomb.bin")); + } + + /// + /// Proves returns a normal-sized binary part's + /// content unchanged, byte-for-byte - a regression safeguard proving the new + /// decompressed-size bound does not disturb ordinary, well within-bound media parts. + /// + [Fact] + public void PptxDocument_GetPartBytes_WellFormedPart_ReturnsExactBytes() + { + // Arrange + var expectedBytes = new byte[256]; + for (var i = 0; i < expectedBytes.Length; i++) + { + expectedBytes[i] = (byte)i; + } + + var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var (name, content) in new[] + { + ("[Content_Types].xml", DefaultContentTypesXml), + ("_rels/.rels", DefaultPackageRelsXml), + ("ppt/presentation.xml", DefaultPresentationXml), + ("ppt/_rels/presentation.xml.rels", DefaultPresentationRelsXml) + }) + { + var entry = archive.CreateEntry(name); + using var entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream, Encoding.UTF8); + writer.Write(content); + } + + var binaryEntry = archive.CreateEntry("ppt/media/image1.bin"); + using var binaryEntryStream = binaryEntry.Open(); + binaryEntryStream.Write(expectedBytes, 0, expectedBytes.Length); + } + + stream.Position = 0; + using var document = PptxDocument.Open(stream); + + // Act + var actualBytes = document.GetPartBytes("ppt/media/image1.bin"); + + // Assert + Assert.Equal(expectedBytes, actualBytes); + } + + /// + /// Proves a .rels part with a malformed root element (neither named + /// Relationships nor in the relationships namespace) throws + /// , rather than silently finding zero + /// Relationship children and proceeding. + /// + [Fact] + public void PptxDocument_Open_PackageRelsWrongRoot_ThrowsInvalidDataException() + { + // Arrange + const string wrongRootRelsXml = + """"""; + using var stream = BuildPackage( + ("[Content_Types].xml", DefaultContentTypesXml), + ("_rels/.rels", wrongRootRelsXml)); + + // Act / Assert + Assert.Throws(() => PptxDocument.Open(stream)); + } + + /// + /// Proves a <Relationship> element missing a required attribute (Id, + /// Type, or Target) throws rather than + /// being silently skipped. + /// + [Fact] + public void PptxDocument_Open_RelationshipMissingRequiredAttribute_ThrowsInvalidDataException() + { + // Arrange: this has no 'Type' attribute. + const string relsWithBadRelationshipXml = + """ + + + + + """; + using var stream = BuildPackage( + ("[Content_Types].xml", DefaultContentTypesXml), + ("_rels/.rels", relsWithBadRelationshipXml)); + + // Act / Assert + Assert.Throws(() => PptxDocument.Open(stream)); + } + + /// + /// Proves two <Relationship> elements sharing the same Id throw + /// rather than the second relationship silently + /// overwriting the first in the relationship lookup. + /// + [Fact] + public void PptxDocument_Open_RelationshipDuplicateId_ThrowsInvalidDataException() + { + // Arrange: two elements sharing Id="rId1" (different Target/Type). + const string relsWithDuplicateIdXml = + """ + + + + + + """; + using var stream = BuildPackage( + ("[Content_Types].xml", DefaultContentTypesXml), + ("_rels/.rels", relsWithDuplicateIdXml)); + + // Act / Assert + Assert.Throws(() => PptxDocument.Open(stream)); + } + + /// + /// Proves throws + /// for a relationship declaring + /// TargetMode="External", which this phase does not support. + /// + [Fact] + public void PptxDocument_ResolveRelationship_ExternalTargetMode_ThrowsInvalidDataException() + { + // Arrange + const string externalRelsXml = + """ + + + + + + """; + using var stream = BuildPackage( + ("[Content_Types].xml", DefaultContentTypesXml), + ("_rels/.rels", externalRelsXml), + ("ppt/presentation.xml", DefaultPresentationXml), + ("ppt/_rels/presentation.xml.rels", DefaultPresentationRelsXml)); + using var document = PptxDocument.Open(stream); + + // Act / Assert + Assert.Throws(() => document.ResolveRelationship(string.Empty, "rIdExternal")); + } + + /// Proves rejects a null stream. + [Fact] + public void PptxDocument_Open_NullStream_ThrowsArgumentNullException() + { + // Act / Assert + Assert.Throws(() => PptxDocument.Open((Stream)null!)); + } + + /// Proves rejects a null path. + [Fact] + public void PptxDocument_Open_NullPath_ThrowsArgumentNullException() + { + // Act / Assert + Assert.Throws(() => PptxDocument.Open((string)null!)); + } + + /// Proves rejects an empty/whitespace-only path. + [Fact] + public void PptxDocument_Open_WhitespacePath_ThrowsArgumentException() + { + // Act / Assert + Assert.Throws(() => PptxDocument.Open(" ")); + } + + /// Proves calling more than once has no ill effect. + [Fact] + public void PptxDocument_Dispose_CalledTwice_DoesNotThrow() + { + // Arrange + using var stream = BuildMinimalValidPackage(); + var document = PptxDocument.Open(stream); + + // Act + document.Dispose(); + var exception = Record.Exception(document.Dispose); + + // Assert + Assert.Null(exception); + } + + // --- Phase 1b: presentation parsing / slide size / slide count ------------------------- + + /// Proves a multi-slide presentation resolves / correctly. + [Fact] + public void PptxDocument_Open_MultiSlidePresentation_SlideCountAndSlideSizeResolveCorrectly() + { + // Arrange + using var stream = BuildPackage( + ("[Content_Types].xml", DefaultContentTypesXml), + ("_rels/.rels", OfficeDocumentPackageRelsXml), + ("ppt/presentation.xml", BuildPresentationXml(ValidSldSz, 3)), + ("ppt/_rels/presentation.xml.rels", BuildPresentationRelsXml(3))); + + // Act + using var document = PptxDocument.Open(stream); + + // Assert + Assert.Equal(3, document.SlideCount); + Assert.Equal(9144000L, document.SlideSize.WidthEmu); + Assert.Equal(6858000L, document.SlideSize.HeightEmu); + } + + /// Proves a package with no /officeDocument relationship throws . + [Fact] + public void PptxDocument_Open_MissingOfficeDocumentRelationship_ThrowsInvalidDataException() + { + // Arrange: the package-level rels declares a relationship, but its Type does not end + // with "/officeDocument". + using var stream = BuildPackage( + ("[Content_Types].xml", DefaultContentTypesXml), + ("_rels/.rels", DefaultPackageRelsXml)); + + // Act / Assert + Assert.Throws(() => PptxDocument.Open(stream)); + } + + /// Proves a presentation part missing <p:sldSz> throws . + [Fact] + public void PptxDocument_Open_PresentationMissingSldSz_ThrowsInvalidDataException() + { + // Arrange + using var stream = BuildPackage( + ("[Content_Types].xml", DefaultContentTypesXml), + ("_rels/.rels", OfficeDocumentPackageRelsXml), + ("ppt/presentation.xml", BuildPresentationXml(null, 1)), + ("ppt/_rels/presentation.xml.rels", BuildPresentationRelsXml(1))); + + // Act / Assert + Assert.Throws(() => PptxDocument.Open(stream)); + } + + /// Proves a presentation declaring an empty <p:sldIdLst> throws . + [Fact] + public void PptxDocument_Open_PresentationEmptySlideList_ThrowsInvalidDataException() + { + // Arrange + using var stream = BuildPackage( + ("[Content_Types].xml", DefaultContentTypesXml), + ("_rels/.rels", OfficeDocumentPackageRelsXml), + ("ppt/presentation.xml", BuildPresentationXmlEmptySlideList(ValidSldSz))); + + // Act / Assert + Assert.Throws(() => PptxDocument.Open(stream)); + } + + /// Proves a presentation with a non-numeric <p:sldSz> attribute throws . + [Fact] + public void PptxDocument_Open_PresentationNonNumericSldSz_ThrowsInvalidDataException() + { + // Arrange + const string invalidSldSz = """"""; + using var stream = BuildPackage( + ("[Content_Types].xml", DefaultContentTypesXml), + ("_rels/.rels", OfficeDocumentPackageRelsXml), + ("ppt/presentation.xml", BuildPresentationXml(invalidSldSz, 1)), + ("ppt/_rels/presentation.xml.rels", BuildPresentationRelsXml(1))); + + // Act / Assert + Assert.Throws(() => PptxDocument.Open(stream)); + } + + /// + /// Proves a <p:sldId r:id="..."/> whose relationship resolves but whose + /// Type does not identify a slide part (here, a theme relationship type) throws + /// at time, + /// rather than only later surfacing from . + /// + [Fact] + public void PptxDocument_Open_SlideIdRelationshipWrongType_ThrowsInvalidDataException() + { + // Arrange: rId2's Type ends with "/theme", not "/slide", even though its target path + // ("slides/slide1.xml") looks plausible. + const string wrongTypePresentationRelsXml = + """ + + + + + """; + using var stream = BuildPackage( + ("[Content_Types].xml", DefaultContentTypesXml), + ("_rels/.rels", OfficeDocumentPackageRelsXml), + ("ppt/presentation.xml", BuildPresentationXml(ValidSldSz, 1)), + ("ppt/_rels/presentation.xml.rels", wrongTypePresentationRelsXml)); + + // Act / Assert + Assert.Throws(() => PptxDocument.Open(stream)); + } + + /// Proves computes the expected pixel size at a known DPI. + [Fact] + public void PptxDocument_GetSlideSizeInPixels_ValidDpi_ComputesExpectedPixelSize() + { + // Arrange: 9144000 EMU = 10in, 6858000 EMU = 7.5in; at 96 DPI that is exactly 960x720px. + using var stream = BuildPackage( + ("[Content_Types].xml", DefaultContentTypesXml), + ("_rels/.rels", OfficeDocumentPackageRelsXml), + ("ppt/presentation.xml", BuildPresentationXml(ValidSldSz, 1)), + ("ppt/_rels/presentation.xml.rels", BuildPresentationRelsXml(1))); + using var document = PptxDocument.Open(stream); + + // Act + var (width, height) = document.GetSlideSizeInPixels(96f); + + // Assert + Assert.Equal(960, width); + Assert.Equal(720, height); + } + + /// Proves rejects a non-positive/non-finite DPI. + [Fact] + public void PptxDocument_GetSlideSizeInPixels_NonPositiveDpi_ThrowsArgumentOutOfRangeException() + { + // Arrange + using var stream = BuildPackage(MinimalOpenableEntries()); + using var document = PptxDocument.Open(stream); + + // Act / Assert + Assert.Throws(() => document.GetSlideSizeInPixels(0f)); + Assert.Throws(() => document.GetSlideSizeInPixels(float.NaN)); + } + + /// + /// Proves an oversized declared slide dimension, which overflows once + /// converted to pixels at the requested DPI, throws + /// rather than silently wrapping/truncating to an incorrect pixel size. + /// + [Fact] + public void PptxDocument_GetSlideSizeInPixels_OversizedDimension_ThrowsInvalidDataException() + { + // Arrange: 9,000,000,000,000 EMU at 300 DPI converts to roughly 2.95 billion pixels, + // which overflows int.MaxValue (~2.147 billion). + const string oversizedSldSz = """"""; + using var stream = BuildPackage( + ("[Content_Types].xml", DefaultContentTypesXml), + ("_rels/.rels", OfficeDocumentPackageRelsXml), + ("ppt/presentation.xml", BuildPresentationXml(oversizedSldSz, 1)), + ("ppt/_rels/presentation.xml.rels", BuildPresentationRelsXml(1))); + using var document = PptxDocument.Open(stream); + + // Act / Assert + Assert.Throws(() => document.GetSlideSizeInPixels(300f)); + } + + // --- Phase 1b: theme parsing ------------------------------------------------------------- + + /// Proves <a:srgbClr> color scheme slots resolve to the expected values. + [Fact] + public void PptxDocument_GetTheme_SrgbClrColorScheme_ResolvesRgba32Values() + { + // Arrange + using var stream = BuildPackage( + [.. MinimalOpenableEntries(), ("ppt/theme/theme1.xml", ThemeXmlAllSrgb)]); + using var document = PptxDocument.Open(stream); + + // Act + var theme = document.GetTheme("ppt/theme/theme1.xml"); + + // Assert + Assert.Equal(new Rgba32(0x01, 0x01, 0x01, 255), theme.ColorScheme.Dark1); + Assert.Equal(new Rgba32(0x02, 0x02, 0x02, 255), theme.ColorScheme.Light1); + Assert.Equal(new Rgba32(0x03, 0x03, 0x03, 255), theme.ColorScheme.Dark2); + Assert.Equal(new Rgba32(0x04, 0x04, 0x04, 255), theme.ColorScheme.Light2); + Assert.Equal(new Rgba32(0x05, 0x05, 0x05, 255), theme.ColorScheme.Accent1); + Assert.Equal(new Rgba32(0x06, 0x06, 0x06, 255), theme.ColorScheme.Accent2); + Assert.Equal(new Rgba32(0x07, 0x07, 0x07, 255), theme.ColorScheme.Accent3); + Assert.Equal(new Rgba32(0x08, 0x08, 0x08, 255), theme.ColorScheme.Accent4); + Assert.Equal(new Rgba32(0x09, 0x09, 0x09, 255), theme.ColorScheme.Accent5); + Assert.Equal(new Rgba32(0x0a, 0x0a, 0x0a, 255), theme.ColorScheme.Accent6); + Assert.Equal(new Rgba32(0x0b, 0x0b, 0x0b, 255), theme.ColorScheme.Hyperlink); + Assert.Equal(new Rgba32(0x0c, 0x0c, 0x0c, 255), theme.ColorScheme.FollowedHyperlink); + } + + /// Proves an <a:sysClr> color scheme slot resolves via its cached lastClr value. + [Fact] + public void PptxDocument_GetTheme_SysClrColorScheme_ResolvesLastClrValue() + { + // Arrange + using var stream = BuildPackage( + [.. MinimalOpenableEntries(), ("ppt/theme/theme1.xml", ThemeXmlWithSysClr)]); + using var document = PptxDocument.Open(stream); + + // Act + var theme = document.GetTheme("ppt/theme/theme1.xml"); + + // Assert + Assert.Equal(new Rgba32(0x11, 0x22, 0x33, 255), theme.ColorScheme.Dark1); + } + + /// + /// Proves an eight-digit AARRGGBB color value (not OOXML's own always-six-digit + /// RRGGBB form) is rejected with rather than + /// silently accepted with its first byte misread as alpha. + /// + [Fact] + public void PptxDocument_GetTheme_SrgbClrEightDigitValue_ThrowsInvalidDataException() + { + // Arrange + const string themeXmlWithEightDigitColor = + """ + + + + + + + + + + + + + + + + + + + + + + + """; + using var stream = BuildPackage( + [.. MinimalOpenableEntries(), ("ppt/theme/theme1.xml", themeXmlWithEightDigitColor)]); + using var document = PptxDocument.Open(stream); + + // Act / Assert + Assert.Throws(() => document.GetTheme("ppt/theme/theme1.xml")); + } + + /// Proves the font scheme's major/minor typefaces (Latin/East Asian/complex script) resolve correctly. + [Fact] + public void PptxDocument_GetTheme_FontScheme_ResolvesMajorMinorTypefaces() + { + // Arrange + using var stream = BuildPackage( + [.. MinimalOpenableEntries(), ("ppt/theme/theme1.xml", ThemeXmlAllSrgb)]); + using var document = PptxDocument.Open(stream); + + // Act + var theme = document.GetTheme("ppt/theme/theme1.xml"); + + // Assert + Assert.Equal("Calibri Light", theme.FontScheme.MajorFont.Latin); + Assert.Equal("MajorEA", theme.FontScheme.MajorFont.EastAsian); + Assert.Equal("MajorCS", theme.FontScheme.MajorFont.ComplexScript); + Assert.Equal("Calibri", theme.FontScheme.MinorFont.Latin); + Assert.Equal("MinorEA", theme.FontScheme.MinorFont.EastAsian); + Assert.Equal("MinorCS", theme.FontScheme.MinorFont.ComplexScript); + } + + // --- Phase 1b: master/layout/slide structural parsing ----------------------------------- + + /// Proves a slide master's placeholder shapes parse with their explicit type/idx attributes. + [Fact] + public void PptxDocument_GetMaster_PlaceholderShapes_ParsedWithTypeAndIdx() + { + // Arrange + using var stream = BuildPackage( + [ + .. MinimalOpenableEntries(), + ("ppt/slideMasters/slideMaster1.xml", MasterXml), + ("ppt/slideMasters/_rels/slideMaster1.xml.rels", MasterRelsXml), + ]); + using var document = PptxDocument.Open(stream); + + // Act + var master = document.GetMaster("ppt/slideMasters/slideMaster1.xml"); + + // Assert + Assert.Equal(2, master.Placeholders.Count); + Assert.Equal("title", master.Placeholders[0].Type); + Assert.Equal(0u, master.Placeholders[0].Idx); + Assert.Equal("body", master.Placeholders[1].Type); + Assert.Equal(1u, master.Placeholders[1].Idx); + Assert.Equal("ppt/theme/theme1.xml", master.ThemePartPath); + } + + /// Proves a slide layout's <p:ph/> with no attributes defaults to type "obj"/idx 0. + [Fact] + public void PptxDocument_GetLayout_PlaceholderShapes_ParsedWithDefaultTypeAndIdx() + { + // Arrange + using var stream = BuildPackage( + [ + .. MinimalOpenableEntries(), + ("ppt/slideLayouts/slideLayout1.xml", LayoutXmlDefaultPlaceholder), + ("ppt/slideLayouts/_rels/slideLayout1.xml.rels", LayoutRelsXml), + ]); + using var document = PptxDocument.Open(stream); + + // Act + var layout = document.GetLayout("ppt/slideLayouts/slideLayout1.xml"); + + // Assert + var placeholder = Assert.Single(layout.Placeholders); + Assert.Equal("obj", placeholder.Type); + Assert.Equal(0u, placeholder.Idx); + Assert.Equal("ppt/slideMasters/slideMaster1.xml", layout.MasterPartPath); + } + + /// Proves a slide's immediate placeholder shapes parse structurally, excluding a non-placeholder shape. + [Fact] + public void PptxDocument_GetSlide_ImmediatePlaceholderShapes_ParsedStructurally() + { + // Arrange + using var stream = BuildPackage( + [ + .. MinimalOpenableEntries(), + ("ppt/slides/slide1.xml", SlideXmlWithPlaceholderAndFreeform), + ("ppt/slides/_rels/slide1.xml.rels", SlideRelsXml), + ]); + using var document = PptxDocument.Open(stream); + + // Act + var slide = document.GetSlide(0); + + // Assert: the "FreeformShape" (no ) is excluded. + var placeholder = Assert.Single(slide.Placeholders); + Assert.Equal("title", placeholder.Type); + Assert.Equal("ppt/slideLayouts/slideLayout1.xml", slide.LayoutPartPath); + } + + /// Proves rejects an out-of-range slide index. + [Fact] + public void PptxDocument_GetSlide_IndexOutOfRange_ThrowsArgumentOutOfRangeException() + { + // Arrange + using var stream = BuildPackage(MinimalOpenableEntries()); + using var document = PptxDocument.Open(stream); + + // Act / Assert + Assert.Throws(() => document.GetSlide(-1)); + Assert.Throws(() => document.GetSlide(document.SlideCount)); + } + + // --- Phase 1b: placeholder property-inheritance resolver -------------------------------- + + /// Proves an idx match at the layout level resolves the effective properties from the matched layout placeholder. + [Fact] + public void PptxDocumentInheritance_ResolvePlaceholderProperties_IdxMatchAtLayout_UsesLayoutSpPr() + { + // Arrange + var theme = BuildTestTheme(); + var slidePlaceholder = new PptxPlaceholder("body", 5, BuildShapeElement()); + var layoutPlaceholder = new PptxPlaceholder("subTitle", 5, BuildShapeElement("layout", "layout")); + var masterPlaceholder = new PptxPlaceholder("body", 0, BuildShapeElement("master", "master")); + + // Act + var result = PptxDocument.ResolvePlaceholderProperties( + slidePlaceholder, [layoutPlaceholder], [masterPlaceholder], theme); + + // Assert + Assert.Equal("layout", (string?)result.EffectiveSpPr?.Attribute("marker")); + Assert.Equal("layout", (string?)result.EffectiveTxBodyListStyle?.Attribute("marker")); + Assert.Same(theme, result.Theme); + } + + /// Proves a hop-1 (idx) miss at the layout level short-circuits the chain - hop 2 (master) is never consulted. + [Fact] + public void PptxDocumentInheritance_ResolvePlaceholderProperties_NoIdxMatchAtLayout_FallsThroughToMasterOnly() + { + // Arrange: layout's idx (5) does not match the slide placeholder's idx (7), so no layout + // match is found - the master (type "body", which would match the slide's own type) must + // NOT be consulted, per the verified algorithm's documented short-circuit. + var theme = BuildTestTheme(); + var slidePlaceholder = new PptxPlaceholder("body", 7, BuildShapeElement("slide", null)); + var layoutPlaceholder = new PptxPlaceholder("body", 5, BuildShapeElement()); + var masterPlaceholder = new PptxPlaceholder("body", 0, BuildShapeElement("master", "master")); + + // Act + var result = PptxDocument.ResolvePlaceholderProperties( + slidePlaceholder, [layoutPlaceholder], [masterPlaceholder], theme); + + // Assert + Assert.Equal("slide", (string?)result.EffectiveSpPr?.Attribute("marker")); + Assert.Null(result.EffectiveTxBodyListStyle); + } + + /// Proves the layout->master hop matches by type alone, ignoring a mismatched idx. + [Fact] + public void PptxDocumentInheritance_ResolvePlaceholderProperties_TypeMatchAtMaster_IgnoresMismatchedIdx() + { + // Arrange: master's idx (99) deliberately differs from the matched layout placeholder's + // idx (10) - the type match ("dt") must still succeed. + var theme = BuildTestTheme(); + var slidePlaceholder = new PptxPlaceholder("dt", 10, BuildShapeElement()); + var layoutPlaceholder = new PptxPlaceholder("dt", 10, BuildShapeElement()); + var masterPlaceholder = new PptxPlaceholder("dt", 99, BuildShapeElement("master", null)); + + // Act + var result = PptxDocument.ResolvePlaceholderProperties( + slidePlaceholder, [layoutPlaceholder], [masterPlaceholder], theme); + + // Assert + Assert.Equal("master", (string?)result.EffectiveSpPr?.Attribute("marker")); + } + + /// Proves a remapped body-like layout type ("subTitle") matches a master "body" placeholder. + [Fact] + public void PptxDocumentInheritance_ResolvePlaceholderProperties_RemappedBodyLikeType_MatchesMasterBodyPlaceholder() + { + // Arrange + var theme = BuildTestTheme(); + var slidePlaceholder = new PptxPlaceholder("subTitle", 3, BuildShapeElement()); + var layoutPlaceholder = new PptxPlaceholder("subTitle", 3, BuildShapeElement()); + var masterPlaceholder = new PptxPlaceholder("body", 0, BuildShapeElement("master", null)); + + // Act + var result = PptxDocument.ResolvePlaceholderProperties( + slidePlaceholder, [layoutPlaceholder], [masterPlaceholder], theme); + + // Assert + Assert.Equal("master", (string?)result.EffectiveSpPr?.Attribute("marker")); + } + + /// Proves each property category falls through independently: slide overrides fill but not text. + [Fact] + public void PptxDocumentInheritance_ResolvePlaceholderProperties_PerCategoryIndependentFallthrough_SlideOverridesFillButNotText() + { + // Arrange + var theme = BuildTestTheme(); + var slidePlaceholder = new PptxPlaceholder("body", 1, BuildShapeElement("slide", null)); + var layoutPlaceholder = new PptxPlaceholder("body", 1, BuildShapeElement("layout", "layout")); + var masterPlaceholder = new PptxPlaceholder("body", 0, BuildShapeElement("master", "master")); + + // Act + var result = PptxDocument.ResolvePlaceholderProperties( + slidePlaceholder, [layoutPlaceholder], [masterPlaceholder], theme); + + // Assert + Assert.Equal("slide", (string?)result.EffectiveSpPr?.Attribute("marker")); + Assert.Equal("layout", (string?)result.EffectiveTxBodyListStyle?.Attribute("marker")); + } + + /// Proves no match at any level leaves both effective property categories null, while the theme is still returned. + [Fact] + public void PptxDocumentInheritance_ResolvePlaceholderProperties_NoMatchAtAnyLevel_ReturnsNullForBothCategories() + { + // Arrange: no layout placeholder at all, so hop 1 always misses. + var theme = BuildTestTheme(); + var slidePlaceholder = new PptxPlaceholder("body", 42, BuildShapeElement()); + var masterPlaceholder = new PptxPlaceholder("body", 0, BuildShapeElement("master", "master")); + + // Act + var result = PptxDocument.ResolvePlaceholderProperties( + slidePlaceholder, [], [masterPlaceholder], theme); + + // Assert + Assert.Null(result.EffectiveSpPr); + Assert.Null(result.EffectiveTxBodyListStyle); + Assert.Same(theme, result.Theme); + } + + /// + /// Proves a slide's own empty-but-present <a:lstStyle/> (no level-override + /// child at all - the exact real-world shape reported from "ERF IWF Breadboard Peer + /// Review.pptx") does not "win" PlaceholderInheritancePerCategory's own whole-element + /// fallback: the idx-matched layout placeholder's own, level-bearing <a:lstStyle> + /// must be selected instead. + /// + [Fact] + public void PptxDocumentInheritance_ResolvePlaceholderProperties_SlideLstStyleEmptyWithNoLevelOverride_FallsThroughToLayoutLevelOverride() + { + // Arrange + var theme = BuildTestTheme(); + var slidePlaceholder = new PptxPlaceholder("ctrTitle", 0, BuildShapeElement(lstStyleMarker: "slide", lstStyleHasLevelOverride: false)); + var layoutPlaceholder = new PptxPlaceholder("ctrTitle", 0, BuildShapeElement(lstStyleMarker: "layout")); + var masterPlaceholder = new PptxPlaceholder("title", 0, BuildShapeElement()); + + // Act + var result = PptxDocument.ResolvePlaceholderProperties( + slidePlaceholder, [layoutPlaceholder], [masterPlaceholder], theme); + + // Assert: the layout's populated wins, not the slide's empty-but-present one. + Assert.Equal("layout", (string?)result.EffectiveTxBodyListStyle?.Attribute("marker")); + } + + /// + /// Proves the pre-existing, correct behavior is preserved: when the slide-level + /// <a:lstStyle> DOES declare a real level override, it still wins over the + /// layout's - no regression to the "slide wins when it actually specifies something" case. + /// + [Fact] + public void PptxDocumentInheritance_ResolvePlaceholderProperties_SlideLstStyleHasLevelOverride_SlideWinsOverLayout() + { + // Arrange + var theme = BuildTestTheme(); + var slidePlaceholder = new PptxPlaceholder("ctrTitle", 0, BuildShapeElement(lstStyleMarker: "slide")); + var layoutPlaceholder = new PptxPlaceholder("ctrTitle", 0, BuildShapeElement(lstStyleMarker: "layout")); + var masterPlaceholder = new PptxPlaceholder("title", 0, BuildShapeElement()); + + // Act + var result = PptxDocument.ResolvePlaceholderProperties( + slidePlaceholder, [layoutPlaceholder], [masterPlaceholder], theme); + + // Assert + Assert.Equal("slide", (string?)result.EffectiveTxBodyListStyle?.Attribute("marker")); + } +} diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/Aiden0zPptxRenderer.LICENSE b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/Aiden0zPptxRenderer.LICENSE new file mode 100644 index 00000000..358b97c5 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/Aiden0zPptxRenderer.LICENSE @@ -0,0 +1,217 @@ +aiden0z/pptx-renderer +---------------------- + + + +The three `aiden0z-*.pptx` files in this folder are real example `.pptx` files taken, unmodified, +from the `aiden0z/pptx-renderer` project's own `docs/example/` directory (see `README.md` in this +same folder for the exact source URL of each individual file). `aiden0z/pptx-renderer` is +distributed under the Apache License, Version 2.0 (SPDX identifier `Apache-2.0`) - **not** MIT - +reproduced below, verbatim, as fetched from the upstream repository's own `LICENSE` file. The +upstream `LICENSE` file's own `Copyright [yyyy] [name of copyright owner]` boilerplate line was +never filled in by the upstream project, and no accompanying `NOTICE` file exists in that +repository; this attribution therefore credits the work honestly by its GitHub repository/owner +identity, "aiden0z and pptx-renderer contributors", rather than inventing a name or year that the +upstream project itself never supplied. + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/PythonPptx.LICENSE b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/PythonPptx.LICENSE new file mode 100644 index 00000000..630d45a0 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/PythonPptx.LICENSE @@ -0,0 +1,31 @@ +python-pptx +----------- + + + +The fifteen `pythonpptx-*.pptx` files in this folder are real test fixture files taken, unmodified, +from the `python-pptx` project's own test suite (see `README.md` in this same folder for the exact +source URL of each individual file). `python-pptx` is distributed under the MIT License, reproduced +below. + +MIT License + +Copyright (c) 2013 Steve Canny, https://github.com/scanny + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/README.md b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/README.md new file mode 100644 index 00000000..b2a013ee --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/README.md @@ -0,0 +1,201 @@ +# PPTX Test Fixtures + + + + +This folder holds a small, real-world corpus of `.pptx` files used to exercise `PptxDocument`'s +full public `Render` API (Phase 2 - "Real-World Corpus Hardening") against documents nobody at +this repository hand-authored, complementing (not replacing) the synthetic, hand-built `.pptx` +packages every other `Pptx*Tests.cs` file in this project already uses for fine-grained, +single-construct unit tests. Every file here is used unmodified, exactly as published by its own +upstream source - nothing in this folder was edited, re-saved, or re-compressed after download. + +Three independent sources are represented: + +- **`python-pptx`** (fifteen files, the `pythonpptx-*.pptx` prefix): real test fixture files taken + unmodified from the [python-pptx](https://github.com/scanny/python-pptx) project's own + `features/steps/test_files/` behavioral-test corpus. `python-pptx` is MIT licensed (copyright + Steve Canny and python-pptx contributors); the full license text, with a short attribution + header, is reproduced in `PythonPptx.LICENSE` in this same folder. Each file's exact upstream + source URL is listed in its own table row below. +- **`samplelib.com`** (two files, the `samplelib-*.pptx` prefix): sample `.pptx` files downloaded + from [samplelib.com](https://www.samplelib.com/)'s PPTX samples page. That site's + [terms.html](https://www.samplelib.com/terms.html) page states "Everything allowed... better to + download the files locally" - a permissive statement of intended use, but **not** a formal + copyright assignment or an OSI-approved license grant of the kind `python-pptx`'s MIT license or + `PngSuite.LICENSE`'s own explicit "Permission to use, copy, modify and distribute... is hereby + granted" wording provides. This corpus entry is documented honestly as exactly that: a + permissively-stated, but not formally licensed, third-party source. A human maintainer revisiting + this corpus in the future may wish to replace these two files with a more rigorously-licensed + equivalent; until then, they remain included because they are genuinely useful, independent + real-world coverage (a second, non-`python-pptx` author's own `.pptx` output) and the permissive + site statement was followed precisely (downloaded once, kept locally, not redistributed from a + live remote link at test time). +- **`aiden0z/pptx-renderer`** (three files, the `aiden0z-*.pptx` prefix): real example `.pptx` + files taken unmodified from the [aiden0z/pptx-renderer](https://github.com/aiden0z/pptx-renderer) + project's own `docs/example/` directory. This repository is distributed under the **Apache + License, Version 2.0** (SPDX identifier `Apache-2.0`, confirmed via `gh api + repos/aiden0z/pptx-renderer`) - **not** MIT; the full license text, with a short attribution + header honestly noting the upstream `LICENSE` file's own unfilled copyright-holder placeholder, + is reproduced in `Aiden0zPptxRenderer.LICENSE` in this same folder. Each file's exact upstream + source URL is listed in its own table row below. + +## Included Files + +| File | Exercises | +| ------ | ----------- | +| `samplelib-sample-blank.pptx` | A single blank slide from an independent, non-`python-pptx` authoring tool. | +| `samplelib-sample-presentation.pptx` | Eight slides: text/autoshapes, a table (slide 3), a chart (slide 4, renders). | +| `pythonpptx-sld-blank.pptx` | A single blank slide (`python-pptx`'s own `sld-blank.pptx`). | +| `pythonpptx-shp-shapes.pptx` | Slide 0: table/chart (renders)/SmartArt (throws). Slide 1: connectors, group, GIF. | +| `pythonpptx-shp-groupshape.pptx` | A `` group with nested/`avLst` autoshapes - group-transform coverage. | +| `pythonpptx-shp-picture.pptx` | Two slides placing an embedded raster picture - picture decode/composite coverage. | +| `pythonpptx-shp-autoshape-props.pptx` | A single autoshape with an `avLst` handle - preset-geometry coverage. | +| `pythonpptx-tbl-cell.pptx` | Three slides of real tables, including merged cells, no chart/OLE relationships. | +| `pythonpptx-txt-font-props.pptx` | Five text-heavy slides exercising run/paragraph font properties. | +| `pythonpptx-txt-text-frame.pptx` | Two slides exercising text-frame-level properties (margins, wrapping, anchoring). | +| `pythonpptx-sld-background.pptx` | Two slides; slide 1's `` solid fill is deferred, not painted. | +| `pythonpptx-ph-inherit-props.pptx` | Two slides exercising placeholder ``/geometry inheritance (regression). | +| `pythonpptx-ph-unpopulated-placeholders.pptx` | Nine slides, each empty, inherited placeholder of a distinct type. | +| `pythonpptx-txt-fit-text.pptx` | A single slide with `wrap="none"` + `` real paragraph text. | +| `pythonpptx-shp-connector-props.pptx` | Two slides; a lone `` connector (silently skipped) plus a picture. | +| `pythonpptx-dml-fill.pptx` | Two slides exercising shape-background picture-fill and pattern-fill (both now render). | +| `pythonpptx-dml-line.pptx` | Four slides of explicit ``/`` stroke variety (width/dash/color). | +| `aiden0z-1-chart-and-complex.pptx` | Two slides: org-chart (connectors/custGeom/avLst) plus a chart slide (renders). | +| `aiden0z-image-crop-css-reset.pptx` | One slide: `` fill, four pictures with distinct `` crops. | +| `aiden0z-table-stale-frame.pptx` | A single slide; table frame's declared extent mismatches its own column widths. | +| `pythonpptx-chart-line.pptx` | A single slide, one line chart (renders) - Phase 4's supported-chart fixture. | +| `pythonpptx-chart-unsupported-radar.pptx` | A single slide, one radar chart (throws, unsupported) - deferred. | + +## Exact `python-pptx` Source URLs + +Each `pythonpptx-*.pptx` file here is byte-for-byte the upstream file at the corresponding URL +below (verified by exact file size against the upstream GitHub API directory listing before +inclusion): + +| This folder's file | Upstream source URL | +| ------ | ----------- | +| `pythonpptx-sld-blank.pptx` | | +| `pythonpptx-shp-shapes.pptx` | | +| `pythonpptx-shp-groupshape.pptx` | | +| `pythonpptx-shp-picture.pptx` | | +| `pythonpptx-shp-autoshape-props.pptx` | | +| `pythonpptx-tbl-cell.pptx` | | +| `pythonpptx-txt-font-props.pptx` | | +| `pythonpptx-txt-text-frame.pptx` | | +| `pythonpptx-sld-background.pptx` | | +| `pythonpptx-ph-inherit-props.pptx` | | +| `pythonpptx-ph-unpopulated-placeholders.pptx` | | +| `pythonpptx-txt-fit-text.pptx` | | +| `pythonpptx-shp-connector-props.pptx` | | +| `pythonpptx-dml-fill.pptx` | | +| `pythonpptx-dml-line.pptx` | | + +The two excluded candidates (see the next section) were also taken from the same upstream +directory: `pythonpptx-minimal.pptx` from +, and +`pythonpptx-mst-placeholders.pptx` from +. + +## Exact `aiden0z/pptx-renderer` Source URLs + +Each `aiden0z-*.pptx` file here is byte-for-byte the upstream file at the corresponding URL below +(verified by exact file size against the upstream GitHub API directory listing before inclusion): + +| This folder's file | Upstream source URL | +| ------ | ----------- | +| `aiden0z-1-chart-and-complex.pptx` | | +| `aiden0z-image-crop-css-reset.pptx` | | +| `aiden0z-table-stale-frame.pptx` | | + +## Excluded Staged Candidates + +Nine additional files were staged as candidates but are **not** included here. + +Two `python-pptx` files, staged in the original Phase 2 pass: +`pythonpptx-minimal.pptx` and `pythonpptx-mst-placeholders.pptx` (python-pptx's own `minimal.pptx` +and `mst-placeholders.pptx`). Both declare **zero slides** - neither file's `ppt/presentation.xml` +contains a `` element at all (`mst-placeholders.pptx` contains only master/layout +placeholder XML, no slide part). `PptxDocument.Open` already rejects any package with no slides +(see `CanvasNetPptx-PptxDocument-PresentationValidation`), so neither file is a navigable +presentation a `Render`-focused corpus can exercise - including them would add no new coverage +toward this phase's actual goal (rendering every slide of every fixture), so they were left out +rather than kept as dead weight. + +Seven additional candidates, staged and empirically evaluated in this corpus-expansion pass (all +verified against a built harness calling `PptxDocument.Open`/`Render` directly), but excluded for +the following reasons: + +- **`shp-freeform.pptx`** (`python-pptx`): Its single slide's shape tree is entirely empty - the freeform shape is added + programmatically by `python-pptx`'s own test code, not baked into the published file, so the file as published has no + freeform content at all. +- **`shp-autoshape-adjustments.pptx`** (`python-pptx`): Strictly redundant with the already-included + `pythonpptx-shp-autoshape-props.pptx`: both use an empty `` (no actual adjustment override) and + style-matrix-only fill, and this candidate additionally has no text run at all, so it is strictly a subset of coverage + already provided. +- **`dml-effect.pptx`** (`python-pptx`): Exercises no new construct: its only effect-related element is an empty + `` override (no actual effect), and `` itself is not parsed anywhere in this codebase; its + fill pattern is already covered by other style-matrix-only fixtures. +- **`mst-shapes.pptx`** (`python-pptx`): Declares zero slides (`PptxDocument.Open` throws `InvalidDataException: no + `) - identical disqualifying precedent to the two candidates above; master/layout full shape-tree + rendering is itself out of scope regardless. +- **`mst-slide-layouts.pptx`** (`python-pptx`): Declares zero slides, same reason as `mst-shapes.pptx`. +- **`lyt-shapes.pptx`** (`python-pptx`): Declares zero slides, same reason as `mst-shapes.pptx`. +- **`docs/example/embedded-font/source.pptx`** (`aiden0z/pptx-renderer`): Near-byte-identical in XML structure to the + included `aiden0z-image-crop-css-reset.pptx` (same `` solid fill, same three `` crop values); the + font-embedding differentiator is not observable to CanvasNet (no custom font-embedding support exists), so keeping + both adds nothing. + +## Locally-Generated Chart Fixtures (Phase 4) + +Unlike every other file in this folder, `pythonpptx-chart-line.pptx` and +`pythonpptx-chart-unsupported-radar.pptx` are **not** files taken unmodified from an upstream +source. Phase 4 (chart-graphic-frame rendering) needed one small, supported chart fixture and one +small, deliberately-unsupported chart fixture, and no file already in `python-pptx`'s own +`features/steps/test_files/` upstream corpus declares a chart at all. Both files were instead +generated locally using the `python-pptx` **library** itself (not one of its own fixture files) - +`pythonpptx-chart-line.pptx` via `Presentation().slides[0].shapes.add_chart(XL_CHART_TYPE.LINE_MARKERS, ...)` +(two series, "Revenue"/"Cost", over three categories, "Q1"/"Q2"/"Q3"), and +`pythonpptx-chart-unsupported-radar.pptx` identically except for `XL_CHART_TYPE.RADAR` in place of +`XL_CHART_TYPE.LINE_MARKERS`. The two throwaway generator scripts (`_gen_chart_line.py`/ +`_gen_chart_radar.py`, kept in this folder purely for reproducibility - they are not part of this +project's build) must be run as two **separate** Python process invocations: `python-pptx`'s own +internal default-template handling corrupts a package's ZIP (duplicate part names) when two +`Presentation()` objects are instantiated in the same process and both saved, a quirk discovered +while authoring these fixtures. Both generated files were verified, before use, by unzipping and +inspecting their own `ppt/charts/chart1.xml` part directly (confirming well-formed +`c:chartSpace`/`c:lineChart`/`c:radarChart` structure with cached `c:numCache`/`c:strCache` values) +and by loading each through `OpenXmlChartParser.Parse` and `PptxDocument.Open`/`Render` directly. + +## A Note on What This Corpus Can - and Cannot - Prove + +As of Phase 4, `PptxDocument` renders a chart `` by delegating to +`DemaConsulting.CanvasNet.Charts` (see `PptxDocument.Charts.cs`'s `ParseChart`) rather than +unconditionally throwing `PptxUnsupportedFeatureException` for every chart. Three slides in this +corpus declare a chart ``: `pythonpptx-shp-shapes.pptx` slide index 0, +`samplelib-sample-presentation.pptx` slide index 4, and `aiden0z-1-chart-and-complex.pptx` slide +index 1 (plus the two Phase-4-authored fixtures documented above) - all five now render that +chart successfully. A SmartArt/diagram `` remains explicitly out of scope (see +`pptx-document.md`'s deferred-items lists): `pythonpptx-shp-shapes.pptx` slide index 0 also +declares a SmartArt/diagram graphic frame ("Diagram 7") alongside its own chart ("Chart 2") and a +real table ("Table 1") - that slide's whole-`Render` call still throws +`PptxUnsupportedFeatureException` (feature token `"pptx-graphic-frame-kind"`), but now because of +"Diagram 7" alone, confirmed directly (not assumed) by a dedicated, narrower isolation test proving +"Chart 2" itself parses to a non-null chart node. `ParseTable`'s own `` `uri` check +still throws that same exception for any graphic-frame kind that is neither a table nor a chart, +*before* attempting to read any diagram-specific XML shape - the same already-graceful, +already-designed behavior for a recognized-but-unsupported construct this project has always had, +now narrower in scope (diagrams/OLE objects only, not charts). + +This smoke test corpus can prove that the whole slide-level `Render` call renders a chart +successfully for five real-world (if, for two of them, locally-authored) chart-bearing slides, that +a SmartArt/diagram graphic frame still surfaces a clean, documented exception, and that every other +slide in every other fixture renders without an ungraceful crash and paints at least one real +pixel. It cannot, by itself, prove that `PptxDocument` would behave identically against every other +possible real-world chart/diagram encoding variant in the wild (different graphic-frame `uri` +casing, every one of the ~24 still-deferred OOXML chart presets/features documented in +`OpenXmlChartParser`'s own XML docs, or an OLE object rather than a chart or diagram) - only that +these specific, genuinely-authored (or, for the two Phase 4 fixtures, library-generated) real files +exercise the documented path cleanly. Nor can a corpus this size (twenty-two files, three upstream +sources plus two locally-generated fixtures) claim to be statistically representative of +"real-world PPTX documents" in general - it is a targeted, honest sample, not an exhaustive one. diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/aiden0z-1-chart-and-complex.pptx b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/aiden0z-1-chart-and-complex.pptx new file mode 100644 index 00000000..776ec0a6 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/aiden0z-1-chart-and-complex.pptx differ diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/aiden0z-image-crop-css-reset.pptx b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/aiden0z-image-crop-css-reset.pptx new file mode 100644 index 00000000..712fbeee Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/aiden0z-image-crop-css-reset.pptx differ diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/aiden0z-table-stale-frame.pptx b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/aiden0z-table-stale-frame.pptx new file mode 100644 index 00000000..025bb411 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/aiden0z-table-stale-frame.pptx differ diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-chart-line.pptx b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-chart-line.pptx new file mode 100644 index 00000000..f16b6548 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-chart-line.pptx differ diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-chart-unsupported-radar.pptx b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-chart-unsupported-radar.pptx new file mode 100644 index 00000000..1372845a Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-chart-unsupported-radar.pptx differ diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-dml-fill.pptx b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-dml-fill.pptx new file mode 100644 index 00000000..1a92d3ce Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-dml-fill.pptx differ diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-dml-line.pptx b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-dml-line.pptx new file mode 100644 index 00000000..816c1348 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-dml-line.pptx differ diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-ph-inherit-props.pptx b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-ph-inherit-props.pptx new file mode 100644 index 00000000..9e84fa13 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-ph-inherit-props.pptx differ diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-ph-unpopulated-placeholders.pptx b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-ph-unpopulated-placeholders.pptx new file mode 100644 index 00000000..320615a5 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-ph-unpopulated-placeholders.pptx differ diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-shp-autoshape-props.pptx b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-shp-autoshape-props.pptx new file mode 100644 index 00000000..ad53535b Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-shp-autoshape-props.pptx differ diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-shp-connector-props.pptx b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-shp-connector-props.pptx new file mode 100644 index 00000000..06213615 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-shp-connector-props.pptx differ diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-shp-groupshape.pptx b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-shp-groupshape.pptx new file mode 100644 index 00000000..b61225cc Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-shp-groupshape.pptx differ diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-shp-picture.pptx b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-shp-picture.pptx new file mode 100644 index 00000000..cb4822f2 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-shp-picture.pptx differ diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-shp-shapes.pptx b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-shp-shapes.pptx new file mode 100644 index 00000000..4e77ac6b Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-shp-shapes.pptx differ diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-sld-background.pptx b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-sld-background.pptx new file mode 100644 index 00000000..c35f984d Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-sld-background.pptx differ diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-sld-blank.pptx b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-sld-blank.pptx new file mode 100644 index 00000000..7a9f6e62 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-sld-blank.pptx differ diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-tbl-cell.pptx b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-tbl-cell.pptx new file mode 100644 index 00000000..3dc73d9b Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-tbl-cell.pptx differ diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-txt-fit-text.pptx b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-txt-fit-text.pptx new file mode 100644 index 00000000..af30120d Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-txt-fit-text.pptx differ diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-txt-font-props.pptx b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-txt-font-props.pptx new file mode 100644 index 00000000..a442747e Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-txt-font-props.pptx differ diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-txt-text-frame.pptx b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-txt-text-frame.pptx new file mode 100644 index 00000000..512aba4e Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/pythonpptx-txt-text-frame.pptx differ diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/samplelib-sample-blank.pptx b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/samplelib-sample-blank.pptx new file mode 100644 index 00000000..fb30d248 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/samplelib-sample-blank.pptx differ diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/samplelib-sample-presentation.pptx b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/samplelib-sample-presentation.pptx new file mode 100644 index 00000000..b18b8b26 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixtures/samplelib-sample-presentation.pptx differ diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixturesCorpusTests.cs b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixturesCorpusTests.cs new file mode 100644 index 00000000..2a104713 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxFixturesCorpusTests.cs @@ -0,0 +1,934 @@ +using System.Xml.Linq; +using DemaConsulting.CanvasNet.Canvas; + +namespace DemaConsulting.CanvasNet.Pptx.Tests; + +// cspell:ignore pptx pythonpptx samplelib groupshape autoshape autoshapes paintable sppr +// cspell:ignore aiden0z blipfill pattfill custgeom avlst gridcol tblgrid srcrect cxnsp lummod prstdash cmpd thickthin xfrm Xfrm FAFAF +// cspell:ignore slidenum + +/// +/// Fixture-conformance tests that exercise 's full public +/// API against the +/// real-world .pptx fixture corpus in PptxFixtures (see +/// PptxFixtures\README.md for provenance - twenty real files from three independent +/// sources, python-pptx's own MIT-licensed test corpus, samplelib.com's +/// permissively-stated sample downloads, and aiden0z/pptx-renderer's own +/// Apache-2.0-licensed example corpus). Each test opens one on-disk fixture and renders +/// every one of its slides, asserting a broad "this real file opens and renders every slide +/// without an ungraceful failure, painting real content where content is expected" result - +/// a tier distinct from (and complementing, not duplicating) the finer-grained, +/// single-construct unit tests already made against hand-authored synthetic packages +/// elsewhere in this project (for example , +/// , ). +/// +/// +/// As of Phase 4, renders a chart <p:graphicFrame> +/// by delegating to DemaConsulting.CanvasNet.Charts (see / +/// PptxDocument.Charts.cs) - two of this corpus's fixtures that previously threw +/// purely because of their own chart content +/// now render that chart successfully (see the Aiden0z and samplelib tests below). A +/// SmartArt/diagram <p:graphicFrame> remains explicitly out of scope (see +/// pptx-document.md's deferred-items lists): 's own +/// ParseTable resolver still throws +/// (feature token "pptx-graphic-frame-kind") for any non-table, non-chart +/// graphic-frame kind before attempting to read any diagram-specific XML - this is the +/// established, already-graceful convention for a recognized-but-unsupported construct, +/// confirmed here against real files rather than merely predicted from static code reading +/// (see PptxFixtures\README.md's own "what this corpus can - and cannot - prove" +/// section for the exact honesty boundary of that claim). +/// +public class PptxFixturesCorpusTests +{ + /// The PresentationML namespace, used to inspect a parsed shape element's own name. + private static readonly XNamespace P = "http://schemas.openxmlformats.org/presentationml/2006/main"; + + /// The directory containing the PPTX fixture corpus, copied to the test output directory + /// by this project's PptxFixtures\** content item. + private static string FixturesPath => Path.Join(AppContext.BaseDirectory, "PptxFixtures"); + + /// Resolves a fixture file within . + private static string Fixture(string name) => Path.Join(FixturesPath, name); + + /// + /// A fully transparent , used so + /// 's "any painted pixel" check keeps proving real + /// content was rendered rather than becoming vacuously true against an opaque-white + /// default background. + /// + private static readonly PptxRenderOptions Transparent = new() { BackgroundColor = new Rgba32(0, 0, 0, 0) }; + + /// + /// The rendering resolution (dots per inch) used for every fixture render in this file - + /// passed to , which + /// derives pixel dimensions from each fixture's own , + /// preserving its own aspect ratio rather than forcing a fixed width/height regardless of + /// the slide's declared shape. + /// + private const float Dpi = 96f; + + /// + /// Proves that at least one non-transparent pixel was painted somewhere on + /// - the broad "real content was rendered, not merely a blank + /// surface" assertion this fixture-conformance tier favors over per-pixel checks. + /// + private static void AssertPaintedSomePixel(Surface surface) + { + for (var y = 0; y < surface.Height; y++) + { + for (var x = 0; x < surface.Width; x++) + { + if (surface[x, y].A > 0) + { + return; + } + } + } + + Assert.Fail("Expected at least one non-transparent pixel to be painted."); + } + + /// + /// Proves both and appear somewhere + /// within the pixel rectangle [x0, x1) x [y0, y1) of - + /// used to prove a pattern-fill tile genuinely paints real foreground/background variation + /// rather than a flat fill, by sampling a strip well inside a pattern-filled shape's own + /// rounded-rectangle bounds (away from its stroke, rounded corners, and centered text) so + /// only the tile's own two colors are expected to appear. + /// + private static void AssertRegionContainsBothColors( + Surface surface, + int x0, + int y0, + int x1, + int y1, + Rgba32 first, + Rgba32 second) + { + var sawFirst = false; + var sawSecond = false; + for (var y = y0; y < y1; y++) + { + for (var x = x0; x < x1; x++) + { + var pixel = surface[x, y]; + if (pixel == first) + { + sawFirst = true; + } + else if (pixel == second) + { + sawSecond = true; + } + } + } + + Assert.True(sawFirst, $"Expected color {first} to appear within [{x0},{y0})-[{x1},{y1})."); + Assert.True(sawSecond, $"Expected color {second} to appear within [{x0},{y0})-[{x1},{y1})."); + } + + /// + /// Proves is fully opaque pure white (no color override at all) + /// throughout the pixel rectangle [x0, x1) x [y0, y1) - used to prove a bare, + /// attribute-less <a:pattFill/> (no prst at all) resolves to + /// rather than falling back to the shape's own + /// <p:style>/<a:fillRef> (which, for this fixture shape, resolves to a + /// visibly non-white accent1 gradient/solid - see the fixture's own + /// fillRef idx="3"). The slide's own background renders fully opaque white (not + /// literally transparent - see this test's own remarks), so "no override fill painted" + /// is proven by the shape's own interior matching that same opaque white, not by a + /// literal zero-alpha pixel. The sampled strip is chosen well inside the shape's own + /// bounds, away from its stroke and its own centered text, so only the shape's own fill + /// (or lack of it) could plausibly affect these pixels. + /// + private static void AssertRegionIsUnfilledWhite(Surface surface, int x0, int y0, int x1, int y1) + { + var white = new Rgba32(255, 255, 255, 255); + for (var y = y0; y < y1; y++) + { + for (var x = x0; x < x1; x++) + { + Assert.True( + surface[x, y] == white, + $"Expected unfilled pure-white pixel at ({x},{y}), found {surface[x, y]}."); + } + } + } + + /// + /// Proves pythonpptx-sld-blank.pptx and samplelib-sample-blank.pptx - one + /// truly empty slide each, from two independent real-world sources - open, report exactly + /// one slide at a sane (positive) declared size, and render without error. Unlike every + /// other fixture in this file, no painted-pixel assertion is made here: an empty slide + /// genuinely has no shape tree content to paint, so asserting painted ink against a + /// transparent background would either be vacuously false or would require abandoning the + /// transparent background this file otherwise relies on to keep its other assertions + /// meaningful - "opens and renders a correctly-sized blank surface without throwing" is the + /// one honest claim a truly blank slide fixture can support. + /// + [Theory] + [InlineData("pythonpptx-sld-blank.pptx")] + [InlineData("samplelib-sample-blank.pptx")] + public void PptxDocument_Render_BlankSlideFixtures_RendersWithoutError(string fixtureName) + { + // Arrange & Act + using var document = PptxDocument.Open(Fixture(fixtureName)); + + // Assert + Assert.Equal(1, document.SlideCount); + Assert.True(document.SlideSize.WidthEmu > 0); + Assert.True(document.SlideSize.HeightEmu > 0); + + using var surface = document.Render(0, Dpi, Transparent); + Assert.True(surface.Width > 0); + Assert.True(surface.Height > 0); + } + + /// + /// Proves pythonpptx-shp-autoshape-props.pptx (a single autoshape with an + /// adjustment-value handle, resolved via the default-proportion approximation - full + /// avLst parsing itself remains deferred) renders its one slide and paints visible + /// content. + /// + [Fact] + public void PptxDocument_Render_ShpAutoshapePropsFixture_PaintsVisibleContent() + { + // Arrange & Act + using var document = PptxDocument.Open(Fixture("pythonpptx-shp-autoshape-props.pptx")); + + // Assert + Assert.Equal(1, document.SlideCount); + using var surface = document.Render(0, Dpi, Transparent); + AssertPaintedSomePixel(surface); + } + + /// + /// Proves pythonpptx-shp-groupshape.pptx (a <p:grpSp> group shape with + /// nested children and several avLst-bearing autoshapes) opens, reports a sane + /// slide count/size, and renders its one slide without throwing - group-transform + /// composition exercised against a real file. Unlike this file's other content fixtures, + /// no painted-pixel assertion is made here: every one of this fixture's four shapes + /// declares its fill/line/font exclusively via a <p:style> shape-style-matrix + /// reference (<a:fillRef>/<a:lnRef>/<a:effectRef>/ + /// <a:fontRef>, each only an idx plus a <a:schemeClr>), + /// never an explicit <a:solidFill>/<a:ln> inside its own + /// <p:spPr> - and every shape's <p:txBody> is empty (an + /// <a:endParaRPr/> only, no actual run). Resolving a shape-style-matrix + /// reference into a concrete fill/line color is explicitly out of this project's + /// documented scope (see pptx-document.md's own "phClr... only meaningful inside a + /// shape-style-reference context this phase does not thread through" and "Group-level + /// style cascading... is not implemented" remarks) - + /// already gracefully resolves an unrecognized/absent fill definition to + /// PptxNoFill rather than throwing (a deliberate, documented Phase 1c + /// simplification, not a bug), so this fixture, as authored, genuinely has zero paintable + /// ink under the currently-implemented feature set - this smoke test can't prove more than + /// "a real group-shape-bearing file opens and renders without an ungraceful failure" for + /// it (see PptxFixtures\README.md's own "what this corpus can - and cannot - prove" + /// section for the identical honesty treatment applied to the chart-exception slides). + /// + [Fact] + public void PptxDocument_Render_ShpGroupShapeFixture_RendersWithoutError() + { + // Arrange & Act + using var document = PptxDocument.Open(Fixture("pythonpptx-shp-groupshape.pptx")); + + // Assert + Assert.Equal(1, document.SlideCount); + Assert.True(document.SlideSize.WidthEmu > 0); + Assert.True(document.SlideSize.HeightEmu > 0); + + using var surface = document.Render(0, Dpi, Transparent); + Assert.True(surface.Width > 0); + Assert.True(surface.Height > 0); + } + + /// + /// Proves every slide of pythonpptx-shp-picture.pptx (two slides, each placing an + /// embedded PNG raster picture) renders and paints visible content - picture decode/ + /// composite exercised against real embedded image bytes. + /// + [Fact] + public void PptxDocument_Render_ShpPictureFixture_RendersEverySlideWithVisibleContent() + { + // Arrange + using var document = PptxDocument.Open(Fixture("pythonpptx-shp-picture.pptx")); + + // Act & Assert + Assert.Equal(2, document.SlideCount); + for (var slideIndex = 0; slideIndex < document.SlideCount; slideIndex++) + { + using var surface = document.Render(slideIndex, Dpi, Transparent); + AssertPaintedSomePixel(surface); + } + } + + /// + /// Proves every slide of pythonpptx-tbl-cell.pptx (three slides of real + /// <a:tbl> tables, including merged cells, and no chart/OLE relationships at + /// all) renders and paints visible content - table/merge-resolution exercised against a + /// real file with no graphic-frame-kind ambiguity. + /// + [Fact] + public void PptxDocument_Render_TblCellFixture_RendersEverySlideWithVisibleContent() + { + // Arrange + using var document = PptxDocument.Open(Fixture("pythonpptx-tbl-cell.pptx")); + + // Act & Assert + Assert.Equal(3, document.SlideCount); + for (var slideIndex = 0; slideIndex < document.SlideCount; slideIndex++) + { + using var surface = document.Render(slideIndex, Dpi, Transparent); + AssertPaintedSomePixel(surface); + } + } + + /// + /// Proves every slide of pythonpptx-txt-font-props.pptx (five text-heavy slides + /// exercising run/paragraph-level font properties) renders and paints visible glyph ink. + /// + [Fact] + public void PptxDocument_Render_TxtFontPropsFixture_RendersEverySlideWithVisibleContent() + { + // Arrange + using var document = PptxDocument.Open(Fixture("pythonpptx-txt-font-props.pptx")); + + // Act & Assert + Assert.Equal(5, document.SlideCount); + for (var slideIndex = 0; slideIndex < document.SlideCount; slideIndex++) + { + using var surface = document.Render(slideIndex, Dpi, Transparent); + AssertPaintedSomePixel(surface); + } + } + + /// + /// Proves every slide of pythonpptx-txt-text-frame.pptx (two slides exercising + /// text-frame-level properties such as margins, wrapping, and anchoring) renders and + /// paints visible glyph ink. + /// + [Fact] + public void PptxDocument_Render_TxtTextFrameFixture_RendersEverySlideWithVisibleContent() + { + // Arrange + using var document = PptxDocument.Open(Fixture("pythonpptx-txt-text-frame.pptx")); + + // Act & Assert + Assert.Equal(2, document.SlideCount); + for (var slideIndex = 0; slideIndex < document.SlideCount; slideIndex++) + { + using var surface = document.Render(slideIndex, Dpi, Transparent); + AssertPaintedSomePixel(surface); + } + } + + /// + /// Proves pythonpptx-shp-shapes.pptx's richest, two-slide mix of real-world + /// constructs behaves exactly as the corpus README documents: slide index 0 (a real + /// <a:tbl> table graphic frame, "Chart 2" - a chart graphic frame now + /// rendered successfully by Phase 4, see + /// below - + /// and "Diagram 7", a SmartArt/diagram graphic frame still explicitly out of scope) + /// throws - the already-graceful, + /// already-designed "unsupported graphic-frame kind" path, triggered by "Diagram 7" + /// alone (confirmed, not assumed - see the dedicated isolation test referenced above) + /// while slide index 1 (connectors, a nested group, several avLst-bearing + /// autoshapes, and a GIF image) renders cleanly and paints visible content. + /// + [Fact] + public void PptxDocument_Render_ShpShapesFixture_Slide0ThrowsUnsupportedFeatureSlide1PaintsContent() + { + // Arrange + using var document = PptxDocument.Open(Fixture("pythonpptx-shp-shapes.pptx")); + + // Assert: slide count + Assert.Equal(2, document.SlideCount); + + // Act & Assert: slide 0's "Diagram 7" SmartArt/diagram graphic frame is a + // recognized-but-unsupported construct - the whole slide's Render call throws, rather + // than silently skipping or crashing ungracefully. "Chart 2" (the slide's own chart + // graphic frame) no longer contributes to this throw as of Phase 4 - see the dedicated + // isolation test below, which proves this directly rather than by elimination. + var exception = Assert.Throws(() => document.Render(0, Dpi, Transparent)); + Assert.Equal("pptx-graphic-frame-kind", exception.Feature); + + // Act & Assert: slide 1 has no chart/diagram content and renders cleanly. + using var surface = document.Render(1, Dpi, Transparent); + AssertPaintedSomePixel(surface); + } + + /// + /// Proves, narrowly and directly (Phase 4), that pythonpptx-shp-shapes.pptx + /// slide index 0's own "Chart 2" graphic frame - sitting alongside "Table 1" and the + /// still-unsupported "Diagram 7" in the very same shape tree (see the test above) - + /// parses to a with a non-null + /// (and a null + /// ), isolating "Chart 2" itself from + /// "Diagram 7" - the sibling frame that still throws + /// and is the one actually responsible for + /// the whole-slide throw proven above, not "Chart 2". + /// + [Fact] + public void PptxDocument_Render_ShpShapesFixture_Chart2NodeHasNonNullChart() + { + // Arrange + using var document = PptxDocument.Open(Fixture("pythonpptx-shp-shapes.pptx")); + const string slidePartPath = "ppt/slides/slide1.xml"; + var root = document.LoadPartXmlRoot(slidePartPath); + var spTree = root.Element(P + "cSld")!.Element(P + "spTree")!; + var layoutPartPath = document.ResolveRelationshipByType(slidePartPath, "/slideLayout"); + + // Act: containUnsupportedGraphicFrames: true isolates "Chart 2" from its sibling + // "Diagram 7" SmartArt graphic frame - which would otherwise still propagate its own + // PptxUnsupportedFeatureException and abort this parse entirely (see the slide-level + // test above, which instead exercises the real, uncontained + // containUnsupportedGraphicFrames: false path that a full Render call actually takes). + // themeResolver resolves this slide's own real layout -> master -> theme chain (needed + // because this slide tree also contains "Table 1", a real table graphic frame). + var nodes = PptxDocument.ParseShapeTree( + spTree, + themeResolver: () => + { + var layout = document.GetLayout(layoutPartPath); + var master = document.GetMaster(layout.MasterPartPath); + return document.GetTheme(master.ThemePartPath); + }, + containUnsupportedGraphicFrames: true, + resolveChartPart: id => document.LoadPartXmlRoot(document.ResolveRelationship(slidePartPath, id))); + + var chart2 = nodes + .OfType() + .SingleOrDefault(node => node.GraphicFrameElement + .Descendants(P + "cNvPr") + .Any(cNvPr => (string?)cNvPr.Attribute("name") == "Chart 2")); + + // Assert + Assert.NotNull(chart2); + Assert.NotNull(chart2.Chart); + Assert.Null(chart2.Table); + } + + /// + /// Proves samplelib-sample-presentation.pptx (an eight-slide, independently- + /// sourced real-world deck) renders every slide cleanly and paints visible content, + /// including slide index 4's own chart graphic frame (rendered successfully as of + /// Phase 4 - see PptxFixtures\README.md's own corrected provenance notes) and + /// slide index 3's own real <a:tbl> table. + /// + [Fact] + public void PptxDocument_Render_SamplelibSamplePresentationFixture_EverySlidePaintsContent() + { + // Arrange + using var document = PptxDocument.Open(Fixture("samplelib-sample-presentation.pptx")); + + // Assert: slide count + Assert.Equal(8, document.SlideCount); + + // Act & Assert: every slide renders cleanly and paints visible content. + for (var slideIndex = 0; slideIndex < document.SlideCount; slideIndex++) + { + using var surface = document.Render(slideIndex, Dpi, Transparent); + AssertPaintedSomePixel(surface); + } + } + + /// + /// Proves both slides of pythonpptx-sld-background.pptx render without error, and + /// that slide index 1's own declared <p:bg> solid-red fill is now painted - + /// a pixel-level real-file regression test for the Phase 2 Follow-Up background-fill + /// feature (see pptx-document.md's "Phase 2 Follow-Up: Slide/Layout/Master + /// Background Fill" design section). Slide index 0 has no shape-tree content and declares + /// no <p:bg> of its own, so it still renders as a blank, fully transparent + /// surface. + /// + [Fact] + public void PptxDocument_Render_SldBackgroundFixture_RendersAndPaintsSlideBackgroundFill() + { + // Arrange + using var document = PptxDocument.Open(Fixture("pythonpptx-sld-background.pptx")); + + // Act & Assert + Assert.Equal(2, document.SlideCount); + using var surface0 = document.Render(0, Dpi, Transparent); + Assert.True(surface0.Width > 0); + Assert.True(surface0.Height > 0); + + using var surface1 = document.Render(1, Dpi, Transparent); + Assert.True(surface1.Width > 0); + Assert.True(surface1.Height > 0); + Assert.Equal(new Rgba32(0xFF, 0x00, 0x00, 255), surface1[surface1.Width / 2, surface1.Height / 2]); + } + + /// + /// Proves both slides of pythonpptx-ph-inherit-props.pptx render without error - + /// real-file regression coverage for placeholder <a:xfrm>/geometry + /// inheritance (see Render_PlaceholderShapeWithEmptySpPr_InheritsXfrmAndGeometryFromLayout + /// and this unit's own Phase 2 design-doc section for the original bug this guards + /// against). Slide index 0 paints (an idx-matched, no-type content placeholder with + /// its own chevron geometry); slide index 1 does not paint (both placeholders have empty + /// text and rely entirely on inherited geometry) - both asserted explicitly, mirroring the + /// existing ShpGroupShapeFixture precedent's honesty about exactly what each slide + /// can and cannot prove. + /// + [Fact] + public void PptxDocument_Render_PhInheritPropsFixture_Slide0PaintsSlide1RendersWithoutError() + { + // Arrange + using var document = PptxDocument.Open(Fixture("pythonpptx-ph-inherit-props.pptx")); + + // Assert: slide count + Assert.Equal(2, document.SlideCount); + + // Act & Assert: slide 0 has its own chevron geometry and paints. + using var surface0 = document.Render(0, Dpi, Transparent); + AssertPaintedSomePixel(surface0); + + // Act & Assert: slide 1's placeholders are both empty text, inherited geometry only. + using var surface1 = document.Render(1, Dpi, Transparent); + Assert.True(surface1.Width > 0); + Assert.True(surface1.Height > 0); + } + + /// + /// Proves every one of pythonpptx-ph-unpopulated-placeholders.pptx's nine slides + /// renders without error. Each slide contains exactly one empty, fully-inherited + /// placeholder of a distinct declared type (title/body/chart/table/diagram/media/clip art/ + /// picture) - no slide has any populated text or non-placeholder shape content, so no + /// painted-pixel assertion is made for any slide (the same honest "renders without an + /// ungraceful failure" claim already established for ShpGroupShapeFixture and the + /// blank-slide fixtures above). + /// + [Fact] + public void PptxDocument_Render_PhUnpopulatedPlaceholdersFixture_RendersEverySlideWithoutError() + { + // Arrange + using var document = PptxDocument.Open(Fixture("pythonpptx-ph-unpopulated-placeholders.pptx")); + + // Act & Assert + Assert.Equal(9, document.SlideCount); + for (var slideIndex = 0; slideIndex < document.SlideCount; slideIndex++) + { + using var surface = document.Render(slideIndex, Dpi, Transparent); + Assert.True(surface.Width > 0); + Assert.True(surface.Height > 0); + } + } + + /// + /// Proves pythonpptx-txt-fit-text.pptx (a single slide with a real paragraph using + /// wrap="none" combined with <a:spAutoFit/> - shape-resize autofit + /// itself remains deferred, see pptx-document.md's deferred-items list, so no + /// scaling is applied) renders its one slide and paints visible glyph ink. + /// + [Fact] + public void PptxDocument_Render_TxtFitTextFixture_PaintsVisibleContent() + { + // Arrange & Act + using var document = PptxDocument.Open(Fixture("pythonpptx-txt-fit-text.pptx")); + + // Assert + Assert.Equal(1, document.SlideCount); + using var surface = document.Render(0, Dpi, Transparent); + AssertPaintedSomePixel(surface); + } + + /// + /// Proves both slides of pythonpptx-shp-connector-props.pptx render without error + /// and now actually paint their own connector's visible line (Phase 2 Follow-Up: + /// Connector Shape Rendering). Slide index 0 contains only a lone, diagonal + /// <p:cxnSp> connector with a <a:tailEnd type="arrow"/> + /// arrowhead and no own width/fill (relying entirely on its own <p:style>/ + /// <a:lnRef idx="2"/> for its visible color/width - see + /// 's own merge-resolver remarks for + /// why this is a real, representative case, not a contrived one); slide index 1 + /// additionally places a picture alongside its own (perfectly horizontal, + /// <a:ext cy="0"/>) connector, so slide 1 paints both the picture and the + /// connector's own line. + /// + [Fact] + public void PptxDocument_Render_ShpConnectorPropsFixture_ConnectorsPaintVisibleLines() + { + // Arrange + using var document = PptxDocument.Open(Fixture("pythonpptx-shp-connector-props.pptx")); + + // Assert: slide count + Assert.Equal(2, document.SlideCount); + + // Act & Assert: slide 0 has only a connector, now painting its own diagonal line (and + // tailEnd arrow), resolved entirely from its own / fallback. + using var surface0 = document.Render(0, Dpi, Transparent); + AssertPaintedSomePixel(surface0); + + // Act & Assert: slide 1 has a picture alongside its own zero-height connector - both + // paint (the picture, and the connector's own now-fixed horizontal line). + using var surface1 = document.Render(1, Dpi, Transparent); + AssertPaintedSomePixel(surface1); + } + + /// + /// Proves slide 0 of pythonpptx-dml-fill.pptx - which places six shapes in document + /// order (inherited fill, explicit <a:noFill/>, solid RGB fill, a real embedded + /// <a:blipFill><a:blip r:embed="..."/><a:tile tx="0" ty="0" sx="100000" + /// sy="100000"/></a:blipFill> picture fill, a linear gradient fill, and finally + /// an <a:pattFill prst="divot"> pattern fill - on this same slide - + /// now renders completely without throwing. Before picture-fill support, rendering slide 0 + /// threw "pptx-picture-fill" as soon as the shape-tree walk reached the fourth + /// (blip/tile-filled) shape; after picture-fill support, it threw "pptx-pattern-fill" + /// instead, once the walk reached the sixth (and last, divot-pattern-filled) shape. Now + /// that pattern fill is also supported (see PptxPaint.cs's + /// and PptxDocument.Paint.cs's ResolveFill), + /// every shape on this slide resolves and paints successfully - proven here by sampling a + /// strip well inside the divot shape's own bounds and asserting both its resolved + /// foreground (srgbClr val="2CB731") and background (prstClr val="white") + /// colors actually appear, confirming real tile variation rather than a flat/degenerate + /// fill. A genuine, full end-to-end "renders without throwing" assertion for an + /// <a:blipFill>/<a:tile> shape fill (this slide cannot isolate + /// that alone, since it also contains other shapes) lives in + /// + /// instead, against a minimal, purpose-built single-shape package. + /// + [Fact] + public void PptxDocument_Render_DmlFillFixture_Slide0RendersPictureAndPatternFillShapesWithoutThrowing() + { + // Arrange + using var document = PptxDocument.Open(Fixture("pythonpptx-dml-fill.pptx")); + + // Act: the walk now gets past both the blip/tile picture-fill shape and the + // divot-pattern-filled shape without throwing. + using var surface = document.Render(0, Dpi, Transparent); + + // Assert: real content was painted somewhere on the slide. + AssertPaintedSomePixel(surface); + + // Assert: the last shape ("Rounded Rectangle 9", off (457200,1836780), ext + // (1368245,914400) EMU - see PptxFixtures\README.md's dml-fill.pptx provenance notes) + // genuinely paints both its resolved divot foreground and background colors - not a flat + // fill - sampled well inside its rounded-rectangle bounds, away from its stroke, rounded + // corners, and centered text. + var divotFg = new Rgba32(0x2C, 0xB7, 0x31, 255); + var divotBg = new Rgba32(0xFF, 0xFF, 0xFF, 255); + AssertRegionContainsBothColors(surface, 68, 213, 171, 233, divotFg, divotBg); + AssertRegionContainsBothColors(surface, 68, 249, 171, 269, divotFg, divotBg); + } + + /// + /// Proves slide 1 of pythonpptx-dml-fill.pptx - three shapes: a bare, attribute-less + /// <a:pattFill/> (no prst at all), a prst="divot" pattern fill, + /// and a prst="wave" pattern fill - renders completely without throwing, now that + /// pattern fill is supported (feature token "pptx-pattern-fill" no longer applies to + /// either covered preset in this fixture). The bare <a:pattFill/> shape (named + /// "autoshape with inherited fill" in its own text run, but in fact resolving to + /// rather than its own style's <a:fillRef idx="3"> - + /// a deliberate, documented design decision; see the planning report's own Assumption 3) + /// is proven to paint no fill-color override of its own: the slide's own background + /// renders fully opaque white (not literally transparent), so its sampled interior (away + /// from its own stroke and centered text) is proven to remain that same unfilled white + /// rather than painting its style's own fillRef accent1 fill. The two named-preset shapes + /// are each proven to paint both their resolved foreground and background colors. + /// + [Fact] + public void PptxDocument_Render_DmlFillFixture_Slide1PatternFillShapesRenderAndBareFillIsTransparent() + { + // Arrange + using var document = PptxDocument.Open(Fixture("pythonpptx-dml-fill.pptx")); + + // Assert: slide count + Assert.Equal(2, document.SlideCount); + + // Act: slide 1 renders completely without throwing. + using var surface = document.Render(1, Dpi, Transparent); + + // Assert: "Rounded Rectangle 1" (off (457200,457200), ext (1368245,914400) EMU) has a + // bare with no prst attribute at all, resolving to PptxNoFill - its interior + // (sampled away from its own stroke and centered text) remains unfilled white, not + // falling back to its style's fillRef accent1 fill. + AssertRegionIsUnfilledWhite(surface, 65, 58, 175, 76); + AssertRegionIsUnfilledWhite(surface, 65, 128, 175, 140); + + // Assert: "Rounded Rectangle 2" (off (2291174,457200), ext (1368245,914400) EMU) has + // prst="divot" with the same fg/bg colors as slide 0's divot shape, and genuinely paints + // both. + var divotFg = new Rgba32(0x2C, 0xB7, 0x31, 255); + var divotBg = new Rgba32(0xFF, 0xFF, 0xFF, 255); + AssertRegionContainsBothColors(surface, 260, 68, 363, 88, divotFg, divotBg); + AssertRegionContainsBothColors(surface, 260, 104, 363, 124, divotFg, divotBg); + + // Assert: "Rounded Rectangle 3" (off (4114800,457200), ext (1368245,914400) EMU) has + // prst="wave" with its own distinct fg/bg colors, and genuinely paints both. + var waveFg = new Rgba32(0xC0, 0x50, 0x4D, 255); + var waveBg = new Rgba32(0xFF, 0xFF, 0xFF, 255); + AssertRegionContainsBothColors(surface, 452, 68, 555, 88, waveFg, waveBg); + AssertRegionContainsBothColors(surface, 452, 104, 555, 124, waveFg, waveBg); + } + + /// + /// Proves every slide of pythonpptx-dml-line.pptx (four slides with real, explicit + /// <a:solidFill>/<a:ln> stroke-property variety - RGB and + /// scheme+lumMod colors, a cmpd="thickThin" compound line, and several + /// prstDash values) renders and paints visible content - genuinely new in-scope + /// stroke-property variety not covered by any style-matrix-only existing fixture. + /// + [Fact] + public void PptxDocument_Render_DmlLineFixture_RendersEverySlideWithVisibleContent() + { + // Arrange + using var document = PptxDocument.Open(Fixture("pythonpptx-dml-line.pptx")); + + // Act & Assert + Assert.Equal(4, document.SlideCount); + for (var slideIndex = 0; slideIndex < document.SlideCount; slideIndex++) + { + using var surface = document.Render(slideIndex, Dpi, Transparent); + AssertPaintedSomePixel(surface); + } + } + + /// + /// Proves aiden0z-1-chart-and-complex.pptx (two slides from + /// aiden0z/pptx-renderer's own Apache-2.0-licensed example corpus) behaves exactly + /// as the corpus README documents: slide index 0 is a dense, real org-chart-style slide + /// (58 shapes, 17 connectors, a <a:custGeom> freeform, and 11 avLst + /// adjustment overrides across varied preset geometries) that renders cleanly and paints + /// visible content with no chart graphic frame of its own, while slide index 1 declares a + /// chart graphic frame and, as of Phase 4, renders it successfully instead of throwing - + /// now delegates a chart graphic frame to + /// DemaConsulting.CanvasNet.Charts rather than unconditionally refusing every + /// non-table graphic-frame kind. + /// + /// + /// Slide index 0 uses ppt/slideLayouts/slideLayout1.xml, which (per the companion + /// Phase 2 Follow-Up planning report) declares a real, non-placeholder, userDrawn="1" + /// <a:custGeom> freeform shape named "Freeform 5" (<a:off x="750334" + /// y="762000"/>, <a:ext cx="3941064" cy="312470"/>), filled + /// <a:schemeClr val="bg1"/> - this fixture's own theme resolves bg1 to + /// pure white, the same color as this fixture's own slide master background + /// (<p:bgRef idx="1001"><a:schemeClr val="bg1"/></p:bgRef>), so a + /// pixel sample against this shape cannot prove a visually dramatic z-order/contrast + /// difference (the synthetic master/layout tests carry that + /// proof with deliberately contrasting colors) - this real-world regression test's primary + /// proof is instead structural: that this layout's own parsed shape tree genuinely + /// contains "Freeform 5" as a non-placeholder shape node at all. A near-white pixel sample + /// in its own bounding box is kept only as a secondary, no-throw regression check, since a + /// pure-white pixel there is also what an unimplemented or broken dispatch path would + /// produce (this fixture's own background is pure white too). + /// + [Fact] + public void PptxDocument_Render_Aiden0zChartAndComplexFixture_Slide0AndSlide1PaintContent() + { + // Arrange + using var document = PptxDocument.Open(Fixture("aiden0z-1-chart-and-complex.pptx")); + + // Assert: slide count + Assert.Equal(2, document.SlideCount); + + // Act & Assert: slide 0 has no chart content and renders cleanly with real paint. + using var surface0 = document.Render(0, Dpi, Transparent); + AssertPaintedSomePixel(surface0); + + // Act & Assert (primary, structurally-discriminating proof): slide layout 1's own parsed + // shape tree genuinely contains "Freeform 5" as a real, non-placeholder PptxSpShapeNode - + // proving the master/layout decorative-shape parsing path actually walks this real file's + // own layout shape tree, independent of this shape's own paint color (see this test's own + // remarks for why a pixel sample alone cannot discriminate this shape from its background). + var layout = document.GetLayout("ppt/slideLayouts/slideLayout1.xml"); + var freeform5 = layout.ShapeTree + .OfType() + .SingleOrDefault(node => node.ShapeElement + .Descendants(P + "cNvPr") + .Any(cNvPr => (string?)cNvPr.Attribute("name") == "Freeform 5")); + Assert.NotNull(freeform5); + Assert.Null(freeform5.Placeholder); + + // Act & Assert (secondary, no-throw regression check): slide layout 1's own "Freeform 5" + // decorative custGeom shape (sampled near its own / bounding box, in the + // vertical band above the slide's own title placeholder so the sample isn't masked by + // slide-level content painted on top) renders without throwing; the sampled pixel is at + // least near-white (a tolerance, not an exact match, because this real custGeom path is a + // thin decorative flourish rather than a solid rectangle - most of its own bounding box is + // unfilled background, and the rasterizer anti-aliases the path's own edges). This pixel + // sample is deliberately NOT the primary proof above, since a pure-white pixel here is + // consistent with both a correctly-rendered shape and a silently-skipped one. + var slideSize = document.SlideSize; + var freeformMidXEmu = 750334 + 3941064 / 2.0; + var freeformMidYEmu = (762000 + 989358) / 2.0; + var x = (int)(freeformMidXEmu / slideSize.WidthEmu * surface0.Width); + var y = (int)(freeformMidYEmu / slideSize.HeightEmu * surface0.Height); + var actual = surface0[Math.Clamp(x, 0, surface0.Width - 1), Math.Clamp(y, 0, surface0.Height - 1)]; + Assert.True(actual is { R: >= 240, G: >= 240, B: >= 240, A: 255 }, + $"Expected a near-white pixel at ({x}, {y}) (this fixture's own bg1 theme color and master background both resolve to pure white), got R{actual.R} G{actual.G} B{actual.B} A{actual.A}."); + + // Act & Assert (Phase 2 Follow-Up: Bullets and Numbering): "Rectangle 5" + // (, , + // ) declares two consecutive + // paragraphs - + // the real-world hanging-indent bullet idiom this phase targets. Because marL+indent==0, + // the bullet-gutter X this phase computes (insetLeft+marL+indent) collapses to exactly + // insetLeft (72000 EMU in from the shape's own left edge) - strictly left of where any + // paragraph's own text can ever start (insetLeft+marL = 243450 EMU in) - so a dark pixel + // found in that narrow gutter column can only be the bullet glyph itself, never run text. + // A second sample just inside the shape's own left inset (before the gutter) proves the + // gutter ink does not simply extend all the way to the shape's own edge. + var shapeOffXEmu = 5322536d; + var shapeOffYEmu = 5031720d; + var shapeCyEmu = 1080000d; + var bulletGutterXEmu = shapeOffXEmu + 72000d; + var beforeGutterXEmu = shapeOffXEmu + 20000d; + var topYEmu = shapeOffYEmu + 40000d; + var bottomYEmu = shapeOffYEmu + shapeCyEmu - 40000d; + + static int ToPixelX(double emu, PptxSlideSize slideSize, int surfaceWidth) => + Math.Clamp((int)(emu / slideSize.WidthEmu * surfaceWidth), 0, surfaceWidth - 1); + + static int ToPixelY(double emu, PptxSlideSize slideSize, int surfaceHeight) => + Math.Clamp((int)(emu / slideSize.HeightEmu * surfaceHeight), 0, surfaceHeight - 1); + + static int Luminance(Rgba32 pixel) => pixel.R + pixel.G + pixel.B; + + var bulletGutterPx = ToPixelX(bulletGutterXEmu, slideSize, surface0.Width); + var beforeGutterPx = ToPixelX(beforeGutterXEmu, slideSize, surface0.Width); + var topPx = ToPixelY(topYEmu, slideSize, surface0.Height); + var bottomPx = ToPixelY(bottomYEmu, slideSize, surface0.Height); + + var darkestInGutter = int.MaxValue; + var darkestBeforeGutter = int.MaxValue; + for (var py = topPx; py <= bottomPx; py++) + { + darkestInGutter = Math.Min(darkestInGutter, Luminance(surface0[bulletGutterPx, py])); + darkestBeforeGutter = Math.Min(darkestBeforeGutter, Luminance(surface0[beforeGutterPx, py])); + } + + Assert.True(darkestInGutter < darkestBeforeGutter - 60, + $"Expected the bullet-gutter column (x={bulletGutterPx}) to contain visibly darker ink than the shape's own left-inset column (x={beforeGutterPx}) just to its left: darkestInGutter={darkestInGutter}, darkestBeforeGutter={darkestBeforeGutter}."); + + // Act & Assert (stray-bullet-on-sldNum regression guard - see the companion quality report + // this cycle closes): the slide's own "Slide Number Placeholder 3" () has no own geometry, inheriting the slide master's "Slide Number + // Placeholder 5" (idx="4") geometry by type-match: , + // , this fixture's slide size cx="12188825" cy="6858000". + // Before the fix, this placeholder's paragraph (a bare , no own + // bullet markup anywhere in its own chain) wrongly inherited the master's bodyStyle + // lvl1pPr's "" bullet (SelectMasterTextStyle routes "sldNum" to + // bodyStyle instead of the bullet-free otherStyle), painting an isolated bullet dot with + // no accompanying text - independently confirmed via a standalone scratch render at 192 + // DPI (darkest pixel luminance 0 before the fix, 765/pure-background after). A baseline + // column just above the placeholder's own bounding box (same slide background, outside any + // shape) proves the comparison isn't simply "both happen to be white". + const double sldNumOffXEmu = 11669529d; + const double sldNumOffYEmu = 6400800d; + const double sldNumCxEmu = 438912d; + const double sldNumCyEmu = 155448d; + + var sldNumX0 = ToPixelX(sldNumOffXEmu, slideSize, surface0.Width); + var sldNumX1 = ToPixelX(sldNumOffXEmu + sldNumCxEmu, slideSize, surface0.Width); + var sldNumY0 = ToPixelY(sldNumOffYEmu, slideSize, surface0.Height); + var sldNumY1 = ToPixelY(sldNumOffYEmu + sldNumCyEmu, slideSize, surface0.Height); + var sldNumBaselineY = ToPixelY(sldNumOffYEmu - sldNumCyEmu, slideSize, surface0.Height); + + var darkestAtSldNum = int.MaxValue; + var darkestAtSldNumBaseline = int.MaxValue; + for (var px = sldNumX0; px <= sldNumX1; px++) + { + for (var py = sldNumY0; py <= sldNumY1; py++) + { + darkestAtSldNum = Math.Min(darkestAtSldNum, Luminance(surface0[px, py])); + } + + darkestAtSldNumBaseline = Math.Min(darkestAtSldNumBaseline, Luminance(surface0[px, sldNumBaselineY])); + } + + Assert.True(darkestAtSldNum >= darkestAtSldNumBaseline - 60, + $"Expected no stray bullet ink at the slide-number placeholder's own geometry (darkest luminance {darkestAtSldNum}), no meaningfully darker than its own background baseline row (darkest luminance {darkestAtSldNumBaseline})."); + + // Act & Assert: slide 1's chart graphic frame now renders successfully (Phase 4). + using var surface1 = document.Render(1, Dpi, Transparent); + AssertPaintedSomePixel(surface1); + } + + /// + /// Proves aiden0z-image-crop-css-reset.pptx (a single slide from + /// aiden0z/pptx-renderer's own example corpus) renders and paints visible content. + /// The slide declares a <p:bg> solid FAFAF9 fill, now painted (a + /// pixel-level real-file regression test for the Phase 2 Follow-Up background-fill + /// feature - see 's + /// own remarks for the companion pythonpptx-sld-background.pptx fixture) beneath + /// four embedded raster pictures, three with distinct <a:srcRect> crop + /// rectangles - new crop-rectangle variety not covered by any existing fixture. + /// + [Fact] + public void PptxDocument_Render_Aiden0zImageCropCssResetFixture_PaintsVisibleContentAndBackgroundFill() + { + // Arrange & Act + using var document = PptxDocument.Open(Fixture("aiden0z-image-crop-css-reset.pptx")); + + // Assert + Assert.Equal(1, document.SlideCount); + using var surface = document.Render(0, Dpi, Transparent); + AssertPaintedSomePixel(surface); + Assert.Equal(new Rgba32(0xFA, 0xFA, 0xF9, 255), surface[0, 0]); + } + + /// + /// Proves aiden0z-table-stale-frame.pptx (a single slide from + /// aiden0z/pptx-renderer's own example corpus) renders and paints visible content + /// despite a real-world authoring artifact: its one <a:tbl> graphic frame's + /// declared <p:xfrm> extent does not match the sum of its own + /// <a:gridCol> widths. Table column/row sizing is derived entirely from + /// <a:tblGrid>/<a:tr h>, independent of the graphic frame's own + /// declared extent, so this "stale frame size" mismatch renders and paints without + /// exception - a genuine, confirmed-graceful real-world edge case, not a bug. + /// + [Fact] + public void PptxDocument_Render_Aiden0zTableStaleFrameFixture_PaintsVisibleContentDespiteFrameSizeMismatch() + { + // Arrange & Act + using var document = PptxDocument.Open(Fixture("aiden0z-table-stale-frame.pptx")); + + // Assert + Assert.Equal(1, document.SlideCount); + using var surface = document.Render(0, Dpi, Transparent); + AssertPaintedSomePixel(surface); + } + + /// + /// Proves pythonpptx-chart-line.pptx (Phase 4's own new fixture - a single + /// python-pptx-generated XL_CHART_TYPE.LINE_MARKERS line chart, see + /// PptxFixtures\README.md for provenance) renders successfully and paints visible + /// content - a real-world (if synthetically generated), end-to-end regression proof for + /// Phase 4's chart-graphic-frame rendering, complementing 's + /// own hand-authored synthetic-package tests. + /// + [Fact] + public void PptxDocument_Render_PythonPptxChartLineFixture_RendersSuccessfully() + { + // Arrange & Act + using var document = PptxDocument.Open(Fixture("pythonpptx-chart-line.pptx")); + + // Assert + Assert.Equal(1, document.SlideCount); + using var surface = document.Render(0, Dpi, Transparent); + AssertPaintedSomePixel(surface); + } + + /// + /// Proves pythonpptx-chart-unsupported-radar.pptx (Phase 4's own new fixture - a + /// single python-pptx-generated XL_CHART_TYPE.RADAR radar chart, see + /// PptxFixtures\README.md for provenance) throws + /// with feature token + /// "pptx-chart-charts-openxml-radar-chart" - a real-world (if synthetically + /// generated) regression proof that 's + /// "pptx-chart-" + ex.Feature wrapping convention (see + /// PptxDocument.Charts.cs) is exercised against an actual OPC chart part, not just + /// hand-authored XML fragments. + /// + [Fact] + public void PptxDocument_Render_PythonPptxChartUnsupportedRadarFixture_ThrowsPptxUnsupportedFeatureException() + { + // Arrange + using var document = PptxDocument.Open(Fixture("pythonpptx-chart-unsupported-radar.pptx")); + + // Act & Assert + Assert.Equal(1, document.SlideCount); + var exception = Assert.Throws(() => document.Render(0, Dpi, Transparent)); + Assert.Equal("pptx-chart-charts-openxml-radar-chart", exception.Feature); + } +} diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxGeometryTests.cs b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxGeometryTests.cs new file mode 100644 index 00000000..db33915b --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxGeometryTests.cs @@ -0,0 +1,712 @@ +using System.Numerics; +using System.Xml.Linq; +using DemaConsulting.CanvasNet.Geometry; +using Path = DemaConsulting.CanvasNet.Geometry.Path; + +namespace DemaConsulting.CanvasNet.Pptx.Tests; + +// cspell:ignore xfrm chOff chExt flipH flipV prstGeom custGeom pathLst lnTo cubicBezTo quadBezTo rtTriangle pptx prst cust unrotated + +/// +/// Unit-level tests for the Phase 1c DrawingML shape-geometry resolvers +/// (, , +/// , ) +/// and 's preset-shape builders. Every resolver under test is a +/// plain static method operating on directly-constructed fragments, so +/// these tests do not need a full in-memory .pptx package (reserved for +/// 's end-to-end scenarios). +/// +public class PptxGeometryTests +{ + private static readonly XNamespace A = "http://schemas.openxmlformats.org/drawingml/2006/main"; + + /// Builds an <a:xfrm> element with the given off/ext/rot/flip attributes. + private static XElement BuildXfrm( + long offX, long offY, long extCx, long extCy, + int? rot = null, bool? flipH = null, bool? flipV = null, + long? chOffX = null, long? chOffY = null, long? chExtCx = null, long? chExtCy = null) + { + var xfrm = new XElement( + A + "xfrm", + new XElement(A + "off", new XAttribute("x", offX), new XAttribute("y", offY)), + new XElement(A + "ext", new XAttribute("cx", extCx), new XAttribute("cy", extCy))); + + if (rot is not null) + { + xfrm.Add(new XAttribute("rot", rot.Value)); + } + + if (flipH is not null) + { + xfrm.Add(new XAttribute("flipH", flipH.Value ? "1" : "0")); + } + + if (flipV is not null) + { + xfrm.Add(new XAttribute("flipV", flipV.Value ? "1" : "0")); + } + + if (chOffX is not null && chOffY is not null) + { + xfrm.Add(new XElement(A + "chOff", new XAttribute("x", chOffX.Value), new XAttribute("y", chOffY.Value))); + } + + if (chExtCx is not null && chExtCy is not null) + { + xfrm.Add(new XElement(A + "chExt", new XAttribute("cx", chExtCx.Value), new XAttribute("cy", chExtCy.Value))); + } + + return xfrm; + } + + /// Assert Vectors Close. + private static void AssertVectorsClose(Vector2 expected, Vector2 actual, float tolerance = 0.01f) + { + Assert.True( + MathF.Abs(expected.X - actual.X) <= tolerance && MathF.Abs(expected.Y - actual.Y) <= tolerance, + $"Expected {expected} but was {actual}."); + } + + // --- ResolveShapeFrame: translate/size --------------------------------------------------- + + /// Resolve Shape Frame - Plain Offset And Size - Maps Local Origin To Offset. + [Fact] + public void ResolveShapeFrame_PlainOffsetAndSize_MapsLocalOriginToOffset() + { + var xfrm = BuildXfrm(1000, 2000, 500, 300); + + var frame = PptxDocument.ResolveShapeFrame(xfrm); + + Assert.Equal(500f, frame.WidthEmu); + Assert.Equal(300f, frame.HeightEmu); + AssertVectorsClose(new Vector2(1000, 2000), Vector2.Transform(Vector2.Zero, frame.Transform)); + AssertVectorsClose(new Vector2(1500, 2300), Vector2.Transform(new Vector2(500, 300), frame.Transform)); + } + + /// Resolve Shape Frame - Missing Off - Throws Invalid Data Exception. + [Fact] + public void ResolveShapeFrame_MissingOff_ThrowsInvalidDataException() + { + var xfrm = new XElement(A + "xfrm", new XElement(A + "ext", new XAttribute("cx", 100), new XAttribute("cy", 100))); + + Assert.Throws(() => PptxDocument.ResolveShapeFrame(xfrm)); + } + + /// Resolve Shape Frame - Missing Ext - Throws Invalid Data Exception. + [Fact] + public void ResolveShapeFrame_MissingExt_ThrowsInvalidDataException() + { + var xfrm = new XElement(A + "xfrm", new XElement(A + "off", new XAttribute("x", 0), new XAttribute("y", 0))); + + Assert.Throws(() => PptxDocument.ResolveShapeFrame(xfrm)); + } + + // --- ResolveShapeFrame: rotation ---------------------------------------------------------- + + /// Resolve Shape Frame - Rotation90 Degrees - Rotates About Own Center Clockwise. + [Fact] + public void ResolveShapeFrame_Rotation90Degrees_RotatesAboutOwnCenterClockwise() + { + // 90 degrees clockwise (5,400,000 sixtieths of a degree) about the shape's own center + // (50,50) within a 100x100 box at offset (0,0): local (100,0) - the top-right corner - + // must land at the box's bottom-right corner (100,100) in parent space. + var xfrm = BuildXfrm(0, 0, 100, 100, rot: 90 * 60000); + + var frame = PptxDocument.ResolveShapeFrame(xfrm); + + AssertVectorsClose(new Vector2(100, 100), Vector2.Transform(new Vector2(100, 0), frame.Transform)); + AssertVectorsClose(new Vector2(50, 50), Vector2.Transform(new Vector2(50, 50), frame.Transform)); + } + + /// Resolve Shape Frame - Missing Rot - Defaults To Zero Rotation. + [Fact] + public void ResolveShapeFrame_MissingRot_DefaultsToZeroRotation() + { + var xfrm = BuildXfrm(0, 0, 100, 100); + + var frame = PptxDocument.ResolveShapeFrame(xfrm); + + AssertVectorsClose(new Vector2(100, 0), Vector2.Transform(new Vector2(100, 0), frame.Transform)); + } + + // --- ResolveShapeFrame: flip -------------------------------------------------------------- + + /// Resolve Shape Frame - Flip Horizontal - Mirrors About Own Center X. + [Fact] + public void ResolveShapeFrame_FlipHorizontal_MirrorsAboutOwnCenterX() + { + var xfrm = BuildXfrm(0, 0, 100, 50, flipH: true); + + var frame = PptxDocument.ResolveShapeFrame(xfrm); + + // Local (0,0) (top-left) must land at (100,0) (top-right) after a horizontal flip. + AssertVectorsClose(new Vector2(100, 0), Vector2.Transform(Vector2.Zero, frame.Transform)); + AssertVectorsClose(new Vector2(0, 0), Vector2.Transform(new Vector2(100, 0), frame.Transform)); + } + + /// Resolve Shape Frame - Flip Vertical - Mirrors About Own Center Y. + [Fact] + public void ResolveShapeFrame_FlipVertical_MirrorsAboutOwnCenterY() + { + var xfrm = BuildXfrm(0, 0, 100, 50, flipV: true); + + var frame = PptxDocument.ResolveShapeFrame(xfrm); + + AssertVectorsClose(new Vector2(0, 50), Vector2.Transform(Vector2.Zero, frame.Transform)); + } + + /// Resolve Shape Frame - Invalid Rot Attribute - Throws Invalid Data Exception. + [Fact] + public void ResolveShapeFrame_InvalidRotAttribute_ThrowsInvalidDataException() + { + var xfrm = BuildXfrm(0, 0, 100, 100); + xfrm.Add(new XAttribute("rot", "not-a-number")); + + Assert.Throws(() => PptxDocument.ResolveShapeFrame(xfrm)); + } + + /// + /// Resolve Shape Frame - Non Numeric Off Attribute - Throws Invalid Data Exception + /// (not the raw an explicit (float?) cast would + /// otherwise let escape). + /// + [Fact] + public void ResolveShapeFrame_NonNumericOffAttribute_ThrowsInvalidDataException() + { + var xfrm = new XElement( + A + "xfrm", + new XElement(A + "off", new XAttribute("x", "abc"), new XAttribute("y", 0)), + new XElement(A + "ext", new XAttribute("cx", 100), new XAttribute("cy", 100))); + + Assert.Throws(() => PptxDocument.ResolveShapeFrame(xfrm)); + } + + /// + /// Resolve Shape Frame - Non Numeric Ext Attribute - Throws Invalid Data Exception + /// (not the raw an explicit (float?) cast would + /// otherwise let escape). + /// + [Fact] + public void ResolveShapeFrame_NonNumericExtAttribute_ThrowsInvalidDataException() + { + var xfrm = new XElement( + A + "xfrm", + new XElement(A + "off", new XAttribute("x", 0), new XAttribute("y", 0)), + new XElement(A + "ext", new XAttribute("cx", "abc"), new XAttribute("cy", 100))); + + Assert.Throws(() => PptxDocument.ResolveShapeFrame(xfrm)); + } + + /// + /// Resolve Shape Frame - Non Finite Off Attribute - Throws Invalid Data Exception + /// (a non-finite <a:off> value must be rejected just like a non-numeric one). + /// + [Theory] + [InlineData("NaN")] + [InlineData("Infinity")] + [InlineData("-Infinity")] + public void ResolveShapeFrame_NonFiniteOffAttribute_ThrowsInvalidDataException(string nonFiniteValue) + { + var xfrm = new XElement( + A + "xfrm", + new XElement(A + "off", new XAttribute("x", nonFiniteValue), new XAttribute("y", 0)), + new XElement(A + "ext", new XAttribute("cx", 100), new XAttribute("cy", 100))); + + Assert.Throws(() => PptxDocument.ResolveShapeFrame(xfrm)); + } + + /// + /// Resolve Shape Frame - Non Finite Ext Attribute - Throws Invalid Data Exception + /// (a non-finite <a:ext> value must be rejected just like a non-numeric one). + /// + [Theory] + [InlineData("NaN")] + [InlineData("Infinity")] + [InlineData("-Infinity")] + public void ResolveShapeFrame_NonFiniteExtAttribute_ThrowsInvalidDataException(string nonFiniteValue) + { + var xfrm = new XElement( + A + "xfrm", + new XElement(A + "off", new XAttribute("x", 0), new XAttribute("y", 0)), + new XElement(A + "ext", new XAttribute("cx", nonFiniteValue), new XAttribute("cy", 100))); + + Assert.Throws(() => PptxDocument.ResolveShapeFrame(xfrm)); + } + + // --- ResolveGroupChildTransform ------------------------------------------------------------- + + /// Resolve Group Child Transform - Identity Child Space - Matches Group Frame Directly. + [Fact] + public void ResolveGroupChildTransform_IdentityChildSpace_MatchesGroupFrameDirectly() + { + // Group placed at (1000,1000) sized 200x200, with its child space declared identical to + // its own off/ext (chOff==off, chExt==ext) - a child's own local point should map exactly + // as if composed only with the group's own plain frame. + var groupXfrm = BuildXfrm(1000, 1000, 200, 200, chOffX: 1000, chOffY: 1000, chExtCx: 200, chExtCy: 200); + + var childTransform = PptxDocument.ResolveGroupChildTransform(groupXfrm); + + AssertVectorsClose(new Vector2(1000, 1000), Vector2.Transform(new Vector2(1000, 1000), childTransform)); + AssertVectorsClose(new Vector2(1200, 1200), Vector2.Transform(new Vector2(1200, 1200), childTransform)); + } + + /// Resolve Group Child Transform - Scaled Child Space - Scales Child Coordinates Into Group Box. + [Fact] + public void ResolveGroupChildTransform_ScaledChildSpace_ScalesChildCoordinatesIntoGroupBox() + { + // Group occupies a 100x100 box at (0,0) in its parent, but its children are authored in a + // 200x200 child coordinate space starting at (0,0) - every child coordinate must be + // halved when mapped into the group's own box. + var groupXfrm = BuildXfrm(0, 0, 100, 100, chOffX: 0, chOffY: 0, chExtCx: 200, chExtCy: 200); + + var childTransform = PptxDocument.ResolveGroupChildTransform(groupXfrm); + + AssertVectorsClose(new Vector2(0, 0), Vector2.Transform(new Vector2(0, 0), childTransform)); + AssertVectorsClose(new Vector2(50, 50), Vector2.Transform(new Vector2(100, 100), childTransform)); + AssertVectorsClose(new Vector2(100, 100), Vector2.Transform(new Vector2(200, 200), childTransform)); + } + + /// Resolve Group Child Transform - Offset Child Space - Translates Child Origin Before Scaling. + [Fact] + public void ResolveGroupChildTransform_OffsetChildSpace_TranslatesChildOriginBeforeScaling() + { + var groupXfrm = BuildXfrm(0, 0, 100, 100, chOffX: 50, chOffY: 50, chExtCx: 100, chExtCy: 100); + + var childTransform = PptxDocument.ResolveGroupChildTransform(groupXfrm); + + // A child point at the declared child-space origin (50,50) must map to the group box's + // own local origin (0,0). + AssertVectorsClose(new Vector2(0, 0), Vector2.Transform(new Vector2(50, 50), childTransform)); + } + + /// Resolve Group Child Transform - Child Of Child - Composes Through Both Transforms. + [Fact] + public void ResolveGroupChildTransform_ChildOfChild_ComposesThroughBothTransforms() + { + // A child shape's own ResolveShapeFrame().Transform must compose (child-local-first) with + // ResolveGroupChildTransform()'s result to yield the child's full parent-space transform. + var groupXfrm = BuildXfrm(0, 0, 100, 100, chOffX: 0, chOffY: 0, chExtCx: 200, chExtCy: 200); + var groupChildTransform = PptxDocument.ResolveGroupChildTransform(groupXfrm); + + var childXfrm = BuildXfrm(0, 0, 200, 200); // child declared at the full child coordinate space size + var childFrame = PptxDocument.ResolveShapeFrame(childXfrm); + + var fullTransform = childFrame.Transform * groupChildTransform; + + // Child-local (200,200) (its own bottom-right corner) -> child space (200,200) -> group + // box (100,100) (halved) -> parent space (100,100) (group itself is at (0,0) unrotated). + AssertVectorsClose(new Vector2(100, 100), Vector2.Transform(new Vector2(200, 200), fullTransform)); + } + + /// Resolve Group Child Transform - Missing Ch Off Ch Ext - Defaults To Identity Child Space. + [Fact] + public void ResolveGroupChildTransform_MissingChOffChExt_DefaultsToIdentityChildSpace() + { + var groupXfrm = BuildXfrm(10, 10, 50, 50); + + var childTransform = PptxDocument.ResolveGroupChildTransform(groupXfrm); + + AssertVectorsClose(new Vector2(10, 10), Vector2.Transform(new Vector2(0, 0), childTransform)); + } + + /// + /// Resolve Group Child Transform - Ch Off Present With Missing Attribute - Throws Invalid + /// Data Exception (a present-but-incomplete <a:chOff> must fail closed, not + /// silently fall back to the "element absent" default). + /// + [Fact] + public void ResolveGroupChildTransform_ChOffPresentWithMissingAttribute_ThrowsInvalidDataException() + { + var groupXfrm = new XElement( + A + "xfrm", + new XElement(A + "off", new XAttribute("x", 0), new XAttribute("y", 0)), + new XElement(A + "ext", new XAttribute("cx", 100), new XAttribute("cy", 100)), + new XElement(A + "chOff", new XAttribute("x", 0))); // missing required 'y' + + Assert.Throws(() => PptxDocument.ResolveGroupChildTransform(groupXfrm)); + } + + /// + /// Resolve Group Child Transform - Ch Ext Present With Missing Attribute - Throws Invalid + /// Data Exception (a present-but-incomplete <a:chExt> must fail closed, not + /// silently fall back to the "element absent" default). + /// + [Fact] + public void ResolveGroupChildTransform_ChExtPresentWithMissingAttribute_ThrowsInvalidDataException() + { + var groupXfrm = new XElement( + A + "xfrm", + new XElement(A + "off", new XAttribute("x", 0), new XAttribute("y", 0)), + new XElement(A + "ext", new XAttribute("cx", 100), new XAttribute("cy", 100)), + new XElement(A + "chExt", new XAttribute("cx", 100))); // missing required 'cy' + + Assert.Throws(() => PptxDocument.ResolveGroupChildTransform(groupXfrm)); + } + + // --- ResolveShapeGeometry: dispatch + failure modes --------------------------------------- + + /// Resolve Shape Geometry - Prst Geom Rect - Returns Rectangle Path. + [Fact] + public void ResolveShapeGeometry_PrstGeomRect_ReturnsRectanglePath() + { + var spPr = new XElement( + A + "spPr", + new XElement(A + "prstGeom", new XAttribute("prst", "rect"))); + + var path = PptxDocument.ResolveShapeGeometry(spPr, 100, 50); + + var bounds = path.GetBounds(); + Assert.Equal(0f, bounds.X); + Assert.Equal(0f, bounds.Y); + Assert.Equal(100f, bounds.Width); + Assert.Equal(50f, bounds.Height); + } + + /// Resolve Shape Geometry - Neither Prst Geom Nor Cust Geom - Throws Invalid Data Exception. + [Fact] + public void ResolveShapeGeometry_NeitherPrstGeomNorCustGeom_ThrowsInvalidDataException() + { + var spPr = new XElement(A + "spPr"); + + Assert.Throws(() => PptxDocument.ResolveShapeGeometry(spPr, 100, 50)); + } + + /// Resolve Shape Geometry - Unsupported Preset - Throws Pptx Unsupported Feature Exception. + [Fact] + public void ResolveShapeGeometry_UnsupportedPreset_ThrowsPptxUnsupportedFeatureException() + { + var spPr = new XElement( + A + "spPr", + new XElement(A + "prstGeom", new XAttribute("prst", "someExoticPresetNotSupported"))); + + var ex = Assert.Throws(() => PptxDocument.ResolveShapeGeometry(spPr, 100, 50)); + Assert.Equal("pptx-preset-geometry", ex.Feature); + } + + /// Resolve Shape Geometry - Prst Geom Missing Prst Attribute - Throws Invalid Data Exception. + [Fact] + public void ResolveShapeGeometry_PrstGeomMissingPrstAttribute_ThrowsInvalidDataException() + { + var spPr = new XElement(A + "spPr", new XElement(A + "prstGeom")); + + Assert.Throws(() => PptxDocument.ResolveShapeGeometry(spPr, 100, 50)); + } + + // --- ResolveCustomGeometry: path command parsing ------------------------------------------- + + /// Resolve Custom Geometry - Move Line Close - Produces Closed Triangle. + [Fact] + public void ResolveCustomGeometry_MoveLineClose_ProducesClosedTriangle() + { + var custGeom = new XElement( + A + "custGeom", + new XElement( + A + "pathLst", + new XElement( + A + "path", + new XAttribute("w", 100), new XAttribute("h", 100), + new XElement(A + "moveTo", new XElement(A + "pt", new XAttribute("x", 0), new XAttribute("y", 100))), + new XElement(A + "lnTo", new XElement(A + "pt", new XAttribute("x", 50), new XAttribute("y", 0))), + new XElement(A + "lnTo", new XElement(A + "pt", new XAttribute("x", 100), new XAttribute("y", 100))), + new XElement(A + "close")))); + + var path = PptxDocument.ResolveCustomGeometry(custGeom, 100, 100); + + Assert.Single(path.Subpaths); + var subpath = path.Subpaths[0]; + Assert.True(subpath.IsClosed); + AssertVectorsClose(new Vector2(0, 100), subpath.Start); + Assert.Equal(PathCommandType.LineTo, subpath.Commands[0].Type); + AssertVectorsClose(new Vector2(50, 0), subpath.Commands[0].EndPoint); + Assert.Equal(PathCommandType.LineTo, subpath.Commands[1].Type); + Assert.Equal(PathCommandType.Close, subpath.Commands[2].Type); + } + + /// Resolve Custom Geometry - Cubic Bez To - Produces Cubic Bezier Command. + [Fact] + public void ResolveCustomGeometry_CubicBezTo_ProducesCubicBezierCommand() + { + var custGeom = new XElement( + A + "custGeom", + new XElement( + A + "pathLst", + new XElement( + A + "path", + new XAttribute("w", 100), new XAttribute("h", 100), + new XElement(A + "moveTo", new XElement(A + "pt", new XAttribute("x", 0), new XAttribute("y", 0))), + new XElement( + A + "cubicBezTo", + new XElement(A + "pt", new XAttribute("x", 10), new XAttribute("y", 0)), + new XElement(A + "pt", new XAttribute("x", 20), new XAttribute("y", 100)), + new XElement(A + "pt", new XAttribute("x", 30), new XAttribute("y", 100)))))); + + var path = PptxDocument.ResolveCustomGeometry(custGeom, 100, 100); + + var command = path.Subpaths[0].Commands[0]; + Assert.Equal(PathCommandType.CubicBezierTo, command.Type); + AssertVectorsClose(new Vector2(10, 0), command.Control1); + AssertVectorsClose(new Vector2(20, 100), command.Control2); + AssertVectorsClose(new Vector2(30, 100), command.EndPoint); + } + + /// Resolve Custom Geometry - Quad Bez To - Produces Quadratic Bezier Command. + [Fact] + public void ResolveCustomGeometry_QuadBezTo_ProducesQuadraticBezierCommand() + { + var custGeom = new XElement( + A + "custGeom", + new XElement( + A + "pathLst", + new XElement( + A + "path", + new XAttribute("w", 100), new XAttribute("h", 100), + new XElement(A + "moveTo", new XElement(A + "pt", new XAttribute("x", 0), new XAttribute("y", 0))), + new XElement( + A + "quadBezTo", + new XElement(A + "pt", new XAttribute("x", 50), new XAttribute("y", 0)), + new XElement(A + "pt", new XAttribute("x", 100), new XAttribute("y", 100)))))); + + var path = PptxDocument.ResolveCustomGeometry(custGeom, 100, 100); + + var command = path.Subpaths[0].Commands[0]; + Assert.Equal(PathCommandType.QuadraticBezierTo, command.Type); + AssertVectorsClose(new Vector2(50, 0), command.Control1); + AssertVectorsClose(new Vector2(100, 100), command.EndPoint); + } + + /// Resolve Custom Geometry - Path Coordinate Space Scaled To Shape Extent. + [Fact] + public void ResolveCustomGeometry_PathCoordinateSpaceScaledToShapeExtent() + { + // The 's own 50x50 coordinate space must be scaled 2x to fill the shape's + // declared 100x100 extent. + var custGeom = new XElement( + A + "custGeom", + new XElement( + A + "pathLst", + new XElement( + A + "path", + new XAttribute("w", 50), new XAttribute("h", 50), + new XElement(A + "moveTo", new XElement(A + "pt", new XAttribute("x", 0), new XAttribute("y", 0))), + new XElement(A + "lnTo", new XElement(A + "pt", new XAttribute("x", 50), new XAttribute("y", 50)))))); + + var path = PptxDocument.ResolveCustomGeometry(custGeom, 100, 100); + + AssertVectorsClose(new Vector2(100, 100), path.Subpaths[0].Commands[0].EndPoint); + } + + /// Resolve Custom Geometry - No Path Lst - Returns Empty Path. + [Fact] + public void ResolveCustomGeometry_NoPathLst_ReturnsEmptyPath() + { + var custGeom = new XElement(A + "custGeom"); + + var path = PptxDocument.ResolveCustomGeometry(custGeom, 100, 100); + + Assert.Empty(path.Subpaths); + } + + /// + /// Proves an <a:path>'s non-numeric w attribute is rejected with + /// rather than letting the explicit (float?) + /// cast's raw propagate uncaught. + /// + [Fact] + public void ResolveCustomGeometry_NonNumericPathW_ThrowsInvalidDataException() + { + var custGeom = new XElement( + A + "custGeom", + new XElement( + A + "pathLst", + new XElement( + A + "path", + new XAttribute("w", "not-a-number"), new XAttribute("h", 50), + new XElement(A + "moveTo", new XElement(A + "pt", new XAttribute("x", 0), new XAttribute("y", 0)))))); + + Assert.Throws(() => PptxDocument.ResolveCustomGeometry(custGeom, 100, 100)); + } + + /// + /// Proves an <a:pt>'s non-numeric x attribute is rejected with + /// rather than letting the explicit (float?) + /// cast's raw propagate uncaught - the "commonly attacker- + /// reachable" custom-geometry point-coordinate path. + /// + [Fact] + public void ResolveCustomGeometry_NonNumericPointX_ThrowsInvalidDataException() + { + var custGeom = new XElement( + A + "custGeom", + new XElement( + A + "pathLst", + new XElement( + A + "path", + new XAttribute("w", 50), new XAttribute("h", 50), + new XElement( + A + "moveTo", + new XElement(A + "pt", new XAttribute("x", "not-a-number"), new XAttribute("y", 0)))))); + + Assert.Throws(() => PptxDocument.ResolveCustomGeometry(custGeom, 100, 100)); + } + + /// + /// Proves an <a:pt>'s overflowing (non-finite) y attribute is rejected + /// with rather than silently producing a non-finite + /// coordinate. + /// + [Fact] + public void ResolveCustomGeometry_OverflowingPointY_ThrowsInvalidDataException() + { + var custGeom = new XElement( + A + "custGeom", + new XElement( + A + "pathLst", + new XElement( + A + "path", + new XAttribute("w", 50), new XAttribute("h", 50), + new XElement( + A + "moveTo", + new XElement(A + "pt", new XAttribute("x", 0), new XAttribute("y", "1e400")))))); + + Assert.Throws(() => PptxDocument.ResolveCustomGeometry(custGeom, 100, 100)); + } + + // --- PptxPresetGeometry: each supported preset produces non-empty, correctly-bounded geometry --- + + /// Pptx Preset Geometry - Build - Each Supported Preset - Produces Path Within Declared Bounds. + [Theory] + [InlineData("rect")] + [InlineData("roundRect")] + [InlineData("ellipse")] + [InlineData("triangle")] + [InlineData("rtTriangle")] + [InlineData("diamond")] + [InlineData("parallelogram")] + [InlineData("trapezoid")] + [InlineData("hexagon")] + [InlineData("octagon")] + [InlineData("pentagon")] + [InlineData("chevron")] + [InlineData("homePlate")] + [InlineData("pie")] + [InlineData("donut")] + [InlineData("plus")] + [InlineData("rightArrow")] + [InlineData("leftArrow")] + [InlineData("upArrow")] + [InlineData("downArrow")] + [InlineData("leftRightArrow")] + [InlineData("upDownArrow")] + [InlineData("star4")] + [InlineData("star5")] + [InlineData("curvedUpArrow")] + [InlineData("curvedDownArrow")] + [InlineData("curvedLeftArrow")] + [InlineData("curvedRightArrow")] + public void PptxPresetGeometry_Build_EachSupportedPreset_ProducesPathWithinDeclaredBounds(string prst) + { + const float w = 200f; + const float h = 120f; + + var path = PptxPresetGeometry.Build(prst, w, h); + + Assert.NotEmpty(path.Subpaths); + var bounds = path.GetBounds(); + Assert.True(bounds.X >= -0.01f, $"{prst}: bounds.X={bounds.X}"); + Assert.True(bounds.Y >= -0.01f, $"{prst}: bounds.Y={bounds.Y}"); + Assert.True(bounds.X + bounds.Width <= w + 0.01f, $"{prst}: right={bounds.X + bounds.Width}"); + Assert.True(bounds.Y + bounds.Height <= h + 0.01f, $"{prst}: bottom={bounds.Y + bounds.Height}"); + } + + /// Pptx Preset Geometry - Build - Up Arrow - Points Upward. + [Fact] + public void PptxPresetGeometry_Build_UpArrow_PointsUpward() + { + // The arrow's apex (topmost point) must be at the top edge (y approx 0), and the shaft + // must occupy the bottom portion - a basic sanity check that the swapped-axis rotation + // used to derive upArrow from rightArrow points the correct direction. + var path = PptxPresetGeometry.Build("upArrow", 100, 100); + var bounds = path.GetBounds(); + + Assert.True(bounds.Y <= 0.5f, $"upArrow's top bound should be ~0, was {bounds.Y}"); + } + + /// Pptx Preset Geometry - Build - Down Arrow - Points Downward. + [Fact] + public void PptxPresetGeometry_Build_DownArrow_PointsDownward() + { + var upPath = PptxPresetGeometry.Build("upArrow", 100, 100); + var downPath = PptxPresetGeometry.Build("downArrow", 100, 100); + + // downArrow's apex should be at the bottom (near y=100), the mirror image of upArrow's + // apex at the top - verified by comparing each path's lowest and highest vertex y. + var upMinY = upPath.Subpaths[0].Commands.Min(c => c.EndPoint.Y); + var downMaxY = downPath.Subpaths[0].Commands.Max(c => c.EndPoint.Y); + + Assert.True(upMinY <= 10f, $"upArrow's min y should be near 0, was {upMinY}"); + Assert.True(downMaxY >= 90f, $"downArrow's max y should be near 100, was {downMaxY}"); + } + + /// Pptx Preset Geometry - Build - Unsupported Preset - Throws With Feature Token. + [Fact] + public void PptxPresetGeometry_Build_UnsupportedPreset_ThrowsWithFeatureToken() + { + var ex = Assert.Throws(() => PptxPresetGeometry.Build("notARealPreset", 100, 100)); + + Assert.Equal("pptx-preset-geometry", ex.Feature); + } + + /// Pptx Preset Geometry - Build - Non Positive Size - Returns Empty Path. + [Fact] + public void PptxPresetGeometry_Build_NonPositiveSize_ReturnsEmptyPath() + { + Assert.Same(Path.Empty, PptxPresetGeometry.Build("rect", 0, 100)); + Assert.Same(Path.Empty, PptxPresetGeometry.Build("rect", 100, 0)); + } + + /// Pptx Preset Geometry - Build - Curved Up Arrow - Points Upward Without Throwing. + [Fact] + public void PptxPresetGeometry_Build_CurvedUpArrow_PointsUpwardWithoutThrowing() + { + // This is the exact regression scenario from the real-world corpus crash report: + // Build must no longer throw PptxUnsupportedFeatureException for "curvedUpArrow", and + // the resulting path's apex (its topmost point) must reach the declared top edge + // (y approx 0), mirroring the existing upArrow/downArrow direction checks. + var path = PptxPresetGeometry.Build("curvedUpArrow", 200, 120); + + Assert.NotEmpty(path.Subpaths); + var bounds = path.GetBounds(); + Assert.True(bounds.Y <= 0.5f, $"curvedUpArrow's top bound should be ~0, was {bounds.Y}"); + } + + /// Pptx Preset Geometry - Build - Curved Arrow Siblings - Each Points Toward Its Own Declared Edge. + [Theory] + [InlineData("curvedDownArrow")] + [InlineData("curvedLeftArrow")] + [InlineData("curvedRightArrow")] + public void PptxPresetGeometry_Build_CurvedArrowSiblings_EachPointsTowardItsOwnDeclaredEdge(string prst) + { + // curvedUpArrow's three siblings are derived via the same Mirror/RotateQuarter + // transforms already used for upArrow/downArrow/leftArrow - this proves each sibling's + // own apex reaches its own named edge of the declared (0,0)-(w,h) bounds. + const float w = 200f; + const float h = 120f; + + var path = PptxPresetGeometry.Build(prst, w, h); + Assert.NotEmpty(path.Subpaths); + + var bounds = path.GetBounds(); + switch (prst) + { + case "curvedDownArrow": + Assert.True(bounds.Y + bounds.Height >= h - 0.5f, $"curvedDownArrow's bottom bound should be ~{h}, was {bounds.Y + bounds.Height}"); + break; + case "curvedLeftArrow": + Assert.True(bounds.X <= 0.5f, $"curvedLeftArrow's left bound should be ~0, was {bounds.X}"); + break; + case "curvedRightArrow": + Assert.True(bounds.X + bounds.Width >= w - 0.5f, $"curvedRightArrow's right bound should be ~{w}, was {bounds.X + bounds.Width}"); + break; + } + } +} diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxGroupsTests.cs b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxGroupsTests.cs new file mode 100644 index 00000000..e2d5bcc7 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxGroupsTests.cs @@ -0,0 +1,426 @@ +using System.Numerics; +using System.Xml.Linq; +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Fonts; + +namespace DemaConsulting.CanvasNet.Pptx.Tests; + +// cspell:ignore pptx grpsp nvsppr nvpr nvgrpsppr grpsppr cxnsp xfrm chOff chExt sptree + +/// +/// Unit-level tests for the Phase 1e recursive shape-tree walker +/// (PptxDocument.ShapeTree.cs's ) and its +/// closed hierarchy, plus the Phase 1e-extracted +/// helper. Every resolver under test is +/// a plain static method operating on directly-constructed fragments, +/// mirroring /'s own +/// "no full in-memory package needed" style. +/// +public class PptxGroupsTests +{ + private static readonly XNamespace A = "http://schemas.openxmlformats.org/drawingml/2006/main"; + private static readonly XNamespace P = "http://schemas.openxmlformats.org/presentationml/2006/main"; + + private static PptxTheme BuildTestTheme() => + new( + new PptxColorScheme( + new Rgba32(10, 10, 10, 255), new Rgba32(20, 20, 20, 255), new Rgba32(30, 30, 30, 255), new Rgba32(40, 40, 40, 255), + new Rgba32(50, 50, 50, 255), new Rgba32(60, 60, 60, 255), new Rgba32(70, 70, 70, 255), new Rgba32(80, 80, 80, 255), + new Rgba32(90, 90, 90, 255), new Rgba32(100, 100, 100, 255), new Rgba32(110, 110, 110, 255), new Rgba32(120, 120, 120, 255)), + new PptxFontScheme( + new PptxFontCollection("ThemeMajorLatin", "MajorEA", "MajorCS"), + new PptxFontCollection("ThemeMinorLatin", "MinorEA", "MinorCS"))); + + private static Func ThemeResolver(PptxTheme theme) => () => theme; + + /// Builds a non-placeholder (freeform) <p:sp> element. + private static XElement BuildFreeformSp(string name = "Freeform") => + new(P + "sp", + new XElement(P + "nvSpPr", + new XElement(P + "cNvPr", new XAttribute("id", 1), new XAttribute("name", name)), + new XElement(P + "cNvSpPr"), + new XElement(P + "nvPr"))); + + /// Builds a placeholder <p:sp> element with a <p:ph> descendant. + private static XElement BuildPlaceholderSp(string type = "title", uint? idx = null) + { + var ph = new XElement(P + "ph", new XAttribute("type", type)); + if (idx is not null) + { + ph.Add(new XAttribute("idx", idx.Value)); + } + + return new XElement(P + "sp", + new XElement(P + "nvSpPr", + new XElement(P + "cNvPr", new XAttribute("id", 1), new XAttribute("name", "Title")), + new XElement(P + "cNvSpPr"), + new XElement(P + "nvPr", ph))); + } + + private static XElement BuildPic(string name = "Picture") => + new(P + "pic", + new XElement(P + "nvPicPr", + new XElement(P + "cNvPr", new XAttribute("id", 1), new XAttribute("name", name)), + new XElement(P + "cNvPicPr"), + new XElement(P + "nvPr"))); + + private static XElement BuildXfrm(long offX, long offY, long extCx, long extCy, long? chOffX = null, long? chOffY = null, long? chExtCx = null, long? chExtCy = null) + { + var xfrm = new XElement( + A + "xfrm", + new XElement(A + "off", new XAttribute("x", offX), new XAttribute("y", offY)), + new XElement(A + "ext", new XAttribute("cx", extCx), new XAttribute("cy", extCy))); + + if (chOffX is not null && chOffY is not null) + { + xfrm.Add(new XElement(A + "chOff", new XAttribute("x", chOffX.Value), new XAttribute("y", chOffY.Value))); + } + + if (chExtCx is not null && chExtCy is not null) + { + xfrm.Add(new XElement(A + "chExt", new XAttribute("cx", chExtCx.Value), new XAttribute("cy", chExtCy.Value))); + } + + return xfrm; + } + + private static XElement BuildGrpSp(XElement xfrm, params XElement[] children) => + new(P + "grpSp", + [ + new XElement(P + "nvGrpSpPr", + new XElement(P + "cNvPr", new XAttribute("id", 1), new XAttribute("name", "Group")), + new XElement(P + "cNvGrpSpPr"), + new XElement(P + "nvPr")), + new XElement(P + "grpSpPr", xfrm), + .. children, + ]); + + private static XElement BuildTbl() => + new(A + "tbl", + new XElement(A + "tblGrid", new XElement(A + "gridCol", new XAttribute("w", 1000f))), + new XElement(A + "tr", new XAttribute("h", 1000f), new XElement(A + "tc"))); + + private static XElement BuildGraphicFrame(string uriSuffix = "/table") => + new(P + "graphicFrame", + new XElement(A + "graphic", + new XElement(A + "graphicData", new XAttribute("uri", $"http://schemas.openxmlformats.org/drawingml/2006{uriSuffix}"), BuildTbl()))); + + // --- ParseShapeTree: dispatch -------------------------------------------------------------- + + /// Proves a <p:sp> with no <p:ph> produces a with a null placeholder. + [Fact] + public void ParseShapeTree_FreeformShape_ProducesSpShapeNodeWithNullPlaceholder() + { + var spTree = new XElement(P + "spTree", BuildFreeformSp()); + + var nodes = PptxDocument.ParseShapeTree(spTree, ThemeResolver(BuildTestTheme())); + + var node = Assert.IsType(Assert.Single(nodes)); + Assert.Null(node.Placeholder); + } + + /// Proves a <p:sp> with a <p:ph> produces a with a resolved placeholder. + [Fact] + public void ParseShapeTree_PlaceholderShape_ProducesSpShapeNodeWithResolvedPlaceholder() + { + var spTree = new XElement(P + "spTree", BuildPlaceholderSp("body", 2)); + + var nodes = PptxDocument.ParseShapeTree(spTree, ThemeResolver(BuildTestTheme())); + + var node = Assert.IsType(Assert.Single(nodes)); + Assert.NotNull(node.Placeholder); + Assert.Equal("body", node.Placeholder.Type); + Assert.Equal(2u, node.Placeholder.Idx); + } + + /// Proves a <p:pic> produces a . + [Fact] + public void ParseShapeTree_Picture_ProducesPictureShapeNode() + { + var pic = BuildPic(); + var spTree = new XElement(P + "spTree", pic); + + var nodes = PptxDocument.ParseShapeTree(spTree, ThemeResolver(BuildTestTheme())); + + var node = Assert.IsType(Assert.Single(nodes)); + Assert.Same(pic, node.PicElement); + } + + /// Proves a <p:cxnSp> produces a wrapping the raw element, dispatched in the same tree walk as every other shape kind (so its document-order position, and therefore z-order among siblings, is preserved). + [Fact] + public void ParseShapeTree_Connector_ProducesConnectorShapeNode() + { + var cxnSp = new XElement(P + "cxnSp", + new XElement(P + "nvCxnSpPr", new XElement(P + "cNvPr", new XAttribute("id", 2), new XAttribute("name", "Connector")), new XElement(P + "cNvCxnSpPr"), new XElement(P + "nvPr")), + new XElement(P + "spPr")); + var spTree = new XElement(P + "spTree", BuildFreeformSp(), cxnSp); + + var nodes = PptxDocument.ParseShapeTree(spTree, ThemeResolver(BuildTestTheme())); + + Assert.Equal(2, nodes.Count); + Assert.IsType(nodes[0]); + var node = Assert.IsType(nodes[1]); + Assert.Same(cxnSp, node.CxnSpElement); + } + + /// Proves a <p:graphicFrame> declaring a table produces a with its table eagerly parsed. + [Fact] + public void ParseShapeTree_GraphicFrameTable_ProducesGraphicFrameShapeNodeWithParsedTable() + { + var spTree = new XElement(P + "spTree", BuildGraphicFrame()); + + var nodes = PptxDocument.ParseShapeTree(spTree, ThemeResolver(BuildTestTheme())); + + var node = Assert.IsType(Assert.Single(nodes)); + Assert.NotNull(node.Table); + Assert.Single(node.Table.ColumnWidthsEmu); + } + + /// Proves a slide with no <p:graphicFrame> never invokes the theme resolver (deferred theme resolution). + [Fact] + public void ParseShapeTree_NoGraphicFrame_NeverInvokesThemeResolver() + { + var spTree = new XElement(P + "spTree", BuildFreeformSp(), BuildPic()); + var invoked = false; + + PptxDocument.ParseShapeTree(spTree, () => + { + invoked = true; + return BuildTestTheme(); + }); + + Assert.False(invoked); + } + + /// Proves an unrecognized element kind (<p:contentPart>) is silently skipped - unlike <p:cxnSp>, which is now recognized and dispatched (see ). + [Fact] + public void ParseShapeTree_UnrecognizedElements_SilentlySkipped() + { + var spTree = new XElement(P + "spTree", + new XElement(P + "contentPart"), + BuildFreeformSp()); + + var nodes = PptxDocument.ParseShapeTree(spTree, ThemeResolver(BuildTestTheme())); + + Assert.Single(nodes); + Assert.IsType(nodes[0]); + } + + // --- ParseShapeTree: groups ----------------------------------------------------------------- + + /// Proves a <p:grpSp> produces a whose children are its own direct siblings (not a nested <p:spTree>), with its child transform resolved from its own <a:xfrm>. + [Fact] + public void ParseShapeTree_Group_ProducesGroupShapeNodeWithDirectSiblingChildren() + { + var xfrm = BuildXfrm(1000, 1000, 200, 200, chOffX: 1000, chOffY: 1000, chExtCx: 200, chExtCy: 200); + var grpSp = BuildGrpSp(xfrm, BuildFreeformSp(), BuildPic()); + var spTree = new XElement(P + "spTree", grpSp); + + var nodes = PptxDocument.ParseShapeTree(spTree, ThemeResolver(BuildTestTheme())); + + var group = Assert.IsType(Assert.Single(nodes)); + Assert.Equal(2, group.Children.Count); + Assert.IsType(group.Children[0]); + Assert.IsType(group.Children[1]); + + // A 1-to-1 offset/extent group with matching chOff/chExt resolves to an identity + // child transform (mirrors PptxGeometryTests's own "identity when ratios match" case). + var expected = PptxDocument.ResolveGroupChildTransform(xfrm); + Assert.Equal(expected, group.ChildTransform); + } + + /// Proves nested <p:grpSp> elements each resolve their own child transform and recurse into their own direct children. + [Fact] + public void ParseShapeTree_NestedGroups_EachResolveOwnChildTransformAndChildren() + { + var innerXfrm = BuildXfrm(0, 0, 100, 100, chOffX: 0, chOffY: 0, chExtCx: 200, chExtCy: 200); + var innerGroup = BuildGrpSp(innerXfrm, BuildFreeformSp("Inner")); + + var outerXfrm = BuildXfrm(0, 0, 100, 100, chOffX: 0, chOffY: 0, chExtCx: 200, chExtCy: 200); + var outerGroup = BuildGrpSp(outerXfrm, innerGroup); + + var spTree = new XElement(P + "spTree", outerGroup); + + var nodes = PptxDocument.ParseShapeTree(spTree, ThemeResolver(BuildTestTheme())); + + var outer = Assert.IsType(Assert.Single(nodes)); + var inner = Assert.IsType(Assert.Single(outer.Children)); + var leaf = Assert.IsType(Assert.Single(inner.Children)); + Assert.Null(leaf.Placeholder); + + Assert.Equal(PptxDocument.ResolveGroupChildTransform(outerXfrm), outer.ChildTransform); + Assert.Equal(PptxDocument.ResolveGroupChildTransform(innerXfrm), inner.ChildTransform); + } + + /// Proves a <p:grpSp> with no <p:grpSpPr>/<a:xfrm> element throws . + [Fact] + public void ParseShapeTree_GroupMissingXfrm_ThrowsInvalidDataException() + { + var grpSp = new XElement(P + "grpSp", + new XElement(P + "nvGrpSpPr", + new XElement(P + "cNvPr", new XAttribute("id", 1), new XAttribute("name", "Group")), + new XElement(P + "cNvGrpSpPr"), + new XElement(P + "nvPr")), + new XElement(P + "grpSpPr")); + var spTree = new XElement(P + "spTree", grpSp); + + Assert.Throws(() => PptxDocument.ParseShapeTree(spTree, ThemeResolver(BuildTestTheme()))); + } + + /// Proves a <p:graphicFrame> inside a group still resolves its table via the propagated theme resolver. + [Fact] + public void ParseShapeTree_GraphicFrameInsideGroup_ParsesTableUsingPropagatedThemeResolver() + { + var xfrm = BuildXfrm(0, 0, 100, 100); + var grpSp = BuildGrpSp(xfrm, BuildGraphicFrame()); + var spTree = new XElement(P + "spTree", grpSp); + + var nodes = PptxDocument.ParseShapeTree(spTree, ThemeResolver(BuildTestTheme())); + + var group = Assert.IsType(Assert.Single(nodes)); + var graphicFrameNode = Assert.IsType(Assert.Single(group.Children)); + Assert.NotNull(graphicFrameNode.Table); + Assert.Single(graphicFrameNode.Table.ColumnWidthsEmu); + } + + /// Builds a chain of nested <p:grpSp> elements, each wrapping the next, with as the innermost element's own child. + private static XElement BuildNestedGroupChain(int depth, XElement leaf) + { + var current = leaf; + for (var i = 0; i < depth; i++) + { + current = BuildGrpSp(BuildXfrm(0, 0, 100, 100), current); + } + + return current; + } + + /// + /// Regression test for the group-shape-nesting-depth safety limit (see the companion + /// code-review finding's bug-fix rationale): a <p:grpSp> chain nested one + /// level beyond PptxDocument.MaxGroupShapeNestingDepth (100) throws + /// rather than overflowing the call stack. + /// + [Fact] + public void ParseShapeTree_GroupNestingExceedsMaxDepth_ThrowsInvalidDataException() + { + var spTree = new XElement(P + "spTree", BuildNestedGroupChain(101, BuildFreeformSp())); + + Assert.Throws(() => PptxDocument.ParseShapeTree(spTree, ThemeResolver(BuildTestTheme()))); + } + + /// + /// Proves a <p:grpSp> chain nested exactly at the documented maximum depth + /// (100 levels) still parses successfully - the boundary case guarding against the depth + /// cap ever being tightened by accident. + /// + [Fact] + public void ParseShapeTree_GroupNestingAtMaxDepth_ParsesSuccessfully() + { + var spTree = new XElement(P + "spTree", BuildNestedGroupChain(100, BuildFreeformSp())); + + var nodes = PptxDocument.ParseShapeTree(spTree, ThemeResolver(BuildTestTheme())); + + var group = Assert.IsType(Assert.Single(nodes)); + for (var i = 0; i < 99; i++) + { + group = Assert.IsType(Assert.Single(group.Children)); + } + + Assert.IsType(Assert.Single(group.Children)); + } + + // --- ParseShapeTree: containUnsupportedGraphicFrames (companion planning report's fix) ----- + + /// + /// Regression test (see the companion planning report's bug-fix rationale): with + /// containUnsupportedGraphicFrames: true (as passed by GetLayout/ + /// GetMaster), an unsupported-kind <p:graphicFrame> (a chart, here) + /// is silently skipped - its own must not + /// abort the rest of the shape tree - while a sibling freeform shape still parses. + /// + [Fact] + public void ParseShapeTree_GraphicFrameUnsupportedKindWithContainmentEnabled_SkipsNodeKeepsSiblings() + { + var spTree = new XElement(P + "spTree", BuildGraphicFrame("/chart"), BuildFreeformSp()); + + var nodes = PptxDocument.ParseShapeTree(spTree, ThemeResolver(BuildTestTheme()), containUnsupportedGraphicFrames: true); + + var node = Assert.IsType(Assert.Single(nodes)); + Assert.Null(node.Placeholder); + } + + /// + /// Regression test: with the default containUnsupportedGraphicFrames: false (as + /// used by GetSlide), an unsupported-kind <p:graphicFrame> still + /// throws , locking in the slide-level + /// hard-fail behavior proven by PptxFixturesCorpusTests.cs's own slide-level-throw + /// tests as a guard against this containment ever being widened by accident. + /// + [Fact] + public void ParseShapeTree_GraphicFrameUnsupportedKindWithContainmentDisabled_StillThrows() + { + var spTree = new XElement(P + "spTree", BuildGraphicFrame("/chart"), BuildFreeformSp()); + + Assert.Throws(() => PptxDocument.ParseShapeTree(spTree, ThemeResolver(BuildTestTheme()))); + } + + /// + /// Proves containUnsupportedGraphicFrames propagates through the recursive + /// <p:grpSp> self-call, mirroring how themeResolver/ + /// tableStyleResolver/colorMapResolver are already propagated (see + /// ). + /// + [Fact] + public void ParseShapeTree_GraphicFrameInsideGroupUnsupportedKindWithContainmentEnabled_SkipsNodeKeepsSiblings() + { + var xfrm = BuildXfrm(0, 0, 100, 100); + var grpSp = BuildGrpSp(xfrm, BuildGraphicFrame("/chart"), BuildFreeformSp()); + var spTree = new XElement(P + "spTree", grpSp); + + var nodes = PptxDocument.ParseShapeTree(spTree, ThemeResolver(BuildTestTheme()), containUnsupportedGraphicFrames: true); + + var group = Assert.IsType(Assert.Single(nodes)); + var child = Assert.IsType(Assert.Single(group.Children)); + Assert.Null(child.Placeholder); + } + + // --- PptxPlaceholderParser.TryParsePlaceholder ---------------------------------------------- + + /// Proves returns null for a freeform shape. + [Fact] + public void TryParsePlaceholder_FreeformShape_ReturnsNull() + { + Assert.Null(PptxPlaceholderParser.TryParsePlaceholder(BuildFreeformSp())); + } + + /// Proves applies the type/idx schema defaults when omitted. + [Fact] + public void TryParsePlaceholder_NoTypeOrIdxAttributes_DefaultsToObjAndZero() + { + var sp = new XElement(P + "sp", + new XElement(P + "nvSpPr", + new XElement(P + "cNvPr", new XAttribute("id", 1), new XAttribute("name", "Ph")), + new XElement(P + "cNvSpPr"), + new XElement(P + "nvPr", new XElement(P + "ph")))); + + var placeholder = PptxPlaceholderParser.TryParsePlaceholder(sp); + + Assert.NotNull(placeholder); + Assert.Equal("obj", placeholder.Type); + Assert.Equal(0u, placeholder.Idx); + } + + /// Proves rejects a non-numeric idx attribute. + [Fact] + public void TryParsePlaceholder_NonNumericIdx_ThrowsInvalidDataException() + { + var sp = new XElement(P + "sp", + new XElement(P + "nvSpPr", + new XElement(P + "cNvPr", new XAttribute("id", 1), new XAttribute("name", "Ph")), + new XElement(P + "cNvSpPr"), + new XElement(P + "nvPr", new XElement(P + "ph", new XAttribute("idx", "not-a-number"))))); + + Assert.Throws(() => PptxPlaceholderParser.TryParsePlaceholder(sp)); + } +} diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxImagesTests.cs b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxImagesTests.cs new file mode 100644 index 00000000..876c27ff --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxImagesTests.cs @@ -0,0 +1,813 @@ +using System.IO.Compression; +using System.Numerics; +using System.Text; +using System.Xml.Linq; +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Codecs; +using Path = DemaConsulting.CanvasNet.Geometry.Path; + +namespace DemaConsulting.CanvasNet.Pptx.Tests; + +// cspell:ignore pptx blipfill srcrect embed sppr nvpicpr nvpr cnvpr cnvpicpr srgb hlink folhlink calibri asvg prst cust + +/// +/// Unit-level tests for the Phase 1e picture-shape resolvers and painting primitive +/// (, , +/// ). The / +/// related tests operate on directly-constructed +/// fragments/in-memory instances; +/// additionally needs a +/// real package/relationship-resolution context, so those tests build a small, self-contained +/// in-memory .pptx-shaped package (mirroring 's +/// own BuildPackage/ pattern, at unit-test scope). A full +/// decode-crop-paint, end-to-end scenario driven through the public +/// API lives in instead. +/// +public class PptxImagesTests +{ + private static readonly XNamespace A = "http://schemas.openxmlformats.org/drawingml/2006/main"; + private static readonly XNamespace R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + + // --- ResolveSrcRect ------------------------------------------------------------------------ + + /// Proves an absent <a:srcRect> resolves to (no crop). + [Fact] + public void ResolveSrcRect_NoSrcRectElement_ReturnsNull() + { + var blipFill = new XElement(PresentationNs + "blipFill"); + + var result = PptxDocument.ResolveSrcRect(blipFill); + + Assert.Null(result); + } + + /// Proves every edge attribute is parsed and scaled from OOXML's 1/100000-of-a-percent unit. + [Fact] + public void ResolveSrcRect_AllEdgesPresent_ParsesScaledFractions() + { + var blipFill = new XElement( + PresentationNs + "blipFill", + new XElement(A + "srcRect", + new XAttribute("l", "10000"), + new XAttribute("t", "20000"), + new XAttribute("r", "30000"), + new XAttribute("b", "40000"))); + + var result = PptxDocument.ResolveSrcRect(blipFill); + + Assert.NotNull(result); + Assert.Equal(0.1f, result.Left, 0.0001f); + Assert.Equal(0.2f, result.Top, 0.0001f); + Assert.Equal(0.3f, result.Right, 0.0001f); + Assert.Equal(0.4f, result.Bottom, 0.0001f); + } + + /// Proves an absent individual edge attribute defaults to 0 (no crop on that edge), per the OOXML schema default. + [Fact] + public void ResolveSrcRect_SomeEdgesAbsent_DefaultsToZero() + { + var blipFill = new XElement( + PresentationNs + "blipFill", + new XElement(A + "srcRect", new XAttribute("l", "10000"))); + + var result = PptxDocument.ResolveSrcRect(blipFill); + + Assert.NotNull(result); + Assert.Equal(0.1f, result.Left, 0.0001f); + Assert.Equal(0f, result.Top); + Assert.Equal(0f, result.Right); + Assert.Equal(0f, result.Bottom); + } + + /// Proves a non-numeric edge attribute value throws . + [Fact] + public void ResolveSrcRect_NonNumericEdgeAttribute_ThrowsInvalidDataException() + { + var blipFill = new XElement( + PresentationNs + "blipFill", + new XElement(A + "srcRect", new XAttribute("l", "not-a-number"))); + + Assert.Throws(() => PptxDocument.ResolveSrcRect(blipFill)); + } + + /// Proves a non-finite edge attribute value throws . + [Theory] + [InlineData("NaN")] + [InlineData("Infinity")] + [InlineData("-Infinity")] + public void ResolveSrcRect_NonFiniteEdgeAttribute_ThrowsInvalidDataException(string nonFiniteValue) + { + var blipFill = new XElement( + PresentationNs + "blipFill", + new XElement(A + "srcRect", new XAttribute("l", nonFiniteValue))); + + Assert.Throws(() => PptxDocument.ResolveSrcRect(blipFill)); + } + + /// Proves rejects a null argument. + [Fact] + public void ResolveSrcRect_NullBlipFillElement_ThrowsArgumentNullException() + { + Assert.Throws(() => PptxDocument.ResolveSrcRect(null!)); + } + + // --- PaintPicture ---------------------------------------------------------------------------- + + /// Builds a small, fully-opaque two-tone test image: red left half, blue right half. + private static Surface BuildTwoToneImage(int width, int height) + { + var image = new Surface(width, height); + for (var y = 0; y < height; y++) + { + for (var x = 0; x < width; x++) + { + image[x, y] = x < width / 2 + ? new Rgba32(255, 0, 0, 255) + : new Rgba32(0, 0, 255, 255); + } + } + + return image; + } + + /// Proves an identity-transformed, uncropped picture paints the source image's pixels at the expected destination location, left-to-right unflipped. + [Fact] + public void PaintPicture_IdentityTransformNoCrop_PaintsImagePixelsUnflipped() + { + using var image = BuildTwoToneImage(10, 10); + using var surface = new Surface(10, 10); + + // Shape occupies the full [0,10]x[0,10] surface box: widthEmu/heightEmu = 10, identity transform. + PptxDocument.PaintPicture(surface, image, srcRect: null, Matrix3x2.Identity, widthEmu: 10f, heightEmu: 10f); + + Assert.Equal(new Rgba32(255, 0, 0, 255), surface[2, 5]); + Assert.Equal(new Rgba32(0, 0, 255, 255), surface[8, 5]); + } + + /// Proves no (1 - v) row flip is applied: a top/bottom asymmetric image paints with the same row order in surface space (unlike the PDF renderer's own y-up image convention). + [Fact] + public void PaintPicture_TopBottomAsymmetricImage_PaintsWithoutVerticalFlip() + { + using var image = new Surface(4, 4); + for (var x = 0; x < 4; x++) + { + image[x, 0] = new Rgba32(255, 255, 0, 255); // top row: yellow + image[x, 1] = new Rgba32(0, 0, 0, 255); + image[x, 2] = new Rgba32(0, 0, 0, 255); + image[x, 3] = new Rgba32(0, 255, 255, 255); // bottom row: cyan + } + + using var surface = new Surface(4, 4); + + PptxDocument.PaintPicture(surface, image, srcRect: null, Matrix3x2.Identity, widthEmu: 4f, heightEmu: 4f); + + // No vertical flip: surface row 0 (top) must match image row 0 (top) - yellow; surface row + // 3 (bottom) must match image row 3 (bottom) - cyan. A PDF-style (1-v) flip would reverse + // this (yellow at the bottom, cyan at the top). + Assert.Equal(new Rgba32(255, 255, 0, 255), surface[1, 0]); + Assert.Equal(new Rgba32(0, 255, 255, 255), surface[1, 3]); + } + + /// Proves a crop re-maps the sampled region: cropping away the left half of a two-tone image leaves only its right (blue) half visible. + [Fact] + public void PaintPicture_WithSrcRectCroppingLeftHalf_SamplesOnlyRightHalf() + { + using var image = BuildTwoToneImage(10, 10); + using var surface = new Surface(10, 10); + var srcRect = new PptxSrcRect(Left: 0.5f, Top: 0f, Right: 0f, Bottom: 0f); + + PptxDocument.PaintPicture(surface, image, srcRect, Matrix3x2.Identity, widthEmu: 10f, heightEmu: 10f); + + // The entire painted destination now samples only the image's right (blue) half. + Assert.Equal(new Rgba32(0, 0, 255, 255), surface[1, 5]); + Assert.Equal(new Rgba32(0, 0, 255, 255), surface[8, 5]); + } + + /// Proves a translated shape-to-surface transform shifts the painted footprint accordingly. + [Fact] + public void PaintPicture_TranslatedTransform_ShiftsPaintedFootprint() + { + using var image = BuildTwoToneImage(10, 10); + using var surface = new Surface(30, 30); + var transform = Matrix3x2.CreateTranslation(10f, 10f); + + PptxDocument.PaintPicture(surface, image, srcRect: null, transform, widthEmu: 10f, heightEmu: 10f); + + // Untouched outside the shifted footprint. + Assert.Equal(default, surface[2, 2]); + // Inside the shifted footprint, pixels resolve exactly as the identity case would, offset by (10,10). + Assert.Equal(new Rgba32(255, 0, 0, 255), surface[12, 15]); + Assert.Equal(new Rgba32(0, 0, 255, 255), surface[18, 15]); + } + + /// Proves a degenerate (non-invertible, zero-area) composed transform paints nothing rather than throwing. + [Fact] + public void PaintPicture_DegenerateTransform_PaintsNothing() + { + using var image = BuildTwoToneImage(10, 10); + using var surface = new Surface(10, 10); + + // Scale(0,0) composed with CreateScale(widthEmu, heightEmu) is non-invertible. + PptxDocument.PaintPicture(surface, image, srcRect: null, Matrix3x2.CreateScale(0f), widthEmu: 10f, heightEmu: 10f); + + for (var y = 0; y < 10; y++) + { + for (var x = 0; x < 10; x++) + { + Assert.Equal(default, surface[x, y]); + } + } + } + + /// Proves rejects a null surface/image argument. + [Fact] + public void PaintPicture_NullSurfaceOrImage_ThrowsArgumentNullException() + { + using var surface = new Surface(10, 10); + using var image = new Surface(10, 10); + + Assert.Throws( + () => PptxDocument.PaintPicture(null!, image, null, Matrix3x2.Identity, 10f, 10f)); + Assert.Throws( + () => PptxDocument.PaintPicture(surface, null!, null, Matrix3x2.Identity, 10f, 10f)); + } + + /// + /// Regression test for the raw-overwrite alpha-compositing bug: proves + /// now alpha-blends each sampled source pixel + /// "over" the existing destination pixel (standard Porter-Duff "over" compositing) rather + /// than overwriting it outright. Covers a fully transparent source pixel with a + /// non-matching (near-white) stored RGB (background must show through unchanged - this is + /// the exact real-world scenario this bug was confirmed against, a logo PNG with an + /// unassociated-alpha white matte), a fully opaque source pixel (exact replacement), and a + /// partially transparent (anti-aliased) source pixel (exact blended bytes per the + /// documented formula). + /// + [Fact] + public void PaintPicture_SourceImageWithAlphaChannel_AlphaBlendsOntoExistingBackground() + { + using var image = new Surface(3, 1); + image[0, 0] = new Rgba32(255, 255, 255, 0); // fully transparent, non-matching near-white RGB + image[1, 0] = new Rgba32(10, 20, 30, 255); // fully opaque + image[2, 0] = new Rgba32(200, 100, 50, 128); // partially transparent (anti-aliased) + + using var surface = new Surface(3, 1); + var background = new Rgba32(0, 255, 0, 255); // known solid green background + surface[0, 0] = background; + surface[1, 0] = background; + surface[2, 0] = background; + + PptxDocument.PaintPicture(surface, image, srcRect: null, Matrix3x2.Identity, widthEmu: 3f, heightEmu: 1f); + + // Fully transparent source pixel: background must be completely unchanged. + Assert.Equal(background, surface[0, 0]); + // Fully opaque source pixel: exact replacement. + Assert.Equal(new Rgba32(10, 20, 30, 255), surface[1, 0]); + // Partially transparent source pixel: exact Porter-Duff "over" blended bytes. + Assert.Equal(new Rgba32(100, 177, 25, 255), surface[2, 0]); + } + + // --- ResolvePictureClipPath ------------------------------------------------------------------ + + /// Proves an explicit <a:prstGeom prst="rect"> resolves to (no clip - equivalent to the full bounding-box rectangle). + [Fact] + public void ResolvePictureClipPath_RectPreset_ReturnsNull() + { + var spPr = new XElement( + PresentationNs + "spPr", + new XElement(A + "prstGeom", new XAttribute("prst", "rect"), new XElement(A + "avLst"))); + + var result = PptxDocument.ResolvePictureClipPath(spPr, 100f, 100f); + + Assert.Null(result); + } + + /// Proves a <p:spPr> with neither <a:prstGeom> nor <a:custGeom> resolves to (no clip, schema-edge-case tolerance). + [Fact] + public void ResolvePictureClipPath_NoGeometryElement_ReturnsNull() + { + var spPr = new XElement(PresentationNs + "spPr"); + + var result = PptxDocument.ResolvePictureClipPath(spPr, 100f, 100f); + + Assert.Null(result); + } + + /// Proves a non-rect preset (ellipse) resolves to a non-null , sized to the shape's own local box. + [Fact] + public void ResolvePictureClipPath_EllipsePreset_ReturnsNonNullPath() + { + var spPr = new XElement( + PresentationNs + "spPr", + new XElement(A + "prstGeom", new XAttribute("prst", "ellipse"), new XElement(A + "avLst"))); + + var result = PptxDocument.ResolvePictureClipPath(spPr, 100f, 100f); + + Assert.NotNull(result); + Assert.NotEqual(Path.Empty, result); + } + + /// Proves a second non-rect preset (roundRect) also resolves to a non-null . + [Fact] + public void ResolvePictureClipPath_RoundRectPreset_ReturnsNonNullPath() + { + var spPr = new XElement( + PresentationNs + "spPr", + new XElement(A + "prstGeom", new XAttribute("prst", "roundRect"), new XElement(A + "avLst"))); + + var result = PptxDocument.ResolvePictureClipPath(spPr, 100f, 100f); + + Assert.NotNull(result); + } + + /// Proves an <a:custGeom> element resolves to a non-null via the same reused resolver auto-shapes already use. + [Fact] + public void ResolvePictureClipPath_CustGeom_ReturnsNonNullPath() + { + var spPr = new XElement( + PresentationNs + "spPr", + new XElement( + A + "custGeom", + new XElement( + A + "pathLst", + new XElement( + A + "path", + new XAttribute("w", "100"), + new XAttribute("h", "100"), + new XElement(A + "moveTo", new XElement(A + "pt", new XAttribute("x", "0"), new XAttribute("y", "0"))), + new XElement(A + "lnTo", new XElement(A + "pt", new XAttribute("x", "100"), new XAttribute("y", "0"))), + new XElement(A + "lnTo", new XElement(A + "pt", new XAttribute("x", "100"), new XAttribute("y", "100"))), + new XElement(A + "close"))))); + + var result = PptxDocument.ResolvePictureClipPath(spPr, 100f, 100f); + + Assert.NotNull(result); + Assert.NotEqual(Path.Empty, result); + } + + /// Proves a <a:prstGeom> with no prst attribute throws , mirroring 's identical check. + [Fact] + public void ResolvePictureClipPath_PrstGeomMissingPrstAttribute_ThrowsInvalidDataException() + { + var spPr = new XElement(PresentationNs + "spPr", new XElement(A + "prstGeom")); + + Assert.Throws(() => PptxDocument.ResolvePictureClipPath(spPr, 100f, 100f)); + } + + /// Proves an unsupported preset name propagates unchanged, exactly as it already propagates for an auto-shape with the same preset. + [Fact] + public void ResolvePictureClipPath_UnsupportedPreset_ThrowsPptxUnsupportedFeatureException() + { + var spPr = new XElement( + PresentationNs + "spPr", + new XElement(A + "prstGeom", new XAttribute("prst", "not-a-real-preset"), new XElement(A + "avLst"))); + + Assert.Throws(() => PptxDocument.ResolvePictureClipPath(spPr, 100f, 100f)); + } + + /// Proves rejects a null argument. + [Fact] + public void ResolvePictureClipPath_NullSpPrElement_ThrowsArgumentNullException() + { + Assert.Throws(() => PptxDocument.ResolvePictureClipPath(null!, 100f, 100f)); + } + + // --- ResolvePictureGeometryPath ---------------------------------------------------------- + // + // Unlike ResolvePictureClipPath (which collapses "no geometry" and an explicit "rect" preset + // to null, a pure image-content-clip optimization), ResolvePictureGeometryPath always returns + // a concrete path - needed so a picture's own stroke outlines a real boundary (an + // implicit/explicit rectangle, a preset, or a custom geometry) rather than being silently + // dropped. See this method's own remarks in PptxDocument.Images.cs. + + /// Proves an explicit <a:prstGeom prst="rect"> resolves to a non-null rectangle - unlike 's own "rect collapses to null" behavior. + [Fact] + public void ResolvePictureGeometryPath_RectPreset_ReturnsNonNullRectanglePath() + { + var spPr = new XElement( + PresentationNs + "spPr", + new XElement(A + "prstGeom", new XAttribute("prst", "rect"), new XElement(A + "avLst"))); + + var result = PptxDocument.ResolvePictureGeometryPath(spPr, 100f, 100f); + + Assert.NotEqual(Path.Empty, result); + } + + /// Proves a <p:spPr> with neither <a:prstGeom> nor <a:custGeom> resolves to the implicit full-rectangle fallback - unlike 's own "no geometry collapses to null" behavior. + [Fact] + public void ResolvePictureGeometryPath_NoGeometryElement_ReturnsImplicitRectanglePath() + { + var spPr = new XElement(PresentationNs + "spPr"); + + var result = PptxDocument.ResolvePictureGeometryPath(spPr, 100f, 100f); + + Assert.NotEqual(Path.Empty, result); + } + + /// Proves a non-rect preset (ellipse) resolves to a non-null , sized to the shape's own local box. + [Fact] + public void ResolvePictureGeometryPath_EllipsePreset_ReturnsNonNullPath() + { + var spPr = new XElement( + PresentationNs + "spPr", + new XElement(A + "prstGeom", new XAttribute("prst", "ellipse"), new XElement(A + "avLst"))); + + var result = PptxDocument.ResolvePictureGeometryPath(spPr, 100f, 100f); + + Assert.NotEqual(Path.Empty, result); + } + + /// Proves an <a:custGeom> element resolves to a non-null via the same reused resolver auto-shapes already use. + [Fact] + public void ResolvePictureGeometryPath_CustGeom_ReturnsNonNullPath() + { + var spPr = new XElement( + PresentationNs + "spPr", + new XElement( + A + "custGeom", + new XElement( + A + "pathLst", + new XElement( + A + "path", + new XAttribute("w", "100"), + new XAttribute("h", "100"), + new XElement(A + "moveTo", new XElement(A + "pt", new XAttribute("x", "0"), new XAttribute("y", "0"))), + new XElement(A + "lnTo", new XElement(A + "pt", new XAttribute("x", "100"), new XAttribute("y", "0"))), + new XElement(A + "lnTo", new XElement(A + "pt", new XAttribute("x", "100"), new XAttribute("y", "100"))), + new XElement(A + "close"))))); + + var result = PptxDocument.ResolvePictureGeometryPath(spPr, 100f, 100f); + + Assert.NotEqual(Path.Empty, result); + } + + /// Proves a <a:prstGeom> with no prst attribute throws , mirroring 's/'s identical check. + [Fact] + public void ResolvePictureGeometryPath_PrstGeomMissingPrstAttribute_ThrowsInvalidDataException() + { + var spPr = new XElement(PresentationNs + "spPr", new XElement(A + "prstGeom")); + + Assert.Throws(() => PptxDocument.ResolvePictureGeometryPath(spPr, 100f, 100f)); + } + + /// Proves an unsupported preset name propagates unchanged, exactly as it already propagates for an auto-shape/ with the same preset. + [Fact] + public void ResolvePictureGeometryPath_UnsupportedPreset_ThrowsPptxUnsupportedFeatureException() + { + var spPr = new XElement( + PresentationNs + "spPr", + new XElement(A + "prstGeom", new XAttribute("prst", "not-a-real-preset"), new XElement(A + "avLst"))); + + Assert.Throws(() => PptxDocument.ResolvePictureGeometryPath(spPr, 100f, 100f)); + } + + /// Proves rejects a null argument. + [Fact] + public void ResolvePictureGeometryPath_NullSpPrElement_ThrowsArgumentNullException() + { + Assert.Throws(() => PptxDocument.ResolvePictureGeometryPath(null!, 100f, 100f)); + } + + // --- PaintPicture clipPath --------------------------------------------------------------------- + + /// Proves an ellipse clip path leaves a bounding-box corner sample unpainted (background shows through) while the shape's own center is painted with the source image's pixel - the confirmed real-world bug this fix resolves. + [Fact] + public void PaintPicture_EllipseClipPath_CornerUnpaintedCenterPainted() + { + using var image = new Surface(20, 20); + for (var y = 0; y < 20; y++) + { + for (var x = 0; x < 20; x++) + { + image[x, y] = new Rgba32(10, 20, 30, 255); + } + } + + using var surface = new Surface(20, 20); + var background = new Rgba32(255, 255, 255, 255); + for (var y = 0; y < 20; y++) + { + for (var x = 0; x < 20; x++) + { + surface[x, y] = background; + } + } + + var clipPath = PptxPresetGeometry.Build("ellipse", 20f, 20f); + + PptxDocument.PaintPicture(surface, image, srcRect: null, Matrix3x2.Identity, widthEmu: 20f, heightEmu: 20f, clipPath); + + // Well inside the ellipse: image content. + Assert.Equal(new Rgba32(10, 20, 30, 255), surface[10, 10]); + // Bounding-box corner, well outside the ellipse: unpainted background. + Assert.Equal(background, surface[0, 0]); + Assert.Equal(background, surface[19, 19]); + } + + /// Proves a clipPath (the default) continues painting the full unclipped rectangle, a direct regression guard for every pre-existing call in this file. + [Fact] + public void PaintPicture_NullClipPath_PaintsFullRectangleUnclipped() + { + using var image = BuildTwoToneImage(10, 10); + using var surface = new Surface(10, 10); + + PptxDocument.PaintPicture(surface, image, srcRect: null, Matrix3x2.Identity, widthEmu: 10f, heightEmu: 10f, clipPath: null); + + Assert.Equal(new Rgba32(255, 0, 0, 255), surface[0, 0]); + Assert.Equal(new Rgba32(0, 0, 255, 255), surface[9, 9]); + } + + // --- ResolvePictureSurface (needs a real package/relationship-resolution context) ----------- + + private static readonly XNamespace PresentationNs = "http://schemas.openxmlformats.org/presentationml/2006/main"; + + /// + /// Builds a minimal, navigable, single-slide in-memory .pptx-shaped package whose + /// slide has a single relationship (rId2) to a media part at + /// ppt/media/image1.<mediaExtension> containing , + /// resolved to via a [Content_Types].xml + /// <Default> extension mapping - exactly enough package structure for + /// 's relationship/content-type/part-bytes + /// resolution path to be exercised without a full slide/layout/master/theme chain (unlike + /// 's own, fuller package-building helpers). + /// + private static (MemoryStream Package, string SlidePartPath) BuildMinimalImagePackage( + string mediaExtension, string mediaContentType, byte[] mediaBytes, bool linkInsteadOfEmbed = false) + { + var relationshipXml = linkInsteadOfEmbed + ? $"""""" + : $""""""; + + var contentTypesXml = + $""" + + + + + + + """; + + const string packageRelsXml = + """ + + + + + """; + + const string presentationXml = + """ + + + + + """; + + const string presentationRelsXml = + """ + + + + + """; + + const string slideXml = + """ + + + + """; + + var slideRelsXml = + $""" + + + + {relationshipXml} + + """; + + const string layoutXml = + """ + + + + """; + + const string layoutRelsXml = + """ + + + + + """; + + const string masterXml = + """ + + + + + + """; + + const string masterRelsXml = + """ + + + + + """; + + const string themeXml = + """ + + + + + + + + + + + + + + + + + + + + + + + """; + + var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + WriteTextEntry(archive, "[Content_Types].xml", contentTypesXml); + WriteTextEntry(archive, "_rels/.rels", packageRelsXml); + WriteTextEntry(archive, "ppt/presentation.xml", presentationXml); + WriteTextEntry(archive, "ppt/_rels/presentation.xml.rels", presentationRelsXml); + WriteTextEntry(archive, "ppt/slides/slide1.xml", slideXml); + WriteTextEntry(archive, "ppt/slides/_rels/slide1.xml.rels", slideRelsXml); + WriteTextEntry(archive, "ppt/slideLayouts/slideLayout1.xml", layoutXml); + WriteTextEntry(archive, "ppt/slideLayouts/_rels/slideLayout1.xml.rels", layoutRelsXml); + WriteTextEntry(archive, "ppt/slideMasters/slideMaster1.xml", masterXml); + WriteTextEntry(archive, "ppt/slideMasters/_rels/slideMaster1.xml.rels", masterRelsXml); + WriteTextEntry(archive, "ppt/theme/theme1.xml", themeXml); + + if (!linkInsteadOfEmbed) + { + WriteBinaryEntry(archive, $"ppt/media/image1.{mediaExtension}", mediaBytes); + } + } + + stream.Position = 0; + return (stream, "ppt/slides/slide1.xml"); + } + + private static void WriteTextEntry(ZipArchive archive, string name, string content) + { + var entry = archive.CreateEntry(name); + using var entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream, Encoding.UTF8); + writer.Write(content); + } + + private static void WriteBinaryEntry(ZipArchive archive, string name, byte[] content) + { + var entry = archive.CreateEntry(name); + using var entryStream = entry.Open(); + entryStream.Write(content, 0, content.Length); + } + + /// Builds a minimal, valid single-pixel PNG's encoded bytes, via the core . + private static byte[] BuildPngBytes(Rgba32 color) + { + using var surface = new Surface(1, 1); + surface[0, 0] = color; + using var buffer = new MemoryStream(); + PngCodec.Save(surface, buffer); + return buffer.ToArray(); + } + + /// Builds a <p:blipFill> element declaring <a:blip r:embed="rId2"/>. + private static XElement BuildBlipFillEmbed(string relationshipId = "rId2") => + new(PresentationNs + "blipFill", new XElement(A + "blip", new XAttribute(R + "embed", relationshipId))); + + /// Proves a well-formed embedded PNG reference decodes successfully into a with the expected pixel content. + [Fact] + public void ResolvePictureSurface_EmbeddedPng_DecodesSurfaceWithExpectedPixel() + { + var expectedColor = new Rgba32(10, 20, 30, 255); + var pngBytes = BuildPngBytes(expectedColor); + var (package, slidePartPath) = BuildMinimalImagePackage("png", "image/png", pngBytes); + using var stream = package; + using var document = PptxDocument.Open(stream); + + var surface = document.ResolvePictureSurface(slidePartPath, BuildBlipFillEmbed()); + + Assert.Equal(1, surface.Width); + Assert.Equal(1, surface.Height); + Assert.Equal(expectedColor, surface[0, 0]); + } + + /// Proves a <p:blipFill> with no <a:blip> child throws . + [Fact] + public void ResolvePictureSurface_NoBlipElement_ThrowsInvalidDataException() + { + var (package, slidePartPath) = BuildMinimalImagePackage("png", "image/png", BuildPngBytes(default)); + using var stream = package; + using var document = PptxDocument.Open(stream); + + var blipFill = new XElement(PresentationNs + "blipFill"); + + Assert.Throws(() => document.ResolvePictureSurface(slidePartPath, blipFill)); + } + + /// Proves an <a:blip> with neither r:embed nor r:link throws . + [Fact] + public void ResolvePictureSurface_BlipMissingEmbedAndLink_ThrowsInvalidDataException() + { + var (package, slidePartPath) = BuildMinimalImagePackage("png", "image/png", BuildPngBytes(default)); + using var stream = package; + using var document = PptxDocument.Open(stream); + + var blipFill = new XElement(PresentationNs + "blipFill", new XElement(A + "blip")); + + Assert.Throws(() => document.ResolvePictureSurface(slidePartPath, blipFill)); + } + + /// Proves a linked-only (r:link, no r:embed) picture throws with feature token "pptx-image-link". + [Fact] + public void ResolvePictureSurface_LinkedBlipOnly_ThrowsPptxUnsupportedFeatureExceptionWithImageLinkToken() + { + var (package, slidePartPath) = BuildMinimalImagePackage("png", "image/png", [], linkInsteadOfEmbed: true); + using var stream = package; + using var document = PptxDocument.Open(stream); + + var blipFill = new XElement(PresentationNs + "blipFill", new XElement(A + "blip", new XAttribute(R + "link", "rId2"))); + + var ex = Assert.Throws(() => document.ResolvePictureSurface(slidePartPath, blipFill)); + Assert.Equal("pptx-image-link", ex.Feature); + } + + /// + /// Proves an <a:blip> declaring neither r:embed nor r:link, but + /// only a Microsoft SVG extension (<a:extLst>/<a:ext uri="{96DAC541-7B7A-43D3- + /// 8B79-37D633B846F1}"><asvg:svgBlip .../></a:ext>) fallback, throws + /// with feature token + /// "pptx-image-svg-only" rather than . + /// + [Fact] + public void ResolvePictureSurface_SvgOnlyBlipExtension_ThrowsPptxUnsupportedFeatureExceptionWithSvgOnlyToken() + { + var (package, slidePartPath) = BuildMinimalImagePackage("png", "image/png", BuildPngBytes(default)); + using var stream = package; + using var document = PptxDocument.Open(stream); + + XNamespace asvg = "http://schemas.microsoft.com/office/drawing/2016/SVG/main"; + var blipFill = new XElement( + PresentationNs + "blipFill", + new XElement( + A + "blip", + new XElement( + A + "extLst", + new XElement( + A + "ext", + new XAttribute("uri", "{96DAC541-7B7A-43D3-8B79-37D633B846F1}"), + new XElement(asvg + "svgBlip", new XAttribute(R + "embed", "rId75")))))); + + var ex = Assert.Throws(() => document.ResolvePictureSurface(slidePartPath, blipFill)); + Assert.Equal("pptx-image-svg-only", ex.Feature); + } + + /// Proves an unrecognized media content type (for example an EMF vector picture) throws with feature token "pptx-image-format". + [Fact] + public void ResolvePictureSurface_UnsupportedContentType_ThrowsPptxUnsupportedFeatureExceptionWithImageFormatToken() + { + var (package, slidePartPath) = BuildMinimalImagePackage("emf", "image/x-emf", [1, 2, 3, 4]); + using var stream = package; + using var document = PptxDocument.Open(stream); + + var ex = Assert.Throws(() => document.ResolvePictureSurface(slidePartPath, BuildBlipFillEmbed())); + Assert.Equal("pptx-image-format", ex.Feature); + } + + /// Proves rejects a null argument. + [Fact] + public void ResolvePictureSurface_NullArguments_ThrowsArgumentNullException() + { + var (package, slidePartPath) = BuildMinimalImagePackage("png", "image/png", BuildPngBytes(default)); + using var stream = package; + using var document = PptxDocument.Open(stream); + + Assert.Throws(() => document.ResolvePictureSurface(null!, BuildBlipFillEmbed())); + Assert.Throws(() => document.ResolvePictureSurface(slidePartPath, null!)); + } +} diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxPaintTests.cs b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxPaintTests.cs new file mode 100644 index 00000000..ab4c15a5 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxPaintTests.cs @@ -0,0 +1,1593 @@ +using System.Numerics; +using System.Xml.Linq; +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Drawing; +using Path = DemaConsulting.CanvasNet.Geometry.Path; + +namespace DemaConsulting.CanvasNet.Pptx.Tests; + +// cspell:ignore srgbclr schemeclr lummod lumoff pptx gsLst prstDash lgDash sysDash sysDot dashDot hlink folhlink Srgb patt + +/// +/// Unit-level tests for the Phase 1c DrawingML paint resolvers (, +/// , , +/// , ). +/// Every resolver under test is a plain static method operating on directly-constructed +/// fragments and a directly-constructed , so +/// these tests do not need a full in-memory .pptx package (reserved for +/// 's end-to-end scenarios). +/// +public class PptxPaintTests +{ + private static readonly XNamespace A = "http://schemas.openxmlformats.org/drawingml/2006/main"; + private static readonly XNamespace P = "http://schemas.openxmlformats.org/presentationml/2006/main"; + private static readonly XNamespace R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + + /// Builds an arbitrary, fully-populated test , with each color-scheme slot a distinct, recognizable value. + private static PptxTheme BuildTestTheme( + IReadOnlyList? fillStyleList = null, IReadOnlyList? lnStyleList = null) => + new( + new PptxColorScheme( + Dark1: new Rgba32(0x10, 0x10, 0x10, 255), + Light1: new Rgba32(0xF0, 0xF0, 0xF0, 255), + Dark2: new Rgba32(0x20, 0x20, 0x20, 255), + Light2: new Rgba32(0xE0, 0xE0, 0xE0, 255), + Accent1: new Rgba32(0x11, 0x22, 0x33, 255), + Accent2: new Rgba32(0x44, 0x55, 0x66, 255), + Accent3: new Rgba32(0x77, 0x88, 0x99, 255), + Accent4: new Rgba32(0xAA, 0xBB, 0xCC, 255), + Accent5: new Rgba32(0x01, 0x02, 0x03, 255), + Accent6: new Rgba32(0x04, 0x05, 0x06, 255), + Hyperlink: new Rgba32(0x07, 0x08, 0x09, 255), + FollowedHyperlink: new Rgba32(0x0A, 0x0B, 0x0C, 255)), + new PptxFontScheme( + new PptxFontCollection("MajorLatin", "MajorEA", "MajorCS"), + new PptxFontCollection("MinorLatin", "MinorEA", "MinorCS")), + FillStyleList: fillStyleList, + LnStyleList: lnStyleList); + + /// Srgb Clr. + private static XElement SrgbClr(string hex, params XElement[] transforms) => + new(A + "srgbClr", new XAttribute("val", hex), transforms); + + /// Scheme Clr. + private static XElement SchemeClr(string val, params XElement[] transforms) => + new(A + "schemeClr", new XAttribute("val", val), transforms); + + /// Prst Clr. + private static XElement PrstClr(string val, params XElement[] transforms) => + new(A + "prstClr", new XAttribute("val", val), transforms); + + /// Transform. + private static XElement Transform(string name, int val100000ths) => + new(A + name, new XAttribute("val", val100000ths)); + + // --- ResolveFill: fill-kind dispatch -------------------------------------------------------- + + /// Resolve Fill - Null Parent - Returns No Fill. + [Fact] + public void ResolveFill_NullParent_ReturnsNoFill() + { + var paint = PptxDocument.ResolveFill(null, BuildTestTheme(), 100, 100); + + Assert.Same(PptxNoFill.Instance, paint); + } + + /// Resolve Fill - Explicit No Fill - Returns No Fill. + [Fact] + public void ResolveFill_ExplicitNoFill_ReturnsNoFill() + { + var spPr = new XElement(A + "spPr", new XElement(A + "noFill")); + + var paint = PptxDocument.ResolveFill(spPr, BuildTestTheme(), 100, 100); + + Assert.Same(PptxNoFill.Instance, paint); + } + + /// Resolve Fill - No Recognized Fill Child - Returns No Fill. + [Fact] + public void ResolveFill_NoRecognizedFillChild_ReturnsNoFill() + { + var spPr = new XElement(A + "spPr"); + + var paint = PptxDocument.ResolveFill(spPr, BuildTestTheme(), 100, 100); + + Assert.Same(PptxNoFill.Instance, paint); + } + + /// Resolve Fill - Solid Fill Srgb Clr - Returns Resolved Solid Fill. + [Fact] + public void ResolveFill_SolidFillSrgbClr_ReturnsResolvedSolidFill() + { + var spPr = new XElement(A + "spPr", new XElement(A + "solidFill", SrgbClr("112233"))); + + var paint = PptxDocument.ResolveFill(spPr, BuildTestTheme(), 100, 100); + + var solid = Assert.IsType(paint); + Assert.Equal(new Rgba32(0x11, 0x22, 0x33, 255), solid.Color); + } + + /// Resolve Fill - Solid Fill Scheme Clr - Resolves Through Theme. + [Fact] + public void ResolveFill_SolidFillSchemeClr_ResolvesThroughTheme() + { + var theme = BuildTestTheme(); + var spPr = new XElement(A + "spPr", new XElement(A + "solidFill", SchemeClr("accent1"))); + + var paint = PptxDocument.ResolveFill(spPr, theme, 100, 100); + + var solid = Assert.IsType(paint); + Assert.Equal(theme.ColorScheme.Accent1, solid.Color); + } + + /// Resolve Fill - Grad Fill Linear - Returns Resolved Gradient Fill. + [Fact] + public void ResolveFill_GradFillLinear_ReturnsResolvedGradientFill() + { + var gradFill = new XElement( + A + "gradFill", + new XElement( + A + "gsLst", + new XElement(A + "gs", new XAttribute("pos", 0), SrgbClr("FF0000")), + new XElement(A + "gs", new XAttribute("pos", 100000), SrgbClr("0000FF"))), + new XElement(A + "lin", new XAttribute("ang", 0))); + var spPr = new XElement(A + "spPr", gradFill); + + var paint = PptxDocument.ResolveFill(spPr, BuildTestTheme(), 200, 100); + + var gradient = Assert.IsType(paint); + var linear = Assert.IsType(gradient.Gradient); + Assert.Equal(2, linear.Stops.Count); + } + + /// Resolve Fill - Pattern Fill - Throws Pptx Unsupported Feature Exception. + /// + /// Resolve Fill - Bare Pattern Fill With No Prst Attribute - Returns No Fill. + /// + /// + /// A non-conformant, attribute-less <a:pattFill/> (no prst attribute) + /// - observed in the project's own real pythonpptx-dml-fill.pptx fixture - is + /// treated as "no override", not a named preset; see 's + /// own remarks. + /// + [Fact] + public void ResolveFill_BarePatternFillWithNoPrstAttribute_ReturnsNoFill() + { + var spPr = new XElement(A + "spPr", new XElement(A + "pattFill")); + + var paint = PptxDocument.ResolveFill(spPr, BuildTestTheme(), 100, 100); + + Assert.Same(PptxNoFill.Instance, paint); + } + + /// Resolve Fill - Pattern Fill Unsupported Preset - Throws Pptx Unsupported Feature Exception. + [Fact] + public void ResolveFill_PatternFillUnsupportedPreset_ThrowsPptxUnsupportedFeatureException() + { + var spPr = new XElement(A + "spPr", new XElement(A + "pattFill", new XAttribute("prst", "zigZag"))); + + var ex = Assert.Throws(() => PptxDocument.ResolveFill(spPr, BuildTestTheme(), 100, 100)); + Assert.Equal("pptx-pattern-fill", ex.Feature); + Assert.Contains("zigZag", ex.Message); + } + + /// Resolve Fill - Pattern Fill Covered Preset - Returns Resolved Pattern Fill. + [Fact] + public void ResolveFill_PatternFillCoveredPreset_ReturnsPptxPatternFill() + { + var pattFill = new XElement( + A + "pattFill", + new XAttribute("prst", "divot"), + new XElement(A + "fgClr", SrgbClr("2CB731")), + new XElement(A + "bgClr", PrstClr("white"))); + var spPr = new XElement(A + "spPr", pattFill); + + var paint = PptxDocument.ResolveFill(spPr, BuildTestTheme(), 100, 100); + + var pattern = Assert.IsType(paint); + Assert.Equal(PptxPresetPattern.Divot, pattern.Preset); + Assert.Equal(new Rgba32(0x2C, 0xB7, 0x31, 255), pattern.Foreground); + Assert.Equal(new Rgba32(255, 255, 255, 255), pattern.Background); + } + + /// Resolve Fill - Pattern Fill Missing Fg Bg Clr - Defaults To Black On White. + [Fact] + public void ResolveFill_PatternFillMissingFgBgClr_DefaultsToBlackOnWhite() + { + var spPr = new XElement(A + "spPr", new XElement(A + "pattFill", new XAttribute("prst", "horz"))); + + var paint = PptxDocument.ResolveFill(spPr, BuildTestTheme(), 100, 100); + + var pattern = Assert.IsType(paint); + Assert.Equal(PptxPresetPattern.Horz, pattern.Preset); + Assert.Equal(new Rgba32(0, 0, 0, 255), pattern.Foreground); + Assert.Equal(new Rgba32(255, 255, 255, 255), pattern.Background); + } + + /// Resolve Fill - Picture Fill - Throws Pptx Unsupported Feature Exception. + [Fact] + public void ResolveFill_PictureFill_ThrowsPptxUnsupportedFeatureException() + { + var spPr = new XElement(A + "spPr", new XElement(A + "blipFill")); + + var ex = Assert.Throws(() => PptxDocument.ResolveFill(spPr, BuildTestTheme(), 100, 100)); + Assert.Equal("pptx-picture-fill", ex.Feature); + } + + /// + /// Proves that, once a resolveBlipImage-shaped resolver is supplied (see + /// 's own remarks), a minimal + /// <a:blipFill><a:blip r:embed="rId1"/><a:stretch/></a:blipFill> + /// no longer throws and instead resolves to a wrapping the + /// resolver's own decoded - the regression this whole feature exists + /// to fix (see + /// for the still-unchanged "no resolver supplied" fallback behavior). + /// + [Fact] + public void ResolveFill_BlipFillWithResolver_ReturnsPptxImageFill() + { + using var image = new Surface(4, 4); + var blip = new XElement(A + "blip", new XAttribute(R + "embed", "rId1")); + var spPr = new XElement( + A + "spPr", + new XElement(A + "blipFill", blip, new XElement(A + "stretch"))); + + var paint = PptxDocument.ResolveFill( + spPr, BuildTestTheme(), 100, 100, resolveBlipImage: resolvedBlip => + { + Assert.Same(blip, resolvedBlip.Element(A + "blip")); + return image; + }); + + var imageFill = Assert.IsType(paint); + Assert.Same(image, imageFill.Image); + } + + // --- ResolveImageFillTransform: / transform derivations ------------------ + + /// + /// Pins down 's own <a:tile> + /// derivation with a non-trivial (non-default, non-100%) tx/ty/sx/sy + /// combination - the mandatory case for this package's own pythonpptx-dml-fill.pptx + /// fixture (see the design document) - independent of that real fixture's own trivial + /// (tx="0" ty="0" sx="100000" sy="100000", i.e. unscaled/untranslated) values. + /// + [Fact] + public void ResolveImageFillTransform_TileWithNonTrivialOffsetAndScale_AppliesBothToEachAxis() + { + using var image = new Surface(10, 20); + var tile = new XElement( + A + "tile", + new XAttribute("tx", "914400"), // 1 inch + new XAttribute("ty", "457200"), // 0.5 inch + new XAttribute("sx", "50000"), // 50% + new XAttribute("sy", "200000")); // 200% + var blipFill = new XElement(A + "blipFill", tile); + + var transform = PptxDocument.ResolveImageFillTransform(blipFill, image, widthEmu: 1000, heightEmu: 1000); + + // 96-DPI EMU-per-pixel is 9525; sx/sy scale that per-axis, tx/ty translate directly. + Assert.Equal(0.5f * 9525f, transform.M11, 2); + Assert.Equal(0f, transform.M12, 2); + Assert.Equal(0f, transform.M21, 2); + Assert.Equal(2f * 9525f, transform.M22, 2); + Assert.Equal(914400f, transform.M31, 2); + Assert.Equal(457200f, transform.M32, 2); + } + + /// + /// Proves a <a:stretch>'s nested <a:fillRect> insets crop the + /// image before the remaining span is stretched to fill the owning shape, by pinning the + /// exact resulting scale/translation for a non-trivial (non-zero, non-degenerate) set of + /// edges. + /// + [Fact] + public void ResolveImageFillTransform_StretchWithFillRectInsets_CropsBeforeStretching() + { + using var image = new Surface(100, 50); + var fillRect = new XElement( + A + "fillRect", new XAttribute("l", "10000"), new XAttribute("r", "10000")); // 10% each side + var blipFill = new XElement(A + "blipFill", new XElement(A + "stretch", fillRect)); + + var transform = PptxDocument.ResolveImageFillTransform(blipFill, image, widthEmu: 200, heightEmu: 100); + + // hSpan = 1 - 0.1 - 0.1 = 0.8; stretchScaleX = 200 / (0.8 * 100) = 2.5; translateX = -0.1*200/0.8 = -25. + Assert.Equal(2.5f, transform.M11, 3); + Assert.Equal(-25f, transform.M31, 3); + // No top/bottom inset: vSpan = 1, stretchScaleY = 100 / (1 * 50) = 2; translateY = 0. + Assert.Equal(2f, transform.M22, 3); + Assert.Equal(0f, transform.M32, 3); + } + + /// + /// Proves a degenerate <a:fillRect> (left+right edges summing to 100% + /// or more) falls back to an uncropped full stretch on that axis, rather than producing a + /// non-finite or divide-by-zero transform - see 's + /// own remarks. + /// + [Fact] + public void ResolveImageFillTransform_DegenerateFillRect_FallsBackToFullStretch() + { + using var image = new Surface(100, 50); + var fillRect = new XElement( + A + "fillRect", new XAttribute("l", "60000"), new XAttribute("r", "50000")); // sums to 110% + var blipFill = new XElement(A + "blipFill", new XElement(A + "stretch", fillRect)); + + var transform = PptxDocument.ResolveImageFillTransform(blipFill, image, widthEmu: 200, heightEmu: 100); + + // Falls back to left=0, hSpan=1: stretchScaleX = 200 / (1 * 100) = 2; translateX = 0. + Assert.Equal(2f, transform.M11, 3); + Assert.Equal(0f, transform.M31, 3); + Assert.True(float.IsFinite(transform.M11)); + Assert.True(float.IsFinite(transform.M31)); + } + + // --- ResolveColor: base color kinds ---------------------------------------------------------- + + /// Resolve Color - Srgb Clr - Parses Hex Directly. + [Fact] + public void ResolveColor_SrgbClr_ParsesHexDirectly() + { + var color = PptxDocument.ResolveColor(SrgbClr("ABCDEF"), BuildTestTheme()); + + Assert.Equal(new Rgba32(0xAB, 0xCD, 0xEF, 255), color); + } + + /// Resolve Color - Sys Clr - Uses Last Clr Attribute. + [Fact] + public void ResolveColor_SysClr_UsesLastClrAttribute() + { + var element = new XElement(A + "sysClr", new XAttribute("val", "windowText"), new XAttribute("lastClr", "123456")); + + var color = PptxDocument.ResolveColor(element, BuildTestTheme()); + + Assert.Equal(new Rgba32(0x12, 0x34, 0x56, 255), color); + } + + /// Resolve Color - Scheme Clr Ordinary Slots - Resolve To Matching Theme Slot. + [Theory] + [InlineData("dk1")] + [InlineData("lt1")] + [InlineData("dk2")] + [InlineData("lt2")] + [InlineData("accent1")] + [InlineData("accent2")] + [InlineData("accent3")] + [InlineData("accent4")] + [InlineData("accent5")] + [InlineData("accent6")] + [InlineData("hlink")] + [InlineData("folHlink")] + public void ResolveColor_SchemeClrOrdinarySlots_ResolveToMatchingThemeSlot(string slot) + { + var theme = BuildTestTheme(); + var expected = slot switch + { + "dk1" => theme.ColorScheme.Dark1, + "lt1" => theme.ColorScheme.Light1, + "dk2" => theme.ColorScheme.Dark2, + "lt2" => theme.ColorScheme.Light2, + "accent1" => theme.ColorScheme.Accent1, + "accent2" => theme.ColorScheme.Accent2, + "accent3" => theme.ColorScheme.Accent3, + "accent4" => theme.ColorScheme.Accent4, + "accent5" => theme.ColorScheme.Accent5, + "accent6" => theme.ColorScheme.Accent6, + "hlink" => theme.ColorScheme.Hyperlink, + _ => theme.ColorScheme.FollowedHyperlink, + }; + + var color = PptxDocument.ResolveColor(SchemeClr(slot), theme); + + Assert.Equal(expected, color); + } + + /// Resolve Color - Scheme Clr Background Text Aliases - Map To Expected Slot. + [Theory] + [InlineData("bg1")] + [InlineData("tx1")] + [InlineData("bg2")] + [InlineData("tx2")] + public void ResolveColor_SchemeClrBackgroundTextAliases_MapToExpectedSlot(string alias) + { + var theme = BuildTestTheme(); + var expected = alias switch + { + "bg1" => theme.ColorScheme.Light1, + "tx1" => theme.ColorScheme.Dark1, + "bg2" => theme.ColorScheme.Light2, + _ => theme.ColorScheme.Dark2, + }; + + var color = PptxDocument.ResolveColor(SchemeClr(alias), theme); + + Assert.Equal(expected, color); + } + + /// Resolve Color - Scheme Clr Unrecognized Slot - Throws Invalid Data Exception. + [Fact] + public void ResolveColor_SchemeClrUnrecognizedSlot_ThrowsInvalidDataException() + { + Assert.Throws(() => PptxDocument.ResolveColor(SchemeClr("notARealSlot"), BuildTestTheme())); + } + + // --- ResolveSchemeColor / ResolveColor: / indirection ---------------- + + /// Resolve Scheme Color - Default Color Map - Tx1 Resolves To Dark1. + [Fact] + public void ResolveSchemeColor_DefaultColorMap_Tx1ResolvesToDark1() + { + var theme = BuildTestTheme(); + + var color = PptxDocument.ResolveSchemeColor("tx1", theme.ColorScheme, PptxColorMap.Default); + + Assert.Equal(theme.ColorScheme.Dark1, color); + } + + /// Resolve Scheme Color - Non Identity Color Map - Tx1 Resolves To Overridden Slot. + [Fact] + public void ResolveSchemeColor_NonIdentityColorMap_Tx1ResolvesToOverriddenSlot() + { + var theme = BuildTestTheme(); + var colorMap = new PptxColorMap(Bg1: "dk1", Tx1: "lt1", Bg2: "dk2", Tx2: "lt2"); + + var color = PptxDocument.ResolveSchemeColor("tx1", theme.ColorScheme, colorMap); + + Assert.Equal(theme.ColorScheme.Light1, color); + } + + /// Resolve Color - Scheme Clr With Non Identity Color Map - Applies Indirection Before Transforms. + [Fact] + public void ResolveColor_SchemeClrWithNonIdentityColorMap_AppliesIndirectionBeforeTransforms() + { + var theme = BuildTestTheme(); + var colorMap = new PptxColorMap(Bg1: "dk1", Tx1: "lt1", Bg2: "dk2", Tx2: "lt2"); + + var color = PptxDocument.ResolveColor(SchemeClr("tx1", Transform("shade", 50000)), theme, colorMap: colorMap); + + var expectedBase = theme.ColorScheme.Light1; + var expectedShaded = PptxDocument.ResolveColor(SrgbClr($"{expectedBase.R:X2}{expectedBase.G:X2}{expectedBase.B:X2}", Transform("shade", 50000)), theme); + + Assert.Equal(expectedShaded, color); + } + + /// Resolve Color - Unsupported Color Kind - Throws Pptx Unsupported Feature Exception. + [Fact] + public void ResolveColor_UnsupportedColorKind_ThrowsPptxUnsupportedFeatureException() + { + var element = new XElement(A + "hslClr", new XAttribute("hue", 0), new XAttribute("sat", "0%"), new XAttribute("lum", "0%")); + + var ex = Assert.Throws(() => PptxDocument.ResolveColor(element, BuildTestTheme())); + Assert.Equal("pptx-color-kind", ex.Feature); + } + + /// Resolve Color - Prst Clr White - Returns White. + [Fact] + public void ResolveColor_PrstClrWhite_ReturnsWhite() + { + var color = PptxDocument.ResolveColor(PrstClr("white"), BuildTestTheme()); + + Assert.Equal(new Rgba32(255, 255, 255, 255), color); + } + + /// Resolve Color - Prst Clr Black - Returns Black. + [Fact] + public void ResolveColor_PrstClrBlack_ReturnsBlack() + { + var color = PptxDocument.ResolveColor(PrstClr("black"), BuildTestTheme()); + + Assert.Equal(new Rgba32(0, 0, 0, 255), color); + } + + /// Resolve Color - Prst Clr Unsupported Name - Throws Pptx Unsupported Feature Exception. + [Fact] + public void ResolveColor_PrstClrUnsupportedName_ThrowsPptxUnsupportedFeatureException() + { + var ex = Assert.Throws(() => PptxDocument.ResolveColor(PrstClr("aliceBlue"), BuildTestTheme())); + Assert.Equal("pptx-color-kind", ex.Feature); + Assert.Contains("aliceBlue", ex.Message); + } + + /// Resolve Color - Prst Clr Missing Val Attribute - Throws Invalid Data Exception. + [Fact] + public void ResolveColor_PrstClrMissingValAttribute_ThrowsInvalidDataException() + { + var element = new XElement(A + "prstClr"); + + Assert.Throws(() => PptxDocument.ResolveColor(element, BuildTestTheme())); + } + + /// + /// Resolve Color - Srgb Clr Eight Digit Value - Throws Invalid Data Exception (rather than + /// silently accepting an AARRGGBB value and misinterpreting its leading byte as + /// alpha, since OOXML's srgbClr/@val is always exactly six hex digits). + /// + [Fact] + public void ResolveColor_SrgbClrEightDigitValue_ThrowsInvalidDataException() + { + Assert.Throws(() => PptxDocument.ResolveColor(SrgbClr("80AABBCC"), BuildTestTheme())); + } + + /// Resolve Color - Sys Clr Eight Digit Last Clr Value - Throws Invalid Data Exception. + [Fact] + public void ResolveColor_SysClrEightDigitLastClrValue_ThrowsInvalidDataException() + { + var element = new XElement(A + "sysClr", new XAttribute("val", "windowText"), new XAttribute("lastClr", "80123456")); + + Assert.Throws(() => PptxDocument.ResolveColor(element, BuildTestTheme())); + } + + // --- ResolveColor: color-transform chain ----------------------------------------------------- + + /// Resolve Color - Alpha - Sets Alpha Channel. + [Fact] + public void ResolveColor_Alpha_SetsAlphaChannel() + { + var color = PptxDocument.ResolveColor(SrgbClr("112233", Transform("alpha", 50000)), BuildTestTheme()); + + Assert.Equal(0x11, color.R); + Assert.Equal(0x22, color.G); + Assert.Equal(0x33, color.B); + Assert.Equal((byte)127, color.A); + } + + /// Resolve Color - Shade - Darkens Proportionally. + [Fact] + public void ResolveColor_Shade_DarkensProportionally() + { + var color = PptxDocument.ResolveColor(SrgbClr("FF0000", Transform("shade", 50000)), BuildTestTheme()); + + Assert.Equal((byte)127, color.R); + Assert.Equal((byte)0, color.G); + Assert.Equal((byte)0, color.B); + } + + /// Resolve Color - Tint - Lightens Proportionally. + [Fact] + public void ResolveColor_Tint_LightensProportionally() + { + var color = PptxDocument.ResolveColor(SrgbClr("000000", Transform("tint", 50000)), BuildTestTheme()); + + // c' = c*0.5 + 255*0.5 = 127.5 (rounds within clamp) + Assert.InRange(color.R, (byte)127, (byte)128); + Assert.InRange(color.G, (byte)127, (byte)128); + Assert.InRange(color.B, (byte)127, (byte)128); + } + + /// Resolve Color - Lum Mod - Decreases Luminance. + [Fact] + public void ResolveColor_LumMod_DecreasesLuminance() + { + var baseColor = PptxDocument.ResolveColor(SrgbClr("808080"), BuildTestTheme()); + var color = PptxDocument.ResolveColor(SrgbClr("808080", Transform("lumMod", 50000)), BuildTestTheme()); + + Assert.True(color.R < baseColor.R, $"Expected darker luminance, base={baseColor.R}, result={color.R}"); + } + + /// Resolve Color - Lum Off - Increases Luminance. + [Fact] + public void ResolveColor_LumOff_IncreasesLuminance() + { + var baseColor = PptxDocument.ResolveColor(SrgbClr("202020"), BuildTestTheme()); + var color = PptxDocument.ResolveColor(SrgbClr("202020", Transform("lumOff", 50000)), BuildTestTheme()); + + Assert.True(color.R > baseColor.R, $"Expected brighter luminance, base={baseColor.R}, result={color.R}"); + } + + /// Resolve Color - Combined Lum Mod Lum Off Shade Tint Alpha - Applies Fixed Pipeline Order. + [Fact] + public void ResolveColor_CombinedLumModLumOffShadeTintAlpha_AppliesFixedPipelineOrder() + { + // Mainly proves this does not throw and alpha is applied last/exactly, regardless of the + // combined effect of the earlier RGB-affecting transforms. + var color = PptxDocument.ResolveColor( + SrgbClr("336699", Transform("lumMod", 80000), Transform("lumOff", 10000), Transform("shade", 90000), Transform("tint", 90000), Transform("alpha", 60000)), + BuildTestTheme()); + + Assert.Equal((byte)153, color.A); + } + + // --- ResolveGradientFill: direction/stop resolution ------------------------------------------ + + /// Resolve Gradient Fill - Linear Zero Degrees - Points Along Positive X. + [Fact] + public void ResolveGradientFill_LinearZeroDegrees_PointsAlongPositiveX() + { + var gradFill = new XElement( + A + "gradFill", + new XElement( + A + "gsLst", + new XElement(A + "gs", new XAttribute("pos", 0), SrgbClr("FF0000")), + new XElement(A + "gs", new XAttribute("pos", 100000), SrgbClr("0000FF"))), + new XElement(A + "lin", new XAttribute("ang", 0))); + + var result = PptxDocument.ResolveGradientFill(gradFill, BuildTestTheme(), 200, 100); + + var linear = Assert.IsType(result.Gradient); + Assert.True(linear.End.X > linear.Start.X, "Expected the gradient to run left-to-right at 0 degrees."); + Assert.InRange(MathF.Abs(linear.End.Y - linear.Start.Y), 0f, 0.01f); + } + + /// Resolve Gradient Fill - Missing Lin - Throws Pptx Unsupported Feature Exception. + [Fact] + public void ResolveGradientFill_MissingLin_ThrowsPptxUnsupportedFeatureException() + { + var gradFill = new XElement( + A + "gradFill", + new XElement(A + "gsLst", new XElement(A + "gs", new XAttribute("pos", 0), SrgbClr("FF0000"))), + new XElement(A + "path")); + + var ex = Assert.Throws( + () => PptxDocument.ResolveGradientFill(gradFill, BuildTestTheme(), 200, 100)); + Assert.Equal("pptx-gradient-path", ex.Feature); + } + + /// Resolve Gradient Fill - Missing Gs Lst - Throws Invalid Data Exception. + [Fact] + public void ResolveGradientFill_MissingGsLst_ThrowsInvalidDataException() + { + var gradFill = new XElement(A + "gradFill", new XElement(A + "lin", new XAttribute("ang", 0))); + + Assert.Throws(() => PptxDocument.ResolveGradientFill(gradFill, BuildTestTheme(), 200, 100)); + } + + /// + /// Resolve Gradient Fill - Non Numeric Gs Pos - Throws Invalid Data Exception (not the + /// raw conversion-failure exception an explicit (float?) cast would otherwise let + /// escape). + /// + [Fact] + public void ResolveGradientFill_NonNumericGsPos_ThrowsInvalidDataException() + { + var gradFill = new XElement( + A + "gradFill", + new XElement(A + "gsLst", new XElement(A + "gs", new XAttribute("pos", "not-a-number"), SrgbClr("FF0000"))), + new XElement(A + "lin", new XAttribute("ang", 0))); + + Assert.Throws(() => PptxDocument.ResolveGradientFill(gradFill, BuildTestTheme(), 200, 100)); + } + + /// + /// Resolve Gradient Fill - Non Finite Gs Pos - Throws Invalid Data Exception (a + /// NaN/Infinity/-Infinity pos value survives + /// + /// but must still be rejected, since + /// would otherwise propagate it unchanged). + /// + [Theory] + [InlineData("NaN")] + [InlineData("Infinity")] + [InlineData("-Infinity")] + public void ResolveGradientFill_NonFiniteGsPos_ThrowsInvalidDataException(string nonFiniteValue) + { + var gradFill = new XElement( + A + "gradFill", + new XElement(A + "gsLst", new XElement(A + "gs", new XAttribute("pos", nonFiniteValue), SrgbClr("FF0000"))), + new XElement(A + "lin", new XAttribute("ang", 0))); + + Assert.Throws(() => PptxDocument.ResolveGradientFill(gradFill, BuildTestTheme(), 200, 100)); + } + + /// + /// Proves an <a:lin>'s non-numeric ang attribute is rejected with + /// rather than letting the explicit (int?) + /// cast's raw propagate uncaught. + /// + [Fact] + public void ResolveGradientFill_NonNumericLinAng_ThrowsInvalidDataException() + { + var gradFill = new XElement( + A + "gradFill", + new XElement(A + "gsLst", new XElement(A + "gs", new XAttribute("pos", 0), SrgbClr("FF0000"))), + new XElement(A + "lin", new XAttribute("ang", "not-a-number"))); + + Assert.Throws(() => PptxDocument.ResolveGradientFill(gradFill, BuildTestTheme(), 200, 100)); + } + + /// + /// Proves an <a:lin>'s overflowing (out-of--range) + /// ang attribute is rejected with rather than + /// letting the explicit (int?) cast's raw + /// propagate uncaught. + /// + [Fact] + public void ResolveGradientFill_OverflowingLinAng_ThrowsInvalidDataException() + { + var gradFill = new XElement( + A + "gradFill", + new XElement(A + "gsLst", new XElement(A + "gs", new XAttribute("pos", 0), SrgbClr("FF0000"))), + new XElement(A + "lin", new XAttribute("ang", "99999999999"))); + + Assert.Throws(() => PptxDocument.ResolveGradientFill(gradFill, BuildTestTheme(), 200, 100)); + } + + // --- ResolveLineStyle -------------------------------------------------------------------------- + + /// Resolve Line Style - Null Element - Returns Null. + [Fact] + public void ResolveLineStyle_NullElement_ReturnsNull() + { + var lineStyle = PptxDocument.ResolveLineStyle(null, BuildTestTheme()); + + Assert.Null(lineStyle); + } + + /// Resolve Line Style - Zero Width - Returns Null. + [Fact] + public void ResolveLineStyle_ZeroWidth_ReturnsNull() + { + var ln = new XElement(A + "ln", new XAttribute("w", 0), new XElement(A + "solidFill", SrgbClr("112233"))); + + Assert.Null(PptxDocument.ResolveLineStyle(ln, BuildTestTheme())); + } + + /// Resolve Line Style - Explicit Zero Width - Returns Null (regression guard). + [Fact] + public void ResolveLineStyle_ExplicitZeroWidth_ReturnsNull() + { + var ln = new XElement(A + "ln", new XAttribute("w", 0), new XElement(A + "solidFill", SrgbClr("FF0000"))); + + Assert.Null(PptxDocument.ResolveLineStyle(ln, BuildTestTheme())); + } + + /// Resolve Line Style - Explicit Negative Width - Returns Null (regression guard). + [Fact] + public void ResolveLineStyle_ExplicitNegativeWidth_ReturnsNull() + { + var ln = new XElement(A + "ln", new XAttribute("w", -100), new XElement(A + "solidFill", SrgbClr("FF0000"))); + + Assert.Null(PptxDocument.ResolveLineStyle(ln, BuildTestTheme())); + } + + /// + /// Resolve Line Style - Missing Width Attribute - Resolves Default Width. A genuinely + /// absent w attribute (as opposed to an explicit w="0") must resolve to + /// PowerPoint's own observed default stroke width (9525 EMU / 0.75pt) rather than "no + /// stroke", matching real-world documents whose <a:ln> declares only a color. + /// + [Fact] + public void ResolveLineStyle_MissingWidthAttribute_ResolvesDefaultWidth() + { + var ln = new XElement(A + "ln", new XElement(A + "solidFill", SrgbClr("FF0000"))); + + var lineStyle = PptxDocument.ResolveLineStyle(ln, BuildTestTheme()); + + Assert.NotNull(lineStyle); + Assert.Equal(9525f, lineStyle.WidthEmu); + var solid = Assert.IsType(lineStyle.Paint); + Assert.Equal(new Rgba32(0xFF, 0x00, 0x00, 255), solid.Color); + } + + /// Resolve Line Style - No Fill Line - Returns Null. + [Fact] + public void ResolveLineStyle_NoFillLine_ReturnsNull() + { + var ln = new XElement(A + "ln", new XAttribute("w", 12700), new XElement(A + "noFill")); + + Assert.Null(PptxDocument.ResolveLineStyle(ln, BuildTestTheme())); + } + + /// Resolve Line Style - Width And Solid Fill - Resolves Width And Color. + [Fact] + public void ResolveLineStyle_WidthAndSolidFill_ResolvesWidthAndColor() + { + var ln = new XElement(A + "ln", new XAttribute("w", 25400), new XElement(A + "solidFill", SrgbClr("00FF00"))); + + var lineStyle = PptxDocument.ResolveLineStyle(ln, BuildTestTheme()); + + Assert.NotNull(lineStyle); + Assert.Equal(25400f, lineStyle.WidthEmu); + var solid = Assert.IsType(lineStyle.Paint); + Assert.Equal(new Rgba32(0x00, 0xFF, 0x00, 255), solid.Color); + Assert.Null(lineStyle.DashArray); + } + + /// + /// Resolve Line Style - Pattern Fill Covered Preset - Returns Line Style With Pptx Pattern + /// Fill. Proves resolves an + /// <a:ln><a:pattFill>...</a:pattFill></a:ln> (a line/stroke + /// fill, as legitimately permitted by ECMA-376's CT_LineProperties) to a + /// wrapping a with the expected + /// preset/colors - the same shared resolver used for + /// shape fills (see ) + /// is reached for a line's own fill-definition child too, so pattern fill is equally + /// resolvable (and, after the companion render-level transform-composition fix, equally + /// renderable) in a line/stroke context - it is not "not applicable" there. + /// + [Fact] + public void ResolveLineStyle_PatternFillCoveredPreset_ReturnsLineStyleWithPptxPatternFill() + { + var pattFill = new XElement( + A + "pattFill", + new XAttribute("prst", "horz"), + new XElement(A + "fgClr", SrgbClr("2CB731")), + new XElement(A + "bgClr", SrgbClr("C0504D"))); + var ln = new XElement(A + "ln", new XAttribute("w", 28575), pattFill); + + var lineStyle = PptxDocument.ResolveLineStyle(ln, BuildTestTheme()); + + Assert.NotNull(lineStyle); + Assert.Equal(28575f, lineStyle.WidthEmu); + var pattern = Assert.IsType(lineStyle.Paint); + Assert.Equal(PptxPresetPattern.Horz, pattern.Preset); + Assert.Equal(new Rgba32(0x2C, 0xB7, 0x31, 255), pattern.Foreground); + Assert.Equal(new Rgba32(0xC0, 0x50, 0x4D, 255), pattern.Background); + } + + /// Proves a non-numeric w attribute throws rather than letting a raw escape uncaught. + [Fact] + public void ResolveLineStyle_NonNumericWidth_ThrowsInvalidDataException() + { + var ln = new XElement(A + "ln", new XAttribute("w", "not-a-number"), new XElement(A + "solidFill", SrgbClr("FF0000"))); + + Assert.Throws(() => PptxDocument.ResolveLineStyle(ln, BuildTestTheme())); + } + + /// Proves a non-finite (NaN/Infinity/-Infinity) w attribute throws . + [Theory] + [InlineData("NaN")] + [InlineData("Infinity")] + [InlineData("-Infinity")] + public void ResolveLineStyle_NonFiniteWidth_ThrowsInvalidDataException(string nonFiniteValue) + { + var ln = new XElement(A + "ln", new XAttribute("w", nonFiniteValue), new XElement(A + "solidFill", SrgbClr("FF0000"))); + + Assert.Throws(() => PptxDocument.ResolveLineStyle(ln, BuildTestTheme())); + } + + /// Resolve Line Style - Dash Presets - Produce Non Null Dash Array. + [Theory] + [InlineData("dash")] + [InlineData("dashDot")] + [InlineData("dot")] + [InlineData("lgDash")] + [InlineData("lgDashDot")] + [InlineData("sysDash")] + [InlineData("sysDot")] + public void ResolveLineStyle_DashPresets_ProduceNonNullDashArray(string presetName) + { + var ln = new XElement( + A + "ln", + new XAttribute("w", 12700), + new XElement(A + "solidFill", SrgbClr("000000")), + new XElement(A + "prstDash", new XAttribute("val", presetName))); + + var lineStyle = PptxDocument.ResolveLineStyle(ln, BuildTestTheme()); + + Assert.NotNull(lineStyle); + Assert.NotNull(lineStyle.DashArray); + Assert.NotEmpty(lineStyle.DashArray); + } + + /// Resolve Line Style - Solid Preset - Produces Null Dash Array. + [Fact] + public void ResolveLineStyle_SolidPreset_ProducesNullDashArray() + { + var ln = new XElement( + A + "ln", + new XAttribute("w", 12700), + new XElement(A + "solidFill", SrgbClr("000000")), + new XElement(A + "prstDash", new XAttribute("val", "solid"))); + + var lineStyle = PptxDocument.ResolveLineStyle(ln, BuildTestTheme()); + + Assert.NotNull(lineStyle); + Assert.Null(lineStyle.DashArray); + } + + // --- ResolveShapeStyleFill: / shape-style reference (Phase 2 Follow-Up) --- + + /// Resolve Shape Style Fill - Null Style Element - Returns No Fill. + [Fact] + public void ResolveShapeStyleFill_NullStyleElement_ReturnsNoFill() + { + var paint = PptxDocument.ResolveShapeStyleFill(null, BuildTestTheme(), 100, 100); + + Assert.Same(PptxNoFill.Instance, paint); + } + + /// Resolve Shape Style Fill - No Fill Ref - Returns No Fill. + [Fact] + public void ResolveShapeStyleFill_NoFillRef_ReturnsNoFill() + { + var style = new XElement(P + "style"); + + var paint = PptxDocument.ResolveShapeStyleFill(style, BuildTestTheme(), 100, 100); + + Assert.Same(PptxNoFill.Instance, paint); + } + + /// Resolve Shape Style Fill - Idx Zero - Returns No Fill. + [Fact] + public void ResolveShapeStyleFill_IdxZero_ReturnsNoFill() + { + var style = new XElement(P + "style", new XElement(A + "fillRef", new XAttribute("idx", "0"), SrgbClr("FF00FF"))); + + var paint = PptxDocument.ResolveShapeStyleFill(style, BuildTestTheme(), 100, 100); + + Assert.Same(PptxNoFill.Instance, paint); + } + + /// + /// Pins the no-offset behavior directly: idx="1" must resolve + /// 's entry 0 (not entry 1, and not a + /// <p:bgRef>-style 1000-offset entry). + /// + [Fact] + public void ResolveShapeStyleFill_FillRefIdxOne_ResolvesFillStyleListEntryZeroNoOffset() + { + var fillStyleList = new List + { + new(A + "solidFill", SrgbClr("111111")), + new(A + "solidFill", SrgbClr("222222")), + new(A + "solidFill", SrgbClr("333333")), + }; + var theme = BuildTestTheme(fillStyleList: fillStyleList); + var style = new XElement(P + "style", new XElement(A + "fillRef", new XAttribute("idx", "1"))); + + var paint = PptxDocument.ResolveShapeStyleFill(style, theme, 100, 100); + + var solid = Assert.IsType(paint); + Assert.Equal(new Rgba32(0x11, 0x11, 0x11, 255), solid.Color); + } + + /// Resolve Shape Style Fill - Fill Ref With Scheme Clr Ph Clr - Substitutes Fill Ref Own Color. + [Fact] + public void ResolveShapeStyleFill_FillRefWithSchemeClrPhClr_SubstitutesFillRefOwnColor() + { + var fillStyleList = new List { new(A + "solidFill", SchemeClr("phClr")) }; + var theme = BuildTestTheme(fillStyleList: fillStyleList); + var style = new XElement(P + "style", new XElement(A + "fillRef", new XAttribute("idx", "1"), SrgbClr("AA00AA"))); + + var paint = PptxDocument.ResolveShapeStyleFill(style, theme, 100, 100); + + var solid = Assert.IsType(paint); + Assert.Equal(new Rgba32(0xAA, 0x00, 0xAA, 255), solid.Color); + } + + /// Resolve Shape Style Fill - Idx Out Of Range - Throws Invalid Data Exception. + [Theory] + [InlineData("4")] + [InlineData("1000")] + public void ResolveShapeStyleFill_IdxOutOfRange_ThrowsInvalidDataException(string idx) + { + var fillStyleList = new List + { + new(A + "solidFill", SrgbClr("111111")), + new(A + "solidFill", SrgbClr("222222")), + new(A + "solidFill", SrgbClr("333333")), + }; + var theme = BuildTestTheme(fillStyleList: fillStyleList); + var style = new XElement(P + "style", new XElement(A + "fillRef", new XAttribute("idx", idx))); + + Assert.Throws(() => PptxDocument.ResolveShapeStyleFill(style, theme, 100, 100)); + } + + /// Resolve Shape Style Fill - Idx Past End Of Empty Fill Style List - Throws Invalid Data Exception. + [Fact] + public void ResolveShapeStyleFill_IdxPastEndOfEmptyFillStyleList_ThrowsInvalidDataException() + { + var style = new XElement(P + "style", new XElement(A + "fillRef", new XAttribute("idx", "1"))); + + Assert.Throws(() => PptxDocument.ResolveShapeStyleFill(style, BuildTestTheme(), 100, 100)); + } + + /// Resolve Shape Style Fill - Fill Ref With No Idx Attribute - Throws Invalid Data Exception. + [Fact] + public void ResolveShapeStyleFill_FillRefWithNoIdxAttribute_ThrowsInvalidDataException() + { + var style = new XElement(P + "style", new XElement(A + "fillRef", SrgbClr("FF00FF"))); + + Assert.Throws(() => PptxDocument.ResolveShapeStyleFill(style, BuildTestTheme(), 100, 100)); + } + + // --- ResolveShapeStyleLineStyle: / shape-style reference (Phase 2 Follow-Up) + + /// Resolve Shape Style Line Style - Null Style Element - Returns Null. + [Fact] + public void ResolveShapeStyleLineStyle_NullStyleElement_ReturnsNull() + { + var lineStyle = PptxDocument.ResolveShapeStyleLineStyle(null, BuildTestTheme()); + + Assert.Null(lineStyle); + } + + /// Resolve Shape Style Line Style - No Ln Ref - Returns Null. + [Fact] + public void ResolveShapeStyleLineStyle_NoLnRef_ReturnsNull() + { + var style = new XElement(P + "style"); + + Assert.Null(PptxDocument.ResolveShapeStyleLineStyle(style, BuildTestTheme())); + } + + /// Resolve Shape Style Line Style - Idx Zero - Returns Null. + [Fact] + public void ResolveShapeStyleLineStyle_IdxZero_ReturnsNull() + { + var style = new XElement(P + "style", new XElement(A + "lnRef", new XAttribute("idx", "0"), SrgbClr("FF00FF"))); + + Assert.Null(PptxDocument.ResolveShapeStyleLineStyle(style, BuildTestTheme())); + } + + /// Resolve Shape Style Line Style - Ln Ref Idx One - Resolves Ln Style List Entry Zero No Offset. + [Fact] + public void ResolveShapeStyleLineStyle_LnRefIdxOne_ResolvesLnStyleListEntryZeroNoOffset() + { + var lnStyleList = new List + { + new(A + "ln", new XAttribute("w", 12700), new XElement(A + "solidFill", SrgbClr("111111"))), + new(A + "ln", new XAttribute("w", 25400), new XElement(A + "solidFill", SrgbClr("222222"))), + new(A + "ln", new XAttribute("w", 38100), new XElement(A + "solidFill", SrgbClr("333333"))), + }; + var theme = BuildTestTheme(lnStyleList: lnStyleList); + var style = new XElement(P + "style", new XElement(A + "lnRef", new XAttribute("idx", "1"))); + + var lineStyle = PptxDocument.ResolveShapeStyleLineStyle(style, theme); + + Assert.NotNull(lineStyle); + Assert.Equal(12700f, lineStyle.WidthEmu); + var solid = Assert.IsType(lineStyle.Paint); + Assert.Equal(new Rgba32(0x11, 0x11, 0x11, 255), solid.Color); + } + + /// Resolve Shape Style Line Style - Ln Ref With Scheme Clr Ph Clr - Substitutes Ln Ref Own Color. + [Fact] + public void ResolveShapeStyleLineStyle_LnRefWithSchemeClrPhClr_SubstitutesLnRefOwnColor() + { + var lnStyleList = new List + { + new(A + "ln", new XAttribute("w", 12700), new XElement(A + "solidFill", SchemeClr("phClr"))), + }; + var theme = BuildTestTheme(lnStyleList: lnStyleList); + var style = new XElement(P + "style", new XElement(A + "lnRef", new XAttribute("idx", "1"), SrgbClr("AA00AA"))); + + var lineStyle = PptxDocument.ResolveShapeStyleLineStyle(style, theme); + + Assert.NotNull(lineStyle); + var solid = Assert.IsType(lineStyle.Paint); + Assert.Equal(new Rgba32(0xAA, 0x00, 0xAA, 255), solid.Color); + } + + /// Resolve Shape Style Line Style - Idx Out Of Range - Throws Invalid Data Exception. + [Fact] + public void ResolveShapeStyleLineStyle_IdxOutOfRange_ThrowsInvalidDataException() + { + var style = new XElement(P + "style", new XElement(A + "lnRef", new XAttribute("idx", "4"))); + + Assert.Throws(() => PptxDocument.ResolveShapeStyleLineStyle(style, BuildTestTheme())); + } + + /// Resolve Shape Style Line Style - Ln Ref With No Idx Attribute - Throws Invalid Data Exception. + [Fact] + public void ResolveShapeStyleLineStyle_LnRefWithNoIdxAttribute_ThrowsInvalidDataException() + { + var style = new XElement(P + "style", new XElement(A + "lnRef", SrgbClr("FF00FF"))); + + Assert.Throws(() => PptxDocument.ResolveShapeStyleLineStyle(style, BuildTestTheme())); + } + + // --- ResolveShapeLineStyle: merges a shape's own with its / + // fallback per the corrected 4-case precedence (Phase 2 Follow-Up: Shape Style References) --- + + /// Resolve Shape Line Style - Ln Element Null - Defers To Style Line Style. + [Fact] + public void ResolveShapeLineStyle_LnElementNull_DefersToStyleLineStyle() + { + var lnStyleList = new List + { + new(A + "ln", new XAttribute("w", 12700), new XElement(A + "solidFill", SrgbClr("111111"))), + }; + var theme = BuildTestTheme(lnStyleList: lnStyleList); + var style = new XElement(P + "style", new XElement(A + "lnRef", new XAttribute("idx", "1"))); + + var viaNewMethod = PptxDocument.ResolveShapeLineStyle(null, style, theme); + var viaStyleOnly = PptxDocument.ResolveShapeStyleLineStyle(style, theme); + + Assert.NotNull(viaNewMethod); + Assert.NotNull(viaStyleOnly); + Assert.Equal(viaStyleOnly.WidthEmu, viaNewMethod.WidthEmu); + Assert.Equal( + Assert.IsType(viaStyleOnly.Paint).Color, + Assert.IsType(viaNewMethod.Paint).Color); + } + + /// Resolve Shape Line Style - Ln Has Explicit Solid Fill - Own Fill Wins Over Style. + [Fact] + public void ResolveShapeLineStyle_LnHasExplicitSolidFill_OwnFillWinsOverStyle() + { + var lnStyleList = new List + { + new(A + "ln", new XAttribute("w", 999999), new XElement(A + "solidFill", SrgbClr("00FF00"))), + }; + var theme = BuildTestTheme(lnStyleList: lnStyleList); + var style = new XElement(P + "style", new XElement(A + "lnRef", new XAttribute("idx", "1"))); + var lnElement = new XElement(A + "ln", new XAttribute("w", 12700), new XElement(A + "solidFill", SrgbClr("FF0000"))); + + var lineStyle = PptxDocument.ResolveShapeLineStyle(lnElement, style, theme); + + Assert.NotNull(lineStyle); + Assert.Equal(12700f, lineStyle.WidthEmu); + var solid = Assert.IsType(lineStyle.Paint); + Assert.Equal(new Rgba32(0xFF, 0x00, 0x00, 255), solid.Color); + } + + /// Resolve Shape Line Style - Ln Has Explicit No Fill - Resolves Null Regardless Of Style. + [Fact] + public void ResolveShapeLineStyle_LnHasExplicitNoFill_ResolvesNullRegardlessOfStyle() + { + var lnStyleList = new List + { + new(A + "ln", new XAttribute("w", 999999), new XElement(A + "solidFill", SrgbClr("00FF00"))), + }; + var theme = BuildTestTheme(lnStyleList: lnStyleList); + var style = new XElement(P + "style", new XElement(A + "lnRef", new XAttribute("idx", "1"))); + var lnElement = new XElement(A + "ln", new XAttribute("w", 12700), new XElement(A + "noFill")); + + var lineStyle = PptxDocument.ResolveShapeLineStyle(lnElement, style, theme); + + Assert.Null(lineStyle); + } + + /// Resolve Shape Line Style - Ln Has Width But No Fill Child - Keeps Own Width Uses Style Color. + [Fact] + public void ResolveShapeLineStyle_LnHasWidthButNoFillChild_KeepsOwnWidthUsesStyleColor() + { + var lnStyleList = new List + { + new(A + "ln", new XAttribute("w", 999999), new XElement(A + "solidFill", SrgbClr("00FF00"))), + }; + var theme = BuildTestTheme(lnStyleList: lnStyleList); + var style = new XElement(P + "style", new XElement(A + "lnRef", new XAttribute("idx", "1"))); + var lnElement = new XElement(A + "ln", new XAttribute("w", 76200)); + + var lineStyle = PptxDocument.ResolveShapeLineStyle(lnElement, style, theme); + + Assert.NotNull(lineStyle); + Assert.Equal(76200f, lineStyle.WidthEmu); + var solid = Assert.IsType(lineStyle.Paint); + Assert.Equal(new Rgba32(0x00, 0xFF, 0x00, 255), solid.Color); + } + + /// Proves a non-numeric w attribute on a case-3 (no fill child) <a:ln> throws rather than letting a raw escape uncaught. + [Fact] + public void ResolveShapeLineStyle_LnHasNonNumericWidth_ThrowsInvalidDataException() + { + var style = new XElement(P + "style", new XElement(A + "lnRef", new XAttribute("idx", "1"))); + var lnElement = new XElement(A + "ln", new XAttribute("w", "not-a-number")); + + Assert.Throws(() => PptxDocument.ResolveShapeLineStyle(lnElement, style, BuildTestTheme())); + } + + /// Proves a non-finite (NaN/Infinity/-Infinity) w attribute on a case-3 (no fill child) <a:ln> throws . + [Theory] + [InlineData("NaN")] + [InlineData("Infinity")] + [InlineData("-Infinity")] + public void ResolveShapeLineStyle_LnHasNonFiniteWidth_ThrowsInvalidDataException(string nonFiniteValue) + { + var style = new XElement(P + "style", new XElement(A + "lnRef", new XAttribute("idx", "1"))); + var lnElement = new XElement(A + "ln", new XAttribute("w", nonFiniteValue)); + + Assert.Throws(() => PptxDocument.ResolveShapeLineStyle(lnElement, style, BuildTestTheme())); + } + + /// Resolve Shape Line Style - Ln Has Width And Own Dash But No Fill Child - Keeps Own Dash Array. + [Fact] + public void ResolveShapeLineStyle_LnHasWidthAndOwnDashButNoFillChild_KeepsOwnDashArray() + { + var lnStyleList = new List + { + new(A + "ln", new XAttribute("w", 999999), new XElement(A + "solidFill", SrgbClr("00FF00"))), + }; + var theme = BuildTestTheme(lnStyleList: lnStyleList); + var style = new XElement(P + "style", new XElement(A + "lnRef", new XAttribute("idx", "1"))); + var lnElement = new XElement( + A + "ln", new XAttribute("w", 76200), new XElement(A + "prstDash", new XAttribute("val", "dash"))); + + var lineStyle = PptxDocument.ResolveShapeLineStyle(lnElement, style, theme); + + Assert.NotNull(lineStyle); + Assert.NotNull(lineStyle.DashArray); + Assert.Equal([76200f * 4f, 76200f * 3f], lineStyle.DashArray); + } + + /// + /// Resolve Shape Line Style - Ln Has Width But No Fill Child And No Style Element - + /// Resolves Null Default. + /// + [Fact] + public void ResolveShapeLineStyle_LnHasWidthButNoFillChildAndNoStyleElement_ResolvesNullDefault() + { + var lnElement = new XElement(A + "ln", new XAttribute("w", 76200)); + + var lineStyle = PptxDocument.ResolveShapeLineStyle(lnElement, null, BuildTestTheme()); + + Assert.Null(lineStyle); + } + + /// + /// Resolve Shape Line Style - Ln Has Width But No Fill Child And Style Ln Ref Idx Zero - + /// Resolves Null Default. + /// + [Fact] + public void ResolveShapeLineStyle_LnHasWidthButNoFillChildAndStyleLnRefIdxZero_ResolvesNullDefault() + { + var style = new XElement(P + "style", new XElement(A + "lnRef", new XAttribute("idx", "0"))); + var lnElement = new XElement(A + "ln", new XAttribute("w", 76200)); + + var lineStyle = PptxDocument.ResolveShapeLineStyle(lnElement, style, BuildTestTheme()); + + Assert.Null(lineStyle); + } + + /// Resolve Shape Line Style - Ln Has No Width And No Fill Child - Resolves Null Regardless Of Style. + [Fact] + public void ResolveShapeLineStyle_LnHasNoWidthAndNoFillChild_ResolvesNullRegardlessOfStyle() + { + var lnStyleList = new List + { + new(A + "ln", new XAttribute("w", 999999), new XElement(A + "solidFill", SrgbClr("00FF00"))), + }; + var theme = BuildTestTheme(lnStyleList: lnStyleList); + var style = new XElement(P + "style", new XElement(A + "lnRef", new XAttribute("idx", "1"))); + var lnElement = new XElement(A + "ln"); + + var lineStyle = PptxDocument.ResolveShapeLineStyle(lnElement, style, theme); + + Assert.Null(lineStyle); + } + + // --- ResolveStrokeOutline: mirrors the PDF renderer's StrokeStyle/PathStroker call pattern --- + + /// Resolve Stroke Outline - Rectangle With Solid Line - Produces Non Empty Outline. + [Fact] + public void ResolveStrokeOutline_RectangleWithSolidLine_ProducesNonEmptyOutline() + { + var shapePath = Path.Rectangle(0, 0, 100, 50); + var lineStyle = new PptxLineStyle(10f, new PptxSolidFill(new Rgba32(0, 0, 0, 255)), null); + + var outline = PptxDocument.ResolveStrokeOutline(shapePath, lineStyle, System.Numerics.Matrix3x2.Identity); + + Assert.NotEmpty(outline.Subpaths); + } + + // --- PptxPatternTileRenderer.RenderTile: per-preset tile-synthesis unit tests ---------------- + + /// An arbitrary, distinct foreground test color. + private static readonly Rgba32 Fg = new(0x2C, 0xB7, 0x31, 255); + + /// An arbitrary, distinct background test color. + private static readonly Rgba32 Bg = new(0xFF, 0xFF, 0xFF, 255); + + /// Asserts every pixel of is either or , and that both colors actually appear. + private static void AssertOnlyFgAndBgPresent(Surface surface) + { + var sawFg = false; + var sawBg = false; + for (var y = 0; y < surface.Height; y++) + { + for (var x = 0; x < surface.Width; x++) + { + var pixel = surface[x, y]; + if (pixel == Fg) + { + sawFg = true; + } + else if (pixel == Bg) + { + sawBg = true; + } + else + { + Assert.Fail($"Unexpected pixel color {pixel} at ({x},{y}); expected only Fg/Bg."); + } + } + } + + Assert.True(sawFg, "Expected at least one foreground-colored pixel."); + Assert.True(sawBg, "Expected at least one background-colored pixel."); + } + + /// Render Tile - Horz - Alternates Full Rows. + [Fact] + public void RenderTile_Horz_AlternatesFullRows() + { + using var tile = PptxPatternTileRenderer.RenderTile(PptxPresetPattern.Horz, Fg, Bg); + + for (var y = 0; y < tile.Height; y++) + { + var expected = y % 2 == 0 ? Fg : Bg; + for (var x = 0; x < tile.Width; x++) + { + Assert.Equal(expected, tile[x, y]); + } + } + + AssertOnlyFgAndBgPresent(tile); + } + + /// Render Tile - Vert - Alternates Full Columns. + [Fact] + public void RenderTile_Vert_AlternatesFullColumns() + { + using var tile = PptxPatternTileRenderer.RenderTile(PptxPresetPattern.Vert, Fg, Bg); + + for (var x = 0; x < tile.Width; x++) + { + var expected = x % 2 == 0 ? Fg : Bg; + for (var y = 0; y < tile.Height; y++) + { + Assert.Equal(expected, tile[x, y]); + } + } + + AssertOnlyFgAndBgPresent(tile); + } + + /// Render Tile - Lt Horz - Sparse Thin Rows. + [Fact] + public void RenderTile_LtHorz_SparseThinRows() + { + using var tile = PptxPatternTileRenderer.RenderTile(PptxPresetPattern.LtHorz, Fg, Bg); + + for (var y = 0; y < tile.Height; y++) + { + var expected = y % 4 == 0 ? Fg : Bg; + Assert.Equal(expected, tile[0, y]); + } + + AssertOnlyFgAndBgPresent(tile); + } + + /// Render Tile - Dk Horz - Dominant Rows. + [Fact] + public void RenderTile_DkHorz_DominantRows() + { + using var tile = PptxPatternTileRenderer.RenderTile(PptxPresetPattern.DkHorz, Fg, Bg); + + for (var y = 0; y < tile.Height; y++) + { + var expected = y % 4 != 0 ? Fg : Bg; + Assert.Equal(expected, tile[0, y]); + } + + AssertOnlyFgAndBgPresent(tile); + } + + /// Render Tile - Lt Vert - Sparse Thin Columns. + [Fact] + public void RenderTile_LtVert_SparseThinColumns() + { + using var tile = PptxPatternTileRenderer.RenderTile(PptxPresetPattern.LtVert, Fg, Bg); + + for (var x = 0; x < tile.Width; x++) + { + var expected = x % 4 == 0 ? Fg : Bg; + Assert.Equal(expected, tile[x, 0]); + } + + AssertOnlyFgAndBgPresent(tile); + } + + /// Render Tile - Dk Vert - Dominant Columns. + [Fact] + public void RenderTile_DkVert_DominantColumns() + { + using var tile = PptxPatternTileRenderer.RenderTile(PptxPresetPattern.DkVert, Fg, Bg); + + for (var x = 0; x < tile.Width; x++) + { + var expected = x % 4 != 0 ? Fg : Bg; + Assert.Equal(expected, tile[x, 0]); + } + + AssertOnlyFgAndBgPresent(tile); + } + + /// Render Tile - Dn Diag - Follows X Plus Y Modulo Rule. + [Fact] + public void RenderTile_DnDiag_FollowsXPlusYModuloRule() + { + using var tile = PptxPatternTileRenderer.RenderTile(PptxPresetPattern.DnDiag, Fg, Bg); + + for (var y = 0; y < tile.Height; y++) + { + for (var x = 0; x < tile.Width; x++) + { + var expected = (x + y) % 4 == 0 ? Fg : Bg; + Assert.Equal(expected, tile[x, y]); + } + } + + AssertOnlyFgAndBgPresent(tile); + } + + /// Render Tile - Up Diag - Follows X Minus Y Modulo Rule. + [Fact] + public void RenderTile_UpDiag_FollowsXMinusYModuloRule() + { + using var tile = PptxPatternTileRenderer.RenderTile(PptxPresetPattern.UpDiag, Fg, Bg); + + for (var y = 0; y < tile.Height; y++) + { + for (var x = 0; x < tile.Width; x++) + { + var expected = (x - y + PptxPatternTileRenderer.TileSize) % 4 == 0 ? Fg : Bg; + Assert.Equal(expected, tile[x, y]); + } + } + + AssertOnlyFgAndBgPresent(tile); + } + + /// Render Tile - Wd Dn Diag - Produces Wide Bands. + [Fact] + public void RenderTile_WdDnDiag_ProducesWideBands() + { + using var tile = PptxPatternTileRenderer.RenderTile(PptxPresetPattern.WdDnDiag, Fg, Bg); + + for (var y = 0; y < tile.Height; y++) + { + for (var x = 0; x < tile.Width; x++) + { + var expected = (x + y) % 8 < 4 ? Fg : Bg; + Assert.Equal(expected, tile[x, y]); + } + } + + AssertOnlyFgAndBgPresent(tile); + } + + /// Render Tile - Cross - Fg At Horizontal Or Vertical Lines. + [Fact] + public void RenderTile_Cross_FgAtHorizontalOrVerticalLines() + { + using var tile = PptxPatternTileRenderer.RenderTile(PptxPresetPattern.Cross, Fg, Bg); + + for (var y = 0; y < tile.Height; y++) + { + for (var x = 0; x < tile.Width; x++) + { + var expected = y % 4 == 0 || x % 4 == 0 ? Fg : Bg; + Assert.Equal(expected, tile[x, y]); + } + } + + AssertOnlyFgAndBgPresent(tile); + } + + /// Render Tile - Diag Cross - Fg At Either Diagonal. + [Fact] + public void RenderTile_DiagCross_FgAtEitherDiagonal() + { + using var tile = PptxPatternTileRenderer.RenderTile(PptxPresetPattern.DiagCross, Fg, Bg); + + for (var y = 0; y < tile.Height; y++) + { + for (var x = 0; x < tile.Width; x++) + { + var expected = (x + y) % 4 == 0 || (x - y + PptxPatternTileRenderer.TileSize) % 4 == 0 ? Fg : Bg; + Assert.Equal(expected, tile[x, y]); + } + } + + AssertOnlyFgAndBgPresent(tile); + } + + /// Render Tile - Percentage Family - Density Increases Monotonically With Percent. + [Theory] + [InlineData("pct5")] + [InlineData("pct10")] + [InlineData("pct20")] + [InlineData("pct25")] + [InlineData("pct30")] + [InlineData("pct40")] + [InlineData("pct50")] + [InlineData("pct60")] + [InlineData("pct70")] + [InlineData("pct75")] + [InlineData("pct80")] + [InlineData("pct90")] + public void RenderTile_PercentageFamily_BothColorsPresent(string prst) + { + using var tile = PptxPatternTileRenderer.RenderTile(ParsePresetName(prst), Fg, Bg); + + AssertOnlyFgAndBgPresent(tile); + } + + /// Render Tile - Percentage Family - Fg Pixel Count Increases With Percent. + [Fact] + public void RenderTile_PercentageFamily_FgPixelCountIncreasesWithPercent() + { + int CountFg(PptxPresetPattern preset) + { + using var tile = PptxPatternTileRenderer.RenderTile(preset, Fg, Bg); + var count = 0; + for (var y = 0; y < tile.Height; y++) + { + for (var x = 0; x < tile.Width; x++) + { + if (tile[x, y] == Fg) + { + count++; + } + } + } + + return count; + } + + var counts = new[] + { + CountFg(PptxPresetPattern.Pct5), CountFg(PptxPresetPattern.Pct10), CountFg(PptxPresetPattern.Pct25), + CountFg(PptxPresetPattern.Pct50), CountFg(PptxPresetPattern.Pct75), CountFg(PptxPresetPattern.Pct90), + }; + + for (var i = 1; i < counts.Length; i++) + { + Assert.True( + counts[i] >= counts[i - 1], + $"Expected non-decreasing foreground pixel density; counts were [{string.Join(", ", counts)}]."); + } + } + + /// Render Tile - Divot - Both Colors Present. + [Fact] + public void RenderTile_Divot_BothColorsPresent() + { + using var tile = PptxPatternTileRenderer.RenderTile(PptxPresetPattern.Divot, Fg, Bg); + + AssertOnlyFgAndBgPresent(tile); + } + + /// Render Tile - Wave - Both Colors Present. + [Fact] + public void RenderTile_Wave_BothColorsPresent() + { + using var tile = PptxPatternTileRenderer.RenderTile(PptxPresetPattern.Wave, Fg, Bg); + + AssertOnlyFgAndBgPresent(tile); + } + + /// Render Tile - Every Covered Preset - Produces Exactly Tile Size Square Surface. + [Theory] + [InlineData("horz")] + [InlineData("vert")] + [InlineData("ltHorz")] + [InlineData("ltVert")] + [InlineData("dkHorz")] + [InlineData("dkVert")] + [InlineData("dnDiag")] + [InlineData("upDiag")] + [InlineData("ltDnDiag")] + [InlineData("ltUpDiag")] + [InlineData("dkDnDiag")] + [InlineData("dkUpDiag")] + [InlineData("wdDnDiag")] + [InlineData("wdUpDiag")] + [InlineData("cross")] + [InlineData("diagCross")] + [InlineData("divot")] + [InlineData("wave")] + public void RenderTile_EveryCoveredPreset_ProducesTileSizeSquareSurface(string prst) + { + using var tile = PptxPatternTileRenderer.RenderTile(ParsePresetName(prst), Fg, Bg); + + Assert.Equal(PptxPatternTileRenderer.TileSize, tile.Width); + Assert.Equal(PptxPatternTileRenderer.TileSize, tile.Height); + } + + /// + /// Maps an ST_PresetPatternVal prst name to its + /// via a real, minimal <a:pattFill>/ + /// round trip - avoiding a second, independent (and possibly divergent) string-to-enum + /// mapping table duplicated purely for test parameterization. + /// + private static PptxPresetPattern ParsePresetName(string prst) + { + var spPr = new XElement(A + "spPr", new XElement(A + "pattFill", new XAttribute("prst", prst))); + var paint = PptxDocument.ResolveFill(spPr, BuildTestTheme(), 100, 100); + return Assert.IsType(paint).Preset; + } +} diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxRenderTests.cs b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxRenderTests.cs new file mode 100644 index 00000000..1a742652 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxRenderTests.cs @@ -0,0 +1,3321 @@ +using System.IO.Compression; +using System.Linq; +using System.Text; +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Codecs; + +namespace DemaConsulting.CanvasNet.Pptx.Tests; + +// cspell:ignore pptx sppr nvsppr nvpr cnvpr cnvsppr grpsp nvgrpsppr grpsppr cxnsp nvcxnsppr cnvcxnsppr +// cspell:ignore nvpicpr nvpr cnvpicpr blipfill srcrect embed graphicframe tbl tblgrid gridcol tcpr srgb +// cspell:ignore calibri txbox xfrm prst Xfrm hlink Hlink cust pythonpptx bbox patt fgclr bgclr + +/// +/// Unit-level tests for the Phase 1f public, slide-level rendering API +/// (PptxDocument.Render.cs's / +/// ). Every test opens a +/// full, navigable, in-memory .pptx-shaped package built by +/// (mirroring 's own BuildTextPackage/ +/// BuildGeometryPaintPackage pattern) and drives rendering purely through the public +/// surface, since this phase is pure integration of already-unit- +/// tested Phase 1c/1d/1e resolvers/painters. +/// +public class PptxRenderTests +{ + /// + /// Builds a full, navigable presentation package (presentation -> slide -> layout -> + /// master -> theme, each with its own relationships part) whose one slide's + /// <p:spTree> content is supplied verbatim by , + /// mirroring 's own BuildGeometryPaintPackage/ + /// BuildTextPackage helpers, extended with an optional embedded media part (for + /// picture-rendering tests). + /// + /// The slide's own <p:spTree> inner content. + /// The slide master's own <p:txStyles> inner content. + /// The slide layout's own <p:spTree> inner content. + /// + /// An optional embedded media part (extension, content type, bytes) referenced by the + /// slide's own relationship rId2 - present only when a test's shape tree declares a + /// <p:pic> referencing r:embed="rId2". + /// + /// + /// The slide's own <p:cSld>/<p:bg> inner content (a <p:bgPr> + /// or <p:bgRef> element), or empty (the default) to omit <p:bg> + /// from the slide entirely. + /// + /// The slide's layout's own <p:bg> inner content - see . + /// That layout's master's own <p:bg> inner content - see . + /// + /// The theme's own <a:fmtScheme>/<a:bgFillStyleLst> inner content (one or + /// more raw fill-definition elements, e.g. <a:solidFill>...</a:solidFill>), + /// or empty (the default) to omit <a:fmtScheme> from the theme entirely - only + /// needed by a <p:bgRef>-based test. + /// + /// + /// The slide master's own <p:spTree> inner content, or empty (the default) for + /// an empty master shape tree - used (Phase 2 Follow-Up) by master decorative-shape tests. + /// + /// + /// An optional embedded media part (extension, content type, bytes) owned by the slide + /// master (its own ppt/slideMasters/_rels/slideMaster1.xml.rels, referenced + /// via r:embed="rId2") - present only when + /// declares a <p:pic> referencing r:embed="rId2", proving a + /// master-owned picture's relationship resolves against the master's own .rels file + /// rather than the slide's (see the companion planning report's ownerPartPath + /// bug-fix rationale). + /// + /// + /// An optional embedded media part (extension, content type, bytes) owned by the slide + /// layout (its own ppt/slideLayouts/_rels/slideLayout1.xml.rels, referenced + /// via r:embed="rId2") - present only when + /// declares a <p:pic> referencing r:embed="rId2". See + /// 's own remarks. + /// + /// + /// The slide's own raw <p:clrMapOvr> inner content (an <a:overrideClrMapping + /// .../> or <a:masterClrMapping/> element), or empty (the default) to + /// omit <p:clrMapOvr> from the slide entirely - used by the + /// <p:clrMap>/<p:clrMapOvr> color-map fix's own render-level + /// regression test. + /// + /// + /// The theme's own <a:fmtScheme>/<a:fillStyleLst> inner content, or + /// empty (the default) to omit it - used by a <p:style>/<a:fillRef>-based + /// test (Phase 2 Follow-Up shape style references). + /// + /// + /// The theme's own <a:fmtScheme>/<a:lnStyleLst> inner content, or empty + /// (the default) to omit it - used by a <p:style>/<a:lnRef>-based test. + /// + /// + /// The presentation's own <p:sldSz cx="..."/> width, in EMU, or + /// 9144000 (the default - a standard 10" 4:3 slide width) - used by the default- + /// line-width render regression test to shrink the slide so a sub-pixel-at-the-default- + /// scale 9525 EMU stroke still renders as several solid pixels wide. + /// + /// + /// The presentation's own <p:sldSz cy="..."/> height, in EMU, or + /// 6858000 (the default - a standard 7.5" 4:3 slide height) - see + /// 's own remarks. + /// + private static Stream BuildRenderPackage( + string spTreeInnerXml, + string masterTxStylesXml = "", + string layoutPlaceholderXml = "", + (string Extension, string ContentType, byte[] Bytes)? media = null, + string slideBackgroundXml = "", + string layoutBackgroundXml = "", + string masterBackgroundXml = "", + string themeBgFillStyleListXml = "", + string masterShapeTreeXml = "", + (string Extension, string ContentType, byte[] Bytes)? masterMedia = null, + (string Extension, string ContentType, byte[] Bytes)? layoutMedia = null, + string slideClrMapOvrXml = "", + string themeFillStyleListXml = "", + string themeLnStyleListXml = "", + float slideWidthEmu = 9144000f, + float slideHeightEmu = 6858000f) + { + // Distinct + + + + {string.Join("\n ", distinctMediaDefaults.Select(m => $""""""))} + + """; + + const string packageRelsXml = + """ + + + + + """; + + var presentationXml = + $""" + + + + + """; + + const string presentationRelsXml = + """ + + + + + """; + + var slideXml = + $""" + + + {(slideBackgroundXml.Length > 0 ? $"""{slideBackgroundXml}""" : string.Empty)} + + {spTreeInnerXml} + + + {(slideClrMapOvrXml.Length > 0 ? $"""{slideClrMapOvrXml}""" : string.Empty)} + + """; + + var slideRelsXml = + $""" + + + + {(media is { } m2 ? $"""""" : string.Empty)} + + """; + + var layoutXml = + $""" + + + {(layoutBackgroundXml.Length > 0 ? $"""{layoutBackgroundXml}""" : string.Empty)} + {layoutPlaceholderXml} + + + """; + + var layoutRelsXml = + $""" + + + + {(layoutMedia is { } lm ? $"""""" : string.Empty)} + + """; + + var masterXml = + $""" + + + {(masterBackgroundXml.Length > 0 ? $"""{masterBackgroundXml}""" : string.Empty)} + {masterShapeTreeXml} + + + {masterTxStylesXml} + + """; + + var masterRelsXml = + $""" + + + + {(masterMedia is { } mm ? $"""""" : string.Empty)} + + """; + + var fillStyleLstXml = themeFillStyleListXml.Length > 0 ? $"{themeFillStyleListXml}" : string.Empty; + var lnStyleLstXml = themeLnStyleListXml.Length > 0 ? $"{themeLnStyleListXml}" : string.Empty; + var bgFillStyleLstXml = themeBgFillStyleListXml.Length > 0 ? $"{themeBgFillStyleListXml}" : string.Empty; + var fmtSchemeXml = themeBgFillStyleListXml.Length > 0 || themeFillStyleListXml.Length > 0 || themeLnStyleListXml.Length > 0 + ? $""" + + {fillStyleLstXml} + {lnStyleLstXml} + {bgFillStyleLstXml} + + """ + : string.Empty; + + var themeXml = + $$""" + + + + + + + + + + + + + + + + + + + + + {{fmtSchemeXml}} + + + """; + + var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + WriteTextEntry(archive, "[Content_Types].xml", contentTypesXml); + WriteTextEntry(archive, "_rels/.rels", packageRelsXml); + WriteTextEntry(archive, "ppt/presentation.xml", presentationXml); + WriteTextEntry(archive, "ppt/_rels/presentation.xml.rels", presentationRelsXml); + WriteTextEntry(archive, "ppt/slides/slide1.xml", slideXml); + WriteTextEntry(archive, "ppt/slides/_rels/slide1.xml.rels", slideRelsXml); + WriteTextEntry(archive, "ppt/slideLayouts/slideLayout1.xml", layoutXml); + WriteTextEntry(archive, "ppt/slideLayouts/_rels/slideLayout1.xml.rels", layoutRelsXml); + WriteTextEntry(archive, "ppt/slideMasters/slideMaster1.xml", masterXml); + WriteTextEntry(archive, "ppt/slideMasters/_rels/slideMaster1.xml.rels", masterRelsXml); + WriteTextEntry(archive, "ppt/theme/theme1.xml", themeXml); + + if (media is { } writtenMedia) + { + WriteBinaryEntry(archive, $"ppt/media/image1.{writtenMedia.Extension}", writtenMedia.Bytes); + } + + if (layoutMedia is { } writtenLayoutMedia) + { + WriteBinaryEntry(archive, $"ppt/media/layoutImage1.{writtenLayoutMedia.Extension}", writtenLayoutMedia.Bytes); + } + + if (masterMedia is { } writtenMasterMedia) + { + WriteBinaryEntry(archive, $"ppt/media/masterImage1.{writtenMasterMedia.Extension}", writtenMasterMedia.Bytes); + } + } + + stream.Position = 0; + return stream; + } + + private static void WriteTextEntry(ZipArchive archive, string name, string content) + { + var entry = archive.CreateEntry(name); + using var entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream, Encoding.UTF8); + writer.Write(content); + } + + private static void WriteBinaryEntry(ZipArchive archive, string name, byte[] content) + { + var entry = archive.CreateEntry(name); + using var entryStream = entry.Open(); + entryStream.Write(content, 0, content.Length); + } + + /// Builds a minimal, valid single-pixel PNG's encoded bytes, via the core . + private static byte[] BuildPngBytes(Rgba32 color) + { + using var surface = new Surface(1, 1); + surface[0, 0] = color; + using var buffer = new MemoryStream(); + PngCodec.Save(surface, buffer); + return buffer.ToArray(); + } + + /// A single, full-slide-sized freeform shape with a solid red fill. + private const string RedFullSlideShapeXml = + """ + + + + + + + + + + + + + """; + + // --- Basic shape rendering ----------------------------------------------------------------- + + /// Proves a single, full-slide solid-filled shape paints its fill color across the rendered surface. + [Fact] + public void Render_SingleFullSlideShape_PaintsFillColorAcrossSurface() + { + using var stream = BuildRenderPackage(RedFullSlideShapeXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 100, 100); + + Assert.Equal(new Rgba32(255, 0, 0, 255), surface[50, 50]); + Assert.Equal(new Rgba32(255, 0, 0, 255), surface[1, 1]); + Assert.Equal(new Rgba32(255, 0, 0, 255), surface[98, 98]); + } + + /// Proves the surface is cleared to the default opaque-white background before any shape paints, for pixels outside every shape's own footprint. + [Fact] + public void Render_DefaultOptions_ClearsUnpaintedAreaToOpaqueWhite() + { + const string spTreeInnerXml = + """ + + + + + + + + + """; + using var stream = BuildRenderPackage(spTreeInnerXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 100, 100); + + // The small blue shape occupies only the top-left ~11x15 pixel corner (1,000,000 / 9,144,000 + // * 100 ~= 10.9; 1,000,000 / 6,858,000 * 100 ~= 14.6) - the bottom-right corner is untouched. + Assert.Equal(new Rgba32(255, 255, 255, 255), surface[90, 90]); + } + + /// Proves a caller-supplied clears the surface to that color instead of the default opaque white. + [Fact] + public void Render_CustomBackgroundColor_ClearsToThatColor() + { + using var stream = BuildRenderPackage(spTreeInnerXml: string.Empty); + using var document = PptxDocument.Open(stream); + var options = new PptxRenderOptions { BackgroundColor = new Rgba32(10, 20, 30, 255) }; + + using var surface = document.Render(0, 20, 20, options); + + Assert.Equal(new Rgba32(10, 20, 30, 255), surface[5, 5]); + } + + /// Proves a fully transparent custom background color is honored (reproducing a transparent render). + [Fact] + public void Render_FullyTransparentBackgroundColor_ClearsToTransparent() + { + using var stream = BuildRenderPackage(spTreeInnerXml: string.Empty); + using var document = PptxDocument.Open(stream); + var options = new PptxRenderOptions { BackgroundColor = new Rgba32(0, 0, 0, 0) }; + + using var surface = document.Render(0, 20, 20, options); + + Assert.Equal(new Rgba32(0, 0, 0, 0), surface[5, 5]); + } + + /// Proves an empty shape tree renders a surface filled with only the background color (no painted shapes at all). + [Fact] + public void Render_EmptyShapeTree_RendersOnlyBackground() + { + using var stream = BuildRenderPackage(spTreeInnerXml: string.Empty); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 50, 50); + + for (var y = 0; y < 50; y++) + { + for (var x = 0; x < 50; x++) + { + Assert.Equal(new Rgba32(255, 255, 255, 255), surface[x, y]); + } + } + } + + // --- Slide/layout/master background fill () ----------------------------------------- + + /// A <p:bgPr> inner content declaring an opaque solid green fill. + private const string GreenSolidBgPrXml = + """"""; + + /// Proves a slide's own solid-fill <p:bg> paints across the full slide, beneath (and visible outside) its own shape tree. + [Fact] + public void Render_SlideLevelSolidBackground_PaintsAcrossFullSlide() + { + using var stream = BuildRenderPackage(spTreeInnerXml: string.Empty, slideBackgroundXml: GreenSolidBgPrXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 50, 50); + + Assert.Equal(new Rgba32(0, 255, 0, 255), surface[0, 0]); + Assert.Equal(new Rgba32(0, 255, 0, 255), surface[25, 25]); + Assert.Equal(new Rgba32(0, 255, 0, 255), surface[49, 49]); + } + + /// Proves a slide's own background fill paints first, with its own shape tree content still painting on top of it. + [Fact] + public void Render_SlideLevelBackgroundWithShape_ShapePaintsOnTopOfBackground() + { + const string smallBlueShapeXml = + """ + + + + + + + + + """; + using var stream = BuildRenderPackage(smallBlueShapeXml, slideBackgroundXml: GreenSolidBgPrXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 100, 100); + + // The small blue shape occupies only the top-left corner (see Render_DefaultOptions_ + // ClearsUnpaintedAreaToOpaqueWhite's own remarks for the EMU -> pixel math) - it must + // paint on top of the green background there, while the green background remains visible + // everywhere else. + Assert.Equal(new Rgba32(0, 0, 255, 255), surface[1, 1]); + Assert.Equal(new Rgba32(0, 255, 0, 255), surface[90, 90]); + } + + /// Proves a layout's own <p:bg> paints when the slide itself declares none. + [Fact] + public void Render_LayoutLevelSolidBackground_PaintsWhenSlideDeclaresNone() + { + using var stream = BuildRenderPackage(spTreeInnerXml: string.Empty, layoutBackgroundXml: GreenSolidBgPrXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 50, 50); + + Assert.Equal(new Rgba32(0, 255, 0, 255), surface[25, 25]); + } + + /// Proves a master's own <p:bg> paints when neither the slide nor its layout declares one. + [Fact] + public void Render_MasterLevelSolidBackground_PaintsWhenNeitherSlideNorLayoutDeclaresOne() + { + using var stream = BuildRenderPackage(spTreeInnerXml: string.Empty, masterBackgroundXml: GreenSolidBgPrXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 50, 50); + + Assert.Equal(new Rgba32(0, 255, 0, 255), surface[25, 25]); + } + + /// Proves the slide's own <p:bg> takes priority over both its layout's and its master's own <p:bg>. + [Fact] + public void Render_SlideLevelBackground_TakesPriorityOverLayoutAndMasterBackgrounds() + { + const string blueSolidBgPrXml = """"""; + const string redSolidBgPrXml = """"""; + using var stream = BuildRenderPackage( + spTreeInnerXml: string.Empty, + slideBackgroundXml: GreenSolidBgPrXml, + layoutBackgroundXml: blueSolidBgPrXml, + masterBackgroundXml: redSolidBgPrXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 50, 50); + + Assert.Equal(new Rgba32(0, 255, 0, 255), surface[25, 25]); + } + + /// Proves a layout's own <p:bg> takes priority over its master's own <p:bg> when the slide itself declares none. + [Fact] + public void Render_LayoutLevelBackground_TakesPriorityOverMasterBackgroundWhenSlideDeclaresNone() + { + const string redSolidBgPrXml = """"""; + using var stream = BuildRenderPackage( + spTreeInnerXml: string.Empty, + layoutBackgroundXml: GreenSolidBgPrXml, + masterBackgroundXml: redSolidBgPrXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 50, 50); + + Assert.Equal(new Rgba32(0, 255, 0, 255), surface[25, 25]); + } + + /// Proves that when none of the slide/layout/master declare a <p:bg>, rendering falls back unchanged to . + [Fact] + public void Render_NoBackgroundAnywhere_FallsBackToOptionsBackgroundColor() + { + using var stream = BuildRenderPackage(spTreeInnerXml: string.Empty); + using var document = PptxDocument.Open(stream); + var options = new PptxRenderOptions { BackgroundColor = new Rgba32(10, 20, 30, 255) }; + + using var surface = document.Render(0, 50, 50, options); + + Assert.Equal(new Rgba32(10, 20, 30, 255), surface[25, 25]); + } + + /// Proves a slide's own theme-indexed <p:bgRef> background resolves the matched <a:bgFillStyleLst> entry, substituting its own color child for the entry's phClr token. + [Fact] + public void Render_SlideLevelThemeIndexedBackgroundReference_ResolvesBgFillStyleListEntryWithPhClrSubstitution() + { + // idx 1001 is the bgFillStyleLst's first (0-based) entry; its own phClr token is + // substituted with the 's own srgbClr child (FF00FF, magenta). + const string bgRefXml = """"""; + const string bgFillStyleListXml = """"""; + using var stream = BuildRenderPackage( + spTreeInnerXml: string.Empty, + slideBackgroundXml: bgRefXml, + themeBgFillStyleListXml: bgFillStyleListXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 50, 50); + + Assert.Equal(new Rgba32(255, 0, 255, 255), surface[25, 25]); + } + + /// Proves a <p:bgRef idx="0"> (the ECMA-376 "no background" sentinel) paints nothing, falling through to the default opaque-white clear. + [Fact] + public void Render_SlideLevelThemeIndexedBackgroundReferenceIdxZero_PaintsNoBackground() + { + const string bgRefXml = """"""; + using var stream = BuildRenderPackage(spTreeInnerXml: string.Empty, slideBackgroundXml: bgRefXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 50, 50); + + Assert.Equal(new Rgba32(255, 255, 255, 255), surface[25, 25]); + } + + /// + /// Proves the EMU-to-pixel base transform does not suffer integer-division truncation to + /// zero (a shape occupying the slide's full EMU extent must still paint at a requested + /// pixel size far smaller than the EMU magnitude). + /// + [Fact] + public void Render_SmallPixelDimensionsAgainstLargeEmuSlideSize_DoesNotTruncateTransformToZero() + { + using var stream = BuildRenderPackage(RedFullSlideShapeXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 4, 3); + + Assert.Equal(new Rgba32(255, 0, 0, 255), surface[2, 1]); + } + + /// Proves two overlapping shapes composite in document order - the later, overlapping shape paints on top of the earlier one. + [Fact] + public void Render_TwoOverlappingShapes_LaterShapePaintsOnTopInDocumentOrder() + { + const string spTreeInnerXml = + """ + + + + + + + + + + + + + + + + + """; + using var stream = BuildRenderPackage(spTreeInnerXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 50, 50); + + Assert.Equal(new Rgba32(0, 255, 0, 255), surface[25, 25]); + } + + /// Proves a shape with no resolvable <a:xfrm> anywhere is skipped silently (no exception, no painted pixels). + [Fact] + public void Render_ShapeWithNoXfrm_SkippedSilently() + { + const string spTreeInnerXml = + """ + + + + + + + + """; + using var stream = BuildRenderPackage(spTreeInnerXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 20, 20); + + Assert.Equal(new Rgba32(255, 255, 255, 255), surface[10, 10]); + } + + /// + /// Proves a <p:cxnSp> straight connector (Phase 2 Follow-Up: Connector Shape + /// Rendering) actually paints a visible diagonal line - the real, positive replacement for + /// the old "connectors are silently skipped" behavior this phase removes. + /// + [Fact] + public void Render_StraightConnector_PaintsDiagonalLineAtExpectedPixels() + { + const string spTreeInnerXml = + """ + + + + + + + + + """; + using var stream = BuildRenderPackage(spTreeInnerXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 100, 100); + + // The connector's own local geometry runs from (0,0) to (w,h) - its full diagonal, here + // coincident with the slide's own full extent - so its midpoint lands at pixel (50,50). + // A point well off that diagonal (the top-right corner) must stay unpainted. + Assert.Equal(new Rgba32(255, 0, 0, 255), surface[50, 50]); + Assert.Equal(new Rgba32(255, 255, 255, 255), surface[95, 5]); + } + + /// + /// Proves the zero-height (perfectly horizontal) connector bug fix: a connector whose own + /// <a:ext cy="0"/> (confirmed in the real-world + /// pythonpptx-shp-connector-props.pptx fixture's own second slide) still paints its + /// horizontal line rather than degrading to under the + /// ordinary preset-geometry "non-positive bounding box" guard. + /// + [Fact] + public void Render_ZeroHeightHorizontalConnector_PaintsLineWithoutDegradingToEmpty() + { + const string spTreeInnerXml = + """ + + + + + + + + + """; + using var stream = BuildRenderPackage(spTreeInnerXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 100, 100); + + // 1000000 EMU is roughly 10.9px (x), and 3000000 EMU is roughly 43.8px (y, out of the + // slide's 6858000 EMU height), within a 9144000x6858000 slide rendered at 100x100 pixels. + Assert.Equal(new Rgba32(0, 0, 255, 255), surface[33, 43]); + Assert.Equal(new Rgba32(255, 255, 255, 255), surface[33, 10]); + } + + /// + /// Proves a connector's own <a:tailEnd type="triangle"/> arrowhead actually + /// paints extra ink beyond the plain stroked line width - the triangle's tip sits at the + /// connector's own end point and its base flares out well past the line's own thickness. + /// + [Fact] + public void Render_ConnectorWithTailArrowhead_PaintsArrowheadNearEndpoint() + { + const string spTreeInnerXml = + """ + + + + + + + + + """; + using var stream = BuildRenderPackage(spTreeInnerXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 200, 200); + + // The connector is a vertical line (cx=0) from (1000000,1000000) to (1000000,5000000) EMU + // - rendered at 200x200px against a 9144000x6858000 slide, that is roughly (21.9, 29.2) + // to (21.9, 145.8) in pixel space, centered on x=22. A thin (182880 EMU, about 4px) line + // alone would never paint more than ~2px either side of that centerline; the "lg" triangle + // tailEnd arrowhead flares out much further than that just above its tip at the endpoint + // - assert a pixel well off the centerline (x=15, 7px off-center), just above the endpoint + // (within the arrowhead's own flared base, not the plain line), is red. + Assert.Equal(new Rgba32(255, 0, 0, 255), surface[15, 122]); + Assert.Equal(new Rgba32(255, 255, 255, 255), surface[15, 60]); + } + + /// + /// Scans 's own [x0,x1) x [y0,y1) rectangle for the + /// presence of both and pixel colors, + /// mirroring PptxFixturesCorpusTests's own identically-named helper (kept as a + /// separate, private copy here since the two test classes do not share a common base type). + /// + private static void AssertRegionContainsBothColors(Surface surface, int x0, int y0, int x1, int y1, Rgba32 first, Rgba32 second) + { + var sawFirst = false; + var sawSecond = false; + for (var y = y0; y < y1; y++) + { + for (var x = x0; x < x1; x++) + { + var pixel = surface[x, y]; + if (pixel == first) + { + sawFirst = true; + } + else if (pixel == second) + { + sawSecond = true; + } + } + } + + Assert.True(sawFirst, $"Expected to find color {first} somewhere in region ({x0},{y0})-({x1},{y1}), but did not."); + Assert.True(sawSecond, $"Expected to find color {second} somewhere in region ({x0},{y0})-({x1},{y1}), but did not."); + } + + /// + /// Proves the fix for the line/stroke-fill transform-composition gap this quality-retry + /// exists to close: a shape positioned away from the render surface's own origin, with + /// <a:noFill/> shape fill and a thick <a:ln><a:pattFill + /// prst="horz">...</a:pattFill></a:ln> stroke, must paint a genuine, + /// correctly-positioned alternating foreground/background stripe pattern along its stroke + /// band - not a single flat color. + /// + /// + /// Rendered against a deliberately small 1"x1" slide (914400x914400 EMU) at 288x288px - + /// exactly 3175 EMU/pixel, chosen so the pattern tile's own fixed 8x8-pixel, + /// 9525-EMU-per-tile-pixel size () + /// maps to an exact 3 surface pixels per tile pixel (9525 / 3175 = 3), giving + /// a clean, exactly-reproducible 24-surface-pixel vertical repeat period for the + /// horz preset's alternating rows. Before this fix, RenderShape's + /// stroke-outline FillPaint call composed + /// instead of the shape's own local-to-surface transform into the pattern tile's own + /// - with no transform composed at all (missing + /// both the shape's own position and the slide's own EMU-to-pixel scale), the + /// pattern tile's addressed texel barely moves across this small render surface, so the + /// entire stroke band collapses onto a single tile pixel's color (here, the foreground + /// color, since tile pixel (0,0) is foreground for horz) - this test's "both colors + /// present" assertion would have failed against that pre-fix behavior (only the foreground + /// color would ever appear), and passes only once the real transform is composed. + /// + [Fact] + public void Render_ShapeWithPatternFillStroke_PaintsCorrectlyTransformedStripesNotFlatColor() + { + const string spTreeInnerXml = + """ + + + + + + + + + + + + + + + """; + using var stream = BuildRenderPackage(spTreeInnerXml, slideWidthEmu: 914400f, slideHeightEmu: 914400f); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 288, 288); + + var fg = new Rgba32(0x2C, 0xB7, 0x31, 255); + var bg = new Rgba32(0xC0, 0x50, 0x4D, 255); + + // The rectangle's own top edge sits at pixel row 48 (152400 / 3175); its 228600-EMU-wide + // (72px) stroke is centered on that edge, spanning roughly rows [12,84]. Sampling that + // whole band at a column well inside the shape's own left/right extent (x=144, the + // rectangle's own horizontal center) proves both the resolved foreground and background + // colors genuinely alternate across the band, rather than the band collapsing to a single + // flat color (the pre-fix identity-transform bug). + AssertRegionContainsBothColors(surface, 140, 12, 148, 84, fg, bg); + } + + /// + /// Proves the identical fix for a <p:cxnSp> connector's own stroke: a + /// horizontal connector positioned away from the render surface's own origin, with a + /// thick <a:ln><a:pattFill prst="horz">...</a:pattFill></a:ln> + /// stroke, must paint a genuine alternating stripe pattern across its own stroke band - not + /// a single flat color. Mirrors + /// 's + /// own rationale and EMU/pixel scale choice, applied to RenderConnector's own + /// stroke-outline FillPaint call site instead of RenderShape's. + /// + [Fact] + public void Render_ConnectorWithPatternFillStroke_PaintsCorrectlyTransformedStripesNotFlatColor() + { + const string spTreeInnerXml = + """ + + + + + + + + + + + + + + """; + using var stream = BuildRenderPackage(spTreeInnerXml, slideWidthEmu: 914400f, slideHeightEmu: 914400f); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 288, 288); + + var fg = new Rgba32(0x2C, 0xB7, 0x31, 255); + var bg = new Rgba32(0xC0, 0x50, 0x4D, 255); + + // The connector's own horizontal line sits at pixel row 96 (304800 / 3175); its + // 228600-EMU-wide (72px) stroke is centered on that line, spanning roughly rows [60,132]. + // Sampling that whole band at a column well inside the connector's own horizontal extent + // (x=120, inside [24,216]) proves both colors genuinely alternate across the band. + AssertRegionContainsBothColors(surface, 116, 60, 124, 132, fg, bg); + } + + /// + /// Proves the slide-level background-fill transform-composition fix: a slide's own + /// <p:bg><p:bgPr><a:pattFill prst="horz">...</a:pattFill></p:bgPr></p:bg> + /// background must paint a genuine, correctly-scaled alternating foreground/background + /// stripe pattern across the slide - not a single flat color. + /// + /// + /// Mirrors 's + /// own rationale and EMU/pixel scale choice (a 1"x1", 914400x914400 EMU slide + /// rendered at 288x288 px, giving exactly 3175 EMU/pixel and an exact + /// 3 surface pixels per 8x8-pixel pattern tile pixel, i.e. a clean + /// 24-surface-pixel vertical repeat period for the horz preset), applied to + /// the slide-level background-fill call site (PptxDocument.Render.cs's own + /// Render method) instead of a shape/connector's own stroke. Before this fix, the + /// background fill's own FillPaint call composed + /// instead of the slide's own EMU-to-pixel baseTransform into the pattern tile's + /// own , so the entire slide collapsed onto a + /// single tile pixel's color - this test's "both colors present" assertion would have + /// failed against that pre-fix behavior (only the foreground color would ever appear), and + /// passes only once the real transform is composed. + /// + [Fact] + public void Render_SlideLevelPatternFillBackground_PaintsCorrectlyTransformedStripesNotFlatColor() + { + const string patternBgPrXml = + """"""; + using var stream = BuildRenderPackage( + spTreeInnerXml: string.Empty, slideBackgroundXml: patternBgPrXml, + slideWidthEmu: 914400f, slideHeightEmu: 914400f); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 288, 288); + + var fg = new Rgba32(0x2C, 0xB7, 0x31, 255); + var bg = new Rgba32(0xC0, 0x50, 0x4D, 255); + + // Sample two full 24px repeat periods (rows [0,48)) across the full slide width to prove + // both the resolved foreground and background colors genuinely alternate, rather than the + // whole slide collapsing to a single flat color (the pre-fix identity-transform bug). + AssertRegionContainsBothColors(surface, 0, 0, 288, 48, fg, bg); + } + + /// + /// Proves a slide's own <p:bg><p:bgPr><a:blipFill> picture + /// background fill is resolved and painted across the full slide (not rejected as + /// unsupported) - ResolveSlideBackgroundFill threads each tier's own + /// resolveBlipImage callback into the shared ResolveFill pipeline + /// exactly like every other fill context. + /// + [Fact] + public void Render_SlideLevelPictureFillBackground_PaintsEmbeddedImageAcrossFullSlide() + { + var pngBytes = BuildPngBytes(new Rgba32(10, 20, 30, 255)); + const string pictureBgPrXml = """"""; + using var stream = BuildRenderPackage( + spTreeInnerXml: string.Empty, slideBackgroundXml: pictureBgPrXml, + media: ("png", "image/png", pngBytes)); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 20, 20); + + Assert.Equal(new Rgba32(10, 20, 30, 255), surface[10, 10]); + } + + /// + /// Proves the real-world-corpus crash is fixed: a shape using + /// <a:prstGeom prst="curvedUpArrow"/> (previously unsupported, throwing + /// for the entire slide) renders without + /// throwing, and paints ink (not merely the unpainted background) within its own shape + /// rectangle. + /// + [Fact] + public void Render_CurvedUpArrowShape_RendersWithoutThrowingAndPaintsInk() + { + const string spTreeInnerXml = + """ + + + + + + + + + """; + using var stream = BuildRenderPackage(spTreeInnerXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 100, 100); + + var paintedSomeInk = false; + for (var y = 0; y < 100 && !paintedSomeInk; y++) + { + for (var x = 0; x < 100; x++) + { + if (surface[x, y] == new Rgba32(255, 0, 0, 255)) + { + paintedSomeInk = true; + break; + } + } + } + + Assert.True(paintedSomeInk, "curvedUpArrow shape should have painted at least one red pixel."); + } + + // --- Group transform composition ------------------------------------------------------------ + + /// Proves a nested group's own transform composes with its parent's, placing a child shape at the expected final surface location. + [Fact] + public void Render_NestedGroup_ComposesChildTransformIntoExpectedSurfaceLocation() + { + const string spTreeInnerXml = + """ + + + + + + + + + + + + + + + + + + """; + using var stream = BuildRenderPackage(spTreeInnerXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 100, 100); + + // The group occupies the slide's right half (offset by half the slide width); its inner + // shape, spanning the full group child extent, therefore paints only the right half. + Assert.Equal(new Rgba32(255, 0, 0, 255), surface[75, 50]); + Assert.Equal(new Rgba32(255, 255, 255, 255), surface[25, 50]); + } + + /// Proves two independently-nested groups each resolve and compose their own child transform correctly. + [Fact] + public void Render_DoublyNestedGroups_EachComposeOwnChildTransform() + { + const string spTreeInnerXml = + """ + + + + + + + + + + + + + + + + + + + + + + + + + + + """; + using var stream = BuildRenderPackage(spTreeInnerXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 100, 100); + + // Outer group: child space (0,0)-(2,000,000, 2,000,000) maps to the full slide + // (9,144,000 x 6,858,000). Inner group occupies child-space quadrant + // (1,000,000..2,000,000, 1,000,000..2,000,000) of that - i.e. its bottom-right quadrant - + // which the leaf shape fills entirely. Sample a point inside that composed footprint. + var x = (int)(9144000.0 / 2000000.0 * 1500000.0 / 9144000.0 * 100); + var y = (int)(6858000.0 / 2000000.0 * 1500000.0 / 6858000.0 * 100); + Assert.Equal(new Rgba32(0, 255, 0, 255), surface[Math.Clamp(x, 0, 99), Math.Clamp(y, 0, 99)]); + Assert.Equal(new Rgba32(255, 255, 255, 255), surface[2, 2]); + } + + /// Builds a string of nested <p:grpSp> levels, each a 1-to-1 identity transform, wrapping an innermost freeform <p:sp> leaf. + private static string BuildNestedGroupShapeTreeXml(int depth) + { + const string leaf = + """ + + + + + + + + + """; + + var xml = leaf; + for (var i = 0; i < depth; i++) + { + xml = + $""" + + + + + + + + + {xml} + + """; + } + + return xml; + } + + /// + /// Regression test for the group-shape-nesting-depth safety limit (see the companion + /// code-review finding's bug-fix rationale): a crafted slide whose <p:grpSp> + /// chain is nested one level beyond the documented maximum (100) throws + /// - not an unrecoverable + /// - when opened/rendered through the full public pipeline. + /// + [Fact] + public void Render_GroupNestingExceedsMaxDepth_ThrowsInvalidDataException() + { + using var stream = BuildRenderPackage(BuildNestedGroupShapeTreeXml(101)); + + Assert.Throws(() => + { + using var document = PptxDocument.Open(stream); + using var surface = document.Render(0, 100, 100); + }); + } + + /// + /// Proves a slide whose <p:grpSp> chain is nested exactly at the documented + /// maximum depth (100 levels) still renders correctly (no regression at the boundary) - + /// each identity-transform group composes to the full slide, so the innermost red leaf + /// shape still paints the full surface. + /// + [Fact] + public void Render_GroupNestingAtMaxDepth_RendersCorrectly() + { + using var stream = BuildRenderPackage(BuildNestedGroupShapeTreeXml(100)); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 100, 100); + + Assert.Equal(new Rgba32(255, 0, 0, 255), surface[50, 50]); + } + + // --- Picture rendering ----------------------------------------------------------------------- + + /// Proves a <p:pic> decodes and composites its embedded image onto the surface at the expected location. + [Fact] + public void Render_Picture_PaintsEmbeddedImageAtExpectedLocation() + { + var pngBytes = BuildPngBytes(new Rgba32(10, 20, 30, 255)); + const string spTreeInnerXml = + """ + + + + + + + + + """; + using var stream = BuildRenderPackage(spTreeInnerXml, media: ("png", "image/png", pngBytes)); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 20, 20); + + Assert.Equal(new Rgba32(10, 20, 30, 255), surface[10, 10]); + } + + /// Proves a <p:pic> missing its own <p:blipFill> is skipped silently rather than throwing. + [Fact] + public void Render_PictureMissingBlipFill_SkippedSilently() + { + const string spTreeInnerXml = + """ + + + + + + + + """; + using var stream = BuildRenderPackage(spTreeInnerXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 20, 20); + + Assert.Equal(new Rgba32(255, 255, 255, 255), surface[10, 10]); + } + + /// + /// Proves a <p:pic> declaring <a:prstGeom prst="ellipse"> is + /// clipped to the ellipse: a bounding-box corner (well outside the ellipse) remains the + /// slide's background color, while the shape's own center paints the embedded image's + /// color - this is the confirmed real-world bug (an unclipped full rectangle) this fix + /// resolves. + /// + [Fact] + public void Render_PictureEllipseGeometry_ClipsImageToEllipticalRegion() + { + var pngBytes = BuildPngBytes(new Rgba32(10, 20, 30, 255)); + const string spTreeInnerXml = + """ + + + + + + + + + """; + using var stream = BuildRenderPackage(spTreeInnerXml, media: ("png", "image/png", pngBytes)); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 20, 20); + + // Well inside the ellipse: the embedded image's own color. + Assert.Equal(new Rgba32(10, 20, 30, 255), surface[10, 10]); + // Bounding-box corners, well outside the ellipse: the slide's background color. + Assert.Equal(new Rgba32(255, 255, 255, 255), surface[0, 0]); + Assert.Equal(new Rgba32(255, 255, 255, 255), surface[19, 19]); + } + + /// + /// Regression guard using the real-world corpus fixture's own exact shape geometry + /// (<a:xfrm>/<a:prstGeom prst="ellipse">/<a:srcRect>/ + /// red <a:ln> transcribed verbatim from the bug report), rendered at the + /// report's own 16:9 slide size (12192000 x 6858000 EMU, 1280x720px) - a near-square + /// (4678591 x 5053859 EMU, ratio ~0.9256) ellipse that the report claimed rendered + /// with an unclipped rectangular top while the bottom clipped correctly. A direct + /// mask-boundary-trace overlay against the real rendered output (see + /// .agent-logs/planning-picture-ellipse-clip-bug-reverify-7c3e1a.md) proved the clip + /// mask is in fact pixel-correct and fully symmetric on all four sides - the reported + /// asymmetry was a visual illusion caused by the embedded photo's own light background + /// color blending into the slide's background near the ellipse's top edge, not a code + /// defect. This test asserts all four bounding-box corners (not just one diagonal pair, as + /// the pre-existing + /// does) remain the slide's background color, including a point in the upper quarter of the + /// bounding box that sits outside the ellipse's own curve (the specific region the bug + /// report claimed was wrongly left unclipped), while the shape's own center still paints + /// the embedded image's color. + /// + [Fact] + public void Render_PictureEllipseGeometry_NearSquareRealWorldAspect_ClipsAllFourBoundingBoxCorners() + { + var pngBytes = BuildPngBytes(new Rgba32(10, 20, 30, 255)); + const string spTreeInnerXml = + """ + + + + + + + + + + + + + """; + + // The report's own 16:9 presentation . + const float slideWidthEmu = 12192000f; + const float slideHeightEmu = 6858000f; + const int renderWidthPx = 1280; + const int renderHeightPx = 720; + + using var stream = BuildRenderPackage( + spTreeInnerXml, media: ("png", "image/png", pngBytes), + slideWidthEmu: slideWidthEmu, slideHeightEmu: slideHeightEmu); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, renderWidthPx, renderHeightPx); + + // Compute the shape's own bounding box in device-pixel space algebraically from its EMU + // , rather than hard-coding magic pixel literals. + var scaleX = renderWidthPx / slideWidthEmu; + var scaleY = renderHeightPx / slideHeightEmu; + const float offXEmu = 406259f; + const float offYEmu = 1259164f; + const float extCxEmu = 4678591f; + const float extCyEmu = 5053859f; + var bboxLeft = offXEmu * scaleX; + var bboxTop = offYEmu * scaleY; + var bboxRight = (offXEmu + extCxEmu) * scaleX; + var bboxBottom = (offYEmu + extCyEmu) * scaleY; + var centerX = (bboxLeft + bboxRight) / 2f; + var centerY = (bboxTop + bboxBottom) / 2f; + + // Sampled a small margin inward from each bounding-box edge, well clear of the clip + // mask's own anti-aliased boundary. + const float marginPx = 8f; + var background = new Rgba32(255, 255, 255, 255); + var imageColor = new Rgba32(10, 20, 30, 255); + + // All four bounding-box corners - including both top corners, the specific region the + // bug report claimed was wrongly rendered as an unclipped rectangle. + Assert.Equal(background, surface[(int)(bboxLeft + marginPx), (int)(bboxTop + marginPx)]); + Assert.Equal(background, surface[(int)(bboxRight - marginPx), (int)(bboxTop + marginPx)]); + Assert.Equal(background, surface[(int)(bboxLeft + marginPx), (int)(bboxBottom - marginPx)]); + Assert.Equal(background, surface[(int)(bboxRight - marginPx), (int)(bboxBottom - marginPx)]); + + // A point in the upper quarter of the bounding box, near its left/right edge (normalized + // ellipse-local u ~= 0.9, v ~= -0.7; u^2 + v^2 ~= 1.3 > 1, i.e. geometrically outside the + // ellipse's own curve while still inside the bounding box) - the single assertion most + // directly aimed at the bug report's literal claim that the top of the shape rendered as + // an unclipped rectangle. + var halfWidth = (bboxRight - bboxLeft) / 2f; + var halfHeight = (bboxBottom - bboxTop) / 2f; + var upperOutsidePointX = centerX + 0.9f * halfWidth; + var upperOutsidePointY = centerY - 0.7f * halfHeight; + Assert.Equal(background, surface[(int)upperOutsidePointX, (int)upperOutsidePointY]); + + // The shape's own center: well inside the ellipse, the embedded image's own color. + Assert.Equal(imageColor, surface[(int)centerX, (int)centerY]); + } + + /// + /// Proves the real-world bug report's root cause is fixed: a <p:pic>'s own + /// <a:ln> (previously never read or painted at all for any picture - see the + /// companion planning report, .agent-logs/planning-picture-ln-stroke-fix-7f2a4d.md) + /// now strokes a COMPLETE, correctly-closed outline around the picture's own resolved + /// ellipse geometry, using the exact bug-report geometry/stroke transcribed verbatim + /// (same <a:xfrm>/<a:prstGeom prst="ellipse">/red + /// <a:ln> as , + /// which remains unchanged and still only proves the separate, already-correct + /// image-content clip). Samples all four of the ellipse's own perimeter midpoints (top/ + /// bottom/left/right-center, not just corners) for the stroke's own red color - a defect + /// covering 0% of the perimeter (the confirmed pre-fix state) would fail at all four. + /// + [Fact] + public void Render_PictureEllipseGeometry_WithRedLnStroke_RendersCompleteClosedEllipseOutline() + { + var pngBytes = BuildPngBytes(new Rgba32(250, 240, 220, 255)); + const string spTreeInnerXml = + """ + + + + + + + + + + + + + """; + + // The report's own 16:9 presentation . + const float slideWidthEmu = 12192000f; + const float slideHeightEmu = 6858000f; + const int renderWidthPx = 1280; + const int renderHeightPx = 720; + + using var stream = BuildRenderPackage( + spTreeInnerXml, media: ("png", "image/png", pngBytes), + slideWidthEmu: slideWidthEmu, slideHeightEmu: slideHeightEmu); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, renderWidthPx, renderHeightPx); + + // Compute the ellipse's own bounding box, and hence its own four perimeter midpoints, in + // device-pixel space algebraically from its EMU - mirroring + // Render_PictureEllipseGeometry_NearSquareRealWorldAspect_ClipsAllFourBoundingBoxCorners's + // own algebraic-bbox pattern. + var scaleX = renderWidthPx / slideWidthEmu; + var scaleY = renderHeightPx / slideHeightEmu; + const float offXEmu = 406259f; + const float offYEmu = 1259164f; + const float extCxEmu = 4678591f; + const float extCyEmu = 5053859f; + var bboxLeft = offXEmu * scaleX; + var bboxTop = offYEmu * scaleY; + var bboxRight = (offXEmu + extCxEmu) * scaleX; + var bboxBottom = (offYEmu + extCyEmu) * scaleY; + var centerX = (bboxLeft + bboxRight) / 2f; + var centerY = (bboxTop + bboxBottom) / 2f; + + // Sampled directly on each perimeter midpoint's own exact geometric edge - the default + // (no explicit "w") PowerPoint stroke width (9525 EMU) is only ~1 device pixel wide at + // this slide's own ~9525 EMU/pixel scale, so a several-pixel inward margin (as used by + // the sibling image-clip corner test, which deliberately avoids the stroke band) would + // overshoot the thin band entirely; mirroring + // Render_FullSlideScaleEllipseWithNoFillAndLnMissingWidth_RendersThinRingNotSolidDisc's + // own zero-margin, exact-boundary-pixel sampling at the same stroke-width/scale ratio. + // + // A red-dominant check (rather than an exact FF0000 match) tolerates the anti-aliased + // blend between the stroke's own red and the picture's pale image color immediately + // beneath it - exactly as that same thin-ring regression test already does for the + // same reason. + AssertRedDominant(surface[(int)centerX, (int)bboxTop], "top-center"); + AssertRedDominant(surface[(int)centerX, (int)bboxBottom], "bottom-center"); + AssertRedDominant(surface[(int)bboxLeft, (int)centerY], "left-center"); + AssertRedDominant(surface[(int)bboxRight, (int)centerY], "right-center"); + } + + /// + /// Proves 's "neither + /// <a:prstGeom> nor <a:custGeom>" fallback (the implicit full + /// rectangle) is actually used for stroking: a <p:pic> with no geometry child + /// at all but a present <a:ln> now strokes a complete rectangle outline around + /// its own bounding box - not nothing, as it did before this fix (when RenderPicture + /// never read <a:ln> at all, regardless of geometry). + /// + [Fact] + public void Render_PictureNoPrstGeom_WithLnStroke_StrokesRectangleOutline() + { + var pngBytes = BuildPngBytes(new Rgba32(10, 20, 30, 255)); + const string spTreeInnerXml = + """ + + + + + + + + + """; + + // A deliberately small 1"x1" slide (914400x914400 EMU) rendered at 200x200px (scale: + // 914400 / 200 = 4572 EMU/pixel), mirroring + // Render_EllipseWithNoFillAndLnMissingWidthButRecognizedColor_PaintsDefaultWidthStroke's + // own precedent, so the default 9525 EMU stroke width (~2.08px at this scale) survives + // anti-aliased rendering as solid, easily-sampled pixels. + using var stream = BuildRenderPackage( + spTreeInnerXml, media: ("png", "image/png", pngBytes), + slideWidthEmu: 914400f, slideHeightEmu: 914400f); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 200, 200); + + // The picture spans x/y in [152400, 609600] EMU (i.e. pixel columns/rows [33.33, 133.33] + // of 200 at this slide's 4572 EMU/pixel scale) - its own center is pixel (83, 83). + Assert.Equal(new Rgba32(10, 20, 30, 255), surface[83, 83]); + + // The picture's own top and left edges (pixel row/column 33, its exact geometric + // boundary) now show the stroke's own red color - proving the "no geometry at all" + // fallback strokes an actual rectangle, not nothing. + AssertRedDominant(surface[83, 33], "top edge"); + AssertRedDominant(surface[33, 83], "left edge"); + + // Well outside the picture's own bounding box entirely (a corner of the slide) remains + // the default opaque-white clear. + Assert.Equal(new Rgba32(255, 255, 255, 255), surface[5, 5]); + } + + /// + /// Asserts is red-dominant (high red, low green/blue channels) - + /// tolerant of anti-aliased blending with whatever color sits immediately beneath the + /// stroke, mirroring 's + /// own boundary-pixel assertion style. + /// + private static void AssertRedDominant(Rgba32 pixel, string label) + { + Assert.True( + pixel.R > 180 && pixel.G < 120 && pixel.B < 120, + $"Expected a red-dominant stroke pixel at the {label} position, but got {pixel}."); + } + + /// + /// Proves a <p:pic> declaring an explicit <a:prstGeom prst="rect"> + /// still paints the full, unclipped bounding-box rectangle - strengthens + /// (which only samples + /// the center) into an explicit no-regression proof by also sampling a bounding-box corner. + /// + [Fact] + public void Render_PictureRectGeometry_PaintsFullBoundingBoxUnclipped() + { + var pngBytes = BuildPngBytes(new Rgba32(10, 20, 30, 255)); + const string spTreeInnerXml = + """ + + + + + + + + + """; + using var stream = BuildRenderPackage(spTreeInnerXml, media: ("png", "image/png", pngBytes)); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 20, 20); + + Assert.Equal(new Rgba32(10, 20, 30, 255), surface[10, 10]); + Assert.Equal(new Rgba32(10, 20, 30, 255), surface[0, 0]); + Assert.Equal(new Rgba32(10, 20, 30, 255), surface[19, 19]); + } + + /// + /// Proves a <p:pic> whose <p:spPr> declares no + /// <a:prstGeom>/<a:custGeom> at all still paints the full, + /// unclipped bounding-box rectangle - a defensive, schema-edge-case regression guard (see + /// 's own remarks). + /// + [Fact] + public void Render_PictureNoPrstGeom_PaintsFullBoundingBoxUnclipped() + { + var pngBytes = BuildPngBytes(new Rgba32(10, 20, 30, 255)); + const string spTreeInnerXml = + """ + + + + + + + + """; + using var stream = BuildRenderPackage(spTreeInnerXml, media: ("png", "image/png", pngBytes)); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 20, 20); + + Assert.Equal(new Rgba32(10, 20, 30, 255), surface[10, 10]); + Assert.Equal(new Rgba32(10, 20, 30, 255), surface[0, 0]); + Assert.Equal(new Rgba32(10, 20, 30, 255), surface[19, 19]); + } + + /// + /// Proves a <p:pic> declaring <a:prstGeom prst="roundRect"> is + /// clipped to the rounded-rectangle outline: a bounding-box corner (outside the rounded + /// corner's radius) remains the slide's background color, while the shape's own center + /// still paints the embedded image's color - the task's required "at least one other + /// common non-rect preset" case. + /// + [Fact] + public void Render_PictureRoundRectGeometry_ClipsImageCorners() + { + var pngBytes = BuildPngBytes(new Rgba32(10, 20, 30, 255)); + const string spTreeInnerXml = + """ + + + + + + + + + """; + + // A square slide/shape (rather than the usual 4:3 slide) keeps the roundRect preset's own + // circular corner arc (radius = min(w,h)/6 in local, pre-transform space) circular in + // device space too - avoiding the anisotropic x/y scale a non-square slide would otherwise + // apply to that arc, which would make an exact-corner-pixel sample unreliable. + using var stream = BuildRenderPackage( + spTreeInnerXml, media: ("png", "image/png", pngBytes), slideWidthEmu: 6096000f, slideHeightEmu: 6096000f); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 100, 100); + + // Well inside the rounded rectangle (its own center): the embedded image's own color. + Assert.Equal(new Rgba32(10, 20, 30, 255), surface[50, 50]); + // Well inside the excluded corner nook (radius is 100px/6 ~= 16.7px; sampled well short of + // the arc itself), outside the rounded corner's own radius: background. + Assert.Equal(new Rgba32(255, 255, 255, 255), surface[2, 2]); + Assert.Equal(new Rgba32(255, 255, 255, 255), surface[97, 97]); + } + + /// + /// Not a permanent regression guard (the [Fact] tests above already provide those) - + /// renders the same ellipse-clipped picture end-to-end through the public + /// API and saves it + /// to an inspectable PNG file (.agent-logs/pptx-picture-ellipse-clip-repro.png) for + /// a human reviewer to open and visually confirm the circular photo-crop effect, mirroring + /// PptxTextLayoutTests.cs's own GeneratePlusMinusDegreeTofuReproPng precedent. + /// + [Fact] + public void GeneratePictureEllipseClipReproPng() + { + var pngBytes = BuildPngBytes(new Rgba32(10, 20, 30, 255)); + const string spTreeInnerXml = + """ + + + + + + + + + """; + using var stream = BuildRenderPackage(spTreeInnerXml, media: ("png", "image/png", pngBytes)); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 300, 300); + + var outputDirectory = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", ".agent-logs"); + outputDirectory = Path.GetFullPath(outputDirectory); + Directory.CreateDirectory(outputDirectory); + var outputPath = Path.Combine(outputDirectory, "pptx-picture-ellipse-clip-repro.png"); + + // Three target-framework test processes may run this test concurrently against the same + // shared output path - render to a process-unique temp file first (identical content + // regardless of which TFM wins), then best-effort copy it into place, tolerating (rather + // than failing on) a transient sharing violation from a sibling process doing the same + // thing at the same moment. + var tempPath = Path.Combine(outputDirectory, $"{Guid.NewGuid():N}.png.tmp"); + PngCodec.Save(surface, tempPath); + try + { + File.Copy(tempPath, outputPath, overwrite: true); + } + catch (IOException) + { + // A sibling TFM process is writing/has already written the identical content - + // nothing further to do here. + } + finally + { + File.Delete(tempPath); + } + + Assert.True(File.Exists(outputPath)); + } + + // --- Table rendering -------------------------------------------------------------------------- + + /// Proves a table's resolved cell fill paints across its own cell rectangle via the graphic frame's own direct <p:xfrm>. + [Fact] + public void Render_Table_PaintsCellFillAcrossCellRectangle() + { + const string spTreeInnerXml = + """ + + + + + + + + + + + + + + + + + """; + using var stream = BuildRenderPackage(spTreeInnerXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 50, 50); + + Assert.Equal(new Rgba32(0, 255, 255, 255), surface[25, 25]); + } + + /// + /// Proves a table cell's own <a:blipFill> picture fill - resolved once at parse + /// time (cached inside the slide's shape tree, via ParseTable) rather than freshly + /// per call - still + /// paints correctly across two separate + /// calls on the same slide, proving the cached, decoded picture is + /// not disposed after the first call (see PptxDocument.ResolveAndOwnPictureSurface's + /// own remarks for why this one picture-fill path is owned by the document rather than the + /// single render call that happens to trigger its first resolution). + /// + [Fact] + public void Render_TableCellPictureFill_PaintsCorrectlyAcrossRepeatedRenderCalls() + { + var pngBytes = BuildPngBytes(new Rgba32(40, 50, 60, 255)); + const string spTreeInnerXml = + """ + + + + + + + + + + + + + + + + + """; + using var stream = BuildRenderPackage(spTreeInnerXml, media: ("png", "image/png", pngBytes)); + using var document = PptxDocument.Open(stream); + + using var firstSurface = document.Render(0, 50, 50); + using var secondSurface = document.Render(0, 50, 50); + + Assert.Equal(new Rgba32(40, 50, 60, 255), firstSurface[25, 25]); + Assert.Equal(new Rgba32(40, 50, 60, 255), secondSurface[25, 25]); + } + + /// + /// Proves a <p:pic>'s own render-time-only decoded picture + /// (resolved fresh every call, rather than cached - see PptxDocument.ResolveAndTrackPictureSurface's + /// own remarks) still paints correctly across two separate + /// calls on the same + /// slide, proving disposing each call's own tracked picture surfaces at the end of that + /// call does not affect a later, independent call. + /// + [Fact] + public void Render_Picture_PaintsCorrectlyAcrossRepeatedRenderCalls() + { + var pngBytes = BuildPngBytes(new Rgba32(10, 20, 30, 255)); + const string spTreeInnerXml = + """ + + + + + + + + + """; + using var stream = BuildRenderPackage(spTreeInnerXml, media: ("png", "image/png", pngBytes)); + using var document = PptxDocument.Open(stream); + + using var firstSurface = document.Render(0, 20, 20); + using var secondSurface = document.Render(0, 20, 20); + + Assert.Equal(new Rgba32(10, 20, 30, 255), firstSurface[10, 10]); + Assert.Equal(new Rgba32(10, 20, 30, 255), secondSurface[10, 10]); + } + + /// Proves a <p:graphicFrame> missing its own direct <p:xfrm> is skipped silently rather than throwing. + [Fact] + public void Render_GraphicFrameMissingXfrm_SkippedSilently() + { + const string spTreeInnerXml = + """ + + + + + + + + + + + + + + + + """; + using var stream = BuildRenderPackage(spTreeInnerXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 20, 20); + + Assert.Equal(new Rgba32(255, 255, 255, 255), surface[10, 10]); + } + + // --- Text rendering ------------------------------------------------------------------------- + + /// Proves a non-placeholder shape's own text body paints glyph ink somewhere inside the shape's own rectangle. + [Fact] + public void Render_NonPlaceholderShapeWithText_PaintsGlyphInkInsideShapeRectangle() + { + const string spTreeInnerXml = + """ + + + + + + + + + Hello + + + """; + using var stream = BuildRenderPackage(spTreeInnerXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 200, 150); + + var paintedAnyInk = false; + for (var y = 0; y < 150 && !paintedAnyInk; y++) + { + for (var x = 0; x < 200; x++) + { + if (surface[x, y] != new Rgba32(255, 255, 255, 255)) + { + paintedAnyInk = true; + break; + } + } + } + + Assert.True(paintedAnyInk, "Expected at least one non-background pixel to be painted for the shape's text."); + } + + /// + /// Proves a placeholder shape's own text content is read from the slide-level shape itself, + /// not from the matched layout placeholder (which declares different text, never used as + /// content, only as a styling-match target) - a layout placeholder supplies styling only. + /// + [Fact] + public void Render_PlaceholderShape_ReadsTextFromSlideLevelShapeNotLayout() + { + const string spTreeInnerXml = + """ + + + + + + + + + + + + + Slide Title + + + """; + const string layoutPlaceholderXml = + """ + + + + + + + + + + Layout placeholder text (never rendered as content) + + + """; + using var stream = BuildRenderPackage(spTreeInnerXml, layoutPlaceholderXml: layoutPlaceholderXml); + using var document = PptxDocument.Open(stream); + + // Rendering must complete without error and paint glyph ink for the slide-level shape's + // own text content, proving placeholder text is read from the slide level, not pulled + // from the matched layout placeholder shape used only as a styling-match target. + using var surface = document.Render(0, 200, 150); + + var paintedAnyInk = false; + for (var y = 0; y < 150 && !paintedAnyInk; y++) + { + for (var x = 0; x < 200; x++) + { + if (surface[x, y] != new Rgba32(255, 255, 255, 255)) + { + paintedAnyInk = true; + break; + } + } + } + + Assert.True(paintedAnyInk, "Expected the slide-level placeholder shape's own text to paint glyph ink."); + } + + /// + /// Proves a placeholder shape whose own <p:spPr/> is present but empty (no + /// <a:xfrm>, no <a:prstGeom>/<a:custGeom> - relying + /// entirely on its matched layout placeholder for position/size/geometry, exactly as real- + /// world (non-PowerPoint-authored) .pptx generators commonly emit) still renders and + /// paints visible content, rather than being silently skipped (an empty-but-present + /// <p:spPr/> must not "win" the slide-vs-layout-vs-master fallback chain the + /// way it does for fill/line, which this project deliberately does not resolve past the + /// first present element - geometry/position, unlike fill/line, has no graceful "absent + /// means no-op" behavior, so failing to walk past an empty element here would either skip + /// the shape outright or throw). + /// + [Fact] + public void Render_PlaceholderShapeWithEmptySpPr_InheritsXfrmAndGeometryFromLayout() + { + const string spTreeInnerXml = + """ + + + + + + + + + + Slide Title + + + """; + const string layoutPlaceholderXml = + """ + + + + + + + + + + + + + Layout placeholder text (never rendered as content) + + + """; + using var stream = BuildRenderPackage(spTreeInnerXml, layoutPlaceholderXml: layoutPlaceholderXml); + using var document = PptxDocument.Open(stream); + + // Rendering must complete without error (no skip, no InvalidDataException from unresolved + // geometry) and paint glyph ink for the slide-level shape's own text content, proving both + // and were inherited from the matched layout placeholder past the + // slide's own empty . + using var surface = document.Render(0, 200, 150); + + var paintedAnyInk = false; + for (var y = 0; y < 150 && !paintedAnyInk; y++) + { + for (var x = 0; x < 200; x++) + { + if (surface[x, y] != new Rgba32(255, 255, 255, 255)) + { + paintedAnyInk = true; + break; + } + } + } + + Assert.True(paintedAnyInk, "Expected the placeholder's inherited geometry/position to allow its own text to paint glyph ink."); + } + + /// + /// Proves a ctrTitle placeholder whose slide-level <a:lstStyle/> is present but + /// empty (no level-override child - the exact shape reported from "ERF IWF Breadboard Peer + /// Review.pptx") resolves its run's effective font size from the idx-matched layout + /// placeholder's own <a:lstStyle>/<a:lvl1pPr>/<a:defRPr + /// sz="6000"> (60pt), not the master's <p:titleStyle> fallback (28pt) - + /// the exact pre-fix symptom magnitude described in the bug report. + /// + [Fact] + public void ResolveEffectiveRunProperties_CtrTitlePlaceholderWithEmptySlideLstStyle_ResolvesLayoutDefRPrSizeNotMasterTitleStyleFallback() + { + const string masterTxStylesXml = + """ + + """; + const string layoutPlaceholderXml = + """ + + + + + + + + + + + + + + Click to edit Master title style + + + """; + + static string BuildSlideXml(string lstStyleXml) => + $""" + + + + + + + + + + {lstStyleXml} + Fluidic Schematics + + + """; + + using var stream = BuildRenderPackage(BuildSlideXml(""), masterTxStylesXml, layoutPlaceholderXml); + using var document = PptxDocument.Open(stream); + + var slide = document.GetSlide(0); + var layout = document.GetLayout(slide.LayoutPartPath); + var master = document.GetMaster(layout.MasterPartPath); + var theme = document.GetTheme(master.ThemePartPath); + var slidePlaceholder = slide.Placeholders[0]; + + var placeholderProperties = PptxDocument.ResolvePlaceholderProperties( + slidePlaceholder, layout.Placeholders, master.Placeholders, theme, master.TxStyles); + + var txBody = slidePlaceholder.ShapeElement.Element("{http://schemas.openxmlformats.org/presentationml/2006/main}txBody")!; + var textBody = PptxDocument.ParseTextBody(txBody); + var paragraph = textBody.Paragraphs[0]; + var run = paragraph.Runs[0]; + + var effective = PptxDocument.ResolveEffectiveRunProperties( + run, paragraph, placeholderProperties, theme, + placeholderProperties.EffectivePlaceholderType ?? slidePlaceholder.Type); + + Assert.Equal(60f * 12700f, effective.SizeEmu); + } + + /// + /// End-to-end proof (through the public + /// API) that a ctrTitle placeholder's own empty-but-present <a:lstStyle/> paints + /// pixel-for-pixel identical glyph ink to the same placeholder whose <a:lstStyle> + /// is omitted entirely - proving the empty element no longer "wins" differently from an + /// absent one, and that the layout's real 60pt override is actually painted (not merely + /// resolved) in both cases. + /// + [Fact] + public void Render_CtrTitlePlaceholderWithEmptySlideLstStyle_PaintsIdenticallyToAbsentLstStyle() + { + const string masterTxStylesXml = + """ + + """; + const string layoutPlaceholderXml = + """ + + + + + + + + + + + + + + Click to edit Master title style + + + """; + + static string BuildSlideXml(string lstStyleXml) => + $""" + + + + + + + + + + {lstStyleXml} + Fluidic Schematics + + + """; + + using var emptyLstStyleStream = BuildRenderPackage(BuildSlideXml(""), masterTxStylesXml, layoutPlaceholderXml); + using var emptyLstStyleDocument = PptxDocument.Open(emptyLstStyleStream); + using var emptyLstStyleSurface = emptyLstStyleDocument.Render(0, 300, 200); + + using var absentLstStyleStream = BuildRenderPackage(BuildSlideXml(string.Empty), masterTxStylesXml, layoutPlaceholderXml); + using var absentLstStyleDocument = PptxDocument.Open(absentLstStyleStream); + using var absentLstStyleSurface = absentLstStyleDocument.Render(0, 300, 200); + + var paintedAnyInk = false; + for (var y = 0; y < 200; y++) + { + for (var x = 0; x < 300; x++) + { + var emptyPixel = emptyLstStyleSurface[x, y]; + if (emptyPixel != new Rgba32(255, 255, 255, 255)) + { + paintedAnyInk = true; + } + + Assert.Equal(emptyPixel, absentLstStyleSurface[x, y]); + } + } + + Assert.True(paintedAnyInk, "Expected the ctrTitle placeholder's inherited 60pt text to paint glyph ink."); + } + + // --- Argument validation --------------------------------------------------------------------- + + /// Proves rejects a negative slide index. + [Fact] + public void Render_NegativeSlideIndex_ThrowsArgumentOutOfRangeException() + { + using var stream = BuildRenderPackage(spTreeInnerXml: string.Empty); + using var document = PptxDocument.Open(stream); + + Assert.Throws(() => document.Render(-1, 10, 10)); + } + + /// Proves rejects a slide index at/beyond . + [Fact] + public void Render_SlideIndexAtSlideCount_ThrowsArgumentOutOfRangeException() + { + using var stream = BuildRenderPackage(spTreeInnerXml: string.Empty); + using var document = PptxDocument.Open(stream); + + Assert.Throws(() => document.Render(1, 10, 10)); + } + + /// Proves propagates 's own non-positive-dimension rejection. + [Fact] + public void Render_NonPositiveWidth_ThrowsArgumentOutOfRangeException() + { + using var stream = BuildRenderPackage(spTreeInnerXml: string.Empty); + using var document = PptxDocument.Open(stream); + + Assert.Throws(() => document.Render(0, 0, 10)); + } + + /// Proves propagates 's own non-positive-dimension rejection for height. + [Fact] + public void Render_NonPositiveHeight_ThrowsArgumentOutOfRangeException() + { + using var stream = BuildRenderPackage(spTreeInnerXml: string.Empty); + using var document = PptxDocument.Open(stream); + + Assert.Throws(() => document.Render(0, 10, 0)); + } + + /// Proves throws once the document has been disposed. + [Fact] + public void Render_DisposedDocument_ThrowsObjectDisposedException() + { + var stream = BuildRenderPackage(spTreeInnerXml: string.Empty); + var document = PptxDocument.Open(stream); + document.Dispose(); + + Assert.Throws(() => document.Render(0, 10, 10)); + } + + /// Proves rejects a non-positive DPI. + [Fact] + public void Render_Dpi_NonPositiveDpi_ThrowsArgumentOutOfRangeException() + { + using var stream = BuildRenderPackage(spTreeInnerXml: string.Empty); + using var document = PptxDocument.Open(stream); + + Assert.Throws(() => document.Render(0, 0f)); + Assert.Throws(() => document.Render(0, -96f)); + } + + /// Proves rejects a non-finite DPI. + [Fact] + public void Render_Dpi_NonFiniteDpi_ThrowsArgumentOutOfRangeException() + { + using var stream = BuildRenderPackage(spTreeInnerXml: string.Empty); + using var document = PptxDocument.Open(stream); + + Assert.Throws(() => document.Render(0, float.NaN)); + Assert.Throws(() => document.Render(0, float.PositiveInfinity)); + } + + /// + /// Proves rejects an + /// extreme-but-finite DPI whose computed pixel width/height would overflow the narrowing + /// cast to or exceed , naming + /// dpi (not width/height) as the offending parameter. + /// + [Fact] + public void Render_Dpi_ExtremeDpiOverflowsPixelDimensions_ThrowsArgumentOutOfRangeException() + { + using var stream = BuildRenderPackage(spTreeInnerXml: string.Empty); + using var document = PptxDocument.Open(stream); + + var ex1 = Assert.Throws(() => document.Render(0, 100000f)); + Assert.Equal("dpi", ex1.ParamName); + + var ex2 = Assert.Throws(() => document.Render(0, float.MaxValue)); + Assert.Equal("dpi", ex2.ParamName); + } + + /// Proves rejects a negative slide index. + [Fact] + public void Render_Dpi_NegativeSlideIndex_ThrowsArgumentOutOfRangeException() + { + using var stream = BuildRenderPackage(spTreeInnerXml: string.Empty); + using var document = PptxDocument.Open(stream); + + Assert.Throws(() => document.Render(-1, 96f)); + } + + /// Proves rejects a slide index at/beyond . + [Fact] + public void Render_Dpi_SlideIndexAtSlideCount_ThrowsArgumentOutOfRangeException() + { + using var stream = BuildRenderPackage(spTreeInnerXml: string.Empty); + using var document = PptxDocument.Open(stream); + + Assert.Throws(() => document.Render(1, 96f)); + } + + // --- DPI convenience overload ----------------------------------------------------------------- + + /// + /// Proves the DPI overload computes the expected pixel width/height from the slide's own + /// EMU size (9,144,000 x 6,858,000 EMU = 10in x 7.5in at 914,400 EMU/inch), preserving the + /// slide's own aspect ratio. + /// + [Fact] + public void Render_DpiOverload_ComputesExpectedPixelDimensionsFromSlideSize() + { + using var stream = BuildRenderPackage(RedFullSlideShapeXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 96f); + + // 10in * 96dpi = 960px; 7.5in * 96dpi = 720px. + Assert.Equal(960, surface.Width); + Assert.Equal(720, surface.Height); + Assert.Equal(new Rgba32(255, 0, 0, 255), surface[480, 360]); + } + + /// + /// Proves a "Section Header" layout-style title placeholder whose slide-level + /// <p:ph idx="0"/> omits type (relying entirely on the idx-matched + /// layout placeholder's own type="title") resolves its run font size from the + /// master's <p:titleStyle> (90pt), not from <p:bodyStyle> (28pt) - + /// the exact reported real-world regression (CanvasNet previously rendered this + /// placeholder's title at roughly 1/3 PowerPoint's own ground-truth size because the + /// render pipeline previously passed the slide placeholder's own raw, schema-defaulted + /// type ("obj") directly into text-style-bucket selection instead of the + /// idx-matched layout placeholder's effective type). + /// + [Fact] + public void ResolveEffectiveRunProperties_SectionHeaderTitlePlaceholderWithOmittedType_ResolvesMasterTitleStyleNotBodyStyle() + { + const string masterTxStylesXml = + """ + + + """; + const string layoutPlaceholderXml = + """ + + + + + + + + + + + + """; + const string spTreeInnerXml = + """ + + + + + + + + + + Section Header Title + + + """; + using var stream = BuildRenderPackage(spTreeInnerXml, masterTxStylesXml, layoutPlaceholderXml); + using var document = PptxDocument.Open(stream); + + var slide = document.GetSlide(0); + var layout = document.GetLayout(slide.LayoutPartPath); + var master = document.GetMaster(layout.MasterPartPath); + var theme = document.GetTheme(master.ThemePartPath); + + var slidePlaceholder = slide.Placeholders[0]; + Assert.Equal("obj", slidePlaceholder.Type); // Schema-defaulted, omitted on the slide itself. + Assert.Null(slidePlaceholder.DeclaredType); + + var placeholderProperties = PptxDocument.ResolvePlaceholderProperties( + slidePlaceholder, layout.Placeholders, master.Placeholders, theme, master.TxStyles); + + // The effective type must be inherited from the idx-matched layout placeholder ("title"), + // not the slide's own schema-defaulted "obj". + Assert.Equal("title", placeholderProperties.EffectivePlaceholderType); + + var txBody = slidePlaceholder.ShapeElement.Element("{http://schemas.openxmlformats.org/presentationml/2006/main}txBody")!; + var textBody = PptxDocument.ParseTextBody(txBody); + var paragraph = textBody.Paragraphs[0]; + var run = paragraph.Runs[0]; + + // This mirrors exactly what RenderShape passes into ResolveEffectiveRunProperties. + var effective = PptxDocument.ResolveEffectiveRunProperties( + run, paragraph, placeholderProperties, theme, + placeholderProperties.EffectivePlaceholderType ?? slidePlaceholder.Type); + + Assert.Equal(90f * 12700f, effective.SizeEmu); + } + + /// + /// Proves an attribute-less <a:normAutofit/> on a subtitle placeholder whose + /// content genuinely fits its declared box does not over-shrink: the resolved + /// stays at 1.0 rather than being + /// driven toward the shrink loop's floor. This directly tests (and refutes, for this + /// scenario) the "subtitle sliver" bug's second hypothesis - that the bounded shrink loop + /// itself over-shrinks given correct inputs - documented as confirmed-correct behavior to + /// guard against a future regression of this mechanism (see this test class's remarks and + /// the companion planning report for the full investigation). + /// + [Fact] + public void ResolveTextLayout_SubtitlePlaceholderWithAttributeLessNormAutofit_DoesNotOverShrinkContentThatFits() + { + const string masterTxStylesXml = + """ + + """; + const string layoutPlaceholderXml = + """ + + + + + + + + + + + + """; + const string spTreeInnerXml = + """ + + + + + + + + + + Subtitle + + + """; + using var stream = BuildRenderPackage(spTreeInnerXml, masterTxStylesXml, layoutPlaceholderXml); + using var document = PptxDocument.Open(stream); + + var slide = document.GetSlide(0); + var layout = document.GetLayout(slide.LayoutPartPath); + var master = document.GetMaster(layout.MasterPartPath); + var theme = document.GetTheme(master.ThemePartPath); + + var slidePlaceholder = slide.Placeholders[0]; + + var placeholderProperties = PptxDocument.ResolvePlaceholderProperties( + slidePlaceholder, layout.Placeholders, master.Placeholders, theme, master.TxStyles); + + var txBody = slidePlaceholder.ShapeElement.Element("{http://schemas.openxmlformats.org/presentationml/2006/main}txBody")!; + var textBody = PptxDocument.ParseTextBody(txBody); + + Assert.NotNull(placeholderProperties.EffectiveXfrmElement); + var frame = PptxDocument.ResolveShapeFrame(placeholderProperties.EffectiveXfrmElement); + + var layoutResult = PptxDocument.ResolveTextLayout( + textBody, placeholderProperties, theme, + placeholderProperties.EffectivePlaceholderType ?? slidePlaceholder.Type, + frame.WidthEmu, frame.HeightEmu, + (family, bold, italic) => DemaConsulting.CanvasNet.Fonts.SystemFontCatalog.LoadBundledFallback(bold, italic, bold, italic)); + + Assert.Equal(1f, layoutResult.AppliedFontScale); + } + + /// + /// End-to-end proof (through the public + /// API, not just the internal resolver) that a "Section Header" title placeholder whose + /// slide-level <p:ph idx="0"/> omits type paints pixel-for-pixel + /// identical glyph ink to the same placeholder declaring its type explicitly + /// (type="title") - both must resolve to the master's <p:titleStyle> + /// font size. Before the fix, the omitted-type variant painted visibly smaller (28pt + /// <p:bodyStyle>) glyph ink than the explicit-type variant (90pt + /// <p:titleStyle>), so the two renders differed. + /// + [Fact] + public void Render_SectionHeaderTitleWithOmittedType_PaintsIdenticallyToExplicitTitleType() + { + const string masterTxStylesXml = + """ + + + """; + const string layoutPlaceholderXml = + """ + + + + + + + + + + + + """; + + string BuildSlideXml(string phXml) => + $""" + + + + + {phXml} + + + + + Section Header Title + + + """; + + using var omittedTypeStream = BuildRenderPackage(BuildSlideXml(""""""), masterTxStylesXml, layoutPlaceholderXml); + using var omittedTypeDocument = PptxDocument.Open(omittedTypeStream); + using var omittedTypeSurface = omittedTypeDocument.Render(0, 300, 160); + + using var explicitTypeStream = BuildRenderPackage(BuildSlideXml(""""""), masterTxStylesXml, layoutPlaceholderXml); + using var explicitTypeDocument = PptxDocument.Open(explicitTypeStream); + using var explicitTypeSurface = explicitTypeDocument.Render(0, 300, 160); + + var paintedAnyInk = false; + for (var y = 0; y < 160; y++) + { + for (var x = 0; x < 300; x++) + { + var omittedPixel = omittedTypeSurface[x, y]; + if (omittedPixel != new Rgba32(255, 255, 255, 255)) + { + paintedAnyInk = true; + } + + Assert.Equal(omittedPixel, explicitTypeSurface[x, y]); + } + } + + Assert.True(paintedAnyInk, "Expected the title placeholder's text to paint glyph ink."); + } + + // --- Master/layout non-placeholder decorative shape rendering (Phase 2 Follow-Up) --------- + + /// + /// Builds a two-slide, two-layout, one-master package (presentation -> 2x slide -> + /// 2x layout -> 1x master -> theme): slide index 0 uses layout 1 (whose own + /// <p:spTree> inner content is ), slide + /// index 1 uses layout 2 () - both layouts share the + /// same, otherwise-empty master. Used by + /// to + /// prove a layout's own decorative shapes paint only on slides using that specific layout, + /// not on sibling slides using a different layout that shares the same master. + /// + private static Stream BuildTwoLayoutTwoSlideRenderPackage(string layout1ShapeTreeXml, string layout2ShapeTreeXml) + { + const string contentTypesXml = + """ + + + + + + """; + + const string packageRelsXml = + """ + + + + + """; + + const string presentationXml = + """ + + + + + """; + + const string presentationRelsXml = + """ + + + + + + """; + + const string emptySpTreeSlideXml = + """ + + + + + + """; + + const string slide1RelsXml = + """ + + + + + """; + + const string slide2RelsXml = + """ + + + + + """; + + var layout1Xml = + $""" + + + {layout1ShapeTreeXml} + + + """; + + var layout2Xml = + $""" + + + {layout2ShapeTreeXml} + + + """; + + const string layoutRelsXml = + """ + + + + + """; + + const string masterXml = + """ + + + + + + + + """; + + const string masterRelsXml = + """ + + + + + """; + + const string themeXml = + """ + + + + + + + + + + + + + + + + + + + + + + + """; + + var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + WriteTextEntry(archive, "[Content_Types].xml", contentTypesXml); + WriteTextEntry(archive, "_rels/.rels", packageRelsXml); + WriteTextEntry(archive, "ppt/presentation.xml", presentationXml); + WriteTextEntry(archive, "ppt/_rels/presentation.xml.rels", presentationRelsXml); + WriteTextEntry(archive, "ppt/slides/slide1.xml", emptySpTreeSlideXml); + WriteTextEntry(archive, "ppt/slides/_rels/slide1.xml.rels", slide1RelsXml); + WriteTextEntry(archive, "ppt/slides/slide2.xml", emptySpTreeSlideXml); + WriteTextEntry(archive, "ppt/slides/_rels/slide2.xml.rels", slide2RelsXml); + WriteTextEntry(archive, "ppt/slideLayouts/slideLayout1.xml", layout1Xml); + WriteTextEntry(archive, "ppt/slideLayouts/_rels/slideLayout1.xml.rels", layoutRelsXml); + WriteTextEntry(archive, "ppt/slideLayouts/slideLayout2.xml", layout2Xml); + WriteTextEntry(archive, "ppt/slideLayouts/_rels/slideLayout2.xml.rels", layoutRelsXml); + WriteTextEntry(archive, "ppt/slideMasters/slideMaster1.xml", masterXml); + WriteTextEntry(archive, "ppt/slideMasters/_rels/slideMaster1.xml.rels", masterRelsXml); + WriteTextEntry(archive, "ppt/theme/theme1.xml", themeXml); + } + + stream.Position = 0; + return stream; + } + + /// A full-slide-sized freeform shape with a solid fill of the given color, as a layout/master decorative shape. + private static string FullSlideShapeXml(string name, string hexColor) => + $""" + + + + + + + + + + + + + """; + + /// A full-slide-sized freeform shape with a solid fill resolving an <a:schemeClr val="..."/> scheme-color slot name. + private static string FullSlideShapeSchemeClrXml(string name, string schemeVal) => + $""" + + + + + + + + + + + + + """; + + /// + /// Proves a slide's own <p:clrMapOvr>/<a:overrideClrMapping> is + /// consulted when resolving a shape's <a:schemeClr val="bg1"/> fill: without + /// the fix, bg1 is hardcoded to the theme's Light1 slot regardless of any + /// slide-level override; with the fix, an inverted color map (bg1 -> dk1) makes + /// the same shape resolve to the theme's Dark1 slot instead. + /// + [Fact] + public void Render_SlideClrMapOvrInvertsBg1_SchemeClrFillResolvesToOverriddenSlotNotHardcodedAlias() + { + var shapeXml = FullSlideShapeSchemeClrXml("Bg1Shape", "bg1"); + const string invertedClrMapOvr = """"""; + using var stream = BuildRenderPackage(shapeXml, slideClrMapOvrXml: invertedClrMapOvr); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 20, 20); + + // The test theme's Dark1 slot is 101010 (see BuildRenderPackage's own themeXml) - without + // the clrMap/clrMapOvr fix, this would instead sample Light1 (F0F0F0). + Assert.Equal(new Rgba32(0x10, 0x10, 0x10, 255), surface[10, 10]); + } + + /// + /// Proves that, absent any <p:clrMapOvr>, a shape's <a:schemeClr + /// val="bg1"/> fill still resolves through the master's own (identity) <p:clrMap> + /// to Light1, preserving this unit's pre-existing baseline behavior. + /// + [Fact] + public void Render_NoClrMapOvr_SchemeClrFillResolvesToMasterColorMapDefault() + { + var shapeXml = FullSlideShapeSchemeClrXml("Bg1Shape", "bg1"); + using var stream = BuildRenderPackage(shapeXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 20, 20); + + Assert.Equal(new Rgba32(0xF0, 0xF0, 0xF0, 255), surface[10, 10]); + } + + /// A full-slide-sized freeform shape with a <p:style> declaring the given <a:fillRef>/<a:lnRef> inner XML, and optionally an explicit <a:solidFill>/<a:ln> of its own. + private static string FullSlideShapeStyleRefXml( + string name, string fillRefXml = "", string lnRefXml = "", string? explicitFillHexColor = null, string explicitLnXml = "", + string xfrmXml = """""") => + $""" + + + + + + + + {xfrmXml} + + {(explicitFillHexColor is not null ? $"""""" : string.Empty)} + {explicitLnXml} + + + {fillRefXml} + {lnRefXml} + + + """; + + /// + /// Proves a shape declaring only a <p:style>/<a:fillRef idx="1"/> shape-style + /// reference (no explicit <p:spPr> fill at all) resolves its fill entirely from + /// the theme's <a:fmtScheme>/<a:fillStyleLst>, substituting the + /// <a:fillRef>'s own color child for the matched entry's phClr token - + /// the direct regression test for the customer-reported "Shape Styles gallery" symptom. + /// + [Fact] + public void Render_ShapeWithOnlyStyleFillRefNoExplicitSpPrFill_RendersResolvedTintedAccentColor() + { + const string fillRefXml = """"""; + const string fillStyleListXml = """"""; + var shapeXml = FullSlideShapeStyleRefXml("StyleFillShape", fillRefXml: fillRefXml); + using var stream = BuildRenderPackage(shapeXml, themeFillStyleListXml: fillStyleListXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 20, 20); + + Assert.Equal(new Rgba32(0xFF, 0x00, 0xFF, 255), surface[10, 10]); + } + + /// + /// Proves an explicit <a:solidFill> on a shape's own <p:spPr> + /// always wins over its own <p:style>/<a:fillRef>, even when both are + /// present on the same shape. + /// + [Fact] + public void Render_ShapeWithBothExplicitSpPrFillAndStyleFillRef_ExplicitFillWins() + { + const string fillRefXml = """"""; + const string fillStyleListXml = """"""; + var shapeXml = FullSlideShapeStyleRefXml("StyleFillShape", fillRefXml: fillRefXml, explicitFillHexColor: "00FF00"); + using var stream = BuildRenderPackage(shapeXml, themeFillStyleListXml: fillStyleListXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 20, 20); + + Assert.Equal(new Rgba32(0x00, 0xFF, 0x00, 255), surface[10, 10]); + } + + /// + /// Proves a shape declaring only a <p:style>/<a:lnRef idx="1"/> shape-style + /// reference (no explicit <a:ln> at all) resolves its stroke entirely from the + /// theme's <a:fmtScheme>/<a:lnStyleLst>, substituting the + /// <a:lnRef>'s own color child for the matched entry's phClr token. + /// + [Fact] + public void Render_ShapeWithOnlyStyleLnRefNoExplicitLn_RendersResolvedStrokeColor() + { + const string lnRefXml = """"""; + const string lnStyleListXml = + """ + + + + """; + const string fillRefXml = """"""; + const string xfrmXml = """"""; + var shapeXml = FullSlideShapeStyleRefXml("StyleLnShape", fillRefXml: fillRefXml, lnRefXml: lnRefXml, xfrmXml: xfrmXml); + using var stream = BuildRenderPackage(shapeXml, themeLnStyleListXml: lnStyleListXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 200, 200); + + // The lnRef-resolved stroke (900000 EMU wide, centered on the shape's inset top edge at + // y=685800 EMU, i.e. pixel row 20 of 200) is thick enough to fully cover several pixel + // rows either side of its centerline - sampled well away from any corner so only this one + // edge's stroke band (not an adjacent edge's) can contribute to the sampled pixel. + Assert.Equal(new Rgba32(0xFF, 0x00, 0xFF, 255), surface[100, 20]); + } + + /// + /// Proves a present <a:ln> (even with no recognized fill child of its own) + /// always wins over a shape's own <p:style>/<a:lnRef> - only a fully + /// absent <a:ln> falls back to it. + /// + [Fact] + public void Render_ShapeWithExplicitLnNoFillAndStyleLnRef_ExplicitLnWins() + { + const string lnRefXml = """"""; + const string lnStyleListXml = + """ + + + + """; + const string fillRefXml = """"""; + const string explicitLnXml = """"""; + const string xfrmXml = """"""; + var shapeXml = FullSlideShapeStyleRefXml("StyleLnShape", fillRefXml: fillRefXml, lnRefXml: lnRefXml, explicitLnXml: explicitLnXml, xfrmXml: xfrmXml); + using var stream = BuildRenderPackage(shapeXml, themeLnStyleListXml: lnStyleListXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 200, 200); + + // The explicit wins - no stroke paints, leaving the default + // opaque-white clear at the same pixel the lnRef's own stroke would otherwise have + // fully covered (see the sibling fact above). + Assert.Equal(new Rgba32(255, 255, 255, 255), surface[100, 20]); + } + + /// + /// Proves the fix for the reported bug: a present <a:ln> that declares a + /// width but no recognized fill-definition child of its own (<a:ln w="76200"/>, + /// the exact reported construct) keeps its own, narrower width but defers its color to the + /// shape's own <p:style>/<a:lnRef> - it no longer silently resolves to + /// "no stroke" (the pre-fix behavior), and it does not merge the style's own, much wider + /// line width either. + /// + [Fact] + public void Render_ShapeWithLnWidthOnlyNoFillChildAndStyleLnRef_UsesOwnWidthAndStyleColor() + { + const string lnRefXml = """"""; + const string lnStyleListXml = + """ + + + + """; + const string fillRefXml = """"""; + const string explicitLnXml = """"""; + const string xfrmXml = """"""; + var shapeXml = FullSlideShapeStyleRefXml("StyleLnShape", fillRefXml: fillRefXml, lnRefXml: lnRefXml, explicitLnXml: explicitLnXml, xfrmXml: xfrmXml); + using var stream = BuildRenderPackage(shapeXml, themeLnStyleListXml: lnStyleListXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 200, 200); + + // Directly on the shape's own top edge (y=685800 EMU, pixel row 20 of 200), the stroke + // paints the style's own magenta color - proving the color fallback fires for a + // fill-less-but-present . + Assert.Equal(new Rgba32(0xFF, 0x00, 0xFF, 255), surface[100, 20]); + + // At pixel row 25 - outside the shape's own narrow 76200 EMU-wide stroke band, but well + // inside where the style's own much wider 900000 EMU stroke would have reached - the + // pixel is left as the default opaque-white clear, proving width is the shape's own + // (narrow), never merged in from the style (wide). + Assert.Equal(new Rgba32(255, 255, 255, 255), surface[100, 25]); + } + + /// + /// Proves the fix for the reported real-world bug: an ellipse shape with + /// <a:noFill/> and an <a:ln> that declares only a color (no + /// w attribute at all - the exact reported construct) now paints a visible stroke + /// at PowerPoint's own default stroke width, instead of rendering completely invisibly + /// (the pre-fix behavior, since an absent w used to collapse to the same "no + /// stroke" outcome as an explicit w="0"). + /// + /// + /// Rendered against a deliberately small 1"x1" slide (914400x914400 EMU) at 200x200px + /// (scale: 914400 / 200 = 4572 EMU/pixel) so the new 9525 EMU default stroke width + /// (~2.08px at this scale) survives anti-aliased rendering as solid, easily-sampled + /// pixels - at the full 9144000 EMU slide width used by every other render test in this + /// class, 9525 EMU would be sub-pixel (~0.21px) and not reliably sampled. + /// + [Fact] + public void Render_EllipseWithNoFillAndLnMissingWidthButRecognizedColor_PaintsDefaultWidthStroke() + { + const string spTreeInnerXml = + """ + + + + + + + + + + + + + + """; + using var stream = BuildRenderPackage(spTreeInnerXml, slideWidthEmu: 914400f, slideHeightEmu: 914400f); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 200, 200); + + // The ellipse spans x/y in [152400, 762000] EMU (center 457200,457200; radius 304800), + // i.e. pixel columns/rows [33.33, 166.67] of 200 at this slide's 4572 EMU/pixel scale. + // Sampled directly on the ellipse's own top-edge stroke band (pixel column 100 = the + // ellipse's horizontal center; pixel row 33 = the ellipse's own top boundary) - a solid + // red stroke pixel proves the default 9525 EMU width now paints visibly. + Assert.Equal(new Rgba32(0xFF, 0x00, 0x00, 255), surface[100, 33]); + + // Sampled well inside the ellipse's interior (its own center) - still the default opaque- + // white clear, proving still means no interior fill; this width-only fix does + // not affect fill resolution at all. + Assert.Equal(new Rgba32(255, 255, 255, 255), surface[100, 100]); + + // Sampled well outside the ellipse's own bounding box entirely (a corner of the slide) - + // also the default opaque-white clear, proving the stroke did not spill far beyond its own + // narrow (~2px) band. + Assert.Equal(new Rgba32(255, 255, 255, 255), surface[5, 5]); + } + + /// + /// Proves the fix for the reported high-priority real-world regression at the ACTUAL scale + /// a real-world .pptx file uses: a full-size standard slide + /// (9144000x6858000 EMU, rendered at 960x720 - exactly 9525 EMU/pixel, + /// i.e. PowerPoint's own default <a:ln> width), an ellipse (Oval 10 in + /// the originally reported file) with <a:noFill/> and an <a:ln> + /// that declares only a color (no w attribute - PowerPoint's own default width) + /// must render as a thin ring - its own center must remain the slide's background color, + /// NOT the stroke color - rather than a solid-filled disc. + /// + /// + /// The existing small-slide/200x200px + /// + /// test alone does NOT catch this regression: at that small scale, the ellipse's own + /// native-EMU-space Bezier curve flattens to far fewer tessellation points (its own radius + /// is far smaller in EMU), so the false inner-ring collapse this test guards against never + /// triggers there. Only at a realistic, full-size slide's own EMU magnitude (hundreds of + /// thousands to low millions of EMU) does StrokeOutliner.BuildClosedSide's inner-ring + /// collapse detector false-positive (before the fix) on the resulting high tessellation + /// density, discarding the inner ring entirely and rendering the whole ellipse interior as + /// solid stroke - the exact defect reported against the real-world file. + /// + [Fact] + public void Render_FullSlideScaleEllipseWithNoFillAndLnMissingWidth_RendersThinRingNotSolidDisc() + { + const string spTreeInnerXml = + """ + + + + + + + + + + + + + + """; + using var stream = BuildRenderPackage(spTreeInnerXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 960, 720); + + // The ellipse spans x/y in [2667000, 6477000] EMU (center 4572000,3429000; radius + // 1905000), i.e. pixel columns/rows [280, 680] of 960x720 at this slide's exact 9525 + // EMU/pixel scale - pixel (480, 360) is therefore the ellipse's own exact center. + Assert.Equal(new Rgba32(255, 255, 255, 255), surface[480, 360]); + + // The ellipse's own top boundary (pixel row 160) must show the stroke's red color, not + // the background - proving the ring itself still paints (this is not simply "nothing + // rendered at all"). + var boundaryPixel = surface[480, 160]; + Assert.True( + boundaryPixel.R > 128 && boundaryPixel.G < 128 && boundaryPixel.B < 128, + $"Expected a red-dominant boundary stroke pixel, but got {boundaryPixel}."); + + // A corner of the slide, well outside the ellipse's own bounding box, remains the default + // opaque-white clear. + Assert.Equal(new Rgba32(255, 255, 255, 255), surface[5, 5]); + } + + /// + /// Proves the same fix applies generally, not only to ellipses: a full-size slide's own + /// roundRect shape with <a:noFill/> and a color-only (default-width) + /// <a:ln> must also render as a thin outline, not a solid-filled shape - the + /// investigation behind this fix confirmed roundRect (enough curved, Bezier- + /// flattened tessellation vertices to trigger the same false inner-ring collapse) is + /// equally affected, while a plain rect (too few vertices - no curves at all) is + /// not. + /// + [Fact] + public void Render_FullSlideScaleRoundRectWithNoFillAndLnMissingWidth_RendersThinOutlineNotSolidFill() + { + const string spTreeInnerXml = + """ + + + + + + + + + + + + + + """; + using var stream = BuildRenderPackage(spTreeInnerXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 960, 720); + + // The shape spans x/y in [2667000, 6477000] EMU (280..680px) at this slide's exact 9525 + // EMU/pixel scale; pixel (480, 360) is comfortably inside its interior, away from the + // rounded corners' own geometry, and must remain the background color. + Assert.Equal(new Rgba32(255, 255, 255, 255), surface[480, 360]); + + // The shape's own top edge (pixel row 160, away from either rounded corner) must show the + // stroke's red color. + var boundaryPixel = surface[480, 160]; + Assert.True( + boundaryPixel.R > 128 && boundaryPixel.G < 128 && boundaryPixel.B < 128, + $"Expected a red-dominant boundary stroke pixel, but got {boundaryPixel}."); + + // A corner of the slide, well outside the shape's own bounding box, remains the default + // opaque-white clear. + Assert.Equal(new Rgba32(255, 255, 255, 255), surface[5, 5]); + } + + /// + /// Proves a master's own non-placeholder <p:pic> paints on a slide using that + /// master even when the slide's own shape tree is empty, and that the picture's embedded- + /// image relationship resolves against the master's own .rels file + /// rather than the slide's - a slide-owned relationship is deliberately given the exact + /// same rId2 identifier but targets a different (wrong) image, proving the + /// ownerPartPath fix (see the companion planning report) is load-bearing and not + /// accidentally correct. + /// + [Fact] + public void Render_MasterNonPlaceholderPicture_PaintsOnEverySlideUsingThatMasterResolvingOwnRels() + { + var masterPngBytes = BuildPngBytes(new Rgba32(10, 20, 30, 255)); + var wrongSlidePngBytes = BuildPngBytes(new Rgba32(200, 0, 200, 255)); + const string masterShapeTreeXml = + """ + + + + + + + + + """; + using var stream = BuildRenderPackage( + spTreeInnerXml: string.Empty, + masterShapeTreeXml: masterShapeTreeXml, + masterMedia: ("png", "image/png", masterPngBytes), + media: ("png", "image/png", wrongSlidePngBytes)); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 20, 20); + + // If RenderPicture incorrectly resolved the master's own against + // the slide's own .rels (which also declares an rId2, deliberately targeting a different + // image), this would instead sample the wrong (200, 0, 200) picture. + Assert.Equal(new Rgba32(10, 20, 30, 255), surface[10, 10]); + } + + /// + /// Proves a layout's own decorative, non-placeholder autoshape paints only on slides using + /// that specific layout, not on a sibling slide using a different layout that shares the + /// same master. + /// + [Fact] + public void Render_LayoutNonPlaceholderAutoshape_PaintsOnlyOnSlidesUsingThatLayout() + { + var layout1ShapeTreeXml = FullSlideShapeXml("LayoutAShape", "00FF00"); + using var stream = BuildTwoLayoutTwoSlideRenderPackage(layout1ShapeTreeXml, layout2ShapeTreeXml: string.Empty); + using var document = PptxDocument.Open(stream); + + using var surfaceUsingLayout1 = document.Render(0, 20, 20); + using var surfaceUsingLayout2 = document.Render(1, 20, 20); + + Assert.Equal(new Rgba32(0, 255, 0, 255), surfaceUsingLayout1[10, 10]); + Assert.Equal(new Rgba32(255, 255, 255, 255), surfaceUsingLayout2[10, 10]); + } + + /// Proves a slide-level shape paints on top of an overlapping master decorative shape (z-order: slide content on top). + [Fact] + public void Render_MasterAndSlideShapesOverlap_SlideShapePaintsOnTopOfMasterShape() + { + var masterShapeTreeXml = FullSlideShapeXml("MasterShape", "FF0000"); + var slideShapeXml = FullSlideShapeXml("SlideShape", "0000FF"); + using var stream = BuildRenderPackage(slideShapeXml, masterShapeTreeXml: masterShapeTreeXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 20, 20); + + Assert.Equal(new Rgba32(0, 0, 255, 255), surface[10, 10]); + } + + /// Proves a layout decorative shape paints on top of an overlapping master decorative shape (z-order: layout above master). + [Fact] + public void Render_MasterAndLayoutShapesOverlap_LayoutShapePaintsOnTopOfMasterShape() + { + var masterShapeTreeXml = FullSlideShapeXml("MasterShape", "FF0000"); + var layoutShapeXml = FullSlideShapeXml("LayoutShape", "00FF00"); + using var stream = BuildRenderPackage(spTreeInnerXml: string.Empty, layoutPlaceholderXml: layoutShapeXml, masterShapeTreeXml: masterShapeTreeXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 20, 20); + + Assert.Equal(new Rgba32(0, 255, 0, 255), surface[10, 10]); + } + + /// + /// Proves a master's own placeholder shape never paints its own prompt content, while a + /// sibling non-placeholder decorative shape on the same master still renders normally. + /// + [Fact] + public void Render_MasterPlaceholderShape_DoesNotRenderItsOwnPromptContent() + { + const string masterShapeTreeXml = + """ + + + + + + + + + + + + + + + + + + + + + + + + + """; + using var stream = BuildRenderPackage(spTreeInnerXml: string.Empty, masterShapeTreeXml: masterShapeTreeXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 100, 100); + + // The master's own title placeholder declares a full-slide red fill - it must never + // paint, so the bottom-right corner (outside the small decorative shape's own footprint) + // remains the cleared default white background. + Assert.Equal(new Rgba32(255, 255, 255, 255), surface[90, 90]); + + // The master's own non-placeholder decorative shape (small blue rect, top-left corner) + // still paints normally. + Assert.Equal(new Rgba32(0, 0, 255, 255), surface[1, 1]); + } + + /// + /// Proves a layout's own placeholder shape never paints its own prompt content, while a + /// sibling non-placeholder decorative shape on the same layout still renders normally. + /// + [Fact] + public void Render_LayoutPlaceholderShape_DoesNotRenderItsOwnPromptContent() + { + const string layoutShapeTreeXml = + """ + + + + + + + + + + + + + + + + + + + + + + + + + """; + using var stream = BuildRenderPackage(spTreeInnerXml: string.Empty, layoutPlaceholderXml: layoutShapeTreeXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 100, 100); + + // The layout's own title placeholder declares a full-slide red fill - it must never + // paint, so the bottom-right corner (outside the small decorative shape's own footprint) + // remains the cleared default white background. + Assert.Equal(new Rgba32(255, 255, 255, 255), surface[90, 90]); + + // The layout's own non-placeholder decorative shape (small blue rect, top-left corner) + // still paints normally. + Assert.Equal(new Rgba32(0, 0, 255, 255), surface[1, 1]); + } + + /// + /// Regression test (see the companion planning report's bug-fix rationale): a master's own + /// non-placeholder <p:pic> referencing an unsupported raster format (an EMF + /// vector picture) must be skipped silently - its own + /// must not abort the rest of the render - so the slide's own content still paints + /// normally. + /// + [Fact] + public void Render_MasterNonPlaceholderPictureWithUnsupportedFormat_SkipsThatShapeAndStillRendersSlideContent() + { + const string masterShapeTreeXml = + """ + + + + + + + + + """; + var slideShapeXml = FullSlideShapeXml("SlideShape", "0000FF"); + using var stream = BuildRenderPackage( + slideShapeXml, + masterShapeTreeXml: masterShapeTreeXml, + masterMedia: ("emf", "image/x-emf", [1, 2, 3, 4])); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 20, 20); + + // The master's own broken EMF picture shape was skipped - the slide's own shape still + // paints over the full slide footprint. + Assert.Equal(new Rgba32(0, 0, 255, 255), surface[10, 10]); + } + + /// + /// Regression test (see the companion planning report's parse-time containment fix): a + /// master's own <p:graphicFrame> declaring an unsupported (non-table, + /// non-chart - here a SmartArt/diagram graphic-frame kind) kind must not abort + /// parsing that master - which would otherwise abort + /// for every slide + /// sharing that master - so the slide's own content still renders normally. + /// + [Fact] + public void Render_MasterGraphicFrameWithUnsupportedKind_SkipsThatShapeAndStillRendersSlideContent() + { + const string masterShapeTreeXml = + """ + + + + + + + + + + + + + """; + var slideShapeXml = FullSlideShapeXml("SlideShape", "0000FF"); + using var stream = BuildRenderPackage(slideShapeXml, masterShapeTreeXml: masterShapeTreeXml); + using var document = PptxDocument.Open(stream); + + using var surface = document.Render(0, 20, 20); + + // The master's own unsupported-kind graphic frame was skipped at parse time - the + // slide's own shape still paints over the full slide footprint. + Assert.Equal(new Rgba32(0, 0, 255, 255), surface[10, 10]); + } + + /// + /// Regression test (see the companion planning report's bug-fix rationale): a + /// slide's own (not a master/layout's) <p:pic> referencing an + /// unsupported raster format must still hard-fail + /// with , exactly as before the master/layout + /// graceful-skip containment was added - proving the containment is scoped to master/ + /// layout-owned shapes only. + /// + [Fact] + public void Render_SlideOwnPictureWithUnsupportedFormat_StillThrowsPptxUnsupportedFeatureException() + { + const string spTreeInnerXml = + """ + + + + + + + + + """; + using var stream = BuildRenderPackage(spTreeInnerXml, media: ("emf", "image/x-emf", [1, 2, 3, 4])); + using var document = PptxDocument.Open(stream); + + Assert.Throws(() => document.Render(0, 20, 20)); + } +} diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxSystemIntegrationTests.cs b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxSystemIntegrationTests.cs new file mode 100644 index 00000000..6b68cff5 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxSystemIntegrationTests.cs @@ -0,0 +1,1093 @@ +using System.IO.Compression; +using System.Text; +using System.Xml.Linq; +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Codecs; + +namespace DemaConsulting.CanvasNet.Pptx.Tests; + +// cspell:ignore pptx ooxml sppr nvsppr nvpr grpsp spsrc xfrm chOff chExt prstGeom srgbclr schemeclr hlink Calibri prst +// cspell:ignore pic blipfill nvpicpr cnvpicpr graphicframe nvgraphicframepr cnvgraphicframepr tbl tblgrid gridcol tcpr +// cspell:ignore nvgrpsppr grpsppr + +/// +/// System-level integration tests for the OOXML package layer via the CanvasNet.Pptx +/// package. Each test proves a CanvasNetPptx-* top-level requirement end-to-end +/// through the public API, complementing (never replacing) the +/// unit-level PptxDocument_* coverage in , which +/// exercises each feature's finer-grained behavioral variations. +/// +public class PptxSystemIntegrationTests +{ + /// + /// Builds a minimal, well-formed, in-memory .pptx-shaped ZIP package: a + /// [Content_Types].xml declaring the default .rels/.xml extensions + /// plus a specific override for ppt/presentation.xml, a package-level + /// _rels/.rels relationship to that part, and the (otherwise unparsed, this phase) + /// ppt/presentation.xml part itself - representative of the minimum a real + /// PowerPoint-produced .pptx file would always contain. + /// + private static Stream BuildWellFormedPresentationPackage() + { + const string contentTypesXml = + """ + + + + + + + """; + + const string packageRelsXml = + """ + + + + + """; + + const string presentationXml = + """ + + + + + """; + + const string presentationRelsXml = + """ + + + + + """; + + var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + WriteEntry(archive, "[Content_Types].xml", contentTypesXml); + WriteEntry(archive, "_rels/.rels", packageRelsXml); + WriteEntry(archive, "ppt/presentation.xml", presentationXml); + WriteEntry(archive, "ppt/_rels/presentation.xml.rels", presentationRelsXml); + } + + stream.Position = 0; + return stream; + } + + private static void WriteEntry(ZipArchive archive, string name, string content) + { + var entry = archive.CreateEntry(name); + using var entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream, Encoding.UTF8); + writer.Write(content); + } + + /// + /// Opens a well-formed, minimal presentation package end-to-end and proves its + /// package-level relationship and presentation-part content resolve correctly - + /// demonstrating the "open once, resolve package structure" property that + /// 's Phase 1a package layer exists to provide - purely through + /// the public surface (/ + /// ), matching this suite's own stated intent of + /// exercising only consumer-visible behavior (the equivalent internal-API assertions, + /// calling ResolveRelationship/ResolvePart directly, already live in the + /// unit-level ). This is also the shared platform-proof + /// test referenced by every CanvasNetPptx-Platform-* requirement. + /// + [Fact] + public void CanvasNetPptx_SystemIntegration_PptxOpen_SucceedsOnWellFormedPackage() + { + // Arrange + using var stream = BuildWellFormedPresentationPackage(); + + // Act + using var document = PptxDocument.Open(stream); + + // Assert + Assert.Equal(1, document.SlideCount); + Assert.Equal(new PptxSlideSize(9144000, 6858000), document.SlideSize); + } + + /// Proves rejects a null stream argument end-to-end. + [Fact] + public void CanvasNetPptx_SystemIntegration_PptxOpenValidationNull_NullStreamThrowsArgumentNullException() + { + // Act / Assert + Assert.Throws(() => PptxDocument.Open((Stream)null!)); + } + + /// Proves rejects an empty path end-to-end. + [Fact] + public void CanvasNetPptx_SystemIntegration_PptxOpenValidationEmptyPath_EmptyPathThrowsArgumentException() + { + // Act / Assert + Assert.Throws(() => PptxDocument.Open(string.Empty)); + } + + /// + /// Opens a multi-slide presentation package end-to-end and proves both + /// and resolve + /// correctly from ppt/presentation.xml's <p:sldIdLst>/<p:sldSz>. + /// + [Fact] + public void CanvasNetPptx_SystemIntegration_PptxOpenPresentation_SlideCountAndSizeResolveEndToEnd() + { + // Arrange + const string contentTypesXml = + """ + + + + + + """; + + const string packageRelsXml = + """ + + + + + """; + + const string presentationXml = + """ + + + + + + + + """; + + const string presentationRelsXml = + """ + + + + + + """; + + var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + WriteEntry(archive, "[Content_Types].xml", contentTypesXml); + WriteEntry(archive, "_rels/.rels", packageRelsXml); + WriteEntry(archive, "ppt/presentation.xml", presentationXml); + WriteEntry(archive, "ppt/_rels/presentation.xml.rels", presentationRelsXml); + } + + stream.Position = 0; + + // Act + using var document = PptxDocument.Open(stream); + + // Assert + Assert.Equal(2, document.SlideCount); + Assert.Equal(9144000L, document.SlideSize.WidthEmu); + Assert.Equal(6858000L, document.SlideSize.HeightEmu); + } + + /// + /// Proves opening a presentation package whose <p:sldIdLst> is empty throws + /// end-to-end. + /// + [Fact] + public void CanvasNetPptx_SystemIntegration_PptxOpenValidationEmptySlideList_ThrowsInvalidDataException() + { + // Arrange + const string contentTypesXml = + """ + + + + + + """; + + const string packageRelsXml = + """ + + + + + """; + + const string presentationXml = + """ + + + + + """; + + var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + WriteEntry(archive, "[Content_Types].xml", contentTypesXml); + WriteEntry(archive, "_rels/.rels", packageRelsXml); + WriteEntry(archive, "ppt/presentation.xml", presentationXml); + } + + stream.Position = 0; + + // Act / Assert + Assert.Throws(() => PptxDocument.Open(stream)); + } + + // --- Phase 1c: geometry + paint, exercised end-to-end through the full package-load path --- + + private static readonly XNamespace P = "http://schemas.openxmlformats.org/presentationml/2006/main"; + private static readonly XNamespace A = "http://schemas.openxmlformats.org/drawingml/2006/main"; + + /// + /// Builds a full, navigable presentation package (presentation -> slide -> layout -> + /// master -> theme, each with its own relationships part) whose one slide's <p:spTree> + /// content is supplied verbatim by - letting each Phase 1c + /// test describe only the shape(s) relevant to its scenario. Phase 1e tests additionally + /// supply (extension, content type, bytes) for a slide-owned + /// <p:pic> referencing r:embed="rId2", mirroring + /// 's own BuildRenderPackage media-parameter pattern. + /// + private static Stream BuildGeometryPaintPackage( + string spTreeInnerXml, + (string Extension, string ContentType, byte[] Bytes)? media = null) + { + var contentTypesXml = + $""" + + + + + {(media is { } m ? $"""""" : string.Empty)} + + """; + + const string packageRelsXml = + """ + + + + + """; + + const string presentationXml = + """ + + + + + """; + + const string presentationRelsXml = + """ + + + + + """; + + var slideXml = + $""" + + + + {spTreeInnerXml} + + + + """; + + var slideRelsXml = + $""" + + + + {(media is { } slideMedia ? $"""""" : string.Empty)} + + """; + + const string layoutXml = + """ + + + + """; + + const string layoutRelsXml = + """ + + + + + """; + + const string masterXml = + """ + + + + + """; + + const string masterRelsXml = + """ + + + + + """; + + const string themeXml = + """ + + + + + + + + + + + + + + + + + + + + + + + """; + + var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + WriteEntry(archive, "[Content_Types].xml", contentTypesXml); + WriteEntry(archive, "_rels/.rels", packageRelsXml); + WriteEntry(archive, "ppt/presentation.xml", presentationXml); + WriteEntry(archive, "ppt/_rels/presentation.xml.rels", presentationRelsXml); + WriteEntry(archive, "ppt/slides/slide1.xml", slideXml); + WriteEntry(archive, "ppt/slides/_rels/slide1.xml.rels", slideRelsXml); + WriteEntry(archive, "ppt/slideLayouts/slideLayout1.xml", layoutXml); + WriteEntry(archive, "ppt/slideLayouts/_rels/slideLayout1.xml.rels", layoutRelsXml); + WriteEntry(archive, "ppt/slideMasters/slideMaster1.xml", masterXml); + WriteEntry(archive, "ppt/slideMasters/_rels/slideMaster1.xml.rels", masterRelsXml); + WriteEntry(archive, "ppt/theme/theme1.xml", themeXml); + + if (media is { } writtenMedia) + { + WriteBinaryEntry(archive, $"ppt/media/image1.{writtenMedia.Extension}", writtenMedia.Bytes); + } + } + + stream.Position = 0; + return stream; + } + + /// Writes a raw binary ZIP entry - used by for embedded media. + private static void WriteBinaryEntry(ZipArchive archive, string name, byte[] content) + { + var entry = archive.CreateEntry(name); + using var entryStream = entry.Open(); + entryStream.Write(content, 0, content.Length); + } + + /// Builds a minimal, valid single-pixel PNG's encoded bytes, via the core - mirroring 's own helper. + private static byte[] BuildPngBytes(Rgba32 color) + { + using var surface = new Surface(1, 1); + surface[0, 0] = color; + using var buffer = new MemoryStream(); + PngCodec.Save(surface, buffer); + return buffer.ToArray(); + } + + /// + /// Resolves the theme that governs 's slide at + /// , by walking its full slide -> layout -> master -> theme + /// relationship chain exactly as a real renderer would - rather than constructing a + /// directly, as the unit-level do. + /// + private static PptxTheme ResolveSlideTheme(PptxDocument document, int slideIndex) + { + var slide = document.GetSlide(slideIndex); + var layout = document.GetLayout(slide.LayoutPartPath); + var master = document.GetMaster(layout.MasterPartPath); + return document.GetTheme(master.ThemePartPath); + } + + /// + /// Opens a full presentation package end-to-end whose one slide contains a single + /// freeform <p:sp> declaring a rotated <a:xfrm>, an + /// <a:prstGeom prst="roundRect">, a theme-scheme-color <a:solidFill>, + /// and an <a:ln> stroke - proving the Phase 1c geometry and paint resolvers + /// compose correctly when driven from real, package-resolved <p:spTree> and + /// inputs (rather than hand-built fragments, as in + /// /). + /// + [Fact] + public void CanvasNetPptx_SystemIntegration_GeometryAndPaint_FreeformShapeResolvesEndToEnd() + { + // Arrange + const string spTreeInnerXml = + """ + + + + + + + + + + + + + + + + + """; + using var stream = BuildGeometryPaintPackage(spTreeInnerXml); + using var document = PptxDocument.Open(stream); + + // Act + var slidePartPath = document.GetSlide(0).PartPath; + var spTree = document.LoadPartXmlRoot(slidePartPath).Element(P + "cSld")!.Element(P + "spTree")!; + var spPr = spTree.Element(P + "sp")!.Element(P + "spPr")!; + var theme = ResolveSlideTheme(document, 0); + + var frame = PptxDocument.ResolveShapeFrame(spPr.Element(A + "xfrm")!); + var geometry = PptxDocument.ResolveShapeGeometry(spPr, frame.WidthEmu, frame.HeightEmu); + var fill = PptxDocument.ResolveFill(spPr, theme, frame.WidthEmu, frame.HeightEmu); + var lineStyle = PptxDocument.ResolveLineStyle(spPr.Element(A + "ln"), theme); + + // Assert + Assert.NotEmpty(geometry.Subpaths); + var solid = Assert.IsType(fill); + Assert.Equal(theme.ColorScheme.Accent1, solid.Color); + Assert.NotNull(lineStyle); + Assert.Equal(25400f, lineStyle.WidthEmu); + + // The shape's own 90-degree rotation (5,400,000 sixtieths of a degree) about its own + // center must carry through to its resolved frame transform. + var transformedTopLeft = System.Numerics.Vector2.Transform(System.Numerics.Vector2.Zero, frame.Transform); + var transformedCenter = System.Numerics.Vector2.Transform( + new System.Numerics.Vector2(frame.WidthEmu / 2, frame.HeightEmu / 2), frame.Transform); + Assert.NotEqual(transformedTopLeft, transformedCenter); + } + + /// + /// Opens a full presentation package end-to-end whose one slide contains a + /// <p:grpSp> (with its own <a:xfrm> declaring both the group's + /// parent-space placement and its child coordinate space) wrapping a single child + /// <p:sp> - proving group child-transform composition resolves correctly + /// when driven through the full package-load path. + /// + [Fact] + public void CanvasNetPptx_SystemIntegration_GeometryAndPaint_GroupShapeChildTransformComposesEndToEnd() + { + // Arrange: group occupies a 100x100 EMU box at (1000,1000) in slide space, but its + // children are authored in a 200x200 child coordinate space starting at (0,0) - every + // child coordinate must be halved, then offset by (1000,1000), to land in slide space. + const string spTreeInnerXml = + """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """; + using var stream = BuildGeometryPaintPackage(spTreeInnerXml); + using var document = PptxDocument.Open(stream); + + // Act + var slidePartPath = document.GetSlide(0).PartPath; + var spTree = document.LoadPartXmlRoot(slidePartPath).Element(P + "cSld")!.Element(P + "spTree")!; + var grpSp = spTree.Element(P + "grpSp")!; + var groupXfrm = grpSp.Element(P + "grpSpPr")!.Element(A + "xfrm")!; + var childSpPr = grpSp.Element(P + "sp")!.Element(P + "spPr")!; + + var groupChildTransform = PptxDocument.ResolveGroupChildTransform(groupXfrm); + var childFrame = PptxDocument.ResolveShapeFrame(childSpPr.Element(A + "xfrm")!); + var fullTransform = childFrame.Transform * groupChildTransform; + + // Assert: child-local (200,200) (its own bottom-right corner) -> child space (200,200) -> + // group box (100,100) (halved) -> slide space (1100,1100) (offset by the group's own + // placement at (1000,1000)). + var resolved = System.Numerics.Vector2.Transform(new System.Numerics.Vector2(200, 200), fullTransform); + Assert.Equal(1100f, resolved.X, 0.01f); + Assert.Equal(1100f, resolved.Y, 0.01f); + } + + // --- Phase 1d: text layout + rendering, exercised end-to-end through the full package-load path --- + + /// + /// Builds a full, navigable presentation package (presentation -> slide -> layout -> + /// master -> theme) whose master declares <p:txStyles> + /// (), whose layout declares + /// placeholder shape(s), and whose one slide's + /// <p:spTree> content is supplied verbatim by + /// - letting each Phase 1d test describe only the text-bearing shape(s) and master text + /// styles relevant to its scenario, exactly as does + /// for Phase 1c. + /// + private static Stream BuildTextPackage(string spTreeInnerXml, string masterTxStylesXml, string layoutPlaceholderXml) + { + const string contentTypesXml = + """ + + + + + + """; + + const string packageRelsXml = + """ + + + + + """; + + const string presentationXml = + """ + + + + + """; + + const string presentationRelsXml = + """ + + + + + """; + + var slideXml = + $""" + + + + {spTreeInnerXml} + + + + """; + + const string slideRelsXml = + """ + + + + + """; + + var layoutXml = + $""" + + {layoutPlaceholderXml} + + """; + + const string layoutRelsXml = + """ + + + + + """; + + var masterXml = + $""" + + + + {masterTxStylesXml} + + """; + + const string masterRelsXml = + """ + + + + + """; + + const string themeXml = + """ + + + + + + + + + + + + + + + + + + + + + + + """; + + var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + WriteEntry(archive, "[Content_Types].xml", contentTypesXml); + WriteEntry(archive, "_rels/.rels", packageRelsXml); + WriteEntry(archive, "ppt/presentation.xml", presentationXml); + WriteEntry(archive, "ppt/_rels/presentation.xml.rels", presentationRelsXml); + WriteEntry(archive, "ppt/slides/slide1.xml", slideXml); + WriteEntry(archive, "ppt/slides/_rels/slide1.xml.rels", slideRelsXml); + WriteEntry(archive, "ppt/slideLayouts/slideLayout1.xml", layoutXml); + WriteEntry(archive, "ppt/slideLayouts/_rels/slideLayout1.xml.rels", layoutRelsXml); + WriteEntry(archive, "ppt/slideMasters/slideMaster1.xml", masterXml); + WriteEntry(archive, "ppt/slideMasters/_rels/slideMaster1.xml.rels", masterRelsXml); + WriteEntry(archive, "ppt/theme/theme1.xml", themeXml); + } + + stream.Position = 0; + return stream; + } + + /// + /// Opens a full presentation package end-to-end whose one slide contains a title + /// placeholder shape (idx="1") with a <p:txBody> run declaring no font + /// size of its own, matched through the real slide -> layout -> master placeholder + /// chain to a layout placeholder of the same idx (itself declaring no sz + /// either) - proving the run's effective font size falls all the way through to the + /// master's own <p:titleStyle> (Phase 1d's master <p:txStyles> + /// bucket selected by the matched layout placeholder's type="title"), then that the + /// resulting text body lays out and paints without error. + /// + [Fact] + public void CanvasNetPptx_SystemIntegration_TextLayoutAndRender_TitlePlaceholderResolvesMasterTitleStyleEndToEnd() + { + // Arrange + const string spTreeInnerXml = + """ + + + + + + + + + + Hello + + + """; + const string layoutPlaceholderXml = + """ + + + + + + + + + """; + const string masterTxStylesXml = + """ + + + """; + using var stream = BuildTextPackage(spTreeInnerXml, masterTxStylesXml, layoutPlaceholderXml); + using var document = PptxDocument.Open(stream); + + // Act + var slide = document.GetSlide(0); + var layout = document.GetLayout(slide.LayoutPartPath); + var master = document.GetMaster(layout.MasterPartPath); + var theme = document.GetTheme(master.ThemePartPath); + + var slidePlaceholder = slide.Placeholders[0]; + var placeholderProperties = PptxDocument.ResolvePlaceholderProperties( + slidePlaceholder, layout.Placeholders, master.Placeholders, theme, master.TxStyles); + + var txBodyElement = slidePlaceholder.ShapeElement.Element(P + "txBody")!; + var textBody = PptxDocument.ParseTextBody(txBodyElement); + var paragraph = textBody.Paragraphs[0]; + var run = paragraph.Runs[0]; + + var runProperties = PptxDocument.ResolveEffectiveRunProperties(run, paragraph, placeholderProperties, theme, slidePlaceholder.Type); + + // Assert: the master's own titleStyle sz="4400" (hundredths of a point) -> EMU. + Assert.Equal(4400f * 127f, runProperties.SizeEmu); + + // Act: lay out and paint the resolved text body end-to-end, using a deterministic, + // installed-font-discovery-bypassing resolver (see PptxTextLayoutTests/PptxTextRenderTests + // for why FindBestMatch is not reliable for cross-machine test determinism). + var textLayout = PptxDocument.ResolveTextLayout( + textBody, placeholderProperties, theme, slidePlaceholder.Type, 2000000f, 500000f, + (_, bold, italic) => DemaConsulting.CanvasNet.Fonts.SystemFontCatalog.LoadBundledFallback(serif: false, fixedPitch: false, bold, italic)); + + using var surface = new Surface(200, 200); + var shapeTransform = System.Numerics.Matrix3x2.CreateScale(200f / 2000000f, 200f / 500000f); + PptxDocument.PaintTextLayout(surface, textLayout, shapeTransform); + + Assert.NotEmpty(textLayout.Glyphs); + } + + /// + /// Opens a full presentation package end-to-end whose one slide contains an ordinary, + /// non-placeholder text box (a <p:sp> with no <p:ph> descendant) + /// - proving the placeholderType: "" sentinel convention (see + /// PptxDocument.TextInheritance.cs's SelectMasterTextStyle) correctly selects + /// the master's <p:otherStyle> bucket, not <p:bodyStyle>, when + /// driven through a real, non-placeholder shape resolved from the full package-load path + /// (not a hand-built fixture). + /// + [Fact] + public void CanvasNetPptx_SystemIntegration_TextLayoutAndRender_NonPlaceholderShapeResolvesMasterOtherStyleEndToEnd() + { + // Arrange + const string spTreeInnerXml = + """ + + + + + + + + + + Plain text + + + """; + const string masterTxStylesXml = + """ + + + """; + using var stream = BuildTextPackage(spTreeInnerXml, masterTxStylesXml, layoutPlaceholderXml: string.Empty); + using var document = PptxDocument.Open(stream); + + // Act + var slide = document.GetSlide(0); + var layout = document.GetLayout(slide.LayoutPartPath); + var master = document.GetMaster(layout.MasterPartPath); + var theme = document.GetTheme(master.ThemePartPath); + + var spTree = document.LoadPartXmlRoot(slide.PartPath).Element(P + "cSld")!.Element(P + "spTree")!; + var shapeElement = spTree.Element(P + "sp")!; + var txBodyElement = shapeElement.Element(P + "txBody")!; + var textBody = PptxDocument.ParseTextBody(txBodyElement); + var paragraph = textBody.Paragraphs[0]; + var run = paragraph.Runs[0]; + + // A non-placeholder shape has no PptxPlaceholder/slide-layout-master match to walk, so + // its PptxPlaceholderProperties carries only the theme/master text styles through, + // exactly as a real renderer would construct for such a shape. + var placeholderProperties = new PptxPlaceholderProperties(null, null, theme, master.TxStyles); + + var runProperties = PptxDocument.ResolveEffectiveRunProperties(run, paragraph, placeholderProperties, theme, string.Empty); + + // Assert: the master's own otherStyle sz="1200" wins, not bodyStyle's sz="1800". + Assert.Equal(1200f * 127f, runProperties.SizeEmu); + } + + // --- Phase 1e/1f: pictures, tables, shape-tree enumeration, and the public Render API, + // exercised end-to-end through the full package-load path ------------------------------- + + /// + /// Opens a full presentation package end-to-end whose one slide contains a single + /// <p:pic> referencing an embedded single-pixel PNG via r:embed="rId2" - + /// proving Phase 1e's picture-shape resolution (content-type dispatch, decode, and + /// y-down compositing) integrates correctly when driven through the public + /// entry point, not + /// just the unit-level fragments exercised by . Also confirms + /// the shape is parsed into the slide's own as a + /// . + /// + [Fact] + public void CanvasNetPptx_SystemIntegration_Images_PictureDecodesAndRendersEndToEnd() + { + // Arrange + var pngBytes = BuildPngBytes(new Rgba32(10, 20, 30, 255)); + const string spTreeInnerXml = + """ + + + + + + + + + """; + using var stream = BuildGeometryPaintPackage(spTreeInnerXml, media: ("png", "image/png", pngBytes)); + using var document = PptxDocument.Open(stream); + + // Act + var shapeTree = document.GetSlide(0).ShapeTree; + using var surface = document.Render(0, 20, 20); + + // Assert + var pictureNode = Assert.IsType(Assert.Single(shapeTree)); + Assert.Equal("Pic", pictureNode.PicElement.Element(P + "nvPicPr")!.Element(P + "cNvPr")!.Attribute("name")!.Value); + Assert.Equal(new Rgba32(10, 20, 30, 255), surface[10, 10]); + } + + /// + /// Opens a full presentation package end-to-end whose one slide contains a single + /// ordinary <p:sp> (not a <p:pic> picture shape) whose + /// <p:spPr> uses a picture as its own shape fill via + /// <a:blipFill><a:blip r:embed="rId2"/><a:tile tx="0" ty="0" sx="100000" + /// sy="100000"/></a:blipFill> - the common-case <a:tile> construct + /// exercised by the real pythonpptx-dml-fill.pptx fixture (see + /// ) - proving the new picture-fill support + /// (PptxPaint.cs's , PptxDocument.Paint.cs's + /// ResolveFill, and PptxDocument.Tables.cs's FillPaint + /// branch) integrates correctly end-to-end through the public + /// entry point, with a + /// pixel-level sanity check that the filled shape's own region actually painted something + /// distinguishable from the untouched background. Unlike that real fixture's own slide 0 + /// (which cannot complete a full render without throwing, because it also contains an + /// unrelated, out-of-scope <a:pattFill> shape later in the same shape tree), + /// this minimal, purpose-built single-shape package renders to completion without + /// exception, satisfying the "end-to-end, no exception" requirement this feature's test + /// plan calls for. + /// + [Fact] + public void CanvasNetPptx_SystemIntegration_Images_TileBlipFillShapeDecodesAndRendersEndToEnd() + { + // Arrange + var pngBytes = BuildPngBytes(new Rgba32(10, 20, 30, 255)); + const string spTreeInnerXml = + """ + + + + + + + + + + + + """; + using var stream = BuildGeometryPaintPackage(spTreeInnerXml, media: ("png", "image/png", pngBytes)); + using var document = PptxDocument.Open(stream); + + // Act + var shapeTree = document.GetSlide(0).ShapeTree; + using var surface = document.Render(0, 20, 20); + + // Assert: the shape tree parsed a non-picture shape node (not a picture shape), + // and the whole slide rendered to completion without throwing, with the shape's own + // region painted with the tiled image content (not left as transparent background). + Assert.IsType(Assert.Single(shapeTree)); + Assert.Equal(new Rgba32(10, 20, 30, 255), surface[10, 10]); + } + + /// + /// Opens a full presentation package end-to-end whose one slide contains a single + /// <p:graphicFrame> declaring an <a:tbl> with one solid-filled + /// cell - proving Phase 1e's table parsing (structure/cell parsing, cell-rect resolution) + /// and cell fill painting integrate correctly when driven through the public + /// entry point, not + /// just the unit-level fragments exercised by . Also confirms + /// the shape is parsed into the slide's own as a + /// carrying the expected table structure. + /// + [Fact] + public void CanvasNetPptx_SystemIntegration_Tables_GraphicFrameParsesAndRendersCellFillEndToEnd() + { + // Arrange + const string spTreeInnerXml = + """ + + + + + + + + + + + + + + + + + """; + using var stream = BuildGeometryPaintPackage(spTreeInnerXml); + using var document = PptxDocument.Open(stream); + + // Act + var shapeTree = document.GetSlide(0).ShapeTree; + using var surface = document.Render(0, 50, 50); + + // Assert + var tableNode = Assert.IsType(Assert.Single(shapeTree)); + Assert.NotNull(tableNode.Table); + var row = Assert.Single(tableNode.Table.Rows); + Assert.Single(row.Cells); + Assert.Equal(new Rgba32(0, 255, 255, 255), surface[25, 25]); + } + + /// + /// Opens a full presentation package end-to-end whose one slide contains a + /// <p:grpSp> nested two levels deep (a group whose only child is another + /// group, whose only child is a freeform <p:sp>) - proving Phase 1e's + /// recursive both enumerates the full nested + /// structure into and composes each level's own child + /// transform correctly when driven through the public + /// entry point - + /// distinct from the single-level-only transform math already proven end-to-end by + /// . + /// + [Fact] + public void CanvasNetPptx_SystemIntegration_ShapeTree_NestedGroupEnumeratesAndComposesTransformEndToEnd() + { + // Arrange: the outer group occupies the slide's right half; its inner group fills the + // outer group's own child space entirely, and the leaf shape fills the inner group's own + // child space entirely - so the leaf should ultimately paint only the slide's right half. + const string spTreeInnerXml = + """ + + + + + + + + + + + + + + + + + + + + + + + + + + + """; + using var stream = BuildGeometryPaintPackage(spTreeInnerXml); + using var document = PptxDocument.Open(stream); + + // Act + var shapeTree = document.GetSlide(0).ShapeTree; + using var surface = document.Render(0, 100, 100); + + // Assert: shape-tree enumeration recovers both nesting levels and the leaf shape. + var outerGroup = Assert.IsType(Assert.Single(shapeTree)); + var innerGroup = Assert.IsType(Assert.Single(outerGroup.Children)); + Assert.IsType(Assert.Single(innerGroup.Children)); + + // Assert: the composed two-level transform places the leaf's fill on the right half only. + Assert.Equal(new Rgba32(0, 255, 0, 255), surface[75, 50]); + Assert.Equal(new Rgba32(255, 255, 255, 255), surface[25, 50]); + } + + /// + /// Opens a full presentation package end-to-end whose one slide contains a single, + /// full-slide, solid-filled freeform shape, then renders it through both public Phase 1f + /// and + /// overloads - the + /// explicit pixel-dimension overload and the DPI-based overload - proving both dispatch to + /// the same underlying shape-tree walk + /// and painter pipeline and that the DPI overload's own documented EMU-to-pixel conversion + /// (dpi / 914400, rounded to the nearest pixel) produces the expected surface size. + /// + [Fact] + public void CanvasNetPptx_SystemIntegration_Render_PixelAndDpiOverloadsProduceConsistentOutputEndToEnd() + { + // Arrange + const string spTreeInnerXml = + """ + + + + + + + + + """; + using var stream = BuildGeometryPaintPackage(spTreeInnerXml); + using var document = PptxDocument.Open(stream); + + // Act: pixel-dimension overload. + using var pixelSurface = document.Render(0, 100, 50); + + // Act: DPI overload - a 9144000x6858000 EMU (10"x7.5") slide at 96 DPI resolves to + // 960x720 pixels (9144000 / 914400 * 96 = 960; 6858000 / 914400 * 96 = 720). + using var dpiSurface = document.Render(0, 96f); + + // Assert + Assert.Equal(100, pixelSurface.Width); + Assert.Equal(50, pixelSurface.Height); + Assert.Equal(new Rgba32(255, 0, 255, 255), pixelSurface[50, 25]); + + Assert.Equal(960, dpiSurface.Width); + Assert.Equal(720, dpiSurface.Height); + Assert.Equal(new Rgba32(255, 0, 255, 255), dpiSurface[480, 360]); + } +} diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxTablesTests.cs b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxTablesTests.cs new file mode 100644 index 00000000..5587e2fe --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxTablesTests.cs @@ -0,0 +1,934 @@ +using System.Numerics; +using System.Xml.Linq; +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Drawing; +using DemaConsulting.CanvasNet.Fonts; +using DemaConsulting.CanvasNet.Tests.TestSupport; + +namespace DemaConsulting.CanvasNet.Pptx.Tests; + +// cspell:ignore pptx tbl tblgrid gridcol hmerge vmerge gridspan rowspan tcpr lnl lnr lnt lnb txbody unitsperem srgb +// cspell:ignore tblpr tblstyle tblstyleid tcstyle tcbdr wholetbl bandrow firstrow insideh insidev + +/// +/// Unit-level tests for the Phase 1e table resolvers and painting primitive +/// (PptxDocument.Tables.cs's , +/// , , +/// ). Every resolver under test is a plain static method +/// operating on directly-constructed fragments and a +/// directly-constructed , mirroring / +/// 's own "no full in-memory package needed" style (table +/// structure/cells/text are fully self-contained XML, same as the Phase 1c/1d resolvers these +/// tests mirror). +/// +public class PptxTablesTests +{ + private static readonly XNamespace A = "http://schemas.openxmlformats.org/drawingml/2006/main"; + private static readonly XNamespace P = "http://schemas.openxmlformats.org/presentationml/2006/main"; + + private static PptxTheme BuildTestTheme() => + new( + new PptxColorScheme( + new Rgba32(10, 10, 10, 255), new Rgba32(20, 20, 20, 255), new Rgba32(30, 30, 30, 255), new Rgba32(40, 40, 40, 255), + new Rgba32(50, 50, 50, 255), new Rgba32(60, 60, 60, 255), new Rgba32(70, 70, 70, 255), new Rgba32(80, 80, 80, 255), + new Rgba32(90, 90, 90, 255), new Rgba32(100, 100, 100, 255), new Rgba32(110, 110, 110, 255), new Rgba32(120, 120, 120, 255)), + new PptxFontScheme( + new PptxFontCollection("ThemeMajorLatin", "MajorEA", "MajorCS"), + new PptxFontCollection("ThemeMinorLatin", "MinorEA", "MinorCS"))); + + /// Builds a <p:graphicFrame> wrapping a table's <a:tbl> with the given column widths/row heights, tagging each cell with its own index via a t-attribute marker text run (for identification in assertions). + private static XElement BuildGraphicFrame(XElement tbl, string uriSuffix = "/table") => + new(P + "graphicFrame", + new XElement(A + "graphic", + new XElement(A + "graphicData", new XAttribute("uri", $"http://schemas.openxmlformats.org/drawingml/2006{uriSuffix}"), + tbl))); + + private static XElement BuildTbl(IReadOnlyList columnWidths, params XElement[] rows) => + new(A + "tbl", + new XElement(A + "tblGrid", columnWidths.Select(w => new XElement(A + "gridCol", new XAttribute("w", w)))), + rows); + + private static XElement BuildTr(float heightEmu, params XElement[] cells) => + new(A + "tr", new XAttribute("h", heightEmu), cells); + + private static XElement BuildTc( + int? gridSpan = null, int? rowSpan = null, bool? hMerge = null, bool? vMerge = null, + XElement? tcPr = null, XElement? txBody = null) + { + var tc = new XElement(A + "tc"); + if (gridSpan is not null) + { + tc.Add(new XAttribute("gridSpan", gridSpan.Value)); + } + + if (rowSpan is not null) + { + tc.Add(new XAttribute("rowSpan", rowSpan.Value)); + } + + if (hMerge is not null) + { + tc.Add(new XAttribute("hMerge", hMerge.Value ? "1" : "0")); + } + + if (vMerge is not null) + { + tc.Add(new XAttribute("vMerge", vMerge.Value ? "1" : "0")); + } + + if (txBody is not null) + { + tc.Add(txBody); + } + + if (tcPr is not null) + { + tc.Add(tcPr); + } + + return tc; + } + + private static XElement BuildTextBody(string text) => + new(A + "txBody", + new XElement(A + "bodyPr", new XAttribute("lIns", 0), new XAttribute("tIns", 0), new XAttribute("rIns", 0), new XAttribute("bIns", 0)), + new XElement(A + "p", new XElement(A + "r", new XElement(A + "rPr", new XAttribute("sz", 100)), new XElement(A + "t", text)))); + + /// Builds an <a:tblPr> element declaring the given tableStyleId/firstRow/bandRow. + private static XElement BuildTblPr(string? tableStyleId = null, bool? firstRow = null, bool? bandRow = null) + { + var tblPr = new XElement(A + "tblPr"); + if (firstRow is not null) + { + tblPr.Add(new XAttribute("firstRow", firstRow.Value ? "1" : "0")); + } + + if (bandRow is not null) + { + tblPr.Add(new XAttribute("bandRow", bandRow.Value ? "1" : "0")); + } + + if (tableStyleId is not null) + { + tblPr.Add(new XElement(A + "tableStyleId", tableStyleId)); + } + + return tblPr; + } + + /// Builds an <a:tbl> carrying an explicit <a:tblPr> (unlike , which omits one). + private static XElement BuildTblWithPr(XElement tblPr, IReadOnlyList columnWidths, params XElement[] rows) => + new(A + "tbl", + tblPr, + new XElement(A + "tblGrid", columnWidths.Select(w => new XElement(A + "gridCol", new XAttribute("w", w)))), + rows); + + /// Builds an <a:fill> (a direct <a:solidFill>/<a:srgbClr> wrapper), matching the shape passes to the existing . + private static XElement BuildFillElement(string colorHex) => + new(A + "fill", new XElement(A + "solidFill", new XElement(A + "srgbClr", new XAttribute("val", colorHex)))); + + /// Builds a single named <a:tcBdr> edge (for example "left"/"insideV"), wrapping an <a:ln> of the given width/color. + private static XElement BuildTcBdrEdge(string edgeName, float widthEmu, string colorHex) => + new(A + edgeName, + new XElement(A + "ln", new XAttribute("w", widthEmu), + new XElement(A + "solidFill", new XElement(A + "srgbClr", new XAttribute("val", colorHex))))); + + /// Builds a single <a:tcPr>-level line edge (for example "lnL"/"lnT") - unlike 's style-tier edges, this is itself the line element (no nested <a:ln>), matching CT_TableCellProperties's schema. + private static XElement BuildTcPrLineEdge(string edgeName, float widthEmu, string colorHex) => + new(A + edgeName, new XAttribute("w", widthEmu), + new XElement(A + "solidFill", new XElement(A + "srgbClr", new XAttribute("val", colorHex)))); + + /// Builds an <a:tcStyle> (a table style tier's cell-style payload) from an optional fill and zero or more <a:tcBdr> edges. + private static XElement BuildTcStyle(XElement? fill = null, params XElement[] tcBdrEdges) + { + var tcStyle = new XElement(A + "tcStyle"); + if (tcBdrEdges.Length > 0) + { + tcStyle.Add(new XElement(A + "tcBdr", tcBdrEdges)); + } + + if (fill is not null) + { + tcStyle.Add(fill); + } + + return tcStyle; + } + + /// Builds an <a:tblStyle styleId="..."> from its optional wholeTbl/band1H/band2H/firstRow tiers (each itself an -built <a:tcStyle>, wrapped here in its own tier element). + private static XElement BuildTblStyle( + string styleId, XElement? wholeTbl = null, XElement? band1H = null, XElement? band2H = null, XElement? firstRow = null) + { + var tblStyle = new XElement(A + "tblStyle", new XAttribute("styleId", styleId)); + if (wholeTbl is not null) + { + tblStyle.Add(new XElement(A + "wholeTbl", wholeTbl)); + } + + if (band1H is not null) + { + tblStyle.Add(new XElement(A + "band1H", band1H)); + } + + if (band2H is not null) + { + tblStyle.Add(new XElement(A + "band2H", band2H)); + } + + if (firstRow is not null) + { + tblStyle.Add(new XElement(A + "firstRow", firstRow)); + } + + return tblStyle; + } + + // --- ParseTable ------------------------------------------------------------------------------ + + /// Proves a well-formed table parses its column widths and row/cell structure. + [Fact] + public void ParseTable_WellFormedTable_ParsesColumnsAndRows() + { + var tbl = BuildTbl([1000f, 2000f], + BuildTr(500f, BuildTc(), BuildTc()), + BuildTr(600f, BuildTc(), BuildTc())); + var graphicFrame = BuildGraphicFrame(tbl); + + var table = PptxDocument.ParseTable(graphicFrame, BuildTestTheme()); + + Assert.Equal([1000f, 2000f], table.ColumnWidthsEmu); + Assert.Equal(2, table.Rows.Count); + Assert.Equal(500f, table.Rows[0].HeightEmu); + Assert.Equal(600f, table.Rows[1].HeightEmu); + Assert.Equal(2, table.Rows[0].Cells.Count); + } + + /// Proves a missing <a:graphic>/<a:graphicData> child throws . + [Fact] + public void ParseTable_MissingGraphicData_ThrowsInvalidDataException() + { + var graphicFrame = new XElement(P + "graphicFrame"); + + Assert.Throws(() => PptxDocument.ParseTable(graphicFrame, BuildTestTheme())); + } + + /// Proves a non-table graphic-frame kind (for example a chart) throws with feature token "pptx-graphic-frame-kind". + [Fact] + public void ParseTable_NonTableGraphicFrameKind_ThrowsPptxUnsupportedFeatureExceptionWithGraphicFrameKindToken() + { + var graphicFrame = new XElement(P + "graphicFrame", + new XElement(A + "graphic", + new XElement(A + "graphicData", new XAttribute("uri", "http://schemas.openxmlformats.org/drawingml/2006/chart")))); + + var ex = Assert.Throws(() => PptxDocument.ParseTable(graphicFrame, BuildTestTheme())); + Assert.Equal("pptx-graphic-frame-kind", ex.Feature); + } + + /// Proves a missing <a:tblGrid> throws . + [Fact] + public void ParseTable_MissingTblGrid_ThrowsInvalidDataException() + { + var graphicFrame = BuildGraphicFrame(new XElement(A + "tbl")); + + Assert.Throws(() => PptxDocument.ParseTable(graphicFrame, BuildTestTheme())); + } + + /// Proves a <a:gridCol> with a non-numeric w attribute throws . + [Fact] + public void ParseTable_GridColNonNumericWidth_ThrowsInvalidDataException() + { + var tbl = new XElement(A + "tbl", + new XElement(A + "tblGrid", new XElement(A + "gridCol", new XAttribute("w", "not-a-number")))); + var graphicFrame = BuildGraphicFrame(tbl); + + Assert.Throws(() => PptxDocument.ParseTable(graphicFrame, BuildTestTheme())); + } + + /// Proves an <a:tr> with a missing h attribute throws . + [Fact] + public void ParseTable_TrMissingHeight_ThrowsInvalidDataException() + { + var tbl = new XElement(A + "tbl", + new XElement(A + "tblGrid", new XElement(A + "gridCol", new XAttribute("w", 1000))), + new XElement(A + "tr", BuildTc())); + var graphicFrame = BuildGraphicFrame(tbl); + + Assert.Throws(() => PptxDocument.ParseTable(graphicFrame, BuildTestTheme())); + } + + /// Proves a <a:gridCol> with a non-finite w attribute throws . + [Theory] + [InlineData("NaN")] + [InlineData("Infinity")] + [InlineData("-Infinity")] + public void ParseTable_NonFiniteColumnWidth_ThrowsInvalidDataException(string nonFiniteValue) + { + var tbl = new XElement(A + "tbl", + new XElement(A + "tblGrid", new XElement(A + "gridCol", new XAttribute("w", nonFiniteValue)))); + var graphicFrame = BuildGraphicFrame(tbl); + + Assert.Throws(() => PptxDocument.ParseTable(graphicFrame, BuildTestTheme())); + } + + /// Proves an <a:tr> with a non-finite h attribute throws . + [Theory] + [InlineData("NaN")] + [InlineData("Infinity")] + [InlineData("-Infinity")] + public void ParseTable_NonFiniteRowHeight_ThrowsInvalidDataException(string nonFiniteValue) + { + var tbl = new XElement(A + "tbl", + new XElement(A + "tblGrid", new XElement(A + "gridCol", new XAttribute("w", 1000))), + new XElement(A + "tr", new XAttribute("h", nonFiniteValue), BuildTc())); + var graphicFrame = BuildGraphicFrame(tbl); + + Assert.Throws(() => PptxDocument.ParseTable(graphicFrame, BuildTestTheme())); + } + + /// Builds an <a:tcPr> wrapping a linear <a:gradFill> at the given 60000ths-of-a-degree angle, matching 's own ResolveGradientFill fixtures. + private static XElement BuildGradFillTcPr(int angle60000ths) => + new(A + "tcPr", + new XElement( + A + "gradFill", + new XElement( + A + "gsLst", + new XElement(A + "gs", new XAttribute("pos", 0), new XElement(A + "srgbClr", new XAttribute("val", "FF0000"))), + new XElement(A + "gs", new XAttribute("pos", 100000), new XElement(A + "srgbClr", new XAttribute("val", "0000FF")))), + new XElement(A + "lin", new XAttribute("ang", angle60000ths)))); + + /// + /// Proves sizes a gridSpan="2" cell's + /// gradient fill against its true summed two-column width, not only its first column's + /// own width - ParseTable's internal ParseTableCell call previously received + /// always-span-1 dimensions, so a merged cell's gradient was built too narrow. + /// + [Fact] + public void ParseTable_GridSpanCellWithGradientFill_UsesMergedWidth() + { + // A horizontal (ang=0) gradient's End.X - Start.X span equals the cell's own widthEmu + // (see ResolveGradientFill's own center +/- direction * extent construction). + var mergedTbl = BuildTbl([1000f, 1500f], BuildTr(500f, BuildTc(gridSpan: 2, tcPr: BuildGradFillTcPr(0)))); + var mergedTable = PptxDocument.ParseTable(BuildGraphicFrame(mergedTbl), BuildTestTheme()); + var mergedFill = Assert.IsType(mergedTable.Rows[0].Cells[0].Fill); + var mergedGradient = Assert.IsType(mergedFill.Gradient); + + var unmergedTbl = BuildTbl([1000f], BuildTr(500f, BuildTc(tcPr: BuildGradFillTcPr(0)))); + var unmergedTable = PptxDocument.ParseTable(BuildGraphicFrame(unmergedTbl), BuildTestTheme()); + var unmergedFill = Assert.IsType(unmergedTable.Rows[0].Cells[0].Fill); + var unmergedGradient = Assert.IsType(unmergedFill.Gradient); + + // The merged (gridSpan=2) cell's own gradient spans the summed two-column width (2500), + // not the single-column width (1000) an un-merge-aware sizing would have produced. + Assert.InRange(mergedGradient.End.X - mergedGradient.Start.X, 2499f, 2501f); + Assert.InRange(unmergedGradient.End.X - unmergedGradient.Start.X, 999f, 1001f); + } + + /// + /// Proves sizes a rowSpan="2" cell's gradient + /// fill against its true summed two-row height (looked ahead via the precomputed + /// rowHeightsEmu list), not only its own declared row's height. + /// + [Fact] + public void ParseTable_RowSpanCellWithGradientFill_UsesMergedHeight() + { + // A vertical (ang=5400000, i.e. 90 degrees) gradient's End.Y - Start.Y span equals the + // cell's own heightEmu. + var mergedTbl = BuildTbl( + [1000f], + BuildTr(400f, BuildTc(rowSpan: 2, tcPr: BuildGradFillTcPr(5400000))), + BuildTr(700f, BuildTc(vMerge: true))); + var mergedTable = PptxDocument.ParseTable(BuildGraphicFrame(mergedTbl), BuildTestTheme()); + var mergedFill = Assert.IsType(mergedTable.Rows[0].Cells[0].Fill); + var mergedGradient = Assert.IsType(mergedFill.Gradient); + + var unmergedTbl = BuildTbl([1000f], BuildTr(400f, BuildTc(tcPr: BuildGradFillTcPr(5400000)))); + var unmergedTable = PptxDocument.ParseTable(BuildGraphicFrame(unmergedTbl), BuildTestTheme()); + var unmergedFill = Assert.IsType(unmergedTable.Rows[0].Cells[0].Fill); + var unmergedGradient = Assert.IsType(unmergedFill.Gradient); + + // The merged (rowSpan=2) cell's own gradient spans the summed two-row height (1100), not + // the single-row height (400) an un-merge-aware sizing would have produced. + Assert.InRange(mergedGradient.End.Y - mergedGradient.Start.Y, 1099f, 1101f); + Assert.InRange(unmergedGradient.End.Y - unmergedGradient.Start.Y, 399f, 401f); + } + + // --- ParseTableCell -------------------------------------------------------------------------- + + /// Proves an absent gridSpan/rowSpan/hMerge/vMerge defaults to span 1, no merge. + [Fact] + public void ParseTableCell_NoAttributes_DefaultsToSpanOneNoMerge() + { + var cell = PptxDocument.ParseTableCell(BuildTc(), BuildTestTheme(), 1000f, 500f); + + Assert.Equal(1, cell.GridSpan); + Assert.Equal(1, cell.RowSpan); + Assert.False(cell.HMerge); + Assert.False(cell.VMerge); + Assert.Null(cell.TextBody); + Assert.IsType(cell.Fill); + } + + /// Proves present gridSpan/rowSpan/hMerge/vMerge attributes are parsed. + [Fact] + public void ParseTableCell_AttributesPresent_ParsesSpanAndMergeFlags() + { + var cell = PptxDocument.ParseTableCell( + BuildTc(gridSpan: 2, rowSpan: 3, hMerge: true, vMerge: false), BuildTestTheme(), 1000f, 500f); + + Assert.Equal(2, cell.GridSpan); + Assert.Equal(3, cell.RowSpan); + Assert.True(cell.HMerge); + Assert.False(cell.VMerge); + } + + /// Proves a non-numeric gridSpan attribute throws . + [Fact] + public void ParseTableCell_NonNumericGridSpan_ThrowsInvalidDataException() + { + var tc = BuildTc(); + tc.Add(new XAttribute("gridSpan", "not-a-number")); + + Assert.Throws(() => PptxDocument.ParseTableCell(tc, BuildTestTheme(), 1000f, 500f)); + } + + /// Proves a zero or negative gridSpan attribute throws . + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void ParseTableCell_NonPositiveGridSpan_ThrowsInvalidDataException(int gridSpan) + { + var tc = BuildTc(gridSpan: gridSpan); + + Assert.Throws(() => PptxDocument.ParseTableCell(tc, BuildTestTheme(), 1000f, 500f)); + } + + /// Proves a zero or negative rowSpan attribute throws . + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void ParseTableCell_NonPositiveRowSpan_ThrowsInvalidDataException(int rowSpan) + { + var tc = BuildTc(rowSpan: rowSpan); + + Assert.Throws(() => PptxDocument.ParseTableCell(tc, BuildTestTheme(), 1000f, 500f)); + } + + /// Proves a cell's <a:tcPr> fill and all four border edges resolve via the reused /. + [Fact] + public void ParseTableCell_TcPrWithFillAndBorders_ResolvesFillAndBorders() + { + var tcPr = new XElement(A + "tcPr", + new XElement(A + "lnL", new XAttribute("w", 100), new XElement(A + "solidFill", new XElement(A + "srgbClr", new XAttribute("val", "FF0000")))), + new XElement(A + "lnR", new XAttribute("w", 200), new XElement(A + "solidFill", new XElement(A + "srgbClr", new XAttribute("val", "00FF00")))), + new XElement(A + "lnT", new XAttribute("w", 300), new XElement(A + "solidFill", new XElement(A + "srgbClr", new XAttribute("val", "0000FF")))), + new XElement(A + "lnB", new XAttribute("w", 400), new XElement(A + "solidFill", new XElement(A + "srgbClr", new XAttribute("val", "FFFF00")))), + new XElement(A + "solidFill", new XElement(A + "srgbClr", new XAttribute("val", "808080")))); + + var cell = PptxDocument.ParseTableCell(BuildTc(tcPr: tcPr), BuildTestTheme(), 1000f, 500f); + + var fill = Assert.IsType(cell.Fill); + Assert.Equal(new Rgba32(0x80, 0x80, 0x80, 255), fill.Color); + Assert.NotNull(cell.LeftBorder); + Assert.Equal(100f, cell.LeftBorder.WidthEmu); + Assert.NotNull(cell.RightBorder); + Assert.Equal(200f, cell.RightBorder.WidthEmu); + Assert.NotNull(cell.TopBorder); + Assert.Equal(300f, cell.TopBorder.WidthEmu); + Assert.NotNull(cell.BottomBorder); + Assert.Equal(400f, cell.BottomBorder.WidthEmu); + } + + /// Proves a cell with no <a:txBody> resolves a (an empty cell). + [Fact] + public void ParseTableCell_NoTxBody_TextBodyIsNull() + { + var cell = PptxDocument.ParseTableCell(BuildTc(), BuildTestTheme(), 1000f, 500f); + + Assert.Null(cell.TextBody); + } + + /// Proves a cell's <a:txBody> is parsed via the reused . + [Fact] + public void ParseTableCell_WithTxBody_ParsesTextBody() + { + var cell = PptxDocument.ParseTableCell(BuildTc(txBody: BuildTextBody("Hello")), BuildTestTheme(), 1000f, 500f); + + Assert.NotNull(cell.TextBody); + Assert.Single(cell.TextBody.Paragraphs); + Assert.Equal("Hello", cell.TextBody.Paragraphs[0].Runs[0].Text); + } + + // --- ResolveCellRects -------------------------------------------------------------------------- + + /// Proves a simple (no merges) 2x2 grid computes cumulative column/row offsets. + [Fact] + public void ResolveCellRects_SimpleGrid_ComputesCumulativeOffsets() + { + var table = new PptxTable( + [1000f, 2000f], + [ + new PptxTableRow(500f, [new PptxTableCell(1, 1, false, false, PptxNoFill.Instance, null, null, null, null, null), new PptxTableCell(1, 1, false, false, PptxNoFill.Instance, null, null, null, null, null)]), + new PptxTableRow(600f, [new PptxTableCell(1, 1, false, false, PptxNoFill.Instance, null, null, null, null, null), new PptxTableCell(1, 1, false, false, PptxNoFill.Instance, null, null, null, null, null)]), + ]); + + var rects = PptxDocument.ResolveCellRects(table); + + Assert.Equal(4, rects.Count); + Assert.Equal((0f, 0f, 1000f, 500f), (rects[0].XEmu, rects[0].YEmu, rects[0].WidthEmu, rects[0].HeightEmu)); + Assert.Equal((1000f, 0f, 2000f, 500f), (rects[1].XEmu, rects[1].YEmu, rects[1].WidthEmu, rects[1].HeightEmu)); + Assert.Equal((0f, 500f, 1000f, 600f), (rects[2].XEmu, rects[2].YEmu, rects[2].WidthEmu, rects[2].HeightEmu)); + Assert.Equal((1000f, 500f, 2000f, 600f), (rects[3].XEmu, rects[3].YEmu, rects[3].WidthEmu, rects[3].HeightEmu)); + } + + /// Proves a merge-continuation cell (HMerge/VMerge) contributes no rectangle of its own. + [Fact] + public void ResolveCellRects_MergeContinuationCells_AreSkipped() + { + var table = new PptxTable( + [1000f, 1000f], + [ + new PptxTableRow(500f, + [ + new PptxTableCell(2, 1, false, false, PptxNoFill.Instance, null, null, null, null, null), + new PptxTableCell(1, 1, true, false, PptxNoFill.Instance, null, null, null, null, null), + ]), + ]); + + var rects = PptxDocument.ResolveCellRects(table); + + var rect = Assert.Single(rects); + Assert.Equal(2000f, rect.WidthEmu); + } + + /// Proves a horizontal merge (gridSpan > 1) computes the spanned width as the sum of the consecutive column widths. + [Fact] + public void ResolveCellRects_HorizontalMerge_ComputesSpannedWidth() + { + var table = new PptxTable( + [1000f, 2000f, 3000f], + [ + new PptxTableRow(500f, + [ + new PptxTableCell(2, 1, false, false, PptxNoFill.Instance, null, null, null, null, null), + new PptxTableCell(1, 1, true, false, PptxNoFill.Instance, null, null, null, null, null), + new PptxTableCell(1, 1, false, false, PptxNoFill.Instance, null, null, null, null, null), + ]), + ]); + + var rects = PptxDocument.ResolveCellRects(table); + + Assert.Equal(2, rects.Count); + Assert.Equal(3000f, rects[0].WidthEmu); // 1000 + 2000 + Assert.Equal(3000f, rects[1].XEmu); + Assert.Equal(3000f, rects[1].WidthEmu); + } + + /// Proves a vertical merge (rowSpan > 1) computes the spanned height as the sum of the consecutive row heights. + [Fact] + public void ResolveCellRects_VerticalMerge_ComputesSpannedHeight() + { + var table = new PptxTable( + [1000f], + [ + new PptxTableRow(500f, [new PptxTableCell(1, 2, false, false, PptxNoFill.Instance, null, null, null, null, null)]), + new PptxTableRow(600f, [new PptxTableCell(1, 1, false, true, PptxNoFill.Instance, null, null, null, null, null)]), + ]); + + var rects = PptxDocument.ResolveCellRects(table); + + var rect = Assert.Single(rects); + Assert.Equal(1100f, rect.HeightEmu); // 500 + 600 + } + + /// + /// Proves defensively compensates when a + /// gridSpan="2" governing cell is followed immediately by a different, independent + /// cell rather than its own hMerge continuation placeholder (the schema - see this + /// method's own <remarks/> - does not guarantee the placeholder's presence). + /// The governing cell's own rect is unaffected, and the following independent cell's + /// XEmu lands at the correct compensated column offset (column 2's own width, + /// 3000), not overlapping the governing cell's merged region (columns 0-1, width 3000). + /// + [Fact] + public void ResolveCellRects_GridSpanMissingHMergePlaceholder_CompensatesColumnAdvance() + { + var table = new PptxTable( + [1000f, 2000f, 3000f], + [ + new PptxTableRow(500f, + [ + new PptxTableCell(2, 1, false, false, PptxNoFill.Instance, null, null, null, null, null), + // No hMerge continuation placeholder for the gridSpan=2 cell's second column - + // a different, independent governing cell follows immediately instead. + new PptxTableCell(1, 1, false, false, PptxNoFill.Instance, null, null, null, null, null), + ]), + ]); + + var rects = PptxDocument.ResolveCellRects(table); + + Assert.Equal(2, rects.Count); + Assert.Equal((0f, 3000f), (rects[0].XEmu, rects[0].WidthEmu)); // columns 0-1 (1000 + 2000), unchanged + Assert.Equal((3000f, 3000f), (rects[1].XEmu, rects[1].WidthEmu)); // column 2, compensated past the missing placeholder + } + + // --- PaintTable -------------------------------------------------------------------------------- + + private static readonly Func ConstantFontResolver = (_, _, _) => NewFont(); + + // Synthetic font: UnitsPerEm 1000, a single 'A' glyph (index 1) whose outline is a filled + // square spanning font-unit [100,900] x [100,900] (mirrors PptxTextRenderTests.NewFilledSquareFont). + private static TrueTypeFont NewFont() + { + var notdef = SyntheticFontBuilder.SimpleGlyph(); + var glyphA = SyntheticFontBuilder.SimpleGlyph( + [(100, 100, true), (900, 100, true), (900, 900, true), (100, 900, true)]); + var cmap = SyntheticFontBuilder.CmapFormat4(3, 1, [(65, 1)]); + + var data = new SyntheticFontBuilder() + .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) + .AddTable("maxp", SyntheticFontBuilder.Maxp(2)) + .AddTable("hhea", SyntheticFontBuilder.Hhea(800, -200, 0, 2)) + .AddTable("hmtx", SyntheticFontBuilder.Hmtx([0, 1000])) + .AddTable("loca", SyntheticFontBuilder.Loca([notdef.Length, glyphA.Length], longFormat: false)) + .AddTable("glyf", [.. notdef, .. glyphA]) + .AddTable("cmap", cmap) + .Build(); + + using var stream = new MemoryStream(data); + return TrueTypeFont.Load(stream); + } + + /// Proves a solid-filled cell paints its fill color across the cell's own rectangle. + [Fact] + public void PaintTable_SolidFilledCell_PaintsFillColorAcrossCellRectangle() + { + var table = new PptxTable( + [100f], + [new PptxTableRow(100f, [new PptxTableCell(1, 1, false, false, new PptxSolidFill(new Rgba32(10, 20, 30, 255)), null, null, null, null, null)])]); + + using var surface = new Surface(100, 100); + + PptxDocument.PaintTable(surface, table, BuildTestTheme(), Matrix3x2.Identity, ConstantFontResolver); + + Assert.Equal(new Rgba32(10, 20, 30, 255), surface[50, 50]); + } + + /// + /// Proves composes a non-identity + /// shapeToSurfaceTransform into a gradient-filled cell's own + /// before filling, so the gradient ramp is positioned/scaled in + /// surface space rather than left in untransformed local-EMU space (see + /// PptxDocument.Tables.cs's own FillPaint overload's XmlDoc). + /// + [Fact] + public void PaintTable_GradientFilledCellUnderNonIdentityTransform_PositionsGradientInSurfaceSpace() + { + // A local-space 100x100 cell with a horizontal (ang=0) red -> blue gradient. A 2x scale + // transform maps the cell onto a 200x200 region of the surface. + var tcElement = BuildTc(tcPr: BuildGradFillTcPr(0)); + var cell = PptxDocument.ParseTableCell(tcElement, BuildTestTheme(), 100f, 100f); + var table = new PptxTable([100f], [new PptxTableRow(100f, [cell])]); + + using var surface = new Surface(200, 200); + + PptxDocument.PaintTable(surface, table, BuildTestTheme(), Matrix3x2.CreateScale(2f), ConstantFontResolver); + + // Sampled comfortably inside each end of the surface-space-mapped gradient ramp (local + // x=0 -> surface x=0, local x=100 -> surface x=200): the near-start pixel should be + // dominated by the first stop's color (red), the near-end pixel by the last stop's color + // (blue). If the gradient's own Transform were left at the default identity (the pre-fix + // behavior), the gradient would instead be evaluated directly against these surface-pixel + // coordinates in the gradient's own un-scaled local-space terms, producing the wrong + // color at both samples. + var nearStart = surface[5, 100]; + var nearEnd = surface[195, 100]; + + Assert.True(nearStart.R > nearStart.B, $"Expected a red-dominant pixel near the gradient's start, got {nearStart}."); + Assert.True(nearEnd.B > nearEnd.R, $"Expected a blue-dominant pixel near the gradient's end, got {nearEnd}."); + } + + /// Proves a cell's resolved border paints a stroked line at the cell's own edge. + [Fact] + public void PaintTable_CellWithTopBorder_PaintsStrokedLineAtTopEdge() + { + var border = new PptxLineStyle(10f, new PptxSolidFill(new Rgba32(255, 0, 0, 255)), null); + var table = new PptxTable( + [100f], + [new PptxTableRow(100f, [new PptxTableCell(1, 1, false, false, PptxNoFill.Instance, null, null, border, null, null)])]); + + using var surface = new Surface(100, 100); + + PptxDocument.PaintTable(surface, table, BuildTestTheme(), Matrix3x2.Identity, ConstantFontResolver); + + // The top border is a 10-EMU-wide stroked line centered on y=0 - comfortably hit at (50, 2). + Assert.Equal(new Rgba32(255, 0, 0, 255), surface[50, 2]); + // Comfortably below the border's own stroke width, inside the (unfilled - PptxNoFill) cell body. + Assert.Equal(default, surface[50, 50]); + } + + /// Proves a cell's text content paints glyph ink inside the cell's own rectangle. + [Fact] + public void PaintTable_CellWithText_PaintsGlyphInkInsideCellRectangle() + { + // sz="100" (1pt) -> 12700 EMU font size; zero insets; a 100000x100000 EMU cell scaled by + // 0.001 maps the whole cell onto the 100x100 surface, comfortably containing the glyph. + var tcElement = BuildTc(txBody: BuildTextBody("A")); + var cell = PptxDocument.ParseTableCell(tcElement, BuildTestTheme(), 100000f, 100000f); + var table = new PptxTable([100000f], [new PptxTableRow(100000f, [cell])]); + + using var surface = new Surface(100, 100); + + PptxDocument.PaintTable(surface, table, BuildTestTheme(), Matrix3x2.CreateScale(0.001f), ConstantFontResolver); + + // Some ink was painted somewhere inside the cell's own 100x100 surface-space footprint - + // a coarse but robust assertion (exact glyph position/alignment math is already proven by + // PptxTextLayoutTests/PptxTextRenderTests; this test only proves the table-paint plumbing + // reaches PaintTextLayout at all). + var anyInk = false; + for (var y = 0; y < 100 && !anyInk; y++) + { + for (var x = 0; x < 100; x++) + { + if (surface[x, y] != default) + { + anyInk = true; + break; + } + } + } + + Assert.True(anyInk); + } + + // --- Table style resolution (Phase 2 Follow-Up: Table Style/Banding Resolution) -------------- + + /// + /// Proves (a): a table with a matching <a:tableStyleId> and + /// <a:tblPr firstRow="1"> renders its header row (row 0) cell using the + /// matched style's <a:firstRow> tier fill, while a non-header row falls back + /// to the style's <a:wholeTbl> base-tier fill. + /// + [Fact] + public void ParseTable_FirstRowTblPrWithMatchingTableStyle_HeaderRowCellUsesFirstRowStyleFill() + { + var tblStyle = BuildTblStyle( + "styleA", + wholeTbl: BuildTcStyle(BuildFillElement("111111")), + firstRow: BuildTcStyle(BuildFillElement("222222"))); + + var tbl = BuildTblWithPr( + BuildTblPr(tableStyleId: "styleA", firstRow: true), + [1000f], + BuildTr(500f, BuildTc()), + BuildTr(500f, BuildTc())); + var graphicFrame = BuildGraphicFrame(tbl); + + var table = PptxDocument.ParseTable( + graphicFrame, BuildTestTheme(), tableStyleResolver: id => id == "styleA" ? tblStyle : null); + + var headerFill = Assert.IsType(table.Rows[0].Cells[0].Fill); + Assert.Equal(new Rgba32(0x22, 0x22, 0x22, 255), headerFill.Color); + + var dataFill = Assert.IsType(table.Rows[1].Cells[0].Fill); + Assert.Equal(new Rgba32(0x11, 0x11, 0x11, 255), dataFill.Color); + } + + /// + /// Proves (b): with a matching style and <a:tblPr firstRow="1" bandRow="1">, + /// the header row uses <a:firstRow>'s fill and the remaining (non-header) + /// rows alternate <a:band1H>/<a:band2H> starting immediately + /// after the header row - with a band tier declaring no <a:fill> at all (here, + /// band2H) falling through to the <a:wholeTbl> base fill rather than + /// "no fill". + /// + [Fact] + public void ParseTable_BandRowTblPrWithMatchingTableStyle_AlternatesBand1HAndBand2HFillStartingAfterHeaderRow() + { + var tblStyle = BuildTblStyle( + "styleB", + wholeTbl: BuildTcStyle(BuildFillElement("AAAAAA")), + band1H: BuildTcStyle(BuildFillElement("BBBBBB")), + band2H: BuildTcStyle(), // deliberately no - must fall through to wholeTbl + firstRow: BuildTcStyle(BuildFillElement("CCCCCC"))); + + var tbl = BuildTblWithPr( + BuildTblPr(tableStyleId: "styleB", firstRow: true, bandRow: true), + [1000f], + BuildTr(500f, BuildTc()), + BuildTr(500f, BuildTc()), + BuildTr(500f, BuildTc()), + BuildTr(500f, BuildTc()), + BuildTr(500f, BuildTc())); + var graphicFrame = BuildGraphicFrame(tbl); + + var table = PptxDocument.ParseTable( + graphicFrame, BuildTestTheme(), tableStyleResolver: id => id == "styleB" ? tblStyle : null); + + Assert.Equal(new Rgba32(0xCC, 0xCC, 0xCC, 255), Assert.IsType(table.Rows[0].Cells[0].Fill).Color); // header (firstRow) + Assert.Equal(new Rgba32(0xBB, 0xBB, 0xBB, 255), Assert.IsType(table.Rows[1].Cells[0].Fill).Color); // band1H + Assert.Equal(new Rgba32(0xAA, 0xAA, 0xAA, 255), Assert.IsType(table.Rows[2].Cells[0].Fill).Color); // band2H -> wholeTbl + Assert.Equal(new Rgba32(0xBB, 0xBB, 0xBB, 255), Assert.IsType(table.Rows[3].Cells[0].Fill).Color); // band1H + Assert.Equal(new Rgba32(0xAA, 0xAA, 0xAA, 255), Assert.IsType(table.Rows[4].Cells[0].Fill).Color); // band2H -> wholeTbl + } + + /// + /// Proves (c), case 1: a table whose <a:tblPr> declares no + /// <a:tableStyleId> at all never even invokes the table-style resolver + /// delegate, and falls back to today's plain, style-less cell-only fill/border resolution. + /// + [Fact] + public void ParseTable_TblPrWithNoTableStyleId_FallsBackToPlainCellOnlyBorderAndNoFill() + { + var tcPr = new XElement( + A + "tcPr", + BuildTcPrLineEdge("lnL", 100f, "FF0000"), + BuildFillElement("00FF00").Elements().First()); // direct child of , matching existing tcPr convention + + var tbl = BuildTblWithPr( + BuildTblPr(firstRow: true, bandRow: true), // no tableStyleId + [1000f], + BuildTr(500f, BuildTc(tcPr: tcPr))); + var graphicFrame = BuildGraphicFrame(tbl); + + var table = PptxDocument.ParseTable( + graphicFrame, BuildTestTheme(), + tableStyleResolver: _ => throw new InvalidOperationException("Resolver must not be invoked with no tableStyleId.")); + + var cell = table.Rows[0].Cells[0]; + var fill = Assert.IsType(cell.Fill); + Assert.Equal(new Rgba32(0x00, 0xFF, 0x00, 255), fill.Color); + Assert.NotNull(cell.LeftBorder); + Assert.Null(cell.RightBorder); + } + + /// + /// Proves (c), case 2: a table whose <a:tableStyleId> does not resolve to any + /// known <a:tblStyle> (the resolver returns ) falls back + /// to the same plain, style-less cell-only fill/border resolution, without throwing. + /// + [Fact] + public void ParseTable_TblPrWithUnresolvableTableStyleId_FallsBackToPlainCellOnlyBorderAndNoFillWithoutThrowing() + { + var tcPr = new XElement(A + "tcPr", BuildTcPrLineEdge("lnT", 150f, "0000FF")); + + var tbl = BuildTblWithPr( + BuildTblPr(tableStyleId: "{NO-SUCH-STYLE}", firstRow: true, bandRow: true), + [1000f], + BuildTr(500f, BuildTc(tcPr: tcPr))); + var graphicFrame = BuildGraphicFrame(tbl); + + var table = PptxDocument.ParseTable(graphicFrame, BuildTestTheme(), tableStyleResolver: _ => null); + + var cell = table.Rows[0].Cells[0]; + Assert.IsType(cell.Fill); + Assert.NotNull(cell.TopBorder); + Assert.Null(cell.LeftBorder); + } + + /// + /// Proves (d): a cell's own explicit <a:tcPr> fill and a single explicit + /// border edge (<a:lnT>) take final precedence over the matched table style's + /// fill/borders, while the cell's remaining (non-overridden) border edges still fall back + /// to the matched style's own <a:wholeTbl> tier. + /// + [Fact] + public void ParseTableCell_TcPrExplicitFillAndBorder_OverridesTableStyleFillAndBorder() + { + var tblStyle = BuildTblStyle( + "styleD", + wholeTbl: BuildTcStyle( + BuildFillElement("111111"), + BuildTcBdrEdge("left", 50f, "AAAAAA"), + BuildTcBdrEdge("right", 50f, "AAAAAA"), + BuildTcBdrEdge("top", 50f, "AAAAAA"), + BuildTcBdrEdge("bottom", 50f, "AAAAAA"))); + + var tcPr = new XElement( + A + "tcPr", + BuildTcPrLineEdge("lnT", 999f, "FF00FF"), + BuildFillElement("FFFFFF").Elements().First()); + + var tbl = BuildTblWithPr( + BuildTblPr(tableStyleId: "styleD"), + [1000f], + BuildTr(500f, BuildTc(tcPr: tcPr))); + var graphicFrame = BuildGraphicFrame(tbl); + + var table = PptxDocument.ParseTable( + graphicFrame, BuildTestTheme(), tableStyleResolver: id => id == "styleD" ? tblStyle : null); + + var cell = table.Rows[0].Cells[0]; + var fill = Assert.IsType(cell.Fill); + Assert.Equal(new Rgba32(0xFF, 0xFF, 0xFF, 255), fill.Color); + + Assert.NotNull(cell.TopBorder); + Assert.Equal(999f, cell.TopBorder.WidthEmu); + + Assert.NotNull(cell.LeftBorder); + Assert.Equal(50f, cell.LeftBorder.WidthEmu); // falls back to the style's own wholeTbl tier + Assert.NotNull(cell.RightBorder); + Assert.Equal(50f, cell.RightBorder.WidthEmu); + Assert.NotNull(cell.BottomBorder); + Assert.Equal(50f, cell.BottomBorder.WidthEmu); + } + + /// + /// Proves (recommended 5th case): an interior column boundary resolves against the style's + /// <a:tcBdr>/<a:insideV> edge rather than its left/right + /// edges, while the table's two true outer-boundary edges (the first cell's own left edge, + /// the last cell's own right edge) still resolve against left/right. + /// + [Fact] + public void ParseTableCell_InteriorColumnBorder_UsesInsideVNotLeftRightTcBdrEdge() + { + var tblStyle = BuildTblStyle( + "styleE", + wholeTbl: BuildTcStyle( + null, + BuildTcBdrEdge("left", 10f, "111111"), + BuildTcBdrEdge("right", 20f, "222222"), + BuildTcBdrEdge("insideV", 30f, "333333"))); + + var tbl = BuildTblWithPr( + BuildTblPr(tableStyleId: "styleE"), + [1000f, 1000f, 1000f], + BuildTr(500f, BuildTc(), BuildTc(), BuildTc())); + var graphicFrame = BuildGraphicFrame(tbl); + + var table = PptxDocument.ParseTable( + graphicFrame, BuildTestTheme(), tableStyleResolver: id => id == "styleE" ? tblStyle : null); + + var firstCell = table.Rows[0].Cells[0]; + Assert.Equal(10f, firstCell.LeftBorder!.WidthEmu); // true outer edge + Assert.Equal(30f, firstCell.RightBorder!.WidthEmu); // interior edge + + var middleCell = table.Rows[0].Cells[1]; + Assert.Equal(30f, middleCell.LeftBorder!.WidthEmu); // interior edge + Assert.Equal(30f, middleCell.RightBorder!.WidthEmu); // interior edge + + var lastCell = table.Rows[0].Cells[2]; + Assert.Equal(30f, lastCell.LeftBorder!.WidthEmu); // interior edge + Assert.Equal(20f, lastCell.RightBorder!.WidthEmu); // true outer edge + } + + /// Proves a no-fill, no-border, no-text cell paints nothing at all. + [Fact] + public void PaintTable_EmptyCell_PaintsNothing() + { + var table = new PptxTable( + [100f], + [new PptxTableRow(100f, [new PptxTableCell(1, 1, false, false, PptxNoFill.Instance, null, null, null, null, null)])]); + + using var surface = new Surface(100, 100); + + PptxDocument.PaintTable(surface, table, BuildTestTheme(), Matrix3x2.Identity, ConstantFontResolver); + + for (var y = 0; y < 100; y++) + { + for (var x = 0; x < 100; x++) + { + Assert.Equal(default, surface[x, y]); + } + } + } +} diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxTextLayoutTests.cs b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxTextLayoutTests.cs new file mode 100644 index 00000000..2203aaac --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxTextLayoutTests.cs @@ -0,0 +1,1406 @@ +using System.Numerics; +using System.Xml.Linq; +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Codecs; +using DemaConsulting.CanvasNet.Fonts; +using DemaConsulting.CanvasNet.Tests.TestSupport; + +namespace DemaConsulting.CanvasNet.Pptx.Tests; + +// cspell:ignore pptx txbody bodypr lnspc normautofit noautofit spautofit fontscale lnspcreduction unitsperem + +/// +/// Unit-level tests for the Phase 1d text layout engine (PptxDocument.TextLayout.cs): +/// word-wrap, horizontal alignment, vertical anchor, and the three-tier autofit policy. Every +/// test injects a deterministic synthetic (via a fontResolver +/// delegate that bypasses installed-font discovery entirely) so expected glyph coordinates can +/// be hand-computed exactly. Complements (parsing/inheritance) and +/// (glyph painting). +/// +public class PptxTextLayoutTests +{ + private static readonly XNamespace DrawingNs = "http://schemas.openxmlformats.org/drawingml/2006/main"; + private static readonly XNamespace PresentationNs = "http://schemas.openxmlformats.org/presentationml/2006/main"; + + // Synthetic font: UnitsPerEm 1000, ascender 800, descender -200, lineGap 0 (natural line + // height == size, since ascender - descender + lineGap == UnitsPerEm exactly - this makes + // every expected line-height/ascent hand-computation a simple multiply, not a messier ratio). + // .notdef (index 0), advance 0, zero contours. + // 'A' (codepoint 65, index 1), advance 500 units, non-empty outline (a filled triangle). + // ' ' (codepoint 32, index 2), advance 200 units, zero contours (whitespace). + private static TrueTypeFont NewFont() + { + var notdef = SyntheticFontBuilder.SimpleGlyph(); + var glyphA = SyntheticFontBuilder.SimpleGlyph([(0, 0, true), (500, 0, true), (250, 800, true)]); + var glyphSpace = SyntheticFontBuilder.SimpleGlyph(); + var cmap = SyntheticFontBuilder.CmapFormat4(3, 1, [(65, 1), (32, 2)]); + + var data = new SyntheticFontBuilder() + .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) + .AddTable("maxp", SyntheticFontBuilder.Maxp(3)) + .AddTable("hhea", SyntheticFontBuilder.Hhea(800, -200, 0, 3)) + .AddTable("hmtx", SyntheticFontBuilder.Hmtx([0, 500, 200])) + .AddTable("loca", SyntheticFontBuilder.Loca([notdef.Length, glyphA.Length, glyphSpace.Length], longFormat: false)) + .AddTable("glyf", [.. notdef, .. glyphA, .. glyphSpace]) + .AddTable("cmap", cmap) + .Build(); + + using var stream = new MemoryStream(data); + return TrueTypeFont.Load(stream); + } + + // Synthetic font: UnitsPerEm 1000, ascender 3000, descender -1000, lineGap 0 - a font with a + // much taller em-box (raw natural-height metric 4000) than NewFont()'s (1000), used to prove + // the per-token "tallest font" comparison in BuildLines scales by UnitsPerEm/SizeEmu before + // comparing, rather than comparing raw font-design units directly: a tiny-point-size run + // using this font must NOT out-rank a much-larger-point-size run using NewFont() merely + // because its raw metrics happen to be numerically bigger. + // .notdef (index 0), advance 0, zero contours. + // 'B' (codepoint 66, index 1), advance 400 units, non-empty outline (a filled triangle). + private static TrueTypeFont NewTallEmBoxFont() + { + var notdef = SyntheticFontBuilder.SimpleGlyph(); + var glyphB = SyntheticFontBuilder.SimpleGlyph([(0, 0, true), (400, 0, true), (200, 3000, true)]); + var cmap = SyntheticFontBuilder.CmapFormat4(3, 1, [(66, 1)]); + + var data = new SyntheticFontBuilder() + .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) + .AddTable("maxp", SyntheticFontBuilder.Maxp(2)) + .AddTable("hhea", SyntheticFontBuilder.Hhea(3000, -1000, 0, 2)) + .AddTable("hmtx", SyntheticFontBuilder.Hmtx([0, 400])) + .AddTable("loca", SyntheticFontBuilder.Loca([notdef.Length, glyphB.Length], longFormat: false)) + .AddTable("glyf", [.. notdef, .. glyphB]) + .AddTable("cmap", cmap) + .Build(); + + using var stream = new MemoryStream(data); + return TrueTypeFont.Load(stream); + } + + private static PptxTheme BuildTestTheme() => + new( + new PptxColorScheme( + new Rgba32(10, 10, 10, 255), new Rgba32(20, 20, 20, 255), new Rgba32(30, 30, 30, 255), new Rgba32(40, 40, 40, 255), + new Rgba32(50, 50, 50, 255), new Rgba32(60, 60, 60, 255), new Rgba32(70, 70, 70, 255), new Rgba32(80, 80, 80, 255), + new Rgba32(90, 90, 90, 255), new Rgba32(100, 100, 100, 255), new Rgba32(110, 110, 110, 255), new Rgba32(120, 120, 120, 255)), + new PptxFontScheme( + new PptxFontCollection("ThemeMajorLatin", "MajorEA", "MajorCS"), + new PptxFontCollection("ThemeMinorLatin", "MinorEA", "MinorCS"))); + + private static readonly Func ConstantFontResolver = (_, _, _) => NewFont(); + + /// + /// Builds a one-paragraph, one-run <p:txBody> with a no-inset + /// <a:bodyPr>, for exact EMU math. / + /// , when supplied, are full standalone + /// <bodyPr>/<pPr> element fragments whose own attributes and + /// child elements are merged onto the real <a:bodyPr>/<a:pPr> + /// element (not nested underneath it - a nested, inner <bodyPr> element would + /// be silently invisible to the parser, which only inspects the outer element's own + /// attributes/children). + /// + private static PptxTextBody BuildSingleRunTextBody(string text, string? bodyPrExtra = null, string? paragraphExtra = null) + { + var bodyPr = new XElement( + DrawingNs + "bodyPr", + new XAttribute("lIns", "0"), new XAttribute("tIns", "0"), new XAttribute("rIns", "0"), new XAttribute("bIns", "0")); + if (bodyPrExtra is not null) + { + var extra = XElement.Parse(bodyPrExtra); + bodyPr.Add(extra.Attributes()); + bodyPr.Add(extra.Elements()); + } + + var pPr = new XElement(DrawingNs + "pPr"); + if (paragraphExtra is not null) + { + var extra = XElement.Parse(paragraphExtra); + pPr.Add(extra.Attributes()); + pPr.Add(extra.Elements()); + } + + var txBody = new XElement( + PresentationNs + "txBody", + bodyPr, + new XElement( + DrawingNs + "p", + pPr, + new XElement( + DrawingNs + "r", + new XElement(DrawingNs + "rPr", new XAttribute("sz", "100")), + new XElement(DrawingNs + "t", text)))); + + return PptxDocument.ParseTextBody(txBody); + } + + /// + /// Builds a two-paragraph, single-run-per-paragraph <p:txBody> with a + /// no-inset <a:bodyPr>, each paragraph's own <a:pPr> optionally + /// extended with / fragments (same + /// merge convention as 's own paragraphExtra), + /// used by the paragraph-spacing (Phase 2 Follow-Up) tests to exercise + /// <a:spcBef>/<a:spcAft> declared on one or both paragraphs. + /// + private static PptxTextBody BuildTwoParagraphTextBody(string text1, string? pPrExtra1, string text2, string? pPrExtra2) + { + XElement BuildPPr(string? extra) + { + var pPr = new XElement(DrawingNs + "pPr"); + if (extra is not null) + { + var parsed = XElement.Parse(extra); + pPr.Add(parsed.Attributes()); + pPr.Add(parsed.Elements()); + } + + return pPr; + } + + var bodyPr = new XElement( + DrawingNs + "bodyPr", + new XAttribute("lIns", "0"), new XAttribute("tIns", "0"), new XAttribute("rIns", "0"), new XAttribute("bIns", "0")); + var txBody = new XElement( + PresentationNs + "txBody", + bodyPr, + new XElement( + DrawingNs + "p", + BuildPPr(pPrExtra1), + new XElement(DrawingNs + "r", new XElement(DrawingNs + "rPr", new XAttribute("sz", "100")), new XElement(DrawingNs + "t", text1))), + new XElement( + DrawingNs + "p", + BuildPPr(pPrExtra2), + new XElement(DrawingNs + "r", new XElement(DrawingNs + "rPr", new XAttribute("sz", "100")), new XElement(DrawingNs + "t", text2)))); + + return PptxDocument.ParseTextBody(txBody); + } + + private static PptxTextLayout Layout(PptxTextBody textBody, float widthEmu, float heightEmu) => + PptxDocument.ResolveTextLayout(textBody, new PptxPlaceholderProperties(null, null, BuildTestTheme()), BuildTestTheme(), "body", widthEmu, heightEmu, ConstantFontResolver); + + #region Word wrap + + /// Proves "AA AA" wraps onto a second line once the available width can no longer hold the trailing word. + [Fact] + public void ResolveTextLayout_WordWrap_NarrowWidth_WrapsAtTokenBoundary() + { + // 'A' advance at sz=100 (SizeEmu 12700): 500/1000 * 12700 = 6350. "AA" = 12700. ' ' = 2540. + // Available width 20000 fits "AA" + ' ' (15240) but not a further "AA" (27940) - the + // second "AA" token wraps onto its own line. + var textBody = BuildSingleRunTextBody("AA AA"); + + var layout = Layout(textBody, 20000f, 100000f); + + Assert.Equal(4, layout.Glyphs.Count); + // Line 1 ("AA"): ascent 800/1000*12700 = 10160, at y = 0 + 10160. + Assert.Equal(0f, layout.Glyphs[0].OriginXEmu, 2); + Assert.Equal(10160f, layout.Glyphs[0].OriginYEmu, 2); + Assert.Equal(6350f, layout.Glyphs[1].OriginXEmu, 2); + Assert.Equal(10160f, layout.Glyphs[1].OriginYEmu, 2); + // Line 2 ("AA"): line height = natural height = 12700 (ascender - descender + lineGap == + // UnitsPerEm, so naturalHeight == sizeEmu exactly), baseline at 12700 + 10160. + Assert.Equal(0f, layout.Glyphs[2].OriginXEmu, 2); + Assert.Equal(22860f, layout.Glyphs[2].OriginYEmu, 2); + Assert.Equal(6350f, layout.Glyphs[3].OriginXEmu, 2); + Assert.Equal(22860f, layout.Glyphs[3].OriginYEmu, 2); + } + + /// Proves a single token wider than the available width is placed alone on its own (overflowing) line rather than looping/throwing. + [Fact] + public void ResolveTextLayout_WordWrap_SingleTokenWiderThanAvailableWidth_PlacedAloneOnOwnLine() + { + // 10 'A's at sz=100: width = 10 * 6350 = 63500, far exceeding the 20000 available width. + var textBody = BuildSingleRunTextBody("AAAAAAAAAA"); + + var layout = Layout(textBody, 20000f, 100000f); + + Assert.Equal(10, layout.Glyphs.Count); + Assert.Equal(0f, layout.Glyphs[0].OriginXEmu, 2); + Assert.Equal(9 * 6350f, layout.Glyphs[9].OriginXEmu, 2); + } + + /// + /// Proves a word split across two adjacent formatting runs with no whitespace between + /// them (for example a bold "Hel" run immediately followed by a plain "lo" run spelling + /// "Hello") is never wrapped at that run boundary: PackTokensIntoLines must treat + /// the two runs' tokens as a single wrap-atomic word group, not two tokens each + /// independently eligible to wrap, even though Tokenize only ever sees one run's + /// text at a time. + /// An available width that sits strictly between the width of either run's own text and + /// their combined width would, under the old per-token wrap logic, incorrectly wrap + /// between the two runs (producing "A" / "A" on two lines) - here both glyphs must still + /// land on the very same (overflowing) line, since there is no whitespace anywhere in the + /// paragraph to legally wrap at, exactly mirroring + /// 's + /// own single-token overflow policy, just for a word spread across two runs instead of one. + /// + [Fact] + public void ResolveTextLayout_WordWrap_WordSplitAcrossRuns_DoesNotWrapAtRunBoundary() + { + var pPr = new XElement(DrawingNs + "pPr"); + var bodyPr = new XElement( + DrawingNs + "bodyPr", + new XAttribute("lIns", "0"), new XAttribute("tIns", "0"), new XAttribute("rIns", "0"), new XAttribute("bIns", "0")); + var txBody = new XElement( + PresentationNs + "txBody", + bodyPr, + new XElement( + DrawingNs + "p", + pPr, + new XElement(DrawingNs + "r", new XElement(DrawingNs + "rPr", new XAttribute("sz", "100")), new XElement(DrawingNs + "t", "A")), + new XElement(DrawingNs + "r", new XElement(DrawingNs + "rPr", new XAttribute("sz", "100")), new XElement(DrawingNs + "t", "A")))); + var textBody = PptxDocument.ParseTextBody(txBody); + + // Each run's "A" at sz=100 is 6350 EMU wide; the combined word "AA" is 12700 EMU. An + // available width of 10000 sits strictly between the two - wide enough for either run's + // text alone, but not their combined word - so the old per-token wrap logic would + // incorrectly wrap between the two runs after placing the first "A". + var layout = Layout(textBody, 10000f, 100000f); + + Assert.Equal(2, layout.Glyphs.Count); + Assert.Equal(0f, layout.Glyphs[0].OriginXEmu, 2); + Assert.Equal(10160f, layout.Glyphs[0].OriginYEmu, 2); + // Both glyphs on the very same line - no wrap at the run boundary. + Assert.Equal(6350f, layout.Glyphs[1].OriginXEmu, 2); + Assert.Equal(10160f, layout.Glyphs[1].OriginYEmu, 2); + } + + /// + /// Proves an <a:br/> between two runs forces an explicit second line even + /// though the available width is ample enough to fit both runs' text on a single + /// word-wrapped line - i.e. "A<br/>B" must lay out as two lines, not "AB" on one + /// line, which is the behavior before this fix. + /// + [Fact] + public void ResolveTextLayout_RunBreakRun_ForcesSecondLine() + { + var pPr = new XElement(DrawingNs + "pPr"); + var bodyPr = new XElement( + DrawingNs + "bodyPr", + new XAttribute("lIns", "0"), new XAttribute("tIns", "0"), new XAttribute("rIns", "0"), new XAttribute("bIns", "0")); + var txBody = new XElement( + PresentationNs + "txBody", + bodyPr, + new XElement( + DrawingNs + "p", + pPr, + new XElement(DrawingNs + "r", new XElement(DrawingNs + "rPr", new XAttribute("sz", "100")), new XElement(DrawingNs + "t", "A")), + new XElement(DrawingNs + "br"), + new XElement(DrawingNs + "r", new XElement(DrawingNs + "rPr", new XAttribute("sz", "100")), new XElement(DrawingNs + "t", "B")))); + var textBody = PptxDocument.ParseTextBody(txBody); + + // Available width 50000 easily fits "AB" (12700) on a single word-wrapped line, so two + // lines can only come from the explicit break being honored, not from word-wrap. + var layout = Layout(textBody, 50000f, 100000f); + + Assert.Equal(2, layout.Glyphs.Count); + // Line 1 ("A"): ascent 800/1000*12700 = 10160, at x=0, y=10160. + Assert.Equal(0f, layout.Glyphs[0].OriginXEmu, 2); + Assert.Equal(10160f, layout.Glyphs[0].OriginYEmu, 2); + // Line 2 ("B"): line height = 12700, baseline at x=0, y = 12700 + 10160 = 22860 - not on + // the same line as "A" (which a dropped break would have produced at x=6350, y=10160). + Assert.Equal(0f, layout.Glyphs[1].OriginXEmu, 2); + Assert.Equal(22860f, layout.Glyphs[1].OriginYEmu, 2); + } + + /// + /// Proves the per-token "tallest font" comparison used to pick a line's height/ascent + /// compares each candidate's actual rendered natural height (scaled by its own + /// UnitsPerEm/SizeEmu), not raw font-design units directly - a tiny-point-size + /// run using a font with a much taller em-box must not out-rank a far larger-point-size run + /// using a more modest em-box, which raw-unit comparison would incorrectly do. + /// + [Fact] + public void ResolveTextLayout_TallestFontComparison_ScalesByUnitsPerEmAndSizeEmu_NotRawFontUnits() + { + var bodyPr = new XElement( + DrawingNs + "bodyPr", + new XAttribute("lIns", "0"), new XAttribute("tIns", "0"), new XAttribute("rIns", "0"), new XAttribute("bIns", "0")); + var txBody = new XElement( + PresentationNs + "txBody", + bodyPr, + new XElement( + DrawingNs + "p", + new XElement(DrawingNs + "pPr"), + // Run 1: NewFont() (ascender 800/descender -200/UnitsPerEm 1000, raw natural-height + // metric 1000) at a large sz=1000 (SizeEmu 127000): actual natural height = + // 1000/1000*127000 = 127000 - the correct tallest candidate. + new XElement( + DrawingNs + "r", + new XElement(DrawingNs + "rPr", new XAttribute("sz", "1000"), new XElement(DrawingNs + "latin", new XAttribute("typeface", "BigFont"))), + new XElement(DrawingNs + "t", "A")), + // Run 2: NewTallEmBoxFont() (ascender 3000/descender -1000/UnitsPerEm 1000, raw + // natural-height metric 4000 - numerically bigger than run 1's raw 1000) at a tiny + // sz=10 (SizeEmu 1270): actual natural height = 4000/1000*1270 = 5080, far smaller + // than run 1's 127000. A raw-unit comparison would incorrectly rank this run + // "tallest" (4000 > 1000) and use its own (much smaller) ascent/line-height. + new XElement( + DrawingNs + "r", + new XElement(DrawingNs + "rPr", new XAttribute("sz", "10"), new XElement(DrawingNs + "latin", new XAttribute("typeface", "TallEmBoxFont"))), + new XElement(DrawingNs + "t", "B")))); + var textBody = PptxDocument.ParseTextBody(txBody); + + var bigFont = NewFont(); + var tallEmBoxFont = NewTallEmBoxFont(); + Func fontResolver = (family, _, _) => + family == "TallEmBoxFont" ? tallEmBoxFont : bigFont; + + var layout = PptxDocument.ResolveTextLayout( + textBody, new PptxPlaceholderProperties(null, null, BuildTestTheme()), BuildTestTheme(), "body", 500000f, 500000f, fontResolver); + + Assert.Equal(2, layout.Glyphs.Count); + // Correct ascent comes from run 1 (the actually-tallest run): 800/1000*127000 = 101600 - + // not run 2's 3000/1000*1270 = 3810, which the pre-fix raw-unit comparison would have used. + Assert.Equal(101600f, layout.Glyphs[0].OriginYEmu, 2); + Assert.Equal(101600f, layout.Glyphs[1].OriginYEmu, 2); + } + + /// + /// Proves a wrap="none" shape's centered paragraph is still positioned against the + /// shape's own real, finite declared width - not against the effectively-infinite width + /// internally uses to suppress word-wrapping + /// for wrap="none" - which would otherwise place the line's glyphs at an + /// astronomical X offset, far outside the shape (and any realistic surface) entirely, + /// painting nothing despite centered alignment being an already-implemented, in-scope + /// feature. + /// + [Fact] + public void ResolveTextLayout_AlignCenter_WrapNone_StillCentersAgainstShapesDeclaredWidth() + { + // "AA" line width = 12700. Shape's own declared width 50000 (passed as widthEmu). + // startX = (50000 - 12700) / 2 = 18650 - identical to the wrap="square" case, proving + // alignment ignores the infinite wrap-suppression width entirely. + var textBody = BuildSingleRunTextBody( + "AA", + bodyPrExtra: """""", + paragraphExtra: """"""); + + var layout = Layout(textBody, 50000f, 100000f); + + Assert.Equal(18650f, layout.Glyphs[0].OriginXEmu, 2); + Assert.Equal(18650f + 6350f, layout.Glyphs[1].OriginXEmu, 2); + } + + /// + /// Proves a literal U+0009 TAB run character (Phase 2 Follow-Up: Default Tab-Stop + /// Expansion) expands the cursor to the next default tab stop (914400 EMU, 1 inch) + /// rather than measuring/advancing by its (near-zero, .notdef-glyph) font advance: + /// 's synthetic cmap has no entry for U+0009, so the pre-fix + /// behavior (resolving it exactly like any other whitespace character via + /// GetGlyphIndex/GetAdvanceWidth) would place "B" at + /// x = 6350 + 0 = 6350 (glyph index 0's hmtx advance is 0) - immediately + /// after "A", jammed together - instead of the correct, PowerPoint-matching + /// x = 914400. + /// + [Fact] + public void ResolveTextLayout_TabCharacter_ExpandsToNextDefaultTabStop() + { + // 'A' at sz=100 (SizeEmu 12700): 500/1000*12700 = 6350. The tab following it expands the + // cursor from 6350 to the next multiple of 914400 strictly greater than 6350, i.e. exactly + // 914400 (floor(6350/914400)+1 = 1) - not a near-zero glyph-advance position. The + // available width (2,000,000 EMU) is ample enough that no word-wrap occurs, isolating the + // tab-expansion behavior itself from the wrap-decision logic (see the next test). + var textBody = BuildSingleRunTextBody("A\tB"); + + var layout = Layout(textBody, 2_000_000f, 1_000_000f); + + Assert.Equal(2, layout.Glyphs.Count); + Assert.Equal(0f, layout.Glyphs[0].OriginXEmu, 2); + Assert.Equal(10160f, layout.Glyphs[0].OriginYEmu, 2); + Assert.Equal(914400f, layout.Glyphs[1].OriginXEmu, 2); + Assert.Equal(10160f, layout.Glyphs[1].OriginYEmu, 2); + } + + /// + /// Proves PackTokensIntoLines' wrap/fit decision uses the tab's dynamically- + /// computed, position-dependent tab-stop-expanded width - not its static, near-zero + /// glyph-measured ResolvedToken.WidthEmu - when deciding where word-wrap + /// breaks occur. + /// + /// + /// "AAAA" (width 25400) + tab + "AAAA" (width 25400) at an available width of 920000 EMU: + /// using the tab's true, expanded width (the next 914400-EMU tab stop strictly past + /// 25400, i.e. 914400 itself, an 889000 EMU advance) places "AAAA\t" on line 1 at a total + /// width of exactly 914400 (<= 920000, fits), but the second "AAAA" would bring the + /// running width to 914400 + 25400 = 939800 (> 920000) - correctly wrapping it onto a + /// second line. Using the tab's glyph-measured (near-zero, .notdef-advance) width + /// instead - the pre-fix behavior - the combined total of both words plus the tab would + /// be only 25400 + 0 + 25400 = 50800, comfortably under 920000, so the second "AAAA" + /// would incorrectly stay on line 1 instead of wrapping. This test asserts the correct + /// (wrapped) outcome. + /// + [Fact] + public void ResolveTextLayout_TabCharacter_WrapDecisionUsesExpandedWidthNotGlyphWidth() + { + var textBody = BuildSingleRunTextBody("AAAA\tAAAA"); + + var layout = Layout(textBody, 920000f, 1_000_000f); + + // 8 visible 'A' glyphs total (the tab itself never emits a glyph) - 4 on each line. + Assert.Equal(8, layout.Glyphs.Count); + + // Line 1 ("AAAA" then tab, which wraps the second "AAAA" away): ascent 10160. + Assert.Equal(0f, layout.Glyphs[0].OriginXEmu, 2); + Assert.Equal(6350f, layout.Glyphs[1].OriginXEmu, 2); + Assert.Equal(12700f, layout.Glyphs[2].OriginXEmu, 2); + Assert.Equal(19050f, layout.Glyphs[3].OriginXEmu, 2); + Assert.Equal(10160f, layout.Glyphs[0].OriginYEmu, 2); + + // Line 2 (the second "AAAA", correctly wrapped): line height 12700, baseline at + // 12700 + 10160 = 22860 - restarting at x=0, not continuing on line 1 at x=914400+. + Assert.Equal(0f, layout.Glyphs[4].OriginXEmu, 2); + Assert.Equal(6350f, layout.Glyphs[5].OriginXEmu, 2); + Assert.Equal(12700f, layout.Glyphs[6].OriginXEmu, 2); + Assert.Equal(19050f, layout.Glyphs[7].OriginXEmu, 2); + Assert.Equal(22860f, layout.Glyphs[4].OriginYEmu, 2); + } + + /// + /// Proves two adjacent tab characters each independently advance to their own next tab + /// stop, computed from the cursor position after the previous tab's own expansion + /// - not both from the pre-first-tab position - and that a tab landing exactly on an + /// already-aligned tab-stop boundary still advances by a full, non-zero interval (never a + /// zero-width result), per 's GetNextTabStopEmu contract. + /// + [Fact] + public void ResolveTextLayout_MultipleTabCharacters_EachAdvancesToItsOwnNextTabStop() + { + // 'A' at sz=100: 6350. First tab: next stop strictly past 6350 is 914400 (one interval). + // Second tab starts exactly on that 914400 boundary - its own next stop must still be a + // full interval further on (1828800), never 914400 itself (a zero-width advance). "B" + // then lands at 1828800 + 0 = 1828800, not at a near-zero position after two collapsed + // tabs. + var textBody = BuildSingleRunTextBody("A\t\tB"); + + var layout = Layout(textBody, 3_000_000f, 1_000_000f); + + Assert.Equal(2, layout.Glyphs.Count); + Assert.Equal(0f, layout.Glyphs[0].OriginXEmu, 2); + Assert.Equal(10160f, layout.Glyphs[0].OriginYEmu, 2); + Assert.Equal(1828800f, layout.Glyphs[1].OriginXEmu, 2); + Assert.Equal(10160f, layout.Glyphs[1].OriginYEmu, 2); + } + + /// + /// Proves a whitespace token that would itself overflow the current line (because the + /// word preceding it already fills the line, and the word following it is also too wide + /// to join) is absorbed into the following line rather than emitted as its own, + /// visually-blank, standalone line. + /// + /// + /// Trace for "AA AAA" at availableWidthEmu = 12700 ('A' advance 6350 EMU at + /// sz="100", ' ' advance 2540 EMU): token "AA" (width 12700) packs first + /// with an empty currentLine (no overflow check) -> currentLine = ["AA"]. + /// Token " " (width 2540) packs next: currentLine has visible content and + /// 12700 + 2540 = 15240 > 12700 -> flush ["AA"] as line 1, + /// currentLine = [" "]. Token "AAA" (width 19050) packs last: before this + /// fix, currentLine.Count > 0 was true (the lone whitespace token counted as + /// "has content"), so ["AAA"] would wrap away from the whitespace token first, + /// stranding [" "] as its own blank line 2 and pushing ["AAA"] onto line 3. + /// With the fix, currentLine holds only whitespace, so it is not treated as "has + /// content" - "AAA" is merged onto the same line instead, producing exactly 2 lines + /// (["AA"], [" ", "AAA"]), matching standard word-processor wrap behavior + /// (the trailing space before a wrapped word is swallowed at the wrap point). + /// + [Fact] + public void ResolveTextLayout_WordWrap_WhitespaceTokenWouldOverflowAlone_DoesNotEmitWhitespaceOnlyLine() + { + var textBody = BuildSingleRunTextBody("AA AAA"); + + var layout = Layout(textBody, 12700f, 100000f); + + // 5 visible glyphs total (2 for "AA", 3 for "AAA") - the whitespace token itself never + // emits a glyph, so glyph count alone cannot distinguish 2 lines from 3; the decisive + // signal is the Y position of "AAA"'s own glyphs. + Assert.Equal(5, layout.Glyphs.Count); + + // Line 1 ("AA"): ascent 10160, at y = 0 + 10160. + Assert.Equal(0f, layout.Glyphs[0].OriginXEmu, 2); + Assert.Equal(10160f, layout.Glyphs[0].OriginYEmu, 2); + Assert.Equal(6350f, layout.Glyphs[1].OriginXEmu, 2); + Assert.Equal(10160f, layout.Glyphs[1].OriginYEmu, 2); + + // Line 2 (" AAA", the leading space carried forward, not its own line): line height + // 12700, baseline at 12700 + 10160 = 22860 - exactly 2 lines' worth of vertical offset. + // Without the fix, the spurious whitespace-only line would push "AAA" to a third line's + // offset instead: 2 * 12700 + 10160 = 35100. + Assert.Equal(2540f, layout.Glyphs[2].OriginXEmu, 2); + Assert.Equal(22860f, layout.Glyphs[2].OriginYEmu, 2); + Assert.Equal(8890f, layout.Glyphs[3].OriginXEmu, 2); + Assert.Equal(22860f, layout.Glyphs[3].OriginYEmu, 2); + Assert.Equal(15240f, layout.Glyphs[4].OriginXEmu, 2); + Assert.Equal(22860f, layout.Glyphs[4].OriginYEmu, 2); + } + + /// + /// Regression guard distinguishing the whitespace-only-line fix (above) from an explicit + /// <a:br/> break: two consecutive explicit breaks still produce a genuinely + /// blank middle line, since explicit breaks bypass Pack's whitespace-content check + /// entirely (PackTokensIntoLines force-flushes the current line directly on an + /// IsLineBreak token, independent of word-wrap width). + /// + [Fact] + public void ResolveTextLayout_WordWrap_ExplicitBreakStillProducesBlankLine_NotAffectedByWhitespaceLineFix() + { + var pPr = new XElement(DrawingNs + "pPr"); + var bodyPr = new XElement( + DrawingNs + "bodyPr", + new XAttribute("lIns", "0"), new XAttribute("tIns", "0"), new XAttribute("rIns", "0"), new XAttribute("bIns", "0")); + var txBody = new XElement( + PresentationNs + "txBody", + bodyPr, + new XElement( + DrawingNs + "p", + pPr, + new XElement(DrawingNs + "r", new XElement(DrawingNs + "rPr", new XAttribute("sz", "100")), new XElement(DrawingNs + "t", "A")), + new XElement(DrawingNs + "br"), + new XElement(DrawingNs + "br"), + new XElement(DrawingNs + "r", new XElement(DrawingNs + "rPr", new XAttribute("sz", "100")), new XElement(DrawingNs + "t", "B")))); + var textBody = PptxDocument.ParseTextBody(txBody); + + var layout = Layout(textBody, 50000f, 1_000_000f); + + // "A" on line 1, a genuinely blank line 2 (from the second ), "B" on line 3 - 3 + // total lines, unaffected by the whitespace-only-line fix. The blank line has no tokens + // of its own, so (pre-existing, unrelated behavior) it falls back to the nominal default + // font size's own proportions (18pt => 228600 EMU) rather than this paragraph's own + // resolved line height: height = 228600 * 1.2 = 274320. Line 3's baseline is therefore + // line1Height (12700) + line2Height (274320) + ascent (10160) = 297180 - not + // 2 * 12700 + 10160, which would (incorrectly) assume the blank line shares line 1's own + // resolved height. + Assert.Equal(2, layout.Glyphs.Count); + Assert.Equal(10160f, layout.Glyphs[0].OriginYEmu, 2); + Assert.Equal(12700f + 274320f + 10160f, layout.Glyphs[1].OriginYEmu, 2); + } + + #endregion + + #region Horizontal alignment + + /// Proves algn="ctr" centers the line within the available width. + [Fact] + public void ResolveTextLayout_AlignCenter_CentersLineWithinAvailableWidth() + { + // "AA" line width = 12700. Available width 50000. + // startX = (50000 - 12700) / 2 = 18650. + var textBody = BuildSingleRunTextBody("AA", paragraphExtra: """"""); + + var layout = Layout(textBody, 50000f, 100000f); + + Assert.Equal(18650f, layout.Glyphs[0].OriginXEmu, 2); + Assert.Equal(18650f + 6350f, layout.Glyphs[1].OriginXEmu, 2); + } + + /// Proves algn="r" right-aligns the line against the available width. + [Fact] + public void ResolveTextLayout_AlignRight_RightAlignsLineAgainstAvailableWidth() + { + // "AA" line width = 12700. Available width 50000. startX = 50000 - 12700 = 37300. + var textBody = BuildSingleRunTextBody("AA", paragraphExtra: """"""); + + var layout = Layout(textBody, 50000f, 100000f); + + Assert.Equal(37300f, layout.Glyphs[0].OriginXEmu, 2); + Assert.Equal(37300f + 6350f, layout.Glyphs[1].OriginXEmu, 2); + } + + #endregion + + #region Vertical anchor + + /// Resolve Text Layout Anchor Top Positions First Baseline At Ascent From Top. + [Fact] + public void ResolveTextLayout_AnchorTop_PositionsFirstBaselineAtAscentFromTop() + { + var textBody = BuildSingleRunTextBody("A"); + + var layout = Layout(textBody, 50000f, 100000f); + + Assert.Equal(10160f, layout.Glyphs[0].OriginYEmu, 2); + } + + /// Resolve Text Layout Anchor Middle Centers Block Vertically Within Available Height. + [Fact] + public void ResolveTextLayout_AnchorMiddle_CentersBlockVerticallyWithinAvailableHeight() + { + // Total block height = 12700 (one line). Available height 100000. + // startY = (100000 - 12700) / 2 = 43650. baseline = 43650 + 10160 = 53810. + var textBody = BuildSingleRunTextBody("A", """"""); + + var layout = Layout(textBody, 50000f, 100000f); + + Assert.Equal(53810f, layout.Glyphs[0].OriginYEmu, 2); + } + + /// Resolve Text Layout Anchor Bottom Positions Block At Bottom Of Available Height. + [Fact] + public void ResolveTextLayout_AnchorBottom_PositionsBlockAtBottomOfAvailableHeight() + { + // startY = 100000 - 12700 = 87300. baseline = 87300 + 10160 = 97460. + var textBody = BuildSingleRunTextBody("A", """"""); + + var layout = Layout(textBody, 50000f, 100000f); + + Assert.Equal(97460f, layout.Glyphs[0].OriginYEmu, 2); + } + + #endregion + + #region Paragraph spacing (Phase 2 Follow-Up: Paragraph Spacing) + + /// + /// Proves a paragraph's own <a:spcAft> and the following paragraph's own + /// <a:spcBef> combine additively into the gap between them - previously + /// neither was ever applied during layout at all. + /// + /// + /// "A" (paragraph 1) declares spcAft of 100 hundredths-of-point (12700 EMU); + /// "B" (paragraph 2) declares spcBef of 100 hundredths-of-point (12700 EMU). + /// Without the fix, paragraph 2's baseline would sit at + /// lineHeight(para1) + ascent(para2) = 12700 + 10160 = 22860. With the additive fix, + /// it sits an extra 12700 + 12700 = 25400 EMU lower: + /// 12700 + 25400 + 10160 = 48260. + /// + [Fact] + public void ResolveTextLayout_TwoParagraphsWithSpcAftAndSpcBef_AddsAdditiveGapBetweenParagraphs() + { + var textBody = BuildTwoParagraphTextBody( + "A", + """""", + "B", + """"""); + + var layout = Layout(textBody, 50000f, 1_000_000f); + + Assert.Equal(2, layout.Glyphs.Count); + Assert.Equal(10160f, layout.Glyphs[0].OriginYEmu, 2); + Assert.Equal(48260f, layout.Glyphs[1].OriginYEmu, 2); + } + + /// + /// Proves <a:spcAft>/<a:spcBef> declared as a percentage + /// (spcPct) also combines additively, resolved against each boundary line's own + /// resolved lineHeight (12700 EMU for this synthetic font/size). + /// + [Fact] + public void ResolveTextLayout_TwoParagraphsWithPercentSpcAftAndSpcBef_AddsAdditiveGapBetweenParagraphs() + { + // 50% of lineHeight (12700) = 6350 each; combined additive gap = 12700. + var textBody = BuildTwoParagraphTextBody( + "A", + """""", + "B", + """"""); + + var layout = Layout(textBody, 50000f, 1_000_000f); + + Assert.Equal(2, layout.Glyphs.Count); + Assert.Equal(10160f, layout.Glyphs[0].OriginYEmu, 2); + // 12700 (para1 line height) + 12700 (additive gap) + 10160 (para2 ascent) = 35560. + Assert.Equal(35560f, layout.Glyphs[1].OriginYEmu, 2); + } + + /// + /// Regression guard: a single paragraph declaring <a:spcBef> still positions + /// its own (only) line's baseline at the ordinary no-spacing Y - proving spcBef on + /// a text body's very first paragraph is correctly suppressed (the additive gap only + /// applies between paragraphs, never before the first). + /// + [Fact] + public void ResolveTextLayout_FirstParagraphSpcBef_DoesNotShiftFirstLineDown() + { + var textBody = BuildSingleRunTextBody( + "A", + paragraphExtra: """"""); + + var layout = Layout(textBody, 50000f, 1_000_000f); + + Assert.Single(layout.Glyphs); + Assert.Equal(10160f, layout.Glyphs[0].OriginYEmu, 2); + } + + /// + /// Regression guard pairing with the above: a single paragraph declaring + /// <a:spcAft> does not change + /// positioning versus the same paragraph without spcAft - proving spcAft on + /// a text body's very last paragraph is correctly suppressed and does not leak into the + /// anchor/autofit total-height calculation. + /// + [Fact] + public void ResolveTextLayout_LastParagraphSpcAft_DoesNotAffectAnchorPositioning() + { + var withoutSpacing = BuildSingleRunTextBody("A", """"""); + var withSpacing = BuildSingleRunTextBody( + "A", + """""", + """"""); + + var layoutWithoutSpacing = Layout(withoutSpacing, 50000f, 100000f); + var layoutWithSpacing = Layout(withSpacing, 50000f, 100000f); + + Assert.Equal(layoutWithoutSpacing.Glyphs[0].OriginYEmu, layoutWithSpacing.Glyphs[0].OriginYEmu, 2); + } + + #endregion + + #region Autofit + + /// Proves <a:noAutofit/> applies no scaling even when the laid-out text overflows the shape's height. + [Fact] + public void ResolveTextLayout_NoAutofit_AppliesNoScalingEvenWhenOverflowing() + { + var textBody = BuildSingleRunTextBody("A", """"""); + + // Available height far too small to hold even one line - must not throw, must not scale. + var layout = Layout(textBody, 50000f, 10f); + + Assert.Equal(1f, layout.AppliedFontScale); + } + + /// Proves <a:spAutoFit/> is a pass-through (no scaling), per Design Decision 4's documented deferral of shape-resize behavior. + [Fact] + public void ResolveTextLayout_SpAutoFit_AppliesNoScaling() + { + var textBody = BuildSingleRunTextBody("A", """"""); + + var layout = Layout(textBody, 50000f, 10f); + + Assert.Equal(1f, layout.AppliedFontScale); + } + + /// Proves an explicit <a:normAutofit fontScale="..." lnSpcReduction="..."/> is applied verbatim, not recomputed. + [Fact] + public void ResolveTextLayout_NormAutofitWithExplicitAttributes_AppliesStoredFactorsVerbatim() + { + var textBody = BuildSingleRunTextBody( + "A", + """"""); + + var layout = Layout(textBody, 50000f, 100000f); + + Assert.Equal(0.5f, layout.AppliedFontScale, 3); + } + + /// + /// Proves a <a:normAutofit fontScale="..."/> with only fontScale + /// present (no lnSpcReduction) still applies its stored fontScale verbatim, + /// rather than discarding it and falling through to the attribute-less shrink loop (which, + /// for this ample-height case, would instead converge on an unscaled 1.0) - OOXML's + /// fontScale/lnSpcReduction are independently optional, so either one alone + /// must still be honored. + /// + [Fact] + public void ResolveTextLayout_NormAutofitFontScaleOnly_AppliesStoredFontScaleVerbatim() + { + var textBody = BuildSingleRunTextBody( + "A", + """"""); + + // Available height is ample for the single unscaled line, so an attribute-less-style + // shrink loop would converge on 1.0 instead of honoring the stored fontScale - proving + // the 0.5 result below comes from the persisted attribute, not the shrink loop. + var layout = Layout(textBody, 50000f, 100000f); + + Assert.Equal(0.5f, layout.AppliedFontScale, 3); + } + + /// + /// Proves a <a:normAutofit lnSpcReduction="..."/> with only + /// lnSpcReduction present (no fontScale) still applies its stored line- + /// spacing reduction - leaving fontScale at its own neutral default (1.0, + /// unscaled) - rather than discarding the reduction entirely. + /// + [Fact] + public void ResolveTextLayout_NormAutofitLnSpcReductionOnly_AppliesStoredReductionWithNeutralFontScale() + { + 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", new XAttribute("lnSpcReduction", "50000"))); + + var paragraphs = Enumerable.Range(0, 2) + .Select(_ => new XElement( + DrawingNs + "p", + new XElement( + DrawingNs + "r", + new XElement(DrawingNs + "rPr", new XAttribute("sz", "100")), + new XElement(DrawingNs + "t", "A")))) + .ToArray(); + + var txBody = new XElement(PresentationNs + "txBody", bodyPr, paragraphs); + var textBody = PptxDocument.ParseTextBody(txBody); + + var layout = Layout(textBody, 50000f, 100000f); + + // fontScale stays unscaled (1.0): sz=100 -> SizeEmu 12700, ascent 10160. + Assert.Equal(1f, layout.AppliedFontScale, 3); + Assert.Equal(2, layout.Glyphs.Count); + Assert.Equal(10160f, layout.Glyphs[0].OriginYEmu, 2); + // Line height reduced to 50%: naturalHeight 12700 * 0.5 = 6350; line 2 baseline = + // 6350 + 10160 = 16510 - not 12700 + 10160 = 22860, which is what an (incorrectly) + // discarded lnSpcReduction would have produced. + Assert.Equal(16510f, layout.Glyphs[1].OriginYEmu, 2); + } + + /// + /// Proves an attribute-less <a:normAutofit/> runs the bounded 10%-step shrink + /// loop, converging on the scale that first fits the available height. Five single-line + /// paragraphs at sz=100 (SizeEmu 12700, naturalHeight 12700 since ascender - descender + + /// lineGap == UnitsPerEm exactly): unscaled total height 63500. Available height 50000: + /// scale 1.0 -> 63500 (no); 0.9 -> 57150 (no); 0.8 -> 50800 (no); 0.7 -> 44450 (fits). + /// + [Fact] + public void ResolveTextLayout_NormAutofitAttributeLess_ShrinkLoopConvergesOnFirstFittingScale() + { + 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 paragraphs = Enumerable.Range(0, 5) + .Select(_ => new XElement( + DrawingNs + "p", + new XElement( + DrawingNs + "r", + new XElement(DrawingNs + "rPr", new XAttribute("sz", "100")), + 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); + } + + /// + /// Proves a <a:normAutofit fontScale="..."/> with a non-numeric + /// fontScale attribute is rejected with rather + /// than letting the explicit (float?) cast's raw + /// propagate uncaught. + /// + [Fact] + public void ResolveTextLayout_NormAutofitNonNumericFontScale_ThrowsInvalidDataException() + { + var textBody = BuildSingleRunTextBody( + "A", + """"""); + + Assert.Throws(() => Layout(textBody, 50000f, 100000f)); + } + + /// + /// Proves a <a:normAutofit lnSpcReduction="..."/> with a non-numeric + /// lnSpcReduction attribute is rejected with + /// rather than letting the explicit (float?) cast's raw + /// propagate uncaught. + /// + [Fact] + public void ResolveTextLayout_NormAutofitNonNumericLnSpcReduction_ThrowsInvalidDataException() + { + var textBody = BuildSingleRunTextBody( + "A", + """"""); + + Assert.Throws(() => Layout(textBody, 50000f, 100000f)); + } + + #endregion + + #region Per-character glyph-coverage fallback + + // Synthetic fallback font: UnitsPerEm 1000, ascender 800, descender -200, lineGap 0. + // .notdef (index 0), advance 0, zero contours. + // U+00B1 PLUS-MINUS SIGN (index 1), advance 400 units, a distinct filled triangle outline. + // U+00B0 DEGREE SIGN (index 2), advance 300 units, a different, distinguishable filled + // triangle outline - every assertion below distinguishes "resolved via the fallback font" + // from "resolved via the primary font" purely by comparing the resolved + // PptxGlyphPlacement.Font reference and GlyphIndex, not by outline shape, so the two + // outlines only need to be non-empty (a real, paintable glyph), not visually distinct. + private static TrueTypeFont NewFallbackFontCoveringPlusMinusAndDegree() + { + var notdef = SyntheticFontBuilder.SimpleGlyph(); + var glyphPlusMinus = SyntheticFontBuilder.SimpleGlyph([(0, 0, true), (400, 0, true), (200, 800, true)]); + var glyphDegree = SyntheticFontBuilder.SimpleGlyph([(0, 0, true), (300, 0, true), (150, 800, true)]); + var cmap = SyntheticFontBuilder.CmapFormat4(3, 1, [(0x00B1, 1), (0x00B0, 2)]); + + var data = new SyntheticFontBuilder() + .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) + .AddTable("maxp", SyntheticFontBuilder.Maxp(3)) + .AddTable("hhea", SyntheticFontBuilder.Hhea(800, -200, 0, 3)) + .AddTable("hmtx", SyntheticFontBuilder.Hmtx([0, 400, 300])) + .AddTable("loca", SyntheticFontBuilder.Loca([notdef.Length, glyphPlusMinus.Length, glyphDegree.Length], longFormat: false)) + .AddTable("glyf", [.. notdef, .. glyphPlusMinus, .. glyphDegree]) + .AddTable("cmap", cmap) + .Build(); + + using var stream = new MemoryStream(data); + return TrueTypeFont.Load(stream); + } + + // Synthetic primary font: identical shape to NewFallbackFontCoveringPlusMinusAndDegree(), but + // covering 'A'/' ' (like NewFont()) plus U+00B0 DEGREE SIGN only - deliberately NOT covering + // U+00B1 PLUS-MINUS SIGN - used by the literal reported-symptom test (c) below, where the + // primary font covers the *second* character of the "±°" pair but not the first. + private static TrueTypeFont NewPrimaryFontCoveringDegreeButNotPlusMinus() + { + var notdef = SyntheticFontBuilder.SimpleGlyph(); + var glyphA = SyntheticFontBuilder.SimpleGlyph([(0, 0, true), (500, 0, true), (250, 800, true)]); + var glyphSpace = SyntheticFontBuilder.SimpleGlyph(); + var glyphDegree = SyntheticFontBuilder.SimpleGlyph([(0, 0, true), (300, 0, true), (150, 800, true)]); + var cmap = SyntheticFontBuilder.CmapFormat4(3, 1, [(65, 1), (32, 2), (0x00B0, 3)]); + + var data = new SyntheticFontBuilder() + .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) + .AddTable("maxp", SyntheticFontBuilder.Maxp(4)) + .AddTable("hhea", SyntheticFontBuilder.Hhea(800, -200, 0, 4)) + .AddTable("hmtx", SyntheticFontBuilder.Hmtx([0, 500, 200, 300])) + .AddTable("loca", SyntheticFontBuilder.Loca([notdef.Length, glyphA.Length, glyphSpace.Length, glyphDegree.Length], longFormat: false)) + .AddTable("glyf", [.. notdef, .. glyphA, .. glyphSpace, .. glyphDegree]) + .AddTable("cmap", cmap) + .Build(); + + using var stream = new MemoryStream(data); + return TrueTypeFont.Load(stream); + } + + private static PptxTextLayout LayoutWithFallback( + PptxTextBody textBody, + TrueTypeFont primaryFont, + TrueTypeFont fallbackFont, + float widthEmu = 500000f, + float heightEmu = 500000f) => + PptxDocument.ResolveTextLayout( + textBody, + new PptxPlaceholderProperties(null, null, BuildTestTheme()), + BuildTestTheme(), + "body", + widthEmu, + heightEmu, + (_, _, _) => primaryFont, + colorMap: null, + fallbackFontResolver: (_, _) => fallbackFont); + + /// + /// Proves the literal two-character tofu-box reproduction: a run's primary font covers + /// neither U+00B1 (PLUS-MINUS SIGN) nor U+00B0 (DEGREE SIGN) that immediately follows it, + /// but the injected bundled fallback font covers both. Both characters must independently + /// resolve to the fallback font with their own correct, non-.notdef glyph index - + /// proving the second character's (U+00B0's) resolution is driven purely by its own + /// coverage check against the fallback font, not "stuck" to or corrupted by the first + /// character's (U+00B1's) own fallback decision (every per-character + /// CmapTable/GetGlyphIndex lookup is already a stateless, pure function of + /// (font, codepoint) alone - see the planning report's refutation of the "sticky + /// fallback" hypothesis - this test additionally proves the new per-character fallback + /// layer built on top of it inherits that same independence). + /// + [Fact] + public void ResolveTextLayout_PlusMinusFollowedByDegree_BothNeitherInPrimary_BothIndependentlyResolveFallback() + { + var primaryFont = NewFont(); // Covers only 'A'/' ' - neither U+00B1 nor U+00B0. + var fallbackFont = NewFallbackFontCoveringPlusMinusAndDegree(); + var textBody = BuildSingleRunTextBody("\u00B1\u00B0"); + + var layout = LayoutWithFallback(textBody, primaryFont, fallbackFont); + + Assert.Equal(2, layout.Glyphs.Count); + + var plusMinusGlyph = layout.Glyphs[0]; + Assert.Same(fallbackFont, plusMinusGlyph.Font); + Assert.NotEqual(0, plusMinusGlyph.GlyphIndex); + Assert.Equal(fallbackFont.GetGlyphIndex('\u00B1'), plusMinusGlyph.GlyphIndex); + + var degreeGlyph = layout.Glyphs[1]; + Assert.Same(fallbackFont, degreeGlyph.Font); + Assert.NotEqual(0, degreeGlyph.GlyphIndex); + Assert.Equal(fallbackFont.GetGlyphIndex('\u00B0'), degreeGlyph.GlyphIndex); + } + + /// + /// Baseline/control isolation test: a run containing only the fallback-triggering + /// character (U+00B1, with no following character) still resolves via the fallback font - + /// proving single-character fallback behavior in isolation, independent of the + /// two-character sequence case above. + /// + [Fact] + public void ResolveTextLayout_PlusMinusAlone_ResolvesFallbackGlyph() + { + var primaryFont = NewFont(); // Covers only 'A'/' ' - not U+00B1. + var fallbackFont = NewFallbackFontCoveringPlusMinusAndDegree(); + var textBody = BuildSingleRunTextBody("\u00B1"); + + var layout = LayoutWithFallback(textBody, primaryFont, fallbackFont); + + Assert.Single(layout.Glyphs); + Assert.Same(fallbackFont, layout.Glyphs[0].Font); + Assert.Equal(fallbackFont.GetGlyphIndex('\u00B1'), layout.Glyphs[0].GlyphIndex); + Assert.NotEqual(0, layout.Glyphs[0].GlyphIndex); + } + + /// + /// Proves the original bug report's exact scenario: the run's primary font lacks U+00B1 + /// (PLUS-MINUS SIGN) but DOES cover the immediately-following U+00B0 (DEGREE SIGN). Both + /// characters must still resolve correctly - U+00B1 via the fallback font, U+00B0 via the + /// primary font - proving the primary font's own, already-correct coverage of the second + /// character is not corrupted or overridden by the first character's fallback + /// substitution. This directly refutes the "sticky fallback" hypothesis for the new + /// per-character fallback layer itself (not just the pre-existing single-font-per-run + /// resolution path the planning report already refuted it for). + /// + [Fact] + public void ResolveTextLayout_PlusMinusFollowedByDegree_PrimaryCoversOnlyDegree_DegreeStaysOnPrimaryFont() + { + var primaryFont = NewPrimaryFontCoveringDegreeButNotPlusMinus(); + var fallbackFont = NewFallbackFontCoveringPlusMinusAndDegree(); + var textBody = BuildSingleRunTextBody("\u00B1\u00B0"); + + var layout = LayoutWithFallback(textBody, primaryFont, fallbackFont); + + Assert.Equal(2, layout.Glyphs.Count); + + // U+00B1: missing from the primary font - resolves via the fallback font. + var plusMinusGlyph = layout.Glyphs[0]; + Assert.Same(fallbackFont, plusMinusGlyph.Font); + Assert.Equal(fallbackFont.GetGlyphIndex('\u00B1'), plusMinusGlyph.GlyphIndex); + Assert.NotEqual(0, plusMinusGlyph.GlyphIndex); + + // U+00B0: already covered by the primary font - stays on the primary font, proving the + // preceding character's fallback substitution did not "stick"/leak onto this character. + var degreeGlyph = layout.Glyphs[1]; + Assert.Same(primaryFont, degreeGlyph.Font); + Assert.Equal(primaryFont.GetGlyphIndex('\u00B0'), degreeGlyph.GlyphIndex); + Assert.NotEqual(0, degreeGlyph.GlyphIndex); + + // Baseline check: without any fallback font configured at all (the pre-fix/default + // production behavior for a font lacking a fallback resolver), U+00B0 alone still + // resolves via the primary font exactly the same way - confirming the primary font's own + // coverage of U+00B0 is what resolves it, not an accidental fallback-font side effect. + var degreeOnlyBody = BuildSingleRunTextBody("\u00B0"); + var degreeOnlyLayout = PptxDocument.ResolveTextLayout( + degreeOnlyBody, + new PptxPlaceholderProperties(null, null, BuildTestTheme()), + BuildTestTheme(), + "body", + 500000f, + 500000f, + (_, _, _) => primaryFont); + Assert.Single(degreeOnlyLayout.Glyphs); + Assert.Same(primaryFont, degreeOnlyLayout.Glyphs[0].Font); + Assert.Equal(primaryFont.GetGlyphIndex('\u00B0'), degreeOnlyLayout.Glyphs[0].GlyphIndex); + } + + /// + /// One-off, non-assertion visual repro generator (not part of the permanent regression + /// suite's guarantees - see the three [Fact] tests above for the actual permanent + /// xunit assertions): renders the "±° tofu box" reproduction to an inspectable PNG file + /// under .agent-logs/, so a reviewer can visually confirm the fix. Top row: the + /// broken "before" behavior (primary font alone, no coverage fallback at all) - both + /// characters paint as empty .notdef tofu boxes. Bottom row: the fixed "after" + /// behavior (this change's per-character fallback) - both characters paint their correct, + /// distinct fallback-font glyph shapes. + /// + [Fact] + public void GeneratePlusMinusDegreeTofuReproPng() + { + var primaryFont = NewFont(); // Covers only 'A'/' ' - neither U+00B1 nor U+00B0. + var fallbackFont = NewFallbackFontCoveringPlusMinusAndDegree(); + + var bodyPr = new XElement( + DrawingNs + "bodyPr", + new XAttribute("lIns", "0"), new XAttribute("tIns", "0"), new XAttribute("rIns", "0"), new XAttribute("bIns", "0")); + var txBody = new XElement( + PresentationNs + "txBody", + bodyPr, + new XElement( + DrawingNs + "p", + new XElement(DrawingNs + "pPr"), + new XElement( + DrawingNs + "r", + new XElement(DrawingNs + "rPr", new XAttribute("sz", "7200")), + new XElement(DrawingNs + "t", "\u00B1\u00B0")))); + var textBody = PptxDocument.ParseTextBody(txBody); + + const float widthEmu = 900000f; + const float heightEmu = 700000f; + const float scale = 300f / widthEmu; // -> 300x~233px surface. + + // "Before" layout: the fallback resolver returns the SAME primary font - i.e. no wider- + // coverage font is ever consulted, reproducing the pre-fix behavior where a run is + // permanently bound to a single resolved font for every character. + var beforeLayout = PptxDocument.ResolveTextLayout( + textBody, + new PptxPlaceholderProperties(null, null, BuildTestTheme()), + BuildTestTheme(), + "body", + widthEmu, + heightEmu, + (_, _, _) => primaryFont, + colorMap: null, + fallbackFontResolver: (_, _) => primaryFont); + + // "After" layout: this change's real per-character glyph-coverage fallback, with a + // fallback font that actually covers both characters. + var afterLayout = LayoutWithFallback(textBody, primaryFont, fallbackFont, widthEmu, heightEmu); + + using var surface = new Surface((int)(widthEmu * scale), (int)(heightEmu * scale * 2)); + for (var y = 0; y < surface.Height; y++) + { + var row = surface.GetRowSpan(y); + row.Fill(new Rgba32(255, 255, 255, 255)); + } + + var topTransform = Matrix3x2.CreateScale(scale); + PptxDocument.PaintTextLayout(surface, beforeLayout, topTransform); + + var bottomTransform = Matrix3x2.CreateScale(scale) * + Matrix3x2.CreateTranslation(0f, heightEmu * scale); + PptxDocument.PaintTextLayout(surface, afterLayout, bottomTransform); + + var outputDirectory = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", ".agent-logs"); + outputDirectory = Path.GetFullPath(outputDirectory); + Directory.CreateDirectory(outputDirectory); + var outputPath = Path.Combine(outputDirectory, "pptx-degree-tofu-glyph-fallback-repro.png"); + + // Three target-framework test processes may run this test concurrently against the + // same shared output path - render to a process-unique temp file first (identical + // content regardless of which TFM wins), then best-effort copy it into place, tolerating + // (rather than failing on) a transient sharing violation from a sibling process doing the + // same thing at the same moment. + var tempPath = Path.Combine(outputDirectory, $"{Guid.NewGuid():N}.png.tmp"); + PngCodec.Save(surface, tempPath); + try + { + File.Copy(tempPath, outputPath, overwrite: true); + } + catch (IOException) + { + // A sibling TFM process is writing/has already written the identical content - + // nothing further to do here. + } + finally + { + File.Delete(tempPath); + } + + Assert.True(File.Exists(outputPath)); + } + + #endregion + + #region Supplementary-plane characters (Finding 4: surrogate pairs) + + // Synthetic font: UnitsPerEm 1000, ascender 800, descender -200, lineGap 0, using a format-12 + // cmap (not format-4, which is BMP-only) so it can map a supplementary-plane codepoint. + // .notdef (index 0), advance 0, zero contours. + // 'A' (codepoint 0x41, index 1), advance 500 units, non-empty outline. + // U+1F600 GRINNING FACE (index 2), advance 600 units, non-empty outline - a + // supplementary-plane codepoint (above U+FFFF), requiring a UTF-16 surrogate pair to + // represent as a .NET string. + private static TrueTypeFont NewSupplementaryPlaneFont() + { + var notdef = SyntheticFontBuilder.SimpleGlyph(); + var glyphA = SyntheticFontBuilder.SimpleGlyph([(0, 0, true), (500, 0, true), (250, 800, true)]); + var glyphEmoji = SyntheticFontBuilder.SimpleGlyph([(0, 0, true), (600, 0, true), (300, 800, true)]); + var cmap = SyntheticFontBuilder.CmapFormat12(3, 10, [(0x41, 0x41, 1), (0x1F600, 0x1F600, 2)]); + + var data = new SyntheticFontBuilder() + .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) + .AddTable("maxp", SyntheticFontBuilder.Maxp(3)) + .AddTable("hhea", SyntheticFontBuilder.Hhea(800, -200, 0, 3)) + .AddTable("hmtx", SyntheticFontBuilder.Hmtx([0, 500, 600])) + .AddTable("loca", SyntheticFontBuilder.Loca([notdef.Length, glyphA.Length, glyphEmoji.Length], longFormat: false)) + .AddTable("glyf", [.. notdef, .. glyphA, .. glyphEmoji]) + .AddTable("cmap", cmap) + .Build(); + + using var stream = new MemoryStream(data); + return TrueTypeFont.Load(stream); + } + + /// + /// Proves a supplementary-plane character (a 2-char UTF-16 surrogate pair in the + /// .NET string, one Unicode scalar value) is measured/placed as exactly one logical glyph + /// unit - before the fix, enumerating by char would split it into two surrogate + /// halves, each independently resolved (almost certainly to .notdef, since no real + /// cmap subtable maps a lone surrogate value), emitting 2 glyphs instead of 1. + /// + [Fact] + public void ResolveTextLayout_SupplementaryPlaneCharacter_EmitsExactlyOneGlyph() + { + var font = NewSupplementaryPlaneFont(); + var emoji = char.ConvertFromUtf32(0x1F600); + var textBody = BuildSingleRunTextBody(emoji); + + var layout = PptxDocument.ResolveTextLayout( + textBody, new PptxPlaceholderProperties(null, null, BuildTestTheme()), BuildTestTheme(), "body", + 500000f, 500000f, (_, _, _) => font); + + Assert.Single(layout.Glyphs); + Assert.Equal(font.GetGlyphIndex(0x1F600), layout.Glyphs[0].GlyphIndex); + Assert.NotEqual(0, layout.Glyphs[0].GlyphIndex); + } + + /// + /// Proves a supplementary-plane character's word-wrap-affecting measurement + /// (MeasureTokenWidthEmu) also treats it as one logical unit: a following BMP + /// character's own X origin must equal exactly one emoji glyph's own advance width, not + /// double it (the pre-fix defect, from measuring both surrogate halves independently) and + /// not some near-zero .notdef-derived width. + /// + [Fact] + public void ResolveTextLayout_SupplementaryPlaneCharacter_MeasuresSingleAdvanceWidth() + { + var font = NewSupplementaryPlaneFont(); + var emoji = char.ConvertFromUtf32(0x1F600); + var textBody = BuildSingleRunTextBody(emoji + "A"); + + var layout = PptxDocument.ResolveTextLayout( + textBody, new PptxPlaceholderProperties(null, null, BuildTestTheme()), BuildTestTheme(), "body", + 500000f, 500000f, (_, _, _) => font); + + Assert.Equal(2, layout.Glyphs.Count); + // Emoji advance at sz=100 (SizeEmu 12700): 600/1000 * 12700 = 7620 - exactly one emoji + // glyph's own advance, not 2x (double-counted surrogate halves) nor ~0 (notdef advance). + Assert.Equal(0f, layout.Glyphs[0].OriginXEmu, 2); + Assert.Equal(7620f, layout.Glyphs[1].OriginXEmu, 2); + } + + /// + /// Mirrors , substituting + /// a supplementary-plane codepoint for '\u00B1': proves the per-character + /// glyph-coverage fallback path (ResolveGlyph) also operates on whole Unicode scalar + /// values, not surrogate halves - a primary font lacking the codepoint falls back to + /// exactly one glyph from the bundled fallback font, not two independent (and + /// independently-failing) surrogate-half fallback lookups. + /// + [Fact] + public void ResolveTextLayout_SupplementaryPlaneCharacterMissingFromPrimaryFont_FallsBackCorrectly() + { + var primaryFont = NewFont(); // Covers only 'A'/' ' - not U+1F600. + var fallbackFont = NewSupplementaryPlaneFont(); + var emoji = char.ConvertFromUtf32(0x1F600); + var textBody = BuildSingleRunTextBody(emoji); + + var layout = LayoutWithFallback(textBody, primaryFont, fallbackFont); + + Assert.Single(layout.Glyphs); + Assert.Same(fallbackFont, layout.Glyphs[0].Font); + Assert.Equal(fallbackFont.GetGlyphIndex(0x1F600), layout.Glyphs[0].GlyphIndex); + Assert.NotEqual(0, layout.Glyphs[0].GlyphIndex); + } + + #endregion + + #region Underlines (Phase 2 Follow-Up: Underline Rendering) + + /// Proves an underlined run emits exactly one spanning the run's own measured width, with matching color/size. + [Fact] + public void ResolveTextLayout_UnderlinedRun_EmitsOneSegmentSpanningRunWidth() + { + var pPr = new XElement(DrawingNs + "pPr"); + var bodyPr = new XElement( + DrawingNs + "bodyPr", + new XAttribute("lIns", "0"), new XAttribute("tIns", "0"), new XAttribute("rIns", "0"), new XAttribute("bIns", "0")); + var txBody = new XElement( + PresentationNs + "txBody", + bodyPr, + new XElement( + DrawingNs + "p", + pPr, + new XElement( + DrawingNs + "r", + new XElement(DrawingNs + "rPr", new XAttribute("sz", "100"), new XAttribute("u", "sng")), + new XElement(DrawingNs + "t", "A")))); + var textBody = PptxDocument.ParseTextBody(txBody); + + var layout = Layout(textBody, 50000f, 100000f); + + // "A" at sz=100 -> SizeEmu 12700; width = 500/1000*12700 = 6350. + var segment = Assert.Single(layout.Underlines); + Assert.Equal(0f, segment.StartXEmu, 2); + Assert.Equal(6350f, segment.EndXEmu, 2); + Assert.Equal(12700f, segment.SizeEmu, 2); + Assert.Equal(PptxUnderlineStyle.Single, segment.Style); + Assert.Equal(layout.Glyphs[0].Color, segment.Color); + } + + /// Proves a non-underlined run emits no underline segment at all. + [Fact] + public void ResolveTextLayout_NonUnderlinedRun_EmitsNoSegment() + { + var textBody = BuildSingleRunTextBody("A"); + + var layout = Layout(textBody, 50000f, 100000f); + + Assert.Empty(layout.Underlines); + } + + /// + /// Proves that, of two runs on one line, only the first underlined, exactly one segment + /// is emitted (the span flushes at the run boundary rather than extending into the + /// second, non-underlined run). + /// + [Fact] + public void ResolveTextLayout_TwoRunsOnlyFirstUnderlined_EmitsExactlyOneSegment() + { + var pPr = new XElement(DrawingNs + "pPr"); + var bodyPr = new XElement( + DrawingNs + "bodyPr", + new XAttribute("lIns", "0"), new XAttribute("tIns", "0"), new XAttribute("rIns", "0"), new XAttribute("bIns", "0")); + var txBody = new XElement( + PresentationNs + "txBody", + bodyPr, + new XElement( + DrawingNs + "p", + pPr, + new XElement( + DrawingNs + "r", + new XElement(DrawingNs + "rPr", new XAttribute("sz", "100"), new XAttribute("u", "sng")), + new XElement(DrawingNs + "t", "A")), + new XElement( + DrawingNs + "r", + new XElement(DrawingNs + "rPr", new XAttribute("sz", "100")), + new XElement(DrawingNs + "t", "A")))); + var textBody = PptxDocument.ParseTextBody(txBody); + + var layout = Layout(textBody, 50000f, 100000f); + + var segment = Assert.Single(layout.Underlines); + Assert.Equal(0f, segment.StartXEmu, 2); + Assert.Equal(6350f, segment.EndXEmu, 2); + } + + /// + /// Proves a single underlined run with interior whitespace (for example "A A") has its + /// underline span cover the interior space too, rather than stopping at the first word - + /// the span is a property of the run, not of each individual word-wrap token. + /// + [Fact] + public void ResolveTextLayout_UnderlinedRunWithInteriorSpace_SpanCoversSpace() + { + var pPr = new XElement(DrawingNs + "pPr"); + var bodyPr = new XElement( + DrawingNs + "bodyPr", + new XAttribute("lIns", "0"), new XAttribute("tIns", "0"), new XAttribute("rIns", "0"), new XAttribute("bIns", "0")); + var txBody = new XElement( + PresentationNs + "txBody", + bodyPr, + new XElement( + DrawingNs + "p", + pPr, + new XElement( + DrawingNs + "r", + new XElement(DrawingNs + "rPr", new XAttribute("sz", "100"), new XAttribute("u", "sng")), + new XElement(DrawingNs + "t", "A A")))); + var textBody = PptxDocument.ParseTextBody(txBody); + + // "A A" at sz=100 -> SizeEmu 12700: 'A' 6350 + ' ' 2540 + 'A' 6350 = 15240 total width. + var layout = Layout(textBody, 50000f, 100000f); + + var segment = Assert.Single(layout.Underlines); + Assert.Equal(0f, segment.StartXEmu, 2); + Assert.Equal(15240f, segment.EndXEmu, 2); + } + + #endregion +} diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxTextRenderTests.cs b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxTextRenderTests.cs new file mode 100644 index 00000000..83c235ca --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxTextRenderTests.cs @@ -0,0 +1,273 @@ +using System.Numerics; +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Fonts; +using DemaConsulting.CanvasNet.Tests.TestSupport; + +namespace DemaConsulting.CanvasNet.Pptx.Tests; + +// cspell:ignore pptx unitsperem + +/// +/// Unit-level tests for the Phase 1d glyph-painting primitive (PptxDocument.TextRender.cs's +/// ): proves ink is painted at the expected, +/// transform-composed pixel location with the resolved run color, and that the shape's own +/// composes correctly with the glyph's own local +/// origin/scale. Complements (parsing/inheritance) and +/// (word-wrap/alignment/anchor/autofit). +/// +public class PptxTextRenderTests +{ + // Synthetic font: UnitsPerEm 1000, a single 'A' glyph (index 1) whose outline is a filled + // square spanning font-unit [100,900] x [100,900] - comfortably inside the em-square so a + // small scale still produces an unambiguous interior/exterior pixel split. + private static TrueTypeFont NewFilledSquareFont() + { + var notdef = SyntheticFontBuilder.SimpleGlyph(); + var glyphA = SyntheticFontBuilder.SimpleGlyph( + [(100, 100, true), (900, 100, true), (900, 900, true), (100, 900, true)]); + var cmap = SyntheticFontBuilder.CmapFormat4(3, 1, [(65, 1)]); + + var data = new SyntheticFontBuilder() + .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) + .AddTable("maxp", SyntheticFontBuilder.Maxp(2)) + .AddTable("hhea", SyntheticFontBuilder.Hhea(800, -200, 0, 2)) + .AddTable("hmtx", SyntheticFontBuilder.Hmtx([0, 1000])) + .AddTable("loca", SyntheticFontBuilder.Loca([notdef.Length, glyphA.Length], longFormat: false)) + .AddTable("glyf", [.. notdef, .. glyphA]) + .AddTable("cmap", cmap) + .Build(); + + using var stream = new MemoryStream(data); + return TrueTypeFont.Load(stream); + } + + // Synthetic font: UnitsPerEm 1000, a single 'A' glyph (index 1) whose outline is a filled + // rectangle confined to font-unit y in [600,900] (comfortably above the baseline, toward the + // ascender) and x in [100,900] - deliberately NOT vertically symmetric (no ink at all in + // [0,600)), unlike NewFilledSquareFont()'s symmetric square. A sign-flip regression (painting + // with a positive, rather than negative, glyph-space Y scale) moves this ink to the opposite + // side of the baseline instead of merely relocating it within a symmetric shape - a square + // glyph cannot distinguish the two cases, but this asymmetric one can. + private static TrueTypeFont NewAsymmetricUpperInkFont() + { + var notdef = SyntheticFontBuilder.SimpleGlyph(); + var glyphA = SyntheticFontBuilder.SimpleGlyph( + [(100, 600, true), (900, 600, true), (900, 900, true), (100, 900, true)]); + var cmap = SyntheticFontBuilder.CmapFormat4(3, 1, [(65, 1)]); + + var data = new SyntheticFontBuilder() + .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) + .AddTable("maxp", SyntheticFontBuilder.Maxp(2)) + .AddTable("hhea", SyntheticFontBuilder.Hhea(900, 0, 0, 2)) + .AddTable("hmtx", SyntheticFontBuilder.Hmtx([0, 1000])) + .AddTable("loca", SyntheticFontBuilder.Loca([notdef.Length, glyphA.Length], longFormat: false)) + .AddTable("glyf", [.. notdef, .. glyphA]) + .AddTable("cmap", cmap) + .Build(); + + using var stream = new MemoryStream(data); + return TrueTypeFont.Load(stream); + } + + /// Proves a glyph paints its resolved color at its expected, identity-transformed pixel location, and leaves pixels outside its outline untouched. + [Fact] + public void PaintTextLayout_IdentityTransform_PaintsGlyphAtExpectedLocationWithResolvedColor() + { + // Arrange: SizeEmu 100 -> scale 100/1000 = 0.1. Font-unit square [100,900]x[100,900] + // scales to [10,90]x[10,90] in font-local space (y-up); the glyph transform's negative Y + // scale flips that into shape-local y-down space before translating by Origin (5,95) -> + // surface x [15,95], y [5,85] (ink above the baseline, which sits near the surface's + // bottom edge at y=95, as expected for a y-down coordinate system). + var font = NewFilledSquareFont(); + var glyphIndex = font.GetGlyphIndex('A'); + var color = new Rgba32(200, 30, 40, 255); + var glyph = new PptxGlyphPlacement(font, glyphIndex, OriginXEmu: 5f, OriginYEmu: 95f, SizeEmu: 100f, color); + var layout = new PptxTextLayout([glyph], AppliedFontScale: 1f); + + using var surface = new Surface(100, 100); + + // Act + PptxDocument.PaintTextLayout(surface, layout, Matrix3x2.Identity); + + // Assert: comfortably inside the painted square. + Assert.Equal(color, surface[50, 50]); + // Assert: comfortably outside the painted square (untouched, still transparent black). + Assert.Equal(default, surface[2, 2]); + Assert.Equal(default, surface[98, 98]); + } + + /// Proves the shape-to-surface transform composes with the glyph's own local scale/origin (a non-identity translation shifts the painted location accordingly). + [Fact] + public void PaintTextLayout_TranslatedShapeTransform_ShiftsPaintedLocation() + { + var font = NewFilledSquareFont(); + var glyphIndex = font.GetGlyphIndex('A'); + var color = new Rgba32(10, 220, 10, 255); + var glyph = new PptxGlyphPlacement(font, glyphIndex, OriginXEmu: 0f, OriginYEmu: 100f, SizeEmu: 100f, color); + var layout = new PptxTextLayout([glyph], AppliedFontScale: 1f); + + using var surface = new Surface(100, 100); + var shapeToSurfaceTransform = Matrix3x2.CreateTranslation(20f, 20f); + + // Act + PptxDocument.PaintTextLayout(surface, layout, shapeToSurfaceTransform); + + // Assert: without the translation, the square would occupy [10,90]x[10,90]; with it, + // [30,110)x[30,110) clipped to the 100x100 surface - (50,50) is inside the shifted square, + // (5,5) (inside the unshifted square) must no longer be painted. + Assert.Equal(color, surface[50, 50]); + Assert.Equal(default, surface[5, 5]); + } + + /// Proves a glyph with an empty outline (no subpaths - for example a whitespace glyph, defensively) is skipped rather than painting anything. + [Fact] + public void PaintTextLayout_EmptyOutlineGlyph_PaintsNothing() + { + var notdef = SyntheticFontBuilder.SimpleGlyph(); + var cmap = SyntheticFontBuilder.CmapFormat4(3, 1, [(32, 0)]); + var data = new SyntheticFontBuilder() + .AddTable("head", SyntheticFontBuilder.Head(1000, 0)) + .AddTable("maxp", SyntheticFontBuilder.Maxp(1)) + .AddTable("hhea", SyntheticFontBuilder.Hhea(800, -200, 0, 1)) + .AddTable("hmtx", SyntheticFontBuilder.Hmtx([0])) + .AddTable("loca", SyntheticFontBuilder.Loca([notdef.Length], longFormat: false)) + .AddTable("glyf", [.. notdef]) + .AddTable("cmap", cmap) + .Build(); + using var stream = new MemoryStream(data); + var font = TrueTypeFont.Load(stream); + + var glyph = new PptxGlyphPlacement(font, 0, OriginXEmu: 5f, OriginYEmu: 5f, SizeEmu: 100f, new Rgba32(1, 2, 3, 255)); + var layout = new PptxTextLayout([glyph], AppliedFontScale: 1f); + using var surface = new Surface(100, 100); + + PptxDocument.PaintTextLayout(surface, layout, Matrix3x2.Identity); + + for (var y = 0; y < 100; y++) + { + for (var x = 0; x < 100; x++) + { + Assert.Equal(default, surface[x, y]); + } + } + } + + /// + /// Proves a glyph's outline is mapped above the baseline (toward smaller y, per + /// CanvasNet's y-down convention), not below it - the regression class a symmetric square + /// glyph cannot detect, since flipping a symmetric shape about its own center leaves it + /// visually/structurally identical. Uses , whose + /// ink occupies only font-space y in [600,900] (toward the ascender, away from the + /// baseline) with none at all in [0,600) - a sign-flip regression would move this ink to + /// the opposite side of the baseline entirely, not merely to a different position within + /// a shape that still looks the same either way. + /// + [Fact] + public void PaintTextLayout_AsymmetricGlyph_PaintsInkAboveBaselineNotBelow() + { + // SizeEmu 50 -> scale 50/1000 = 0.05. Font-unit ink y in [600,900] -> scaled [30,45]. + // OriginY 75 (baseline). Correct (negative Y scale): shape_y = 75 - fontY*0.05, so ink + // occupies shape_y in [75-45, 75-30] = [30,45] - above the baseline (smaller y). + // A sign-flip bug (positive Y scale) would instead place it at shape_y = 75 + fontY*0.05, + // i.e. [105,120] - below the baseline (larger y) - a disjoint region on a tall-enough + // surface, so the two outcomes are unambiguously distinguishable. + var font = NewAsymmetricUpperInkFont(); + var glyphIndex = font.GetGlyphIndex('A'); + var color = new Rgba32(80, 140, 220, 255); + var glyph = new PptxGlyphPlacement(font, glyphIndex, OriginXEmu: 0f, OriginYEmu: 75f, SizeEmu: 50f, color); + var layout = new PptxTextLayout([glyph], AppliedFontScale: 1f); + + using var surface = new Surface(150, 150); + + PptxDocument.PaintTextLayout(surface, layout, Matrix3x2.Identity); + + // Font-unit x in [100,900] -> scaled [5,45]; sample x=25, comfortably inside. + // Correctly above the baseline (within the correct [30,45] ink band). + Assert.Equal(color, surface[25, 37]); + // Must NOT appear below the baseline (within the bugged [105,120] ink band) - this is + // exactly where the pre-fix, positive-Y-scale code would have painted it instead. + Assert.Equal(default, surface[25, 112]); + } + + /// 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() + { + var font = PptxDocument.ResolveTextFont("SomeFamilyNameHint", bold: false, italic: false); + + Assert.NotNull(font); + Assert.True(font.UnitsPerEm > 0); + } + + #region Underlines (Phase 2 Follow-Up: Underline Rendering) + + /// Proves a segment paints a visible stroke below the baseline at the expected position/color, and leaves a pixel above the baseline untouched. + [Fact] + public void PaintTextLayout_SingleUnderlineSegment_PaintsStrokeBelowBaselineAtExpectedPositionAndColor() + { + var color = new Rgba32(200, 30, 40, 255); + var segment = new PptxUnderlineSegment(StartXEmu: 10f, EndXEmu: 90f, BaselineYEmu: 50f, SizeEmu: 100f, PptxUnderlineStyle.Single, color); + var layout = new PptxTextLayout([], 1f, [segment]); + + using var surface = new Surface(100, 100); + + PptxDocument.PaintTextLayout(surface, layout, Matrix3x2.Identity); + + // thicknessEmu = 100*0.05 = 5; offsetEmu = 100*0.08 = 8 -> stroke occupies y in [58,63). + Assert.Equal(color, surface[50, 60]); + // A pixel above the baseline (y=50, the baseline itself) must remain untouched. + Assert.Equal(default, surface[50, 45]); + // Outside the segment's own X range must remain untouched too. + Assert.Equal(default, surface[5, 60]); + } + + /// Proves an empty list paints no extra ink at all (regression guard). + [Fact] + public void PaintTextLayout_NoUnderlineSegments_PaintsNoExtraInk() + { + var layout = new PptxTextLayout([], 1f); + using var surface = new Surface(100, 100); + + PptxDocument.PaintTextLayout(surface, layout, Matrix3x2.Identity); + + for (var y = 0; y < 100; y++) + { + for (var x = 0; x < 100; x++) + { + Assert.Equal(default, surface[x, y]); + } + } + } + + /// Proves a segment paints without throwing and produces visible ink. + [Fact] + public void PaintTextLayout_DoubleUnderlineSegment_DoesNotThrowAndPaintsInk() + { + var color = new Rgba32(10, 100, 200, 255); + var segment = new PptxUnderlineSegment(StartXEmu: 10f, EndXEmu: 90f, BaselineYEmu: 50f, SizeEmu: 100f, PptxUnderlineStyle.Double, color); + var layout = new PptxTextLayout([], 1f, [segment]); + using var surface = new Surface(100, 100); + + var exception = Record.Exception(() => PptxDocument.PaintTextLayout(surface, layout, Matrix3x2.Identity)); + + Assert.Null(exception); + Assert.Equal(color, surface[50, 58]); + } + + /// Proves a segment paints without throwing and produces visible ink (exactly one solid rectangle, same as ). + [Fact] + public void PaintTextLayout_OtherUnderlineSegment_DoesNotThrowAndPaintsInk() + { + var color = new Rgba32(50, 150, 60, 255); + var segment = new PptxUnderlineSegment(StartXEmu: 10f, EndXEmu: 90f, BaselineYEmu: 50f, SizeEmu: 100f, PptxUnderlineStyle.Other, color); + var layout = new PptxTextLayout([], 1f, [segment]); + using var surface = new Surface(100, 100); + + var exception = Record.Exception(() => PptxDocument.PaintTextLayout(surface, layout, Matrix3x2.Identity)); + + Assert.Null(exception); + Assert.Equal(color, surface[50, 60]); + } + + #endregion +} diff --git a/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxTextTests.cs b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxTextTests.cs new file mode 100644 index 00000000..cb51bb6c --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Pptx.Tests/PptxTextTests.cs @@ -0,0 +1,1038 @@ +using System.Xml.Linq; +using DemaConsulting.CanvasNet.Canvas; + +namespace DemaConsulting.CanvasNet.Pptx.Tests; + +// cspell:ignore pptx txbody bodypr lstyle lstStyle lnspc spcpct spcpts defrpr lststyle sng calibri arial +// cspell:ignore Srgb srgb EEECE BACC hlink Hlink folhlink + +/// +/// Unit-level tests for the Phase 1d DrawingML text parser (PptxDocument.Text.cs) and +/// the attribute-level run/paragraph property-inheritance resolver +/// (PptxDocument.TextInheritance.cs), including the master <p:txStyles> +/// plumbing added to PptxDocument.Masters.cs/PptxPlaceholder.cs. Complements +/// (word-wrap/alignment/anchor/autofit), +/// (glyph painting), and +/// (end-to-end). +/// +public class PptxTextTests +{ + private static readonly XNamespace DrawingNs = "http://schemas.openxmlformats.org/drawingml/2006/main"; + private static readonly XNamespace PresentationNs = "http://schemas.openxmlformats.org/presentationml/2006/main"; + + /// Builds an arbitrary, fully-populated test for parser/inheritance tests. + private static PptxTheme BuildTestTheme() => + new( + new PptxColorScheme( + new Rgba32(10, 10, 10, 255), new Rgba32(20, 20, 20, 255), new Rgba32(30, 30, 30, 255), new Rgba32(40, 40, 40, 255), + new Rgba32(50, 50, 50, 255), new Rgba32(60, 60, 60, 255), new Rgba32(70, 70, 70, 255), new Rgba32(80, 80, 80, 255), + new Rgba32(90, 90, 90, 255), new Rgba32(100, 100, 100, 255), new Rgba32(110, 110, 110, 255), new Rgba32(120, 120, 120, 255)), + new PptxFontScheme( + new PptxFontCollection("ThemeMajorLatin", "MajorEA", "MajorCS"), + new PptxFontCollection("ThemeMinorLatin", "MinorEA", "MinorCS"))); + + private static PptxPlaceholderProperties EmptyPlaceholderProperties(PptxTheme theme, PptxMasterTextStyles? masterTextStyles = null) => + new(null, null, theme, masterTextStyles); + + #region ParseTextBody / ParseBodyProperties + + /// Proves an absent <a:bodyPr> resolves every documented OOXML schema default. + [Fact] + public void ParseBodyProperties_Absent_ResolvesSchemaDefaults() + { + var result = PptxDocument.ParseBodyProperties(null); + + Assert.Equal(PptxTextAnchor.Top, result.Anchor); + Assert.Equal(PptxTextWrap.Square, result.Wrap); + Assert.Equal(91440f, result.InsetLeftEmu); + Assert.Equal(45720f, result.InsetTopEmu); + Assert.Equal(91440f, result.InsetRightEmu); + Assert.Equal(45720f, result.InsetBottomEmu); + Assert.Null(result.AutofitElement); + } + + /// Parse Body Properties Anchor Ctr Resolves Middle. + [Fact] + public void ParseBodyProperties_AnchorCtr_ResolvesMiddle() + { + var bodyPr = new XElement(DrawingNs + "bodyPr", new XAttribute("anchor", "ctr")); + + var result = PptxDocument.ParseBodyProperties(bodyPr); + + Assert.Equal(PptxTextAnchor.Middle, result.Anchor); + } + + /// Parse Body Properties Anchor B Resolves Bottom. + [Fact] + public void ParseBodyProperties_AnchorB_ResolvesBottom() + { + var bodyPr = new XElement(DrawingNs + "bodyPr", new XAttribute("anchor", "b")); + + var result = PptxDocument.ParseBodyProperties(bodyPr); + + Assert.Equal(PptxTextAnchor.Bottom, result.Anchor); + } + + /// Parse Body Properties Anchor T Resolves Top. + [Fact] + public void ParseBodyProperties_AnchorT_ResolvesTop() + { + var bodyPr = new XElement(DrawingNs + "bodyPr", new XAttribute("anchor", "t")); + + var result = PptxDocument.ParseBodyProperties(bodyPr); + + Assert.Equal(PptxTextAnchor.Top, result.Anchor); + } + + /// Parse Body Properties Wrap None Resolves Wrap None. + [Fact] + public void ParseBodyProperties_WrapNone_ResolvesWrapNone() + { + var bodyPr = new XElement(DrawingNs + "bodyPr", new XAttribute("wrap", "none")); + + var result = PptxDocument.ParseBodyProperties(bodyPr); + + Assert.Equal(PptxTextWrap.None, result.Wrap); + } + + /// Parse Body Properties Custom Insets Resolves Declared Values. + [Fact] + public void ParseBodyProperties_CustomInsets_ResolvesDeclaredValues() + { + var bodyPr = new XElement( + DrawingNs + "bodyPr", + new XAttribute("lIns", "10000"), + new XAttribute("tIns", "20000"), + new XAttribute("rIns", "30000"), + new XAttribute("bIns", "40000")); + + var result = PptxDocument.ParseBodyProperties(bodyPr); + + Assert.Equal(10000f, result.InsetLeftEmu); + Assert.Equal(20000f, result.InsetTopEmu); + Assert.Equal(30000f, result.InsetRightEmu); + Assert.Equal(40000f, result.InsetBottomEmu); + } + + /// Proves a non-numeric bIns attribute throws (with the original preserved as ) rather than letting the raw escape uncaught. + [Fact] + public void ParseBodyProperties_NonNumericBIns_ThrowsInvalidDataException() + { + var bodyPr = new XElement(DrawingNs + "bodyPr", new XAttribute("bIns", "not-a-number")); + + var ex = Assert.Throws(() => PptxDocument.ParseBodyProperties(bodyPr)); + + Assert.IsType(ex.InnerException); + } + + /// Proves a non-numeric lIns attribute throws (with the original preserved as ) rather than letting the raw escape uncaught. + [Fact] + public void ParseBodyProperties_NonNumericLIns_ThrowsInvalidDataException() + { + var bodyPr = new XElement(DrawingNs + "bodyPr", new XAttribute("lIns", "not-a-number")); + + var ex = Assert.Throws(() => PptxDocument.ParseBodyProperties(bodyPr)); + + Assert.IsType(ex.InnerException); + } + + /// Proves a non-numeric tIns attribute throws (with the original preserved as ) rather than letting the raw escape uncaught. + [Fact] + public void ParseBodyProperties_NonNumericTIns_ThrowsInvalidDataException() + { + var bodyPr = new XElement(DrawingNs + "bodyPr", new XAttribute("tIns", "not-a-number")); + + var ex = Assert.Throws(() => PptxDocument.ParseBodyProperties(bodyPr)); + + Assert.IsType(ex.InnerException); + } + + /// Proves a non-numeric rIns attribute throws (with the original preserved as ) rather than letting the raw escape uncaught. + [Fact] + public void ParseBodyProperties_NonNumericRIns_ThrowsInvalidDataException() + { + var bodyPr = new XElement(DrawingNs + "bodyPr", new XAttribute("rIns", "not-a-number")); + + var ex = Assert.Throws(() => PptxDocument.ParseBodyProperties(bodyPr)); + + Assert.IsType(ex.InnerException); + } + + /// Parse Body Properties Autofit Element Is Retained Unparsed. + [Theory] + [InlineData("noAutofit")] + [InlineData("normAutofit")] + [InlineData("spAutoFit")] + public void ParseBodyProperties_AutofitElement_IsRetainedUnparsed(string autofitElementName) + { + var bodyPr = new XElement(DrawingNs + "bodyPr", new XElement(DrawingNs + autofitElementName)); + + var result = PptxDocument.ParseBodyProperties(bodyPr); + + Assert.NotNull(result.AutofitElement); + Assert.Equal(autofitElementName, result.AutofitElement.Name.LocalName); + } + + /// Proves a <p:txBody> with no paragraphs still parses to an empty, non-null paragraph list. + [Fact] + public void ParseTextBody_NoParagraphs_ResolvesEmptyParagraphList() + { + var txBody = new XElement(PresentationNs + "txBody", new XElement(DrawingNs + "bodyPr")); + + var result = PptxDocument.ParseTextBody(txBody); + + Assert.Empty(result.Paragraphs); + } + + /// Parse Text Body Single Paragraph Single Run Parses Body And Paragraph. + [Fact] + public void ParseTextBody_SingleParagraphSingleRun_ParsesBodyAndParagraph() + { + var txBody = new XElement( + PresentationNs + "txBody", + new XElement(DrawingNs + "bodyPr", new XAttribute("anchor", "ctr")), + new XElement( + DrawingNs + "p", + new XElement( + DrawingNs + "r", + new XElement(DrawingNs + "rPr", new XAttribute("b", "1")), + new XElement(DrawingNs + "t", "Hello")))); + + var result = PptxDocument.ParseTextBody(txBody); + + Assert.Equal(PptxTextAnchor.Middle, result.Properties.Anchor); + var paragraph = Assert.Single(result.Paragraphs); + var run = Assert.Single(paragraph.Runs); + Assert.Equal("Hello", run.Text); + Assert.NotNull(run.RawRPr); + } + + #endregion + + #region ParseParagraph / ParseParagraphProperties + + /// Parse Paragraph Properties Absent Resolves Level Zero And Null Optionals. + [Fact] + public void ParseParagraphProperties_Absent_ResolvesLevelZeroAndNullOptionals() + { + var result = PptxDocument.ParseParagraphProperties(null); + + Assert.Equal(0, result.Level); + Assert.Null(result.Algn); + Assert.Null(result.MarLEmu); + Assert.Null(result.IndentEmu); + Assert.Null(result.LnSpcElement); + Assert.Null(result.SpcBeforeElement); + Assert.Null(result.SpcAfterElement); + Assert.Null(result.DefRPrElement); + } + + /// Parse Paragraph Properties Level Attribute Clamps To Zero To Eight. + [Theory] + [InlineData("0", 0)] + [InlineData("8", 8)] + [InlineData("20", 8)] + public void ParseParagraphProperties_LevelAttribute_ClampsToZeroToEight(string lvl, int expected) + { + var pPr = new XElement(DrawingNs + "pPr", new XAttribute("lvl", lvl)); + + var result = PptxDocument.ParseParagraphProperties(pPr); + + Assert.Equal(expected, result.Level); + } + + /// Parse Paragraph Properties Fully Populated Resolves Every Field. + [Fact] + public void ParseParagraphProperties_FullyPopulated_ResolvesEveryField() + { + var pPr = new XElement( + DrawingNs + "pPr", + new XAttribute("algn", "ctr"), + new XAttribute("marL", "123"), + new XAttribute("indent", "-456"), + new XElement(DrawingNs + "lnSpc", new XElement(DrawingNs + "spcPct", new XAttribute("val", "150000"))), + new XElement(DrawingNs + "spcBef", new XElement(DrawingNs + "spcPts", new XAttribute("val", "600"))), + new XElement(DrawingNs + "spcAft", new XElement(DrawingNs + "spcPts", new XAttribute("val", "600"))), + new XElement(DrawingNs + "defRPr", new XAttribute("sz", "2400"))); + + var result = PptxDocument.ParseParagraphProperties(pPr); + + Assert.Equal("ctr", result.Algn); + Assert.Equal(123f, result.MarLEmu); + Assert.Equal(-456f, result.IndentEmu); + Assert.NotNull(result.LnSpcElement); + Assert.NotNull(result.SpcBeforeElement); + Assert.NotNull(result.SpcAfterElement); + Assert.NotNull(result.DefRPrElement); + } + + /// Proves an <a:p> with no recognized child is a valid, empty paragraph - not an error. + [Fact] + public void ParseParagraph_NoChildren_ResolvesEmptyRunList() + { + var p = new XElement(DrawingNs + "p"); + + var result = PptxDocument.ParseParagraph(p); + + Assert.Empty(result.Runs); + Assert.Equal(0, result.RawProperties.Level); + } + + /// + /// Proves an interleaved <a:br/> is preserved as an explicit + /// in document order alongside its surrounding runs - + /// not silently dropped - so the layout stage can later honor it as a forced line + /// boundary. + /// + [Fact] + public void ParseParagraph_RunBreakRun_PreservesBreakInDocumentOrder() + { + var p = new XElement( + DrawingNs + "p", + new XElement(DrawingNs + "r", new XElement(DrawingNs + "t", "A")), + new XElement(DrawingNs + "br"), + new XElement(DrawingNs + "r", new XElement(DrawingNs + "t", "B"))); + + var result = PptxDocument.ParseParagraph(p); + + Assert.Equal(3, result.Items.Count); + var firstRun = Assert.IsType(result.Items[0]); + Assert.Equal("A", firstRun.Run.Text); + Assert.IsType(result.Items[1]); + var secondRun = Assert.IsType(result.Items[2]); + Assert.Equal("B", secondRun.Run.Text); + + // The run-only convenience accessor still reflects just the two runs, in order. + Assert.Equal(2, result.Runs.Count); + Assert.Equal("A", result.Runs[0].Text); + Assert.Equal("B", result.Runs[1].Text); + } + + /// + /// Proves an <a:fld> auto-text field (e.g. type="slidenum") is parsed + /// as a run using its cached <a:t> text - not silently dropped - matching how + /// real-world decks embed a slide-number/date placeholder inside a footer text body. + /// + [Fact] + public void ParseParagraph_FieldRun_IsPreservedUsingCachedText() + { + var p = new XElement( + DrawingNs + "p", + new XElement(DrawingNs + "r", new XElement(DrawingNs + "t", "Page ")), + new XElement( + DrawingNs + "fld", + new XAttribute("id", "{12345678-1234-1234-1234-123456789012}"), + new XAttribute("type", "slidenum"), + new XElement(DrawingNs + "rPr"), + new XElement(DrawingNs + "t", "\u2039#\u203a"))); + + var result = PptxDocument.ParseParagraph(p); + + Assert.Equal(2, result.Items.Count); + var textRun = Assert.IsType(result.Items[0]); + Assert.Equal("Page ", textRun.Run.Text); + var fieldRun = Assert.IsType(result.Items[1]); + Assert.Equal("\u2039#\u203a", fieldRun.Run.Text); + + Assert.Equal(2, result.Runs.Count); + Assert.Equal("\u2039#\u203a", result.Runs[1].Text); + } + + #endregion + + #region ParseRun + + /// Parse Run No Text Resolves Empty String. + [Fact] + public void ParseRun_NoText_ResolvesEmptyString() + { + var r = new XElement(DrawingNs + "r"); + + var result = PptxDocument.ParseRun(r); + + Assert.Equal(string.Empty, result.Text); + Assert.Null(result.RawRPr); + } + + /// Parse Run With Text And R Pr Resolves Both. + [Fact] + public void ParseRun_WithTextAndRPr_ResolvesBoth() + { + var r = new XElement( + DrawingNs + "r", + new XElement(DrawingNs + "rPr", new XAttribute("i", "1")), + new XElement(DrawingNs + "t", "World")); + + var result = PptxDocument.ParseRun(r); + + Assert.Equal("World", result.Text); + Assert.NotNull(result.RawRPr); + } + + #endregion + + #region ResolveEffectiveRunProperties - attribute-level inheritance, each tier in isolation + + private static PptxTextRun Run(XElement? rPr, string text = "x") => new(rPr, text); + + private static PptxParagraph Paragraph(XElement? pPr, params PptxTextRun[] runs) => + new(PptxDocument.ParseParagraphProperties(pPr), runs.Select(run => (PptxParagraphItem)new PptxRunItem(run)).ToList()); + + /// Resolve Effective Run Properties Run Override Wins Over Every Other Tier. + [Fact] + public void ResolveEffectiveRunProperties_RunOverride_WinsOverEveryOtherTier() + { + var theme = BuildTestTheme(); + var run = Run(new XElement( + DrawingNs + "rPr", + new XAttribute("sz", "3600"), + new XAttribute("b", "1"), + new XElement(DrawingNs + "latin", new XAttribute("typeface", "RunFont")))); + var paragraph = Paragraph(null, run); + var placeholderProperties = EmptyPlaceholderProperties(theme); + + var result = PptxDocument.ResolveEffectiveRunProperties(run, paragraph, placeholderProperties, theme, "body"); + + Assert.Equal("RunFont", result.FontFamily); + Assert.Equal(3600f * 127f, result.SizeEmu); + Assert.True(result.Bold); + } + + /// + /// Proves a run's non-numeric <a:rPr sz="..."/> is rejected with + /// rather than letting the explicit (float?) + /// cast's raw propagate uncaught. + /// + [Fact] + public void ResolveEffectiveRunProperties_NonNumericSize_ThrowsInvalidDataException() + { + var theme = BuildTestTheme(); + var run = Run(new XElement(DrawingNs + "rPr", new XAttribute("sz", "not-a-number"))); + var paragraph = Paragraph(null, run); + var placeholderProperties = EmptyPlaceholderProperties(theme); + + Assert.Throws( + () => PptxDocument.ResolveEffectiveRunProperties(run, paragraph, placeholderProperties, theme, "body")); + } + + /// Resolve Effective Run Properties Paragraph Def R Pr Only Wins When Run Declares Nothing. + [Fact] + public void ResolveEffectiveRunProperties_ParagraphDefRPrOnly_WinsWhenRunDeclaresNothing() + { + var theme = BuildTestTheme(); + var run = Run(null); + var pPr = new XElement( + DrawingNs + "pPr", + new XElement(DrawingNs + "defRPr", new XAttribute("sz", "2000"), new XAttribute("i", "1"))); + var paragraph = Paragraph(pPr, run); + var placeholderProperties = EmptyPlaceholderProperties(theme); + + var result = PptxDocument.ResolveEffectiveRunProperties(run, paragraph, placeholderProperties, theme, "body"); + + Assert.Equal(2000f * 127f, result.SizeEmu); + Assert.True(result.Italic); + } + + /// Resolve Effective Run Properties Placeholder Level Style Only Wins When Run And Paragraph Declare Nothing. + [Fact] + public void ResolveEffectiveRunProperties_PlaceholderLevelStyleOnly_WinsWhenRunAndParagraphDeclareNothing() + { + var theme = BuildTestTheme(); + var run = Run(null); + var paragraph = Paragraph(null, run); // level 0 + var lstStyle = new XElement( + DrawingNs + "lstStyle", + new XElement(DrawingNs + "lvl1pPr", new XElement(DrawingNs + "defRPr", new XAttribute("sz", "1400")))); + var placeholderProperties = new PptxPlaceholderProperties(null, lstStyle, theme); + + var result = PptxDocument.ResolveEffectiveRunProperties(run, paragraph, placeholderProperties, theme, "body"); + + Assert.Equal(1400f * 127f, result.SizeEmu); + } + + /// Resolve Effective Run Properties Master Tx Styles Only Wins When Everything Above Is Absent. + [Fact] + public void ResolveEffectiveRunProperties_MasterTxStylesOnly_WinsWhenEverythingAboveIsAbsent() + { + var theme = BuildTestTheme(); + var run = Run(null); + var paragraph = Paragraph(null, run); // level 0 + var bodyStyle = new XElement( + DrawingNs + "bodyStyle", + new XElement(DrawingNs + "lvl1pPr", new XElement(DrawingNs + "defRPr", new XAttribute("sz", "3300")))); + var masterTextStyles = new PptxMasterTextStyles(null, bodyStyle, null); + var placeholderProperties = EmptyPlaceholderProperties(theme, masterTextStyles); + + var result = PptxDocument.ResolveEffectiveRunProperties(run, paragraph, placeholderProperties, theme, "body"); + + Assert.Equal(3300f * 127f, result.SizeEmu); + } + + /// Resolve Effective Run Properties Master Title Style Selected For Title Placeholder Type. + [Fact] + public void ResolveEffectiveRunProperties_MasterTitleStyle_SelectedForTitlePlaceholderType() + { + var theme = BuildTestTheme(); + var run = Run(null); + var paragraph = Paragraph(null, run); + var titleStyle = new XElement( + DrawingNs + "titleStyle", + new XElement(DrawingNs + "lvl1pPr", new XElement(DrawingNs + "defRPr", new XAttribute("sz", "4400")))); + var bodyStyle = new XElement( + DrawingNs + "bodyStyle", + new XElement(DrawingNs + "lvl1pPr", new XElement(DrawingNs + "defRPr", new XAttribute("sz", "1800")))); + var masterTextStyles = new PptxMasterTextStyles(titleStyle, bodyStyle, null); + var placeholderProperties = EmptyPlaceholderProperties(theme, masterTextStyles); + + var result = PptxDocument.ResolveEffectiveRunProperties(run, paragraph, placeholderProperties, theme, "title"); + + Assert.Equal(4400f * 127f, result.SizeEmu); + } + + /// Resolve Effective Run Properties Master Other Style Selected For Non Placeholder Shape. + [Fact] + public void ResolveEffectiveRunProperties_MasterOtherStyle_SelectedForNonPlaceholderShape() + { + var theme = BuildTestTheme(); + var run = Run(null); + var paragraph = Paragraph(null, run); + var otherStyle = new XElement( + DrawingNs + "otherStyle", + new XElement(DrawingNs + "lvl1pPr", new XElement(DrawingNs + "defRPr", new XAttribute("sz", "1200")))); + var masterTextStyles = new PptxMasterTextStyles(null, null, otherStyle); + var placeholderProperties = EmptyPlaceholderProperties(theme, masterTextStyles); + + var result = PptxDocument.ResolveEffectiveRunProperties(run, paragraph, placeholderProperties, theme, string.Empty); + + Assert.Equal(1200f * 127f, result.SizeEmu); + } + + /// Resolve Effective Run Properties None Declared Resolves Hard Coded Defaults. + [Fact] + public void ResolveEffectiveRunProperties_NoneDeclared_ResolvesHardCodedDefaults() + { + var theme = BuildTestTheme(); + var run = Run(null); + var paragraph = Paragraph(null, run); + var placeholderProperties = EmptyPlaceholderProperties(theme); + + var bodyResult = PptxDocument.ResolveEffectiveRunProperties(run, paragraph, placeholderProperties, theme, "body"); + var titleResult = PptxDocument.ResolveEffectiveRunProperties(run, paragraph, placeholderProperties, theme, "title"); + + Assert.Equal("ThemeMinorLatin", bodyResult.FontFamily); + Assert.Equal("ThemeMajorLatin", titleResult.FontFamily); + Assert.Equal(18f * 12700f, bodyResult.SizeEmu); + Assert.False(bodyResult.Bold); + Assert.False(bodyResult.Italic); + Assert.Equal(PptxUnderlineStyle.None, bodyResult.UnderlineStyle); + Assert.Equal(theme.ColorScheme.Dark1, bodyResult.Color); + } + + /// Resolve Effective Run Properties Underline Attribute Resolves Expected Style. + [Theory] + [InlineData("sng", "Single")] + [InlineData("none", "None")] + [InlineData("dbl", "Double")] + [InlineData("wavy", "Other")] + [InlineData("heavy", "Other")] + public void ResolveEffectiveRunProperties_UnderlineAttribute_ResolvesExpectedStyle(string u, string expectedStyleName) + { + var theme = BuildTestTheme(); + var run = Run(new XElement(DrawingNs + "rPr", new XAttribute("u", u))); + var paragraph = Paragraph(null, run); + var placeholderProperties = EmptyPlaceholderProperties(theme); + + var result = PptxDocument.ResolveEffectiveRunProperties(run, paragraph, placeholderProperties, theme, "body"); + + Assert.Equal(expectedStyleName, result.UnderlineStyle.ToString()); + } + + /// Resolve Effective Run Properties - Explicit uFill Overrides The Run's Own Text Color. + [Fact] + public void ResolveEffectiveRunProperties_ExplicitUFill_OverridesTextColor() + { + var theme = BuildTestTheme(); + var run = Run(new XElement( + DrawingNs + "rPr", + new XAttribute("u", "sng"), + new XElement(DrawingNs + "solidFill", new XElement(DrawingNs + "srgbClr", new XAttribute("val", "FF0000"))), + new XElement( + DrawingNs + "uFill", + new XElement(DrawingNs + "solidFill", new XElement(DrawingNs + "srgbClr", new XAttribute("val", "00FF00")))))); + var paragraph = Paragraph(null, run); + var placeholderProperties = EmptyPlaceholderProperties(theme); + + var result = PptxDocument.ResolveEffectiveRunProperties(run, paragraph, placeholderProperties, theme, "body"); + + Assert.Equal(new Rgba32(255, 0, 0, 255), result.Color); + Assert.Equal(new Rgba32(0, 255, 0, 255), result.UnderlineColor); + } + + /// Resolve Effective Run Properties - No uFill/uFillTx - Defaults Underline Color To The Run's Own Text Color. + [Fact] + public void ResolveEffectiveRunProperties_NoUnderlineFillMarkup_DefaultsUnderlineColorToTextColor() + { + var theme = BuildTestTheme(); + var run = Run(new XElement( + DrawingNs + "rPr", + new XAttribute("u", "sng"), + 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.Equal(new Rgba32(255, 0, 0, 255), result.UnderlineColor); + } + + /// Resolve Effective Run Properties - Explicit uFillTx - Also Defaults Underline Color To The Run's Own Text Color (not "no color"). + [Fact] + public void ResolveEffectiveRunProperties_ExplicitUFillTx_DefaultsUnderlineColorToTextColor() + { + var theme = BuildTestTheme(); + var run = Run(new XElement( + DrawingNs + "rPr", + new XAttribute("u", "sng"), + new XElement(DrawingNs + "solidFill", new XElement(DrawingNs + "srgbClr", new XAttribute("val", "FF0000"))), + new XElement(DrawingNs + "uFillTx"))); + var paragraph = Paragraph(null, run); + var placeholderProperties = EmptyPlaceholderProperties(theme); + + var result = PptxDocument.ResolveEffectiveRunProperties(run, paragraph, placeholderProperties, theme, "body"); + + Assert.Equal(new Rgba32(255, 0, 0, 255), result.UnderlineColor); + } + + /// Resolve Effective Run Properties Run Solid Fill Srgb Clr Resolves Explicit Color. + [Fact] + public void ResolveEffectiveRunProperties_RunSolidFillSrgbClr_ResolvesExplicitColor() + { + 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.Equal(new Rgba32(255, 0, 0, 255), result.Color); + } + + /// + /// Resolve Effective Run Properties - Theme Font Tokens - Resolve Through The Theme's + /// FontScheme (rather than being passed through as literal, unresolvable family name + /// strings, which would miss the theme's actual font and fall back to the font + /// resolver's own default). + /// + [Theory] + [InlineData("+mj-lt", "ThemeMajorLatin")] + [InlineData("+mn-lt", "ThemeMinorLatin")] + [InlineData("+mj-ea", "MajorEA")] + [InlineData("+mn-ea", "MinorEA")] + [InlineData("+mj-cs", "MajorCS")] + [InlineData("+mn-cs", "MinorCS")] + public void ResolveEffectiveRunProperties_ThemeFontToken_ResolvesThroughFontScheme(string token, string expectedFamily) + { + var theme = BuildTestTheme(); + var run = Run(new XElement(DrawingNs + "rPr", new XElement(DrawingNs + "latin", new XAttribute("typeface", token)))); + var paragraph = Paragraph(null, run); + var placeholderProperties = EmptyPlaceholderProperties(theme); + + var result = PptxDocument.ResolveEffectiveRunProperties(run, paragraph, placeholderProperties, theme, "body"); + + Assert.Equal(expectedFamily, result.FontFamily); + } + + /// + /// Resolve Effective Run Properties - Literal Typeface - Passes Through Unchanged (only + /// the reserved +mj-*/+mn-* theme-font tokens are resolved through the + /// theme; every other typeface string is a literal family name). + /// + [Fact] + public void ResolveEffectiveRunProperties_LiteralTypeface_PassesThroughUnchanged() + { + var theme = BuildTestTheme(); + var run = Run(new XElement(DrawingNs + "rPr", new XElement(DrawingNs + "latin", new XAttribute("typeface", "Calibri")))); + var paragraph = Paragraph(null, run); + var placeholderProperties = EmptyPlaceholderProperties(theme); + + var result = PptxDocument.ResolveEffectiveRunProperties(run, paragraph, placeholderProperties, theme, "body"); + + Assert.Equal("Calibri", result.FontFamily); + } + + #endregion + + #region ResolveEffectiveParagraphProperties + + /// Resolve Effective Paragraph Properties None Declared Resolves Hard Coded Defaults. + [Fact] + public void ResolveEffectiveParagraphProperties_NoneDeclared_ResolvesHardCodedDefaults() + { + var theme = BuildTestTheme(); + var paragraph = Paragraph(null); + var placeholderProperties = EmptyPlaceholderProperties(theme); + + var result = PptxDocument.ResolveEffectiveParagraphProperties(paragraph, placeholderProperties, "body"); + + Assert.Equal("l", result.Alignment); + Assert.Equal(0f, result.MarginLeftEmu); + Assert.Equal(0f, result.IndentEmu); + Assert.Equal(1.0f, result.LineSpacing.Percent); + Assert.Null(result.LineSpacing.FixedEmu); + } + + /// Resolve Effective Paragraph Properties Alignment Normalizes Justification To Left. + [Theory] + [InlineData("l", "l")] + [InlineData("ctr", "ctr")] + [InlineData("r", "r")] + [InlineData("just", "l")] + public void ResolveEffectiveParagraphProperties_Alignment_NormalizesJustificationToLeft(string algn, string expected) + { + var theme = BuildTestTheme(); + var pPr = new XElement(DrawingNs + "pPr", new XAttribute("algn", algn)); + var paragraph = Paragraph(pPr); + var placeholderProperties = EmptyPlaceholderProperties(theme); + + var result = PptxDocument.ResolveEffectiveParagraphProperties(paragraph, placeholderProperties, "body"); + + Assert.Equal(expected, result.Alignment); + } + + /// Resolve Effective Paragraph Properties Fixed Line Spacing Resolves Points To Emu. + [Fact] + public void ResolveEffectiveParagraphProperties_FixedLineSpacing_ResolvesPointsToEmu() + { + var theme = BuildTestTheme(); + var pPr = new XElement( + DrawingNs + "pPr", + new XElement(DrawingNs + "lnSpc", new XElement(DrawingNs + "spcPts", new XAttribute("val", "2400")))); + var paragraph = Paragraph(pPr); + var placeholderProperties = EmptyPlaceholderProperties(theme); + + var result = PptxDocument.ResolveEffectiveParagraphProperties(paragraph, placeholderProperties, "body"); + + Assert.Null(result.LineSpacing.Percent); + Assert.Equal(2400f * 127f, result.LineSpacing.FixedEmu); + } + + /// Resolve Effective Paragraph Properties Placeholder Level Style Only Wins When Paragraph Declares Nothing. + [Fact] + public void ResolveEffectiveParagraphProperties_PlaceholderLevelStyleOnly_WinsWhenParagraphDeclaresNothing() + { + var theme = BuildTestTheme(); + var paragraph = Paragraph(null); + var lstStyle = new XElement( + DrawingNs + "lstStyle", + new XElement(DrawingNs + "lvl1pPr", new XAttribute("algn", "r"), new XAttribute("marL", "500"))); + var placeholderProperties = new PptxPlaceholderProperties(null, lstStyle, theme); + + var result = PptxDocument.ResolveEffectiveParagraphProperties(paragraph, placeholderProperties, "body"); + + Assert.Equal("r", result.Alignment); + Assert.Equal(500f, result.MarginLeftEmu); + } + + /// Resolve Effective Paragraph Properties Master Tx Styles Only Wins When Everything Above Is Absent. + [Fact] + public void ResolveEffectiveParagraphProperties_MasterTxStylesOnly_WinsWhenEverythingAboveIsAbsent() + { + var theme = BuildTestTheme(); + var paragraph = Paragraph(null); + var bodyStyle = new XElement( + DrawingNs + "bodyStyle", + new XElement(DrawingNs + "lvl1pPr", new XAttribute("algn", "ctr"), new XAttribute("indent", "-100"))); + var masterTextStyles = new PptxMasterTextStyles(null, bodyStyle, null); + var placeholderProperties = EmptyPlaceholderProperties(theme, masterTextStyles); + + var result = PptxDocument.ResolveEffectiveParagraphProperties(paragraph, placeholderProperties, "body"); + + Assert.Equal("ctr", result.Alignment); + Assert.Equal(-100f, result.IndentEmu); + } + + /// + /// Proves a placeholder level style's non-numeric marL is rejected with + /// rather than letting the explicit (float?) + /// cast's raw propagate uncaught. + /// + [Fact] + public void ResolveEffectiveParagraphProperties_PlaceholderLevelNonNumericMarL_ThrowsInvalidDataException() + { + var theme = BuildTestTheme(); + var paragraph = Paragraph(null); + var lstStyle = new XElement( + DrawingNs + "lstStyle", + new XElement(DrawingNs + "lvl1pPr", new XAttribute("marL", "not-a-number"))); + var placeholderProperties = new PptxPlaceholderProperties(null, lstStyle, theme); + + Assert.Throws( + () => PptxDocument.ResolveEffectiveParagraphProperties(paragraph, placeholderProperties, "body")); + } + + /// + /// Proves a master level style's non-numeric indent is rejected with + /// rather than letting the explicit (float?) + /// cast's raw propagate uncaught. + /// + [Fact] + public void ResolveEffectiveParagraphProperties_MasterLevelNonNumericIndent_ThrowsInvalidDataException() + { + var theme = BuildTestTheme(); + var paragraph = Paragraph(null); + var bodyStyle = new XElement( + DrawingNs + "bodyStyle", + new XElement(DrawingNs + "lvl1pPr", new XAttribute("indent", "not-a-number"))); + var masterTextStyles = new PptxMasterTextStyles(null, bodyStyle, null); + var placeholderProperties = EmptyPlaceholderProperties(theme, masterTextStyles); + + Assert.Throws( + () => PptxDocument.ResolveEffectiveParagraphProperties(paragraph, placeholderProperties, "body")); + } + + /// + /// Proves a paragraph's own <a:spcBef>/<a:spcAft> fixed + /// (spcPts) spacing resolves to the correct EMU value (Phase 2 Follow-Up: + /// Paragraph Spacing) - previously these elements were parsed into + /// but never resolved into the effective + /// paragraph properties at all. + /// + [Fact] + public void ResolveEffectiveParagraphProperties_SpcBefSpcAftPoints_ResolvesFixedEmu() + { + var theme = BuildTestTheme(); + var pPr = new XElement( + DrawingNs + "pPr", + new XElement(DrawingNs + "spcBef", new XElement(DrawingNs + "spcPts", new XAttribute("val", "600"))), + new XElement(DrawingNs + "spcAft", new XElement(DrawingNs + "spcPts", new XAttribute("val", "600")))); + var paragraph = Paragraph(pPr); + var placeholderProperties = EmptyPlaceholderProperties(theme); + + var result = PptxDocument.ResolveEffectiveParagraphProperties(paragraph, placeholderProperties, "body"); + + Assert.Null(result.EffectiveSpaceBefore.Percent); + Assert.Equal(600f * 127f, result.EffectiveSpaceBefore.FixedEmu); + Assert.Null(result.EffectiveSpaceAfter.Percent); + Assert.Equal(600f * 127f, result.EffectiveSpaceAfter.FixedEmu); + } + + /// + /// Proves a paragraph's own <a:spcBef>/<a:spcAft> percentage + /// (spcPct) spacing resolves to the correct fraction (Phase 2 Follow-Up: Paragraph + /// Spacing). + /// + [Fact] + public void ResolveEffectiveParagraphProperties_SpcBefSpcAftPercent_ResolvesPercent() + { + var theme = BuildTestTheme(); + var pPr = new XElement( + DrawingNs + "pPr", + new XElement(DrawingNs + "spcBef", new XElement(DrawingNs + "spcPct", new XAttribute("val", "50000"))), + new XElement(DrawingNs + "spcAft", new XElement(DrawingNs + "spcPct", new XAttribute("val", "50000")))); + var paragraph = Paragraph(pPr); + var placeholderProperties = EmptyPlaceholderProperties(theme); + + var result = PptxDocument.ResolveEffectiveParagraphProperties(paragraph, placeholderProperties, "body"); + + Assert.Equal(0.5f, result.EffectiveSpaceBefore.Percent); + Assert.Null(result.EffectiveSpaceBefore.FixedEmu); + Assert.Equal(0.5f, result.EffectiveSpaceAfter.Percent); + Assert.Null(result.EffectiveSpaceAfter.FixedEmu); + } + + /// + /// Proves a paragraph that never declares <a:spcBef>/<a:spcAft> + /// at any inheritance tier resolves to the "zero extra gap" sentinel, not + /// LineSpacing's own "100% of line height" default - regression guard for every + /// pre-existing paragraph that never declared spacing. + /// + [Fact] + public void ResolveEffectiveParagraphProperties_NoSpcBefSpcAft_ResolvesToNone() + { + var theme = BuildTestTheme(); + var paragraph = Paragraph(null); + var placeholderProperties = EmptyPlaceholderProperties(theme); + + var result = PptxDocument.ResolveEffectiveParagraphProperties(paragraph, placeholderProperties, "body"); + + Assert.Equal(0f, result.EffectiveSpaceBefore.Percent); + Assert.Null(result.EffectiveSpaceBefore.FixedEmu); + Assert.Equal(0f, result.EffectiveSpaceAfter.Percent); + Assert.Null(result.EffectiveSpaceAfter.FixedEmu); + } + + #endregion + + #region GetMaster parsing (Phase 1d plumbing) + + /// Proves GetMaster parses a master's own <p:txStyles> sibling of <p:cSld> into a . + [Fact] + public void GetMaster_TxStylesSiblingOfCSld_ParsesAllThreeStyles() + { + using var stream = BuildMasterPackageWithTxStyles(); + using var document = PptxDocument.Open(stream); + + var master = document.GetMaster("ppt/slideMasters/slideMaster1.xml"); + + Assert.NotNull(master.TxStyles.TitleStyle); + Assert.NotNull(master.TxStyles.BodyStyle); + Assert.NotNull(master.TxStyles.OtherStyle); + } + + private static Stream BuildMasterPackageWithTxStyles() + { + const string contentTypesXml = + """ + + + + + + """; + + const string packageRelsXml = + """ + + + + + """; + + const string presentationXml = + """ + + + + + + + + """; + + const string presentationRelsXml = + """ + + + + + + """; + + const string masterXml = + """ + + + + + + + + + + + + + """; + + const string masterRelsXml = + """ + + + + + """; + + const string themeXml = + """ + + + + + + + + + + + + + + + + + + + + + + + + """; + + const string slideXml = + """ + + + + + + + """; + + const string slideRelsXml = + """ + + + + + """; + + const string layoutXml = + """ + + + + + + + """; + + const string layoutRelsXml = + """ + + + + + """; + + var memoryStream = new MemoryStream(); + using (var archive = new System.IO.Compression.ZipArchive(memoryStream, System.IO.Compression.ZipArchiveMode.Create, leaveOpen: true)) + { + WriteEntry(archive, "[Content_Types].xml", contentTypesXml); + WriteEntry(archive, "_rels/.rels", packageRelsXml); + WriteEntry(archive, "ppt/presentation.xml", presentationXml); + WriteEntry(archive, "ppt/_rels/presentation.xml.rels", presentationRelsXml); + WriteEntry(archive, "ppt/slideMasters/slideMaster1.xml", masterXml); + WriteEntry(archive, "ppt/slideMasters/_rels/slideMaster1.xml.rels", masterRelsXml); + WriteEntry(archive, "ppt/theme/theme1.xml", themeXml); + WriteEntry(archive, "ppt/slides/slide1.xml", slideXml); + WriteEntry(archive, "ppt/slides/_rels/slide1.xml.rels", slideRelsXml); + WriteEntry(archive, "ppt/slideLayouts/slideLayout1.xml", layoutXml); + WriteEntry(archive, "ppt/slideLayouts/_rels/slideLayout1.xml.rels", layoutRelsXml); + } + + memoryStream.Position = 0; + return memoryStream; + } + + private static void WriteEntry(System.IO.Compression.ZipArchive archive, string name, string content) + { + var entry = archive.CreateEntry(name); + using var writer = new StreamWriter(entry.Open()); + writer.Write(content); + } + + #endregion +} diff --git a/test/DemaConsulting.CanvasNet.Tests/Canvas/Rgba32Tests.cs b/test/DemaConsulting.CanvasNet.Tests/Canvas/Rgba32Tests.cs index a3189884..0c9d7f41 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Canvas/Rgba32Tests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Canvas/Rgba32Tests.cs @@ -2,7 +2,12 @@ namespace DemaConsulting.CanvasNet.Tests.Canvas; -/// Unit tests for the parse/tryparse behavior. +/// +/// Unit tests for the parse/tryparse behavior and the shared internal +/// single-pixel Porter-Duff "over" alpha-blending helper +/// (reused by DemaConsulting.CanvasNet.Pptx's PaintPicture and +/// DemaConsulting.CanvasNet.Pdf's CompositeImageOntoSurface). +/// public class Rgba32Tests { /// Rgba32_Parse_6HexUppercase_ReturnsExpectedRgbaWithAlpha255. @@ -149,4 +154,85 @@ public void Rgba32_Parse_NonHexCharacter_MessageIncludesOffendingCharacter() var ex = Assert.Throws(() => Rgba32.Parse("#GGGGGG")); Assert.Contains("'G'", ex.Message, StringComparison.Ordinal); } + + // --- CompositeOver (shared single-pixel Porter-Duff "over" helper) ------------------------- + + /// + /// Proves a fully transparent foreground (alpha == 0) leaves the background pixel + /// completely unchanged, regardless of the (irrelevant, since fully transparent) RGB + /// stored alongside that zero alpha. + /// + [Fact] + public void Rgba32_CompositeOver_FullyTransparentForeground_ReturnsBackgroundUnchanged() + { + var background = new Rgba32(10, 20, 30, 255); + var foreground = new Rgba32(255, 255, 255, 0); + + var result = Rgba32.CompositeOver(background, foreground); + + Assert.Equal(background, result); + } + + /// + /// Proves a fully opaque foreground (alpha == 255) exactly replaces the background + /// pixel, regardless of the background's own color/alpha. + /// + [Fact] + public void Rgba32_CompositeOver_FullyOpaqueForeground_ReturnsForegroundExactly() + { + var background = new Rgba32(10, 20, 30, 255); + var foreground = new Rgba32(7, 8, 9, 255); + + var result = Rgba32.CompositeOver(background, foreground); + + Assert.Equal(foreground, result); + } + + /// + /// Proves a partially transparent foreground composited over a fully opaque background + /// blends per the documented Porter-Duff "over" formula, asserting the exact expected + /// bytes (computed independently via the documented formula with round-half-away-from-zero). + /// + [Fact] + public void Rgba32_CompositeOver_PartiallyTransparentForegroundOverOpaqueBackground_BlendsExactly() + { + var background = new Rgba32(0, 255, 0, 255); + var foreground = new Rgba32(200, 100, 50, 128); + + var result = Rgba32.CompositeOver(background, foreground); + + Assert.Equal(new Rgba32(100, 177, 25, 255), result); + } + + /// + /// Proves two partially transparent pixels (both background and foreground with + /// non-0/non-255 alpha) composite to the correctly premultiplied-then-unpremultiplied + /// color, with the composited alpha channel computed per outA = fgA + bgA * (1 - fgA). + /// + [Fact] + public void Rgba32_CompositeOver_BothBackgroundAndForegroundPartiallyTransparent_BlendsExactly() + { + var background = new Rgba32(100, 150, 200, 100); + var foreground = new Rgba32(50, 60, 70, 90); + + var result = Rgba32.CompositeOver(background, foreground); + + Assert.Equal(new Rgba32(71, 98, 124, 155), result); + } + + /// + /// Proves two fully transparent pixels (both background and foreground with + /// alpha == 0) composite to the fully transparent, zeroed-color degenerate result, + /// rather than a division-by-zero NaN. + /// + [Fact] + public void Rgba32_CompositeOver_BothBackgroundAndForegroundFullyTransparent_ReturnsZeroedResult() + { + var background = new Rgba32(123, 45, 67, 0); + var foreground = new Rgba32(89, 10, 11, 0); + + var result = Rgba32.CompositeOver(background, foreground); + + Assert.Equal(default, result); + } } diff --git a/test/DemaConsulting.CanvasNet.Tests/Drawing/StrokeOutlinerTests.cs b/test/DemaConsulting.CanvasNet.Tests/Drawing/StrokeOutlinerTests.cs index 558ac983..c74478d5 100644 --- a/test/DemaConsulting.CanvasNet.Tests/Drawing/StrokeOutlinerTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/Drawing/StrokeOutlinerTests.cs @@ -632,4 +632,160 @@ private static bool IsPointInPolygon(Vector2 point, IReadOnlyList polyg return inside; } + + /// + /// Builds a closed, near-circular polygon approximation of a circle at the requested + /// point count - standing in for a PPTX ellipse shape's own Bezier-flattened geometry at + /// native EMU-space coordinate magnitude and tessellation density, without depending on + /// the Bezier flattener itself. + /// + private static List BuildCircle(Vector2 center, float radius, int pointCount) + { + var points = new List(pointCount); + for (var i = 0; i < pointCount; i++) + { + var angle = MathF.Tau * i / pointCount; + points.Add(center + radius * new Vector2(MathF.Cos(angle), MathF.Sin(angle))); + } + + return points; + } + + /// + /// Proves the fix for the "closed-contour thin-stroke false collapse" regression: a + /// large-coordinate (EMU-scale-like magnitude), finely-tessellated closed contour - the + /// same shape of input the PPTX renderer's own stroke-outline resolution actually produces + /// for a real-world PPTX ellipse shape at native slide scale - stroked with a thin + /// half-width must still produce a genuine thin ring (two counter-wound rings), not a + /// false "inner-ring collapse" that discards the hole and fills the whole interior solid. + /// + /// + /// Before the fix, StrokeOutliner.BuildClosedSide's inner-ring collapse + /// detector compared the (coordinate-scale-dependent) projected offset-edge length against + /// a fixed absolute NearZeroDistance (1e-6f). At this coordinate magnitude + /// and tessellation density, ordinary float32 storage-quantization noise in the flattened + /// vertices - amplified by the near-parallel-offset-lines division inherent to resolving + /// each forced exact-intersection point at such a tiny per-vertex turn angle - produced a + /// measurably negative projected length even though the stroke's half-width is nowhere + /// close to this contour's inradius, misclassifying this ring as collapsed. This exact + /// scenario (a ~914400-unit-radius circle, a ~9525-unit stroke width - PowerPoint's own + /// default <a:ln> width, and ~8192-point tessellation density, matching what + /// StrokePathFlattener.Flatten actually produces for this geometry at the library's + /// pixel-tuned default flattenTolerance: 0.25f) reproduced the reported real-world + /// bug (an oval shape with <a:noFill/> and a thin, color-only + /// <a:ln> rendering as a solid disc instead of a thin ring). + /// + [Fact] + public void StrokeOutliner_Outline_LargeCoordinateFinelyTessellatedClosedContour_ProducesValidThinRing() + { + // Arrange: a circle at native PPTX EMU-space magnitude (radius ~1 inch) tessellated as + // finely as StrokePathFlattener's own default 0.25f tolerance actually produces for this + // geometry, stroked with PowerPoint's own default width (9525 EMU, half-width + // 4762.5 EMU) - nowhere close to this circle's own inradius of ~914400 EMU. + const float radius = 914400f; + var center = new Vector2(radius, radius); + var points = BuildCircle(center, radius, pointCount: 8192); + var style = new StrokeStyle(9525f); + + // Act + var polygons = StrokeOutliner.Outline(points, isClosed: true, style, flattenTolerance: 0.25f); + + // Assert: a genuine thin ring - one outer ring and one counter-wound inner ring (the + // hole), never a single collapsed-to-solid outer ring. + Assert.Equal(2, polygons.Count); + Assert.True(GetSignedArea(polygons[0]) * GetSignedArea(polygons[1]) < 0f); + + var outerRing = polygons[0]; + var innerRing = polygons[1]; + + // The circle's own center must fall inside the hole (the inner ring) - i.e. NOT covered + // by the stroke band - exactly the "center sample must not be covered" assertion from the + // bug report. + Assert.True(IsPointInPolygon(center, outerRing)); + Assert.True(IsPointInPolygon(center, innerRing)); + + // A point on the circle's own boundary must be covered by the stroke band (inside the + // outer ring, but outside the inner ring/hole) - the "boundary sample must be covered" + // assertion from the bug report. + var boundaryPoint = center + new Vector2(radius, 0f); + Assert.True(IsPointInPolygon(boundaryPoint, outerRing)); + Assert.False(IsPointInPolygon(boundaryPoint, innerRing)); + } + + /// + /// Proves the false-collapse fix (above) does not regress the genuine "half-width exceeds + /// inradius" collapse-to-solid case at the same large coordinate magnitude/tessellation + /// density the false-positive fix targets - a thick stroke on a small, finely-tessellated + /// closed contour must still correctly collapse to a solid fill with no hole. + /// + [Fact] + public void StrokeOutliner_Outline_LargeCoordinateFinelyTessellatedSmallCircleThickStroke_CollapsesToSolid() + { + // Arrange: a small circle (radius 10, at a large coordinate offset so its own absolute + // magnitude is still large) tessellated just as finely as the large-radius case above, + // stroked with a half-width (50) that comfortably exceeds its own inradius (10). + const float radius = 10f; + var center = new Vector2(1_000_000f, 1_000_000f); + var points = BuildCircle(center, radius, pointCount: 8192); + var style = new StrokeStyle(100f); + + // Act + var polygons = StrokeOutliner.Outline(points, isClosed: true, style, flattenTolerance: 0.25f); + + // Assert: exactly one polygon (no hole) - the whole circle interior renders as solid + // stroke, and the circle's own center is covered by it. + var polygon = Assert.Single(polygons); + Assert.True(IsPointInPolygon(center, polygon)); + } + + /// + /// Proves the inner-ring collapse detector in StrokeOutliner.BuildClosedSide + /// correctly detects a genuine "half-width exceeds inradius" collapse at a realistic + /// tessellation density for a small shape, not just at the unrealistically fine + /// 8192-point density used by the large-coordinate regression tests above. + /// + /// + /// Before the fix, the collapse detector compared each edge's projected (potentially + /// backwards-running) offset-edge length against a single tolerance computed once for the + /// whole contour from its bounding-box geometric-mean span + /// (relativeCollapseTolerance * sqrt(RingAreaScale(points))). That raw projected + /// length - for a circle of true radius R stroked with half-width h and + /// tessellated into N vertices - shrinks proportionally to 1/N as the chord + /// between two adjacent forced-exact-intersection inner-ring vertices (each at radius + /// R - h, independent of N), purely as an artifact of finer tessellation + /// producing more, shorter inner-ring edges - NOT because the genuine over-erosion + /// shrinks. A tolerance tied only to the whole contour's (constant, N-independent) + /// bounding-box span cannot track this density-dependent shrinkage of the signal it is + /// compared against, so at a realistic tessellation density for a small radius-10 object + /// (64 points, matching what actually produces at + /// this library's own default flattenTolerance: 0.25f), the detector failed to + /// flag a half-width only 1% over this circle's own inradius as collapsed, instead + /// emitting a spurious, invalid inner ring that still covered the circle's own center. + /// The fix replaces the whole-contour tolerance with a per-edge tolerance derived from + /// that edge's own flattened source-segment length, which scales with density the same + /// way the raw signal does, restoring a stable, density-invariant comparison. + /// + [Fact] + public void StrokeOutliner_Outline_SmallCircleRealisticTessellation_HalfWidthJustOverInradius_CollapsesToSolid() + { + // Arrange: a radius-10 circle tessellated at a realistic point count for this object + // size/scale (64 points - a reasonable proxy for what the library's own default + // flattenTolerance: 0.25f produces for a radius-10 shape), stroked with a half-width + // (10.1) only 1% over this circle's own inradius (10) - the exact scenario reported by + // the code-review finding. + const float radius = 10f; + var center = new Vector2(0f, 0f); + var points = BuildCircle(center, radius, pointCount: 64); + var style = new StrokeStyle(20.2f); + + // Act + var polygons = StrokeOutliner.Outline(points, isClosed: true, style, flattenTolerance: 0.25f); + + // Assert: exactly one polygon (no hole) - the whole circle interior renders as solid + // stroke, and the circle's own center is covered by it. A spurious/invalid inner ring + // being retained instead (e.g. two polygons, or one polygon that does not cover the + // center) indicates the collapse was not detected. + var polygon = Assert.Single(polygons); + Assert.True(IsPointInPolygon(center, polygon)); + } }