Skip to content

Commit 4b69409

Browse files
authored
Merge pull request #165 from jongalloway/copilot/support-marpit-image-alignment
Support Marpit image alignment keywords and split-background layout
2 parents 1672f87 + 53152b5 commit 4b69409

9 files changed

Lines changed: 211 additions & 51 deletions

File tree

doc/marp-markdown.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ CLI options, theme CSS loading, template usage, and PPTX rendering behavior. The
4949
| Marpit | Spot directives with `_` prefix | Apply a local directive to one slide only | Supported | All recognised directive keys work with a `_` prefix (e.g. `_class`, `_paginate`, `_backgroundColor`, `_color`). Spot directives apply to the current slide only and do not carry forward to subsequent slides. |
5050
| Marpit | `headingDivider` | Split slides before headings automatically | Supported subset | Parsed from front matter as an integer 1–6; slides are split before headings at or above that level. |
5151
| Marpit | Header / footer directives | Per-slide repeated content | Supported | `header` and `footer` string values are stored in `SlideStyle` and emitted into PPTX text shapes on each slide. |
52-
| Marpit | Extended image syntax | Width, height, filters, `bg`, split backgrounds | Partially supported | Normal Markdown images are parsed; `![bg](url)` is promoted to a slide background. Other Marpit image keywords (width, height, percentage, split) are treated as alt text with no effect on sizing or layout. |
53-
| Marpit | Background image syntax via image alt text | `![bg](...)` and related | Supported subset | `![bg](url)` sets the slide background image. Modifiers in the alt text (`bg cover`, `bg contain`, `bg left`, `bg right`, percentage sizing) are not yet parsed; the image is always rendered full-bleed. A `backgroundImage` directive on the same slide takes precedence over `![bg](...)`. See [Background Image Precedence](#background-image-precedence) below. |
52+
| Marpit | Extended image syntax | Width, height, filters, `bg`, split backgrounds | Partially supported | Normal Markdown images are parsed; `![bg](url)` is promoted to a slide background. Marpit width/height/percentage sizing keywords (`w:`, `h:`, `N%`) are parsed and applied. Alignment keywords `left`/`right` are parsed and affect image placement within the content frame. Split backgrounds (`![bg left]`, `![bg right]`) are supported and render each image in its respective slide half. Other modifiers (filters, `bg cover`, `bg contain`, percentage-sized backgrounds) are not yet implemented. |
53+
| Marpit | Background image syntax via image alt text | `![bg](...)` and related | Supported subset | `![bg](url)` sets the slide background image (always full-bleed). `![bg left](url)` and `![bg right](url)` render the image in the left or right half of the slide respectively (split background layout). A `backgroundImage` directive on the same slide takes precedence over plain `![bg](...)`. Other modifiers (`bg cover`, `bg contain`, percentage sizing in the alt text) are not yet parsed. See [Background Image Precedence](#background-image-precedence) below. |
5454
| Marpit | Fragmented lists | Incremental list reveal | Not supported | No fragment model in parser or renderer. |
5555
| Marpit | Theme CSS | CSS-driven slide theming | Supported subset | CSS extraction for fonts, sizes, colors, padding, background, line-height, letter-spacing, text-transform, font-weight, and code style. See the CSS property reference below. |
5656
| Marp Core | Built-in theme names | Themes such as `default`, `gaia`, `uncover` | Name-only unless CSS is supplied | Theme name is stored, but real styling comes from parsed CSS or defaults. |

src/MarpToPptx.Core/Authoring/SlideIdentityGenerator.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,14 @@ public static string ComputeSlideContentHash(Slide slideModel)
146146
{
147147
sb.Append("\x01smartArt\x01").Append(smartArt);
148148
}
149+
if (slideModel.Style.SplitBackgroundLeft is { } splitLeft)
150+
{
151+
sb.Append("\x01splitBgLeft\x01").Append(splitLeft);
152+
}
153+
if (slideModel.Style.SplitBackgroundRight is { } splitRight)
154+
{
155+
sb.Append("\x01splitBgRight\x01").Append(splitRight);
156+
}
149157

150158
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString()));
151159
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();

src/MarpToPptx.Core/Models/SlideDeck.cs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,18 @@ public sealed class SlideStyle
105105
/// </summary>
106106
public string? SmartArtHint { get; init; }
107107

108+
/// <summary>
109+
/// Source path for the left-half split background image, promoted from a <c>![bg left](url)</c> syntax image.
110+
/// When set, the image is rendered in the left half of the slide as a full-bleed background.
111+
/// </summary>
112+
public string? SplitBackgroundLeft { get; init; }
113+
114+
/// <summary>
115+
/// Source path for the right-half split background image, promoted from a <c>![bg right](url)</c> syntax image.
116+
/// When set, the image is rendered in the right half of the slide as a full-bleed background.
117+
/// </summary>
118+
public string? SplitBackgroundRight { get; init; }
119+
108120
public Dictionary<string, string> Directives { get; } = new(StringComparer.OrdinalIgnoreCase);
109121
}
110122

@@ -179,19 +191,26 @@ public sealed record BulletListItem(IReadOnlyList<InlineSpan> Spans, int Depth =
179191
/// image width to this percentage of the full slide width; height is preserved from aspect ratio.
180192
/// Ignored when <see cref="ExplicitWidth"/> or <see cref="ExplicitHeight"/> is set.
181193
/// </param>
194+
/// <param name="Alignment">
195+
/// Horizontal alignment hint parsed from a Marpit alignment keyword in the alt text
196+
/// (e.g. <c>![left](img.png)</c> or <c>![right](img.png)</c>).
197+
/// Accepted values are <c>"left"</c>, <c>"right"</c>, or <c>null</c> for the default centered placement.
198+
/// The keyword is stripped from the alt text when recognised.
199+
/// </param>
182200
public sealed record ImageElement(
183201
string Source,
184202
string AltText,
185203
string? Caption = null,
186204
double? ExplicitWidth = null,
187205
double? ExplicitHeight = null,
188-
double? SizePercent = null) : ISlideElement
206+
double? SizePercent = null,
207+
string? Alignment = null) : ISlideElement
189208
{
190209
/// <summary>
191210
/// Backward-compatible constructor matching the original 3-parameter signature.
192211
/// </summary>
193212
public ImageElement(string Source, string AltText, string? Caption = null)
194-
: this(Source, AltText, Caption, null, null, null)
213+
: this(Source, AltText, Caption, null, null, null, null)
195214
{
196215
}
197216
}

src/MarpToPptx.Core/Parsing/MarpDirectiveParser.cs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ public static SlideStyle ApplyDirective(SlideStyle style, string key, string val
1111
return ApplyKnownDirective(updatedStyle, key, value);
1212
}
1313

14+
/// <summary>
15+
/// Returns a clone of <paramref name="style"/> with the split background image properties
16+
/// set to the provided values. A <see langword="null"/> argument for either side preserves
17+
/// the existing value from <paramref name="style"/>.
18+
/// </summary>
19+
internal static SlideStyle CloneWithSplitBackground(SlideStyle style, string? splitBackgroundLeft = null, string? splitBackgroundRight = null)
20+
=> Clone(style, splitBackgroundLeft: splitBackgroundLeft, splitBackgroundRight: splitBackgroundRight);
21+
1422
/// <summary>
1523
/// Parses HTML-comment directives from a single slide chunk.
1624
/// Returns the effective style (local + spot directives applied),
@@ -202,7 +210,9 @@ private static SlideStyle Clone(
202210
SlideTransition? transition = null,
203211
int? fontSize = null,
204212
string? shrinkToFit = null,
205-
string? smartArtHint = null)
213+
string? smartArtHint = null,
214+
string? splitBackgroundLeft = null,
215+
string? splitBackgroundRight = null)
206216
{
207217
var clone = new SlideStyle
208218
{
@@ -222,6 +232,8 @@ private static SlideStyle Clone(
222232
FontSize = fontSize ?? source.FontSize,
223233
ShrinkToFit = shrinkToFit ?? source.ShrinkToFit,
224234
SmartArtHint = smartArtHint ?? source.SmartArtHint,
235+
SplitBackgroundLeft = splitBackgroundLeft ?? source.SplitBackgroundLeft,
236+
SplitBackgroundRight = splitBackgroundRight ?? source.SplitBackgroundRight,
225237
};
226238

227239
foreach (var pair in source.Directives)

src/MarpToPptx.Core/Parsing/MarpMarkdownParser.cs

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -116,18 +116,35 @@ public SlideDeck Parse(string markdown, string? sourcePath = null, string? theme
116116

117117
var allElements = ParseElements(cleaned);
118118

119-
// Promote any ![bg](url) images to slide background image.
120-
// Only exact "bg" alt text (case-insensitive) is recognized in this slice.
121-
// A directive-specified backgroundImage always takes precedence: if a directive
122-
// (including an empty-value clear) has set BackgroundImage, bg syntax is ignored.
119+
// Promote any ![bg](url) images to slide background.
120+
// Supported forms:
121+
// ![bg](url) — full-slide background (sets BackgroundImage)
122+
// ![bg left](url) — left-half split background (sets SplitBackgroundLeft)
123+
// ![bg right](url) — right-half split background (sets SplitBackgroundRight)
124+
// A directive-specified backgroundImage always takes precedence for the plain bg form:
125+
// if a directive (including an empty-value clear) has set BackgroundImage, plain bg
126+
// syntax is ignored. Split-background images are always promoted regardless.
123127
var bgImages = allElements
124128
.OfType<ImageElement>()
125129
.Where(img => IsBgAltText(img.AltText))
126130
.ToList();
127131

128-
if (bgImages.Count > 0 && effectiveStyle.BackgroundImage is null)
132+
foreach (var bgImg in bgImages)
129133
{
130-
effectiveStyle = MarpDirectiveParser.ApplyDirective(effectiveStyle, "backgroundimage", bgImages[0].Source);
134+
var trimmedAlt = bgImg.AltText.Trim();
135+
if (IsBgLeftAltText(trimmedAlt))
136+
{
137+
effectiveStyle = MarpDirectiveParser.CloneWithSplitBackground(effectiveStyle, splitBackgroundLeft: bgImg.Source);
138+
}
139+
else if (IsBgRightAltText(trimmedAlt))
140+
{
141+
effectiveStyle = MarpDirectiveParser.CloneWithSplitBackground(effectiveStyle, splitBackgroundRight: bgImg.Source);
142+
}
143+
else if (effectiveStyle.BackgroundImage is null)
144+
{
145+
// Plain ![bg](url) — only the first image is promoted; directive takes precedence.
146+
effectiveStyle = MarpDirectiveParser.ApplyDirective(effectiveStyle, "backgroundimage", bgImg.Source);
147+
}
131148
}
132149

133150
var slide = new Slide { Style = effectiveStyle, Notes = notes, NoteSpans = ParseNoteSpans(notes) };
@@ -543,8 +560,8 @@ private static IEnumerable<ISlideElement> ExtractMediaElements(ContainerInline?
543560

544561
if (!isBgAlt)
545562
{
546-
var (explicitWidth, explicitHeight, sizePercent, cleanAltText) = MarpitImageSizingParser.Parse(altText);
547-
yield return new ImageElement(url, cleanAltText, caption, explicitWidth, explicitHeight, sizePercent);
563+
var (explicitWidth, explicitHeight, sizePercent, alignment, cleanAltText) = MarpitImageSizingParser.Parse(altText);
564+
yield return new ImageElement(url, cleanAltText, caption, explicitWidth, explicitHeight, sizePercent, alignment);
548565
}
549566
else
550567
{
@@ -704,9 +721,29 @@ private static string ExtractInlineText(ContainerInline? inline)
704721
}
705722

706723
/// <summary>
707-
/// Returns <see langword="true"/> when <paramref name="altText"/> is exactly <c>bg</c>
708-
/// (case-insensitive), indicating a Marpit background image marker.
724+
/// Returns <see langword="true"/> when <paramref name="altText"/> is exactly <c>bg</c>,
725+
/// <c>bg left</c>, or <c>bg right</c> (case-insensitive), indicating a Marpit background
726+
/// image marker (full-slide, left-half, or right-half respectively).
727+
/// </summary>
728+
private static bool IsBgAltText(string altText)
729+
{
730+
var trimmed = altText.Trim();
731+
return string.Equals(trimmed, "bg", StringComparison.OrdinalIgnoreCase)
732+
|| IsBgLeftAltText(trimmed)
733+
|| IsBgRightAltText(trimmed);
734+
}
735+
736+
/// <summary>
737+
/// Returns <see langword="true"/> when <paramref name="trimmedAlt"/> is <c>bg left</c>
738+
/// (case-insensitive), indicating a Marpit left-half split background image.
739+
/// </summary>
740+
private static bool IsBgLeftAltText(string trimmedAlt) =>
741+
string.Equals(trimmedAlt, "bg left", StringComparison.OrdinalIgnoreCase);
742+
743+
/// <summary>
744+
/// Returns <see langword="true"/> when <paramref name="trimmedAlt"/> is <c>bg right</c>
745+
/// (case-insensitive), indicating a Marpit right-half split background image.
709746
/// </summary>
710-
private static bool IsBgAltText(string altText) =>
711-
string.Equals(altText.Trim(), "bg", StringComparison.OrdinalIgnoreCase);
747+
private static bool IsBgRightAltText(string trimmedAlt) =>
748+
string.Equals(trimmedAlt, "bg right", StringComparison.OrdinalIgnoreCase);
712749
}

src/MarpToPptx.Core/Parsing/MarpitImageSizingParser.cs

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ namespace MarpToPptx.Core.Parsing;
1111
/// <item><c>w:200px</c> — explicit width in CSS pixels</item>
1212
/// <item><c>h:150px</c> — explicit height in CSS pixels</item>
1313
/// <item><c>50%</c> — percentage of slide width</item>
14+
/// <item><c>left</c> — align image to the left of its frame</item>
15+
/// <item><c>right</c> — align image to the right of its frame</item>
1416
/// </list>
1517
/// Sizing tokens are stripped from the alt text; any remaining text becomes the
1618
/// accessible description. Units other than <c>px</c> are not currently supported
@@ -30,9 +32,13 @@ internal static partial class MarpitImageSizingParser
3032
[GeneratedRegex(@"(?<!\S)(\d+(?:\.\d+)?)%(?!\S)")]
3133
private static partial Regex PercentPattern();
3234

35+
// Matches standalone "left" or "right" alignment keywords (case-insensitive, whole word).
36+
[GeneratedRegex(@"(?<!\S)(left|right)(?!\S)", RegexOptions.IgnoreCase)]
37+
private static partial Regex AlignmentPattern();
38+
3339
/// <summary>
34-
/// Parses Marpit image sizing tokens from <paramref name="altText"/> and returns the
35-
/// parsed sizing values along with the alt text with sizing tokens removed.
40+
/// Parses Marpit image sizing and alignment tokens from <paramref name="altText"/> and returns the
41+
/// parsed values along with the alt text with recognised tokens removed.
3642
/// </summary>
3743
/// <param name="altText">Raw alt-text string from the Markdown image (e.g. <c>"w:200px My photo"</c>).</param>
3844
/// <returns>
@@ -41,19 +47,21 @@ internal static partial class MarpitImageSizingParser
4147
/// <item><c>ExplicitWidth</c> — layout units, or <c>null</c> if not specified.</item>
4248
/// <item><c>ExplicitHeight</c> — layout units, or <c>null</c> if not specified.</item>
4349
/// <item><c>SizePercent</c> — 0–100 value, or <c>null</c> if not specified.</item>
44-
/// <item><c>CleanAltText</c> — alt text with all recognised sizing tokens removed.</item>
50+
/// <item><c>Alignment</c> — <c>"left"</c>, <c>"right"</c>, or <c>null</c> if not specified.</item>
51+
/// <item><c>CleanAltText</c> — alt text with all recognised tokens removed.</item>
4552
/// </list>
4653
/// </returns>
47-
public static (double? ExplicitWidth, double? ExplicitHeight, double? SizePercent, string CleanAltText) Parse(string altText)
54+
public static (double? ExplicitWidth, double? ExplicitHeight, double? SizePercent, string? Alignment, string CleanAltText) Parse(string altText)
4855
{
4956
if (string.IsNullOrWhiteSpace(altText))
5057
{
51-
return (null, null, null, altText ?? string.Empty);
58+
return (null, null, null, null, altText ?? string.Empty);
5259
}
5360

5461
double? explicitWidth = null;
5562
double? explicitHeight = null;
5663
double? sizePercent = null;
64+
string? alignment = null;
5765

5866
var cleaned = altText;
5967

@@ -97,9 +105,20 @@ public static (double? ExplicitWidth, double? ExplicitHeight, double? SizePercen
97105
return string.Empty;
98106
});
99107

108+
// Process alignment keywords (left/right): strip and record the first one found.
109+
cleaned = AlignmentPattern().Replace(cleaned, match =>
110+
{
111+
if (alignment is null)
112+
{
113+
alignment = match.Groups[1].Value.ToLowerInvariant();
114+
}
115+
116+
return string.Empty;
117+
});
118+
100119
// Normalise whitespace left by removed tokens.
101120
cleaned = string.Join(' ', cleaned.Split(' ', StringSplitOptions.RemoveEmptyEntries));
102121

103-
return (explicitWidth, explicitHeight, sizePercent, cleaned);
122+
return (explicitWidth, explicitHeight, sizePercent, alignment, cleaned);
104123
}
105124
}

src/MarpToPptx.Pptx/Rendering/OpenXmlPptxRenderer.cs

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -869,8 +869,14 @@ titleRect is not null
869869
}
870870
break;
871871
case ImageElement image:
872+
var imageXAlign = image.Alignment switch
873+
{
874+
"left" => 0.0,
875+
"right" => 1.0,
876+
_ => 0.5,
877+
};
872878
AddImage(context, frame, image.Source, image.AltText, image.Caption,
873-
explicitWidth: image.ExplicitWidth, explicitHeight: image.ExplicitHeight, sizePercent: image.SizePercent);
879+
xAlign: imageXAlign, explicitWidth: image.ExplicitWidth, explicitHeight: image.ExplicitHeight, sizePercent: image.SizePercent);
874880
break;
875881
case VideoElement video:
876882
AddVideo(context, frame, video.Source, video.AltText);
@@ -1314,8 +1320,14 @@ element is HeadingElement h &&
13141320
}
13151321
break;
13161322
case ImageElement image:
1323+
var residualXAlign = image.Alignment switch
1324+
{
1325+
"left" => 0.0,
1326+
"right" => 1.0,
1327+
_ => 0.5,
1328+
};
13171329
AddImage(context, frame, image.Source, image.AltText, image.Caption,
1318-
explicitWidth: image.ExplicitWidth, explicitHeight: image.ExplicitHeight, sizePercent: image.SizePercent);
1330+
xAlign: residualXAlign, explicitWidth: image.ExplicitWidth, explicitHeight: image.ExplicitHeight, sizePercent: image.SizePercent);
13191331
break;
13201332
case VideoElement video:
13211333
AddVideo(context, frame, video.Source, video.AltText);
@@ -2300,6 +2312,22 @@ private static void AddBackground(SlideStyle style, SlideRenderContext context)
23002312
var (xAlign, yAlign) = ParseBackgroundPosition(backgroundPosition);
23012313
AddImage(context, new Rect(0, 0, SlideWidthEmu / LayoutScale, SlideHeightEmu / LayoutScale), backgroundImage, string.Empty, useFullBleed: useFullBleed, xAlign: xAlign, yAlign: yAlign);
23022314
}
2315+
2316+
// Render split background images (from ![bg left] / ![bg right] syntax).
2317+
// Each image is placed in the corresponding half of the slide as a full-bleed background.
2318+
var slideWidth = SlideWidthEmu / LayoutScale;
2319+
var slideHeight = SlideHeightEmu / LayoutScale;
2320+
var halfWidth = slideWidth / 2.0;
2321+
2322+
if (!string.IsNullOrWhiteSpace(style.SplitBackgroundLeft))
2323+
{
2324+
AddImage(context, new Rect(0, 0, halfWidth, slideHeight), style.SplitBackgroundLeft, string.Empty, useFullBleed: true);
2325+
}
2326+
2327+
if (!string.IsNullOrWhiteSpace(style.SplitBackgroundRight))
2328+
{
2329+
AddImage(context, new Rect(halfWidth, 0, halfWidth, slideHeight), style.SplitBackgroundRight, string.Empty, useFullBleed: true);
2330+
}
23032331
}
23042332

23052333
private static void AddTextShape(SlideRenderContext context, Rect frame, string text, TextStyle style, bool isTitle = false)

0 commit comments

Comments
 (0)