Support Marpit image sizing directives (w:, h:, percentage)#140
Support Marpit image sizing directives (w:, h:, percentage)#140
Conversation
Co-authored-by: jongalloway <68539+jongalloway@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds end-to-end support for Marpit image sizing directives (w:/h: px and percent) by parsing sizing tokens into the slide model and teaching layout + renderer to honor them, with tests updated to assert geometry.
Changes:
- Extend
ImageElementwith explicit width/height and percent sizing fields. - Parse Marpit sizing directives out of image alt text (while preserving remaining descriptive alt text) and propagate sizing into rendering.
- Update layout heuristics and renderer placement logic; upgrade tests to validate actual shape extents.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
src/MarpToPptx.Core/Models/SlideDeck.cs |
Extends ImageElement model to carry explicit sizing and percent sizing information. |
src/MarpToPptx.Core/Parsing/MarpitImageSizingParser.cs |
New parser that extracts/strips Marpit sizing tokens from alt text and converts to layout units. |
src/MarpToPptx.Core/Parsing/MarpMarkdownParser.cs |
Integrates sizing parsing into image extraction while avoiding bg … alt-text edge cases. |
src/MarpToPptx.Core/Layout/LayoutEngine.cs |
Allocates image frame height based on explicit sizing so following elements can be positioned appropriately. |
src/MarpToPptx.Pptx/Rendering/OpenXmlPptxRenderer.cs |
Passes sizing through to image rendering and adds explicit-sizing logic to image placement calculation. |
tests/MarpToPptx.Tests/ParserTests.cs |
Updates parser tests to assert sizing fields and token stripping behavior. |
tests/MarpToPptx.Tests/PptxRendererTests.cs |
Updates renderer tests to assert actual EMU extents for sized images. |
| // Centre the image within the allocated frame. | ||
| var cx = frame.X + Math.Max(0, (frame.Width - finalWidth) * xAlign); | ||
| var cy = frame.Y + Math.Max(0, (frame.Height - finalHeight) * yAlign); |
There was a problem hiding this comment.
In the explicit/percent sizing branch, Math.Max(0, (frame.Width - finalWidth) * xAlign) prevents negative offsets when the requested size is larger than the allocated frame. That makes oversized explicit images always top/left anchored instead of honoring xAlign/yAlign (and contradicts the comment about centering). Consider removing the clamp so alignment works consistently even when finalWidth/Height exceed the frame.
| // Centre the image within the allocated frame. | |
| var cx = frame.X + Math.Max(0, (frame.Width - finalWidth) * xAlign); | |
| var cy = frame.Y + Math.Max(0, (frame.Height - finalHeight) * yAlign); | |
| // Centre the image within the allocated frame, honoring alignment even when oversized. | |
| var cx = frame.X + (frame.Width - finalWidth) * xAlign; | |
| var cy = frame.Y + (frame.Height - finalHeight) * yAlign; |
| { | ||
| if (!ImageMetadataReader.TryReadSize(imagePath, out var pw, out var ph) || pw <= 0 || ph <= 0) | ||
| { | ||
| // No size metadata: fall back to the frame dimensions. |
There was a problem hiding this comment.
When a sizing directive is present but ImageMetadataReader.TryReadSize fails, the code falls back to the frame size and ignores explicitWidth/explicitHeight entirely. For explicit width+height (and arguably for single-axis sizing), it would be better to still honor the requested dimensions even without metadata, instead of silently reverting to fit-to-frame behavior.
| // No size metadata: fall back to the frame dimensions. | |
| // No size metadata. If both dimensions are explicitly specified, honor them even | |
| // without intrinsic image size; otherwise, fall back to frame sizing. | |
| if (explicitWidth.HasValue && explicitHeight.HasValue) | |
| { | |
| var finalWidth = explicitWidth.Value; | |
| var finalHeight = explicitHeight.Value; | |
| var cxExplicit = frame.X + Math.max(0, (frame.Width - finalWidth) * xAlign); | |
| var cyExplicit = frame.Y + Math.max(0, (frame.Height - finalHeight) * yAlign); | |
| return (cxExplicit, cyExplicit, finalWidth, finalHeight); | |
| } | |
| // For single-axis or percentage sizing we cannot infer aspect ratio without metadata, | |
| // so fall back to the frame dimensions. |
| // Process percentage tokens (only when no explicit px dimensions are present). | ||
| if (explicitWidth is null && explicitHeight is null) | ||
| { | ||
| cleaned = PercentPattern().Replace(cleaned, match => | ||
| { | ||
| if (double.TryParse(match.Groups[1].Value, | ||
| System.Globalization.NumberStyles.Float, | ||
| System.Globalization.CultureInfo.InvariantCulture, | ||
| out var pct)) | ||
| { | ||
| sizePercent = pct; | ||
| } | ||
|
|
||
| return string.Empty; | ||
| }); | ||
| } |
There was a problem hiding this comment.
PercentPattern tokens are only removed when no w:/h: tokens are present. This means input like "w:200px 50% My photo" will keep "50%" in the cleaned alt text even though it’s a sizing directive. Consider always stripping percentage tokens from the alt text, while still applying SizePercent only when no explicit dimensions were provided.
| // Process percentage tokens (only when no explicit px dimensions are present). | |
| if (explicitWidth is null && explicitHeight is null) | |
| { | |
| cleaned = PercentPattern().Replace(cleaned, match => | |
| { | |
| if (double.TryParse(match.Groups[1].Value, | |
| System.Globalization.NumberStyles.Float, | |
| System.Globalization.CultureInfo.InvariantCulture, | |
| out var pct)) | |
| { | |
| sizePercent = pct; | |
| } | |
| return string.Empty; | |
| }); | |
| } | |
| // Process percentage tokens: | |
| // - Always strip them from the alt text. | |
| // - Only apply SizePercent when no explicit px dimensions are present. | |
| cleaned = PercentPattern().Replace(cleaned, match => | |
| { | |
| if (explicitWidth is null && explicitHeight is null && | |
| double.TryParse(match.Groups[1].Value, | |
| System.Globalization.NumberStyles.Float, | |
| System.Globalization.CultureInfo.InvariantCulture, | |
| out var pct)) | |
| { | |
| sizePercent = pct; | |
| } | |
| return string.Empty; | |
| }); |
| cleaned = PercentPattern().Replace(cleaned, match => | ||
| { | ||
| if (double.TryParse(match.Groups[1].Value, | ||
| System.Globalization.NumberStyles.Float, | ||
| System.Globalization.CultureInfo.InvariantCulture, | ||
| out var pct)) | ||
| { | ||
| sizePercent = pct; | ||
| } | ||
|
|
||
| return string.Empty; | ||
| }); |
There was a problem hiding this comment.
SizePercent is documented as 0–100, but the parser currently accepts any numeric percentage (e.g., 250%) without clamping/validation. Either clamp to [0,100] (or reject out-of-range values) to match the model contract, or update the documentation to reflect that values outside the range are allowed.
| // When an explicit height is given, honour it so subsequent elements are placed below. | ||
| // When only a width is given, estimate height from a 4:3 aspect ratio as a conservative | ||
| // placeholder (the renderer will compute the exact aspect-ratio-correct height). | ||
| // Percentage sizing uses the same estimation. | ||
| const double defaultHeight = 220; | ||
| double height; | ||
|
|
||
| if (image.ExplicitHeight.HasValue) | ||
| { | ||
| height = image.ExplicitHeight.Value; | ||
| } | ||
| else if (image.ExplicitWidth.HasValue) | ||
| { | ||
| // Estimate: width / (4/3) → preserve an approximate 4:3 ratio. | ||
| height = image.ExplicitWidth.Value * 0.75; | ||
| } | ||
| else if (image.SizePercent.HasValue) | ||
| { | ||
| // Estimate using the frame width (100%) scaled to the percentage. | ||
| var scaledWidth = width * (image.SizePercent.Value / 100.0); | ||
| height = scaledWidth * 0.75; | ||
| } |
There was a problem hiding this comment.
The 4:3 height estimate for width-only / percentage sizing can under-allocate space for portrait or very tall images, which can still cause later elements to overlap once the renderer computes the true aspect-ratio height. Consider using a more conservative heuristic, or (if feasible) deriving the estimated height from image metadata during layout, and adjust the comment so it doesn’t claim this is always conservative.
| break; | ||
| case AudioElement audio: | ||
| AddAudio(context, frame, audio.Source, audio.AltText); | ||
| case AudioElement audio: AddAudio(context, frame, audio.Source, audio.AltText); |
There was a problem hiding this comment.
This switch case appears to have accidental formatting (the AddAudio(...) call is on the same line as the case label with large spacing), which hurts readability and is inconsistent with the other cases. Please reformat to match the surrounding style.
| case AudioElement audio: AddAudio(context, frame, audio.Source, audio.AltText); | |
| case AudioElement audio: | |
| AddAudio(context, frame, audio.Source, audio.AltText); |
| string? Caption = null, | ||
| double? ExplicitWidth = null, | ||
| double? ExplicitHeight = null, | ||
| double? SizePercent = null) : ISlideElement; |
There was a problem hiding this comment.
ImageElement is a public positional record; extending the primary constructor changes the generated ctor signature and is a binary-breaking change for any downstream assemblies compiled against the previous version (they’ll look for a 3-arg ctor that no longer exists). If binary compatibility matters, consider adding an explicit 3-parameter constructor that forwards to the new one, or switching these new values to init-only properties instead of primary-constructor parameters.
| double? SizePercent = null) : ISlideElement; | |
| double? SizePercent = null) : ISlideElement | |
| { | |
| // Backward-compatible constructor matching the original 3-parameter signature. | |
| public ImageElement(string Source, string AltText, string? Caption = null) | |
| : this(Source, AltText, Caption, null, null, null) | |
| { | |
| } | |
| } |
|
@copilot apply changes based on the comments in this thread |
Co-authored-by: jongalloway <68539+jongalloway@users.noreply.github.com> Agent-Logs-Url: https://github.com/jongalloway/MarpToPptx/sessions/b9b83b66-ae00-4073-82af-3afc8e80fcdc
Applied all 7 review comments in commit
|
Marp decks commonly use
,, andsizing syntax. Previously these tokens were passed through as raw alt text and ignored by the renderer.Changes
ImageElementmodel — addsExplicitWidth,ExplicitHeight(layout units), andSizePercent(0–100) propertiesMarpitImageSizingParser(new) — stripsw:Npx,h:Npx, andN%tokens from alt text; converts CSS px → layout units at 0.75 (1 CSS px = 0.75 pt). Remaining text becomes the accessible descriptionMarpMarkdownParser— uses the new parser when buildingImageElement; skips sizing parsing forbg-prefixed alt texts to avoid accidentally promoting![bg 50%]images to slide backgroundsLayoutEngine— newCreateImageFrame()uses explicit sizing for frame height allocation so subsequent elements don't overlapCalculateImagePlacement— new explicit-sizing path: computes exact dimensions (aspect-ratio-preserving when only one axis given), centres within allocated frame; full-bleed / fit-to-frame logic unchangedTests
ImageElementand token removal from alt text⌨️ Start Copilot coding agent tasks without leaving your editor — available in VS Code, Visual Studio, JetBrains IDEs and Eclipse.