From a2bb2a1bbc0792cbec14c73a6dc1358d911a184c Mon Sep 17 00:00:00 2001 From: Scribe Date: Mon, 11 May 2026 20:14:58 +0200 Subject: [PATCH 1/3] Add template-aware text colors and code font auto-scaling - Build theme from template color scheme (lt1/dk1) to pick readable text color based on background luminance - Detect low-contrast placeholder text in layouts and override with scheme-appropriate color - Resolve scheme color references (bg1, tx1, dk1, lt1, etc.) through the template's color scheme - Scale code font to 75% of template body text size (capped at 32pt) - Scale code font to fill available frame height per line count - Supports solidFill backgrounds via both srgbClr and schemeClr --- .../Rendering/OpenXmlPptxRenderer.cs | 326 +++++++++++++++++- 1 file changed, 317 insertions(+), 9 deletions(-) diff --git a/src/MarpToPptx.Pptx/Rendering/OpenXmlPptxRenderer.cs b/src/MarpToPptx.Pptx/Rendering/OpenXmlPptxRenderer.cs index 4535093..6328421 100644 --- a/src/MarpToPptx.Pptx/Rendering/OpenXmlPptxRenderer.cs +++ b/src/MarpToPptx.Pptx/Rendering/OpenXmlPptxRenderer.cs @@ -767,7 +767,7 @@ private SlidePart AddSlide(PresentationPart presentationPart, SlideLayoutPart sl } var effectiveTheme = useTemplateStyle - ? ThemeDefinition.Default + ? BuildTemplateAwareTheme(ThemeDefinition.Default, slideLayoutPart) : theme.ApplyClassVariant(classVariant); effectiveTheme = ApplySlideTextColor(effectiveTheme, slideModel.Style.Color, useTemplateStyle); var context = new SlideRenderContext(slidePart, shapeTree, sourceDirectory, effectiveTheme, remoteAssets, useTemplateStyle, language, globalDiagramTheme); @@ -937,6 +937,264 @@ private static ThemeDefinition ApplySlideTextColor(ThemeDefinition theme, string }; } + /// + /// Builds a whose text colors are derived from the + /// template's OOXML color scheme instead of the Marp defaults. This ensures that + /// standalone/residual shapes (headings, paragraphs, bullets) rendered outside + /// placeholders use colors that contrast with the template's background. + /// + /// Strategy: read dk1 (dark text) and lt1 (light text) from the + /// slide master's theme. Determine the slide background luminance; if dark, use + /// lt1 as the text color, otherwise use dk1. + /// + private static ThemeDefinition BuildTemplateAwareTheme(ThemeDefinition baseTheme, SlideLayoutPart slideLayoutPart) + { + var colorScheme = slideLayoutPart.SlideMasterPart?.ThemePart?.Theme + ?.ThemeElements?.ColorScheme; + if (colorScheme is null) + { + return baseTheme; + } + + // Extract dk1 and lt1 from the template's color scheme. + var dk1Hex = GetColorSchemeHex(colorScheme.Dark1Color); + var lt1Hex = GetColorSchemeHex(colorScheme.Light1Color); + if (dk1Hex is null && lt1Hex is null) + { + return baseTheme; + } + + // Determine background color: check slide layout, then master, then dk2 heuristic. + var bgColor = GetLayoutOrMasterBackground(slideLayoutPart) + ?? GetColorSchemeHex(colorScheme.Dark2Color) + ?? "FFFFFF"; + + // Pick appropriate text color based on background luminance. + var bgNorm = NormalizeColor(bgColor); + var bgLuminance = ComputeLuminance(bgNorm); + + // Dark background → use lt1 (light text); light background → use dk1 (dark text). + var textColor = bgLuminance < 128 + ? (lt1Hex ?? "FFFFFF") + : (dk1Hex ?? "1F2937"); + + var textColorPrefixed = "#" + textColor; + + var headings = baseTheme.Headings.ToDictionary( + static pair => pair.Key, + pair => pair.Value with { Color = textColorPrefixed }); + + return baseTheme with + { + TextColor = textColorPrefixed, + Body = baseTheme.Body with { Color = textColorPrefixed }, + InlineCode = baseTheme.InlineCode with { Color = textColorPrefixed }, + Code = ScaleCodeStyleForTemplate(baseTheme.Code, slideLayoutPart), + Headings = headings, + }; + } + + /// + /// Extracts the hex color value from a color scheme element (dk1, lt1, dk2, etc.). + /// + private static string? GetColorSchemeHex(DocumentFormat.OpenXml.Drawing.Color2Type? color2) + { + if (color2 is null) return null; + return color2.RgbColorModelHex?.Val?.Value + ?? color2.SystemColor?.LastColor?.Value; + } + + /// + /// Scales the code text style's font size relative to the template's body text + /// default size. Code is typically ~75% of body text size, capped at + /// . This ensures code blocks grow proportionally + /// when the template uses large body text (e.g. 32pt body → 24pt code). + /// + private static TextStyle ScaleCodeStyleForTemplate(TextStyle baseCode, SlideLayoutPart slideLayoutPart) + { + // Read body lvl1 default size from the slide master's bodyStyle. + var bodyStyle = slideLayoutPart.SlideMasterPart?.SlideMaster?.TextStyles?.BodyStyle; + var bodyLvl1 = bodyStyle?.Elements().FirstOrDefault(); + var bodyDefRPr = bodyLvl1?.GetFirstChild(); + var bodySzAttr = bodyDefRPr?.FontSize?.Value; + if (bodySzAttr is null or <= 0) return baseCode; + + var bodyFontSizePt = bodySzAttr.Value / 100.0; // hundredths to points + var scaledCodeSize = Math.Round(bodyFontSizePt * 0.75, 1); + var clampedSize = Math.Clamp(scaledCodeSize, baseCode.FontSize, MaxCodeFontSize); + + return clampedSize > baseCode.FontSize + ? baseCode with { FontSize = clampedSize } + : baseCode; + } + + /// + /// Reads the background fill color from the slide layout or its master. + /// Returns the hex color (no '#') or null if no solid fill is found. + /// + private static string? GetLayoutOrMasterBackground(SlideLayoutPart slideLayoutPart) + { + // Check layout background. + var layoutBgPr = slideLayoutPart.SlideLayout?.CommonSlideData?.Background?.BackgroundProperties; + if (layoutBgPr is not null) + { + var hex = GetSolidFillHex(layoutBgPr, slideLayoutPart); + if (hex is not null) return hex; + } + + // Check master background. + var masterBgPr = slideLayoutPart.SlideMasterPart?.SlideMaster?.CommonSlideData?.Background?.BackgroundProperties; + if (masterBgPr is not null) + { + var hex = GetSolidFillHex(masterBgPr, slideLayoutPart); + if (hex is not null) return hex; + } + + return null; + } + + /// + /// Reads the hex color from a solid fill child element. Handles both direct + /// srgbClr hex values and schemeClr references (resolved via the + /// template's color scheme). + /// + private static string? GetSolidFillHex(DocumentFormat.OpenXml.OpenXmlElement parent, SlideLayoutPart slideLayoutPart) + { + var fill = parent.GetFirstChild(); + if (fill is null) return null; + + var directHex = fill.GetFirstChild()?.Val?.Value; + if (directHex is not null) return directHex; + + var schemeClr = fill.GetFirstChild()?.Val?.Value; + if (schemeClr is { } sc) return ResolveSchemeColor(slideLayoutPart, sc); + + return null; + } + + /// + /// Returns a six-character hex color override (no '#') when the layout placeholder's + /// default text color (lstStyledefRPrsolidFill) would + /// have poor contrast against the slide background. Returns null when the + /// inherited color is readable or when no determination can be made. + /// + private static string? GetPlaceholderColorOverrideIfNeeded(SlideLayoutPart slideLayoutPart, TemplatePlaceholder? placeholder) + { + if (placeholder is null) return null; + + // Find the layout shape matching this placeholder. + var shapeTree = slideLayoutPart.SlideLayout?.CommonSlideData?.ShapeTree; + if (shapeTree is null) return null; + + P.Shape? layoutShape = null; + foreach (var shape in shapeTree.Elements()) + { + var ph = shape.NonVisualShapeProperties? + .ApplicationNonVisualDrawingProperties? + .GetFirstChild(); + if (ph is null) continue; + + var matches = placeholder.Type is { } t + ? ph.Type?.Value == t + : ph.Index?.Value == placeholder.Index; + if (matches == true) + { + layoutShape = shape; + break; + } + } + + if (layoutShape is null) return null; + + // Read the lstStyle default text color. + var lvl1pPr = layoutShape.TextBody?.ListStyle + ?.Elements().FirstOrDefault(); + var defRPr = lvl1pPr?.GetFirstChild(); + var textHex = defRPr?.GetFirstChild()?.GetFirstChild()?.Val?.Value; + + // If the lstStyle uses a scheme color reference, resolve it. + if (textHex is null) + { + var schemeClr = defRPr?.GetFirstChild()?.GetFirstChild()?.Val?.Value; + if (schemeClr is { } resolvedSchemeClr) + { + textHex = ResolveSchemeColor(slideLayoutPart, resolvedSchemeClr); + } + } + + if (textHex is null) return null; + + // Determine the background color. + var bgHex = GetLayoutOrMasterBackground(slideLayoutPart); + if (bgHex is null) + { + // If no explicit background, try dk2 as a dark-theme heuristic. + var colorScheme = slideLayoutPart.SlideMasterPart?.ThemePart?.Theme + ?.ThemeElements?.ColorScheme; + bgHex = GetColorSchemeHex(colorScheme?.Dark2Color) ?? "FFFFFF"; + } + + // Check if the text color has sufficient contrast with the background. + var bgNorm = NormalizeColor(bgHex); + var txtNorm = NormalizeColor(textHex); + var bgLum = ComputeLuminance(bgNorm); + var txtLum = ComputeLuminance(txtNorm); + var contrast = Math.Abs(bgLum - txtLum); + + // If contrast is too low (both dark or both light), return a better color. + if (contrast >= 80) return null; // Sufficient contrast, no override needed. + + // Pick lt1 (light text) for dark backgrounds, dk1 (dark text) for light backgrounds. + var colorScheme2 = slideLayoutPart.SlideMasterPart?.ThemePart?.Theme + ?.ThemeElements?.ColorScheme; + return bgLum < 128 + ? NormalizeColor(GetColorSchemeHex(colorScheme2?.Light1Color) ?? "FFFFFF") + : NormalizeColor(GetColorSchemeHex(colorScheme2?.Dark1Color) ?? "1F2937"); + } + + /// + /// Resolves a scheme color name (e.g. bg1, tx1) to a hex color + /// by looking up the template's color scheme. + /// + private static string? ResolveSchemeColor(SlideLayoutPart slideLayoutPart, A.SchemeColorValues schemeColor) + { + var colorScheme = slideLayoutPart.SlideMasterPart?.ThemePart?.Theme + ?.ThemeElements?.ColorScheme; + if (colorScheme is null) return null; + + DocumentFormat.OpenXml.Drawing.Color2Type? color2 = null; + if (schemeColor == A.SchemeColorValues.Dark1 || schemeColor == A.SchemeColorValues.Text1) + color2 = colorScheme.Dark1Color; + else if (schemeColor == A.SchemeColorValues.Light1 || schemeColor == A.SchemeColorValues.Background1) + color2 = colorScheme.Light1Color; + else if (schemeColor == A.SchemeColorValues.Dark2 || schemeColor == A.SchemeColorValues.Text2) + color2 = colorScheme.Dark2Color; + else if (schemeColor == A.SchemeColorValues.Light2 || schemeColor == A.SchemeColorValues.Background2) + color2 = colorScheme.Light2Color; + else if (schemeColor == A.SchemeColorValues.Accent1) + color2 = colorScheme.Accent1Color; + else if (schemeColor == A.SchemeColorValues.Accent2) + color2 = colorScheme.Accent2Color; + else if (schemeColor == A.SchemeColorValues.Accent3) + color2 = colorScheme.Accent3Color; + else if (schemeColor == A.SchemeColorValues.Accent4) + color2 = colorScheme.Accent4Color; + else if (schemeColor == A.SchemeColorValues.Accent5) + color2 = colorScheme.Accent5Color; + else if (schemeColor == A.SchemeColorValues.Accent6) + color2 = colorScheme.Accent6Color; + + return GetColorSchemeHex(color2); + } + + private static double ComputeLuminance(string sixCharHex) + { + var r = int.Parse(sixCharHex[..2], NumberStyles.HexNumber, CultureInfo.InvariantCulture); + var g = int.Parse(sixCharHex[2..4], NumberStyles.HexNumber, CultureInfo.InvariantCulture); + var b = int.Parse(sixCharHex[4..6], NumberStyles.HexNumber, CultureInfo.InvariantCulture); + return (0.299 * r) + (0.587 * g) + (0.114 * b); + } + /// /// Applies a element to the slide based on the supplied /// model. Any existing transition on a cloned template @@ -1117,6 +1375,13 @@ private bool TryRenderIntoTemplatePlaceholders(SlideRenderContext context, Slide return false; } + // Determine whether placeholder text colors need an explicit override. + // Some templates define a dark default text color in their lstStyle (e.g. #0F0F0F) + // even though the slide background is dark. When the inherited color would be + // unreadable, compute a contrasting color from the template's color scheme. + var titleColorOverride = GetPlaceholderColorOverrideIfNeeded(slideLayoutPart, titlePlaceholder); + var bodyColorOverride = GetPlaceholderColorOverrideIfNeeded(slideLayoutPart, bodyPlaceholder); + // Split elements into: optional title heading, body text, and non-text remainder. // The title placeholder receives the very first element when it is a heading of // any level. Level is intentionally ignored because a template-bound "Title and @@ -1156,7 +1421,7 @@ element is HeadingElement h && if (titleHeading is not null) { var titleParagraphs = SplitSpansIntoParagraphs(titleHeading.Spans) - .Select(group => CreateTemplateParagraphFromSpans(group, context.SlidePart, level: null, ordered: false, forceBold: false, context.Language)) + .Select(group => CreateTemplateParagraphFromSpans(group, context.SlidePart, level: null, ordered: false, forceBold: false, context.Language, colorOverride: titleColorOverride)) .ToArray(); context.ShapeTree.Append(CreateSlidePlaceholderShape( context.NextShapeId(), @@ -1179,20 +1444,20 @@ element is HeadingElement h && case HeadingElement heading: foreach (var group in SplitSpansIntoParagraphs(heading.Spans)) { - bodyParagraphs.Add(CreateTemplateParagraphFromSpans(group, context.SlidePart, level: null, ordered: false, forceBold: true, context.Language, fontSizeOverride: bodyFontSizeOverride)); + bodyParagraphs.Add(CreateTemplateParagraphFromSpans(group, context.SlidePart, level: null, ordered: false, forceBold: true, context.Language, fontSizeOverride: bodyFontSizeOverride, colorOverride: bodyColorOverride)); } break; case ParagraphElement paragraph: foreach (var group in SplitSpansIntoParagraphs(paragraph.Spans)) { - bodyParagraphs.Add(CreateTemplateParagraphFromSpans(group, context.SlidePart, level: null, ordered: false, forceBold: false, context.Language, fontSizeOverride: bodyFontSizeOverride)); + bodyParagraphs.Add(CreateTemplateParagraphFromSpans(group, context.SlidePart, level: null, ordered: false, forceBold: false, context.Language, fontSizeOverride: bodyFontSizeOverride, colorOverride: bodyColorOverride)); } break; case BulletListElement list: var orderNumber = 1; foreach (var item in list.Items) { - bodyParagraphs.Add(CreateTemplateParagraphFromSpans(item.Spans, context.SlidePart, level: item.Depth, list.Ordered, forceBold: false, context.Language, orderNumber, fontSizeOverride: bodyFontSizeOverride)); + bodyParagraphs.Add(CreateTemplateParagraphFromSpans(item.Spans, context.SlidePart, level: item.Depth, list.Ordered, forceBold: false, context.Language, orderNumber, fontSizeOverride: bodyFontSizeOverride, colorOverride: bodyColorOverride)); orderNumber++; } break; @@ -1672,6 +1937,9 @@ private static P.Shape CreateSlidePlaceholderShape(uint shapeId, string name, Te /// placeholder's default bullet. /// When is set, emits an explicit sz attribute /// on each run so the author-specified size takes precedence over the placeholder default. + /// When is set (six-character hex, no '#'), emits an + /// explicit <a:solidFill> on each run. This is used when the layout + /// placeholder's default text color has poor contrast with the slide background. /// private static A.Paragraph CreateTemplateParagraphFromSpans( IReadOnlyList spans, @@ -1681,7 +1949,8 @@ private static A.Paragraph CreateTemplateParagraphFromSpans( bool forceBold, string language, int orderNumber = 1, - int? fontSizeOverride = null) + int? fontSizeOverride = null, + string? colorOverride = null) { var paragraph = new A.Paragraph(); var paragraphProperties = new A.ParagraphProperties(); @@ -1712,6 +1981,10 @@ private static A.Paragraph CreateTemplateParagraphFromSpans( { runProperties.FontSize = sz; } + if (colorOverride is not null) + { + runProperties.Append(new A.SolidFill(new A.RgbColorModelHex { Val = colorOverride })); + } if (span.Bold || forceBold) { runProperties.Bold = true; @@ -2508,13 +2781,17 @@ private static void WriteSmartArtPart(OpenXmlPart part, string xmlContent) private static void AddCodeBlock(SlideRenderContext context, Rect frame, CodeBlockElement code, TextStyle style) { + // Scale code font size up to fill the available frame height, capped at a + // reasonable maximum so code stays readable without becoming oversized. + var effectiveStyle = ScaleCodeFontToFit(style, frame, code.Code); + A.Paragraph[] paragraphs; if (SyntaxHighlighter.IsSupported(code.Language)) { var tokenizedLines = SyntaxHighlighter.Tokenize(code.Language, code.Code); paragraphs = tokenizedLines - .Select(runs => CreateHighlightedParagraph(runs, style, context.Language)) + .Select(runs => CreateHighlightedParagraph(runs, effectiveStyle, context.Language)) .ToArray(); } else @@ -2522,7 +2799,7 @@ private static void AddCodeBlock(SlideRenderContext context, Rect frame, CodeBlo paragraphs = code.Code .Replace("\r\n", "\n", StringComparison.Ordinal) .Split('\n', StringSplitOptions.None) - .Select(line => CreateParagraph(line, style, null, false, 1, context.Language)) + .Select(line => CreateParagraph(line, effectiveStyle, null, false, 1, context.Language)) .ToArray(); } @@ -2532,10 +2809,41 @@ private static void AddCodeBlock(SlideRenderContext context, Rect frame, CodeBlo frame, paragraphs, noFill: false, - fillColor: NormalizeColor(style.BackgroundColor ?? "#0F172A"), + fillColor: NormalizeColor(effectiveStyle.BackgroundColor ?? "#0F172A"), lineColor: NormalizeColor(context.Theme.AccentColor))); } + /// + /// Computes an optimal code font size that fills the available frame height + /// without exceeding it. The result is clamped between the theme's configured + /// code font size (floor) and (ceiling) so code + /// is never smaller than the theme default and never absurdly large. + /// + private const double MaxCodeFontSize = 32.0; + + private static TextStyle ScaleCodeFontToFit(TextStyle style, Rect frame, string codeText) + { + var lineCount = codeText.Split('\n').Length; + if (lineCount <= 0) return style; + + // Estimate how tall a single line is at a given font size, using a typical + // code line-height multiplier and vertical padding inside the shape. + var lineHeight = style.LineHeight ?? 1.45; + const double verticalPadding = 18.0; // top+bottom padding inside the code shape + var availableHeight = frame.Height - verticalPadding; + if (availableHeight <= 0) return style; + + // Font size that would make lineCount lines exactly fill the frame. + var optimalSize = availableHeight / (lineCount * lineHeight); + + // Clamp: never shrink below theme default, never exceed max. + var clampedSize = Math.Clamp(Math.Round(optimalSize, 1), style.FontSize, MaxCodeFontSize); + + if (Math.Abs(clampedSize - style.FontSize) < 0.5) return style; + + return style with { FontSize = clampedSize }; + } + private static void AddDiagram(SlideRenderContext context, Rect frame, string source, string fenceName, ThemeDefinition effectiveTheme, TextStyle fallbackStyle) { var effectiveSource = InjectDiagramThemeIfNeeded(source, context.GlobalDiagramTheme); From d328e7de2cf67784e5d998a937740274355d12f7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 18:27:10 +0000 Subject: [PATCH 2/3] Fix review feedback in renderer --- .../Rendering/OpenXmlPptxRenderer.cs | 19 ++++- tests/MarpToPptx.Tests/PptxRendererTests.cs | 76 +++++++++++++++++++ 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/src/MarpToPptx.Pptx/Rendering/OpenXmlPptxRenderer.cs b/src/MarpToPptx.Pptx/Rendering/OpenXmlPptxRenderer.cs index 6328421..b29ea16 100644 --- a/src/MarpToPptx.Pptx/Rendering/OpenXmlPptxRenderer.cs +++ b/src/MarpToPptx.Pptx/Rendering/OpenXmlPptxRenderer.cs @@ -1018,6 +1018,7 @@ private static TextStyle ScaleCodeStyleForTemplate(TextStyle baseCode, SlideLayo var bodyDefRPr = bodyLvl1?.GetFirstChild(); var bodySzAttr = bodyDefRPr?.FontSize?.Value; if (bodySzAttr is null or <= 0) return baseCode; + if (baseCode.FontSize >= MaxCodeFontSize) return baseCode; var bodyFontSizePt = bodySzAttr.Value / 100.0; // hundredths to points var scaledCodeSize = Math.Round(bodyFontSizePt * 0.75, 1); @@ -1094,10 +1095,7 @@ private static TextStyle ScaleCodeStyleForTemplate(TextStyle baseCode, SlideLayo .GetFirstChild(); if (ph is null) continue; - var matches = placeholder.Type is { } t - ? ph.Type?.Value == t - : ph.Index?.Value == placeholder.Index; - if (matches == true) + if (PlaceholderMatches(ph, placeholder)) { layoutShape = shape; break; @@ -1152,6 +1150,18 @@ private static TextStyle ScaleCodeStyleForTemplate(TextStyle baseCode, SlideLayo : NormalizeColor(GetColorSchemeHex(colorScheme2?.Dark1Color) ?? "1F2937"); } + private static bool PlaceholderMatches(P.PlaceholderShape placeholderShape, TemplatePlaceholder placeholder) + { + if (placeholder.Index is not null && placeholderShape.Index?.Value != placeholder.Index) + { + return false; + } + + return placeholder.Type is { } type + ? placeholderShape.Type?.Value == type + : placeholder.Index is not null && placeholderShape.Index?.Value == placeholder.Index; + } + /// /// Resolves a scheme color name (e.g. bg1, tx1) to a hex color /// by looking up the template's color scheme. @@ -2825,6 +2835,7 @@ private static TextStyle ScaleCodeFontToFit(TextStyle style, Rect frame, string { var lineCount = codeText.Split('\n').Length; if (lineCount <= 0) return style; + if (style.FontSize >= MaxCodeFontSize) return style; // Estimate how tall a single line is at a given font size, using a typical // code line-height multiplier and vertical padding inside the shape. diff --git a/tests/MarpToPptx.Tests/PptxRendererTests.cs b/tests/MarpToPptx.Tests/PptxRendererTests.cs index a981dbe..70844d7 100644 --- a/tests/MarpToPptx.Tests/PptxRendererTests.cs +++ b/tests/MarpToPptx.Tests/PptxRendererTests.cs @@ -1,9 +1,11 @@ using DocumentFormat.OpenXml.Packaging; using MarpToPptx.Core; +using MarpToPptx.Core.Layout; using MarpToPptx.Core.Themes; using MarpToPptx.Pptx.Rendering; using MarpToPptx.Pptx.Validation; using System.IO.Compression; +using System.Reflection; using System.Xml.Linq; using A = DocumentFormat.OpenXml.Drawing; using P = DocumentFormat.OpenXml.Presentation; @@ -257,6 +259,61 @@ public void Renderer_CreatesNativePptxTable_ForMarkdownTable() Assert.Empty(validationErrors); } + [Fact] + public void Renderer_PlaceholderMatches_RequiresMatchingIndex_WhenTypeAndIndexAreSet() + { + var placeholder = CreateTemplatePlaceholder(P.PlaceholderValues.Body, 1U); + + var matchingShape = new P.PlaceholderShape { Type = P.PlaceholderValues.Body, Index = 1U }; + var wrongIndexShape = new P.PlaceholderShape { Type = P.PlaceholderValues.Body, Index = 2U }; + + Assert.True(InvokeRendererPrivate("PlaceholderMatches", matchingShape, placeholder)); + Assert.False(InvokeRendererPrivate("PlaceholderMatches", wrongIndexShape, placeholder)); + } + + [Fact] + public void Renderer_ScaleCodeFontToFit_LeavesOversizedConfiguredCodeFontUnchanged() + { + var style = new TextStyle(40, "#FFFFFF", "Consolas", false, "#0F172A", 1.45); + + var scaled = InvokeRendererPrivate( + "ScaleCodeFontToFit", + style, + new Rect(0, 0, 400, 120), + "line 1\nline 2\nline 3"); + + Assert.Equal(style, scaled); + } + + [Fact] + public void Renderer_ScaleCodeStyleForTemplate_LeavesOversizedConfiguredCodeFontUnchanged() + { + using var workspace = TestWorkspace.Create(); + + var templatePath = workspace.GetPath("template.pptx"); + CreateTemplateWithPlaceholderLayout(templatePath); + + using (var templateDocument = PresentationDocument.Open(templatePath, true)) + { + var slideMaster = templateDocument.PresentationPart!.SlideMasterParts.Single().SlideMaster!; + slideMaster.TextStyles = new P.TextStyles( + new P.TitleStyle(), + new P.BodyStyle( + new A.Level1ParagraphProperties( + new A.DefaultRunProperties { FontSize = 3200 })), + new P.OtherStyle()); + slideMaster.Save(); + } + + using var readOnlyTemplateDocument = PresentationDocument.Open(templatePath, false); + var slideLayoutPart = readOnlyTemplateDocument.PresentationPart!.SlideMasterParts.Single().SlideLayoutParts.Single(); + var style = new TextStyle(40, "#FFFFFF", "Consolas", false, "#0F172A", 1.45); + + var scaled = InvokeRendererPrivate("ScaleCodeStyleForTemplate", style, slideLayoutPart); + + Assert.Equal(style, scaled); + } + [Fact] public void Renderer_TableUsesReadableColors_WhenSlideBodyUsesLightText() { @@ -2771,6 +2828,25 @@ Body paragraph text. Assert.Empty(validationErrors); } + private static T InvokeRendererPrivate(string methodName, params object[] args) + { + var method = typeof(OpenXmlPptxRenderer).GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(method); + + var result = method!.Invoke(null, args); + Assert.IsType(result); + return (T)result!; + } + + private static object CreateTemplatePlaceholder(P.PlaceholderValues? type, uint? index) + { + var placeholderType = typeof(OpenXmlPptxRenderer).Assembly + .GetType("MarpToPptx.Pptx.Rendering.TemplatePlaceholder", throwOnError: true); + Assert.NotNull(placeholderType); + + return Activator.CreateInstance(placeholderType!, type, index)!; + } + /// /// Creates a minimal template PPTX with a single "Title Only" layout that carries a /// title placeholder but no body placeholder. When From 567c23ed35a6d647ad7b6bae4d2b9e834ad99de3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 18:29:36 +0000 Subject: [PATCH 3/3] Refine placeholder matcher logic --- src/MarpToPptx.Pptx/Rendering/OpenXmlPptxRenderer.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/MarpToPptx.Pptx/Rendering/OpenXmlPptxRenderer.cs b/src/MarpToPptx.Pptx/Rendering/OpenXmlPptxRenderer.cs index b29ea16..a317f01 100644 --- a/src/MarpToPptx.Pptx/Rendering/OpenXmlPptxRenderer.cs +++ b/src/MarpToPptx.Pptx/Rendering/OpenXmlPptxRenderer.cs @@ -1152,14 +1152,14 @@ private static TextStyle ScaleCodeStyleForTemplate(TextStyle baseCode, SlideLayo private static bool PlaceholderMatches(P.PlaceholderShape placeholderShape, TemplatePlaceholder placeholder) { - if (placeholder.Index is not null && placeholderShape.Index?.Value != placeholder.Index) + if (placeholder.Type is { } type) { - return false; + return placeholder.Index is null + ? placeholderShape.Type?.Value == type + : placeholderShape.Type?.Value == type && placeholderShape.Index?.Value == placeholder.Index; } - return placeholder.Type is { } type - ? placeholderShape.Type?.Value == type - : placeholder.Index is not null && placeholderShape.Index?.Value == placeholder.Index; + return placeholder.Index is not null && placeholderShape.Index?.Value == placeholder.Index; } ///