diff --git a/.squad/agents/cheritto/history.md b/.squad/agents/cheritto/history.md
index 0e2ce3b..9399720 100644
--- a/.squad/agents/cheritto/history.md
+++ b/.squad/agents/cheritto/history.md
@@ -154,3 +154,15 @@
- **Files:** PresentationService.Deduplication.cs (new partial), PptxTools.Deduplication.cs, DeduplicateMediaResult.cs, DeduplicateMediaTests.cs
- **Build:** 0 errors; 542/542 tests passing (10 new tests)
- **PR:** on branch squad/84-deduplicate-media
+
+### Issue #85 — pptx_optimize_images (2026-03-26)
+- **Implementation:** Phase 4 Tier 2 write operation — compresses/optimizes images by downscaling, format conversion, and recompression
+- **Dependency:** Magick.NET-Q8-AnyCPU v14.2.0 (cross-platform ImageMagick wrapper, Apache 2.0 license per Nate's research)
+- **Key logic:** Read image dimensions with MagickImageInfo → find Picture shape via Blip.Embed → extract display dimensions from Transform2D.Extents → calculate target dimensions based on targetDpi (EMU → pixels: emu / 914400 * dpi) → downscale if pixel dimensions exceed display dimensions → convert BMP/TIFF to PNG/JPEG → recompress JPEG at specified quality → only replace if optimized size < original
+- **Type challenges:** MagickImageInfo returns uint for Width/Height; MagickImage.Resize() requires uint; MagickImage.Width/Height properties are uint; model uses int — required explicit casts throughout
+- **Namespace aliasing:** Used `P = DocumentFormat.OpenXml.Presentation` and `A = DocumentFormat.OpenXml.Drawing` to resolve ambiguous Picture/BlipFill references
+- **Models:** ImageOptimizationResult with OptimizedImageInfo; reuses ValidationStatus from RemoveLayoutsResult.cs
+- **Files:** PresentationService.ImageOptimization.cs (new partial), PptxTools.Optimization.cs (added tool method), ImageOptimizationResult.cs
+- **Build:** 0 errors; 542/542 tests passing (no new tests yet — Shiherlis owns test creation)
+- **PR:** #93 on branch squad/85-optimize-images
+
diff --git a/.squad/agents/nate/history.md b/.squad/agents/nate/history.md
index 9d778e4..3907596 100644
--- a/.squad/agents/nate/history.md
+++ b/.squad/agents/nate/history.md
@@ -250,3 +250,33 @@
**Deliverable:** `.squad/decisions/inbox/nate-phase4-openxml-research.md` — 40 KB comprehensive research with code sketches, gotchas, validation strategies for all 7 issues.
**Impact:** Unblocks Phase 4 implementation planning. Team can now scope work with confidence: #80–#82 are quick wins; #83–#84 require diligent testing; #85 is optional enhancement; #86 safely deferred.
+
+### 2026-03-26: Magick.NET Feasibility Research — Issue #85 (Image Compression)
+
+**Research Scope:** Jon directive to investigate Magick.NET (instead of SkiaSharp) for issue #85 image compression/optimization tool. Evaluate feasibility, cross-platform support, bundle size, API surface, and integration with PresentationService.
+
+**Key Findings:**
+- **Verdict: GO** ✅ — Magick.NET is fully viable for #85. Covers all requirements (resize, re-encode JPEG quality 85%, convert BMP/TIFF→PNG/JPEG, read dimensions, stream-based I/O)
+- **Licensing:** Apache-2.0 (permissive, commercial-friendly, OSI-approved)
+- **Cross-Platform:** Full Linux support on ubuntu-latest; bundles static-linked native binaries (no separate ImageMagick install needed)
+- **Recommended Package:** `Magick.NET-Q8-x64` v14.11.0+ (Q8=8-bit component, x64=platform-specific, reduces bundle size)
+- **Bundle Size Impact:** ~15-18 MB added to published binary (AnyCPU variant would be ~27-35 MB). Acceptable for open-source MCP server.
+- **API Strength:** Clean Magick.NET surface for all operations:
+ - Dimensions: `MagickImageInfo.Width`, `MagickImageInfo.Height`
+ - Resize: `image.Resize(width, height)` with aspect ratio control
+ - JPEG Quality: `image.Quality = 85` before `image.Write()`
+ - Format Conversion: `image.Format = MagickFormat.Jpeg` / `MagickFormat.Png`
+ - I/O: Stream-based (`new MagickImage(stream)`, `image.Write(outputStream)`)
+- **Integration Pattern:** Create `PresentationService.ImageOptimization.cs` with public `OptimizeImages()` tool method; thin wrapper around Magick.NET with in-place image replacement via `imagePart.FeedData(stream)`
+- **Magick.NET vs. SkiaSharp:** Magick.NET slower for simple resize (SkiaSharp 2-4x faster) but superior for image compression workflow: richer format support (100+ formats), JPEG quality control, BMP/TIFF handling. Bundle size tradeoff acceptable; performance not a bottleneck for one-time optimization.
+- **Risks:** Native library compatibility on Linux (mitigated by .csproj properties for binary bundling), aspect ratio preservation (use `Resize(width, 0)` for auto-height), JPEG quality trade-offs (document as expected for compression)
+- **Implementation Estimate:** 6-8 hours (dependency setup, tool method, E2E test, README update)
+
+**Deliverable:** `.squad/decisions/inbox/nate-magick-net-research.md` — 10 KB feasibility report with API sketches, integration pattern, bundle size breakdown, comparison table vs. SkiaSharp, gotchas, and handoff recommendations for Cheritto.
+
+**Impact:** Unblocks #85 decision. Go/no-go clear; Jon's Magick.NET preference validated. Ready for development phase with high confidence in approach.
+
+**File Paths:**
+- `src/PptxMcp/Services/PresentationService.Media.cs` (lines 104–145) — current image traversal pattern (Foundation for new ImageOptimization.cs)
+- NuGet: https://www.nuget.org/packages/Magick.NET-Q8-x64/
+- Magick.NET GitHub: https://github.com/dlemstra/Magick.NET (reference for API/cross-platform docs)
diff --git a/README.md b/README.md
index 3281b2e..85edadb 100644
--- a/README.md
+++ b/README.md
@@ -85,6 +85,7 @@ See [docs/QUICKSTART.md](docs/QUICKSTART.md) for a full walkthrough.
| `pptx_find_unused_layouts` | Find unused slide masters and layouts with estimated space savings |
| `pptx_remove_unused_layouts` | Remove unused slide layouts and orphaned masters with before/after validation |
| `pptx_deduplicate_media` | Deduplicate identical media by hash, redirect references, remove orphaned copies |
+| `pptx_optimize_images` | Compress/optimize images by downscaling, format conversion, and recompression |
**When to use `pptx_update_slide_data` vs `pptx_update_text`:** Use `pptx_update_slide_data` when shapes have descriptive names (check `pptx_get_slide_content`) — it targets shapes by name and preserves their existing formatting. Use `pptx_update_text` for anonymous placeholders identified only by index.
diff --git a/src/PptxMcp/Models/ImageOptimizationResult.cs b/src/PptxMcp/Models/ImageOptimizationResult.cs
new file mode 100644
index 0000000..ec6f008
--- /dev/null
+++ b/src/PptxMcp/Models/ImageOptimizationResult.cs
@@ -0,0 +1,49 @@
+namespace PptxMcp.Models;
+
+/// Details about a single optimized image.
+/// Package URI of the image part.
+/// Original image format (PNG, JPEG, BMP, TIFF, etc.).
+/// Format after optimization.
+/// Original image width in pixels.
+/// Original image height in pixels.
+/// Width after downscaling (same as original if not downscaled).
+/// Height after downscaling (same as original if not downscaled).
+/// Original image size in bytes.
+/// Size after optimization in bytes.
+/// Bytes saved by optimization.
+/// Description of action taken (downscaled, converted, recompressed, skipped).
+public record OptimizedImageInfo(
+ string ImagePath,
+ string OriginalFormat,
+ string OptimizedFormat,
+ int OriginalWidth,
+ int OriginalHeight,
+ int OptimizedWidth,
+ int OptimizedHeight,
+ long OriginalSizeBytes,
+ long OptimizedSizeBytes,
+ long BytesSaved,
+ string Action);
+
+/// Structured result for pptx_optimize_images.
+/// True when the operation completed without errors.
+/// Path to the modified presentation file.
+/// Number of images that were optimized.
+/// Number of images that were skipped (no optimization possible).
+/// Total size of all images before optimization.
+/// Total size of all images after optimization.
+/// Total bytes saved by optimization.
+/// Details for each optimized or skipped image.
+/// OpenXML validation status before and after.
+/// Human-readable status or error message.
+public record ImageOptimizationResult(
+ bool Success,
+ string FilePath,
+ int ImagesProcessed,
+ int ImagesSkipped,
+ long TotalBytesBefore,
+ long TotalBytesAfter,
+ long TotalBytesSaved,
+ IReadOnlyList OptimizedImages,
+ ValidationStatus Validation,
+ string Message);
diff --git a/src/PptxMcp/PptxMcp.csproj b/src/PptxMcp/PptxMcp.csproj
index c7fa976..1984558 100644
--- a/src/PptxMcp/PptxMcp.csproj
+++ b/src/PptxMcp/PptxMcp.csproj
@@ -24,6 +24,7 @@
+
diff --git a/src/PptxMcp/Services/PresentationService.ImageOptimization.cs b/src/PptxMcp/Services/PresentationService.ImageOptimization.cs
new file mode 100644
index 0000000..f6789e6
--- /dev/null
+++ b/src/PptxMcp/Services/PresentationService.ImageOptimization.cs
@@ -0,0 +1,316 @@
+using DocumentFormat.OpenXml.Packaging;
+using DocumentFormat.OpenXml.Validation;
+using ImageMagick;
+using PptxMcp.Models;
+using A = DocumentFormat.OpenXml.Drawing;
+using P = DocumentFormat.OpenXml.Presentation;
+
+namespace PptxMcp.Services;
+
+public partial class PresentationService
+{
+ ///
+ /// Optimize images in a PPTX file by downscaling, converting formats, and recompressing.
+ ///
+ /// Path to the PPTX file.
+ /// Target DPI for display (default 150 for screen).
+ /// JPEG quality 1-100 (default 85).
+ /// Convert BMP/TIFF to PNG/JPEG (default true).
+ public ImageOptimizationResult OptimizeImages(
+ string filePath,
+ int targetDpi = 150,
+ int jpegQuality = 85,
+ bool convertFormats = true)
+ {
+ using var doc = PresentationDocument.Open(filePath, true);
+ var presentationPart = doc.PresentationPart
+ ?? throw new InvalidOperationException("Presentation part is missing.");
+
+ var validator = new OpenXmlValidator();
+ int errorsBefore = validator.Validate(doc).Count();
+
+ var optimizedImages = new List();
+ long totalBytesBefore = 0;
+ long totalBytesAfter = 0;
+
+ // Collect all owner parts (slides, layouts, masters) and their images.
+ var allOwnerParts = CollectAllOwnerParts(presentationPart);
+
+ // Track which ImageParts we've already processed (same part may be shared).
+ var processedImageUris = new HashSet();
+
+ foreach (var ownerPart in allOwnerParts)
+ {
+ foreach (var idPartPair in ownerPart.Parts)
+ {
+ if (idPartPair.OpenXmlPart is not ImagePart imagePart)
+ continue;
+
+ var uri = imagePart.Uri.ToString();
+ if (!processedImageUris.Add(uri))
+ continue; // Already processed this image
+
+ var imageInfo = OptimizeImagePart(
+ ownerPart,
+ imagePart,
+ targetDpi,
+ jpegQuality,
+ convertFormats);
+
+ if (imageInfo is not null)
+ {
+ optimizedImages.Add(imageInfo);
+ totalBytesBefore += imageInfo.OriginalSizeBytes;
+ totalBytesAfter += imageInfo.OptimizedSizeBytes;
+ }
+ }
+ }
+
+ // Save and validate after modification.
+ presentationPart.Presentation.Save();
+ int errorsAfter = validator.Validate(doc).Count();
+
+ int imagesProcessed = optimizedImages.Count(i => i.BytesSaved > 0);
+ int imagesSkipped = optimizedImages.Count(i => i.BytesSaved == 0);
+ long totalBytesSaved = totalBytesBefore - totalBytesAfter;
+
+ string message = imagesProcessed > 0
+ ? $"Optimized {imagesProcessed} image(s), skipped {imagesSkipped}. Saved {totalBytesSaved:N0} bytes."
+ : "No images optimized.";
+
+ return new ImageOptimizationResult(
+ Success: true,
+ FilePath: filePath,
+ ImagesProcessed: imagesProcessed,
+ ImagesSkipped: imagesSkipped,
+ TotalBytesBefore: totalBytesBefore,
+ TotalBytesAfter: totalBytesAfter,
+ TotalBytesSaved: totalBytesSaved,
+ OptimizedImages: optimizedImages,
+ Validation: new ValidationStatus(errorsBefore, errorsAfter, errorsAfter == 0),
+ Message: message);
+ }
+
+ ///
+ /// Optimize a single image part based on display dimensions and target DPI.
+ /// Returns optimization details or null if the image should be skipped.
+ ///
+ private static OptimizedImageInfo? OptimizeImagePart(
+ OpenXmlPart ownerPart,
+ ImagePart imagePart,
+ int targetDpi,
+ int jpegQuality,
+ bool convertFormats)
+ {
+ var originalCopy = new MemoryStream();
+ using (var originalStream = imagePart.GetStream())
+ {
+ originalStream.CopyTo(originalCopy);
+ }
+ originalCopy.Position = 0;
+
+ long originalSize = originalCopy.Length;
+
+ // Read image metadata with lightweight MagickImageInfo.
+ MagickImageInfo info;
+ try
+ {
+ info = new MagickImageInfo(originalCopy);
+ originalCopy.Position = 0;
+ }
+ catch
+ {
+ // Corrupted or unsupported image format.
+ return null;
+ }
+
+ int originalWidth = (int)info.Width;
+ int originalHeight = (int)info.Height;
+ var originalFormat = info.Format.ToString();
+
+ // Find the display dimensions of this image on the slide.
+ var displaySize = GetImageDisplaySize(ownerPart, imagePart);
+
+ int targetWidth = originalWidth;
+ int targetHeight = originalHeight;
+ bool needsDownscaling = false;
+
+ if (displaySize is not null)
+ {
+ // Convert EMU to pixels at target DPI.
+ // 1 inch = 914400 EMU; pixels = emu / 914400 * dpi
+ double displayWidthPixels = displaySize.Value.Cx / 914400.0 * targetDpi;
+ double displayHeightPixels = displaySize.Value.Cy / 914400.0 * targetDpi;
+
+ // Downscale if image is significantly larger than display size.
+ if (originalWidth > displayWidthPixels * 1.1 || originalHeight > displayHeightPixels * 1.1)
+ {
+ needsDownscaling = true;
+ double aspectRatio = (double)originalWidth / originalHeight;
+ targetWidth = (int)Math.Ceiling(displayWidthPixels);
+ targetHeight = (int)Math.Ceiling(displayHeightPixels);
+
+ // Preserve aspect ratio.
+ if (targetWidth / (double)targetHeight > aspectRatio)
+ targetWidth = (int)Math.Ceiling(targetHeight * aspectRatio);
+ else
+ targetHeight = (int)Math.Ceiling(targetWidth / aspectRatio);
+ }
+ }
+
+ // Determine if format conversion is needed.
+ bool needsConversion = convertFormats &&
+ (info.Format == MagickFormat.Bmp ||
+ info.Format == MagickFormat.Tiff ||
+ info.Format == MagickFormat.Tiff64);
+
+ // Determine target format.
+ MagickFormat targetFormat = info.Format;
+ if (needsConversion)
+ {
+ // Convert BMP/TIFF to PNG for lossless, or JPEG for photos.
+ // Use PNG as default for safety.
+ targetFormat = MagickFormat.Png;
+ }
+
+ // Perform optimization if needed.
+ if (!needsDownscaling && !needsConversion && info.Format != MagickFormat.Jpeg)
+ {
+ // No optimization possible.
+ return new OptimizedImageInfo(
+ ImagePath: imagePart.Uri.ToString(),
+ OriginalFormat: originalFormat,
+ OptimizedFormat: originalFormat,
+ OriginalWidth: originalWidth,
+ OriginalHeight: originalHeight,
+ OptimizedWidth: originalWidth,
+ OptimizedHeight: originalHeight,
+ OriginalSizeBytes: originalSize,
+ OptimizedSizeBytes: originalSize,
+ BytesSaved: 0,
+ Action: "skipped");
+ }
+
+ using var image = new MagickImage(originalCopy);
+
+ bool modified = false;
+ var actions = new List();
+
+ // Downscale if needed.
+ if (needsDownscaling)
+ {
+ image.Resize((uint)targetWidth, (uint)targetHeight);
+ actions.Add("downscaled");
+ modified = true;
+ }
+
+ // Convert format if needed.
+ if (needsConversion)
+ {
+ image.Format = targetFormat;
+ actions.Add($"converted to {targetFormat}");
+ modified = true;
+ }
+
+ // Recompress JPEG.
+ if (image.Format == MagickFormat.Jpeg)
+ {
+ image.Quality = (uint)jpegQuality;
+ actions.Add("recompressed");
+ modified = true;
+ }
+
+ if (!modified)
+ {
+ return new OptimizedImageInfo(
+ ImagePath: imagePart.Uri.ToString(),
+ OriginalFormat: originalFormat,
+ OptimizedFormat: originalFormat,
+ OriginalWidth: originalWidth,
+ OriginalHeight: originalHeight,
+ OptimizedWidth: originalWidth,
+ OptimizedHeight: originalHeight,
+ OriginalSizeBytes: originalSize,
+ OptimizedSizeBytes: originalSize,
+ BytesSaved: 0,
+ Action: "skipped");
+ }
+
+ // Write optimized image to memory.
+ var optimizedStream = new MemoryStream();
+ image.Write(optimizedStream);
+ optimizedStream.Position = 0;
+
+ long optimizedSize = optimizedStream.Length;
+
+ // Only replace if the new image is smaller.
+ if (optimizedSize < originalSize)
+ {
+ imagePart.FeedData(optimizedStream);
+
+ return new OptimizedImageInfo(
+ ImagePath: imagePart.Uri.ToString(),
+ OriginalFormat: originalFormat,
+ OptimizedFormat: image.Format.ToString(),
+ OriginalWidth: originalWidth,
+ OriginalHeight: originalHeight,
+ OptimizedWidth: (int)image.Width,
+ OptimizedHeight: (int)image.Height,
+ OriginalSizeBytes: originalSize,
+ OptimizedSizeBytes: optimizedSize,
+ BytesSaved: originalSize - optimizedSize,
+ Action: string.Join(", ", actions));
+ }
+ else
+ {
+ // Optimized image is larger; skip replacement.
+ return new OptimizedImageInfo(
+ ImagePath: imagePart.Uri.ToString(),
+ OriginalFormat: originalFormat,
+ OptimizedFormat: originalFormat,
+ OriginalWidth: originalWidth,
+ OriginalHeight: originalHeight,
+ OptimizedWidth: originalWidth,
+ OptimizedHeight: originalHeight,
+ OriginalSizeBytes: originalSize,
+ OptimizedSizeBytes: originalSize,
+ BytesSaved: 0,
+ Action: "skipped (no savings)");
+ }
+ }
+
+ ///
+ /// Get the display dimensions (in EMU) of an image on a slide.
+ /// Returns null if the image is not directly displayed (e.g., embedded in layout/master).
+ ///
+ private static (long Cx, long Cy)? GetImageDisplaySize(OpenXmlPart ownerPart, ImagePart imagePart)
+ {
+ if (ownerPart is not SlidePart slidePart)
+ return null; // Only process images directly on slides.
+
+ var relId = FindRelationshipId(ownerPart, imagePart);
+ if (relId is null)
+ return null;
+
+ // Find the Picture shape that references this image via Blip.Embed.
+ var shapeTree = slidePart.Slide?.CommonSlideData?.ShapeTree;
+ if (shapeTree is null)
+ return null;
+
+ foreach (var picture in shapeTree.Elements())
+ {
+ var blip = picture.GetFirstChild()?.GetFirstChild();
+ if (blip?.Embed?.Value == relId)
+ {
+ // Found the picture shape; extract display dimensions.
+ var extents = picture.ShapeProperties?.Transform2D?.Extents;
+ if (extents?.Cx?.HasValue == true && extents.Cy?.HasValue == true)
+ {
+ return (extents.Cx.Value, extents.Cy.Value);
+ }
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/src/PptxMcp/Tools/PptxTools.Optimization.cs b/src/PptxMcp/Tools/PptxTools.Optimization.cs
index 4d4c6fb..6cd68cb 100644
--- a/src/PptxMcp/Tools/PptxTools.Optimization.cs
+++ b/src/PptxMcp/Tools/PptxTools.Optimization.cs
@@ -79,4 +79,34 @@ public partial Task pptx_remove_unused_layouts(string filePath, string[]
BytesSaved: 0,
Validation: new ValidationStatus(0, 0, false),
Message: error));
+
+ ///
+ /// Optimize images in a PowerPoint presentation by downscaling, converting formats, and recompressing.
+ /// Scans all images across slides, layouts, and masters. Downscales images that are larger than their
+ /// display dimensions warrant based on target DPI. Converts BMP/TIFF to PNG/JPEG. Recompresses JPEG images
+ /// at the specified quality level. Only replaces images when optimization results in smaller file size.
+ ///
+ /// Absolute or relative path to the .pptx file to modify.
+ /// Target DPI for screen display (default 150; use 300 for print).
+ /// JPEG compression quality 1-100 (default 85; higher = larger file).
+ /// Convert BMP/TIFF to PNG/JPEG (default true).
+ [McpServerTool(Title = "Optimize Images")]
+ public partial Task pptx_optimize_images(
+ string filePath,
+ int targetDpi = 150,
+ int jpegQuality = 85,
+ bool convertFormats = true) =>
+ ExecuteToolStructured(filePath,
+ () => _service.OptimizeImages(filePath, targetDpi, jpegQuality, convertFormats),
+ error => new ImageOptimizationResult(
+ Success: false,
+ FilePath: filePath,
+ ImagesProcessed: 0,
+ ImagesSkipped: 0,
+ TotalBytesBefore: 0,
+ TotalBytesAfter: 0,
+ TotalBytesSaved: 0,
+ OptimizedImages: [],
+ Validation: new ValidationStatus(0, 0, false),
+ Message: error));
}
diff --git a/tests/PptxMcp.Tests/Services/ImageOptimizationTests.cs b/tests/PptxMcp.Tests/Services/ImageOptimizationTests.cs
new file mode 100644
index 0000000..f010b84
--- /dev/null
+++ b/tests/PptxMcp.Tests/Services/ImageOptimizationTests.cs
@@ -0,0 +1,516 @@
+using DocumentFormat.OpenXml;
+using DocumentFormat.OpenXml.Packaging;
+using DocumentFormat.OpenXml.Presentation;
+using ImageMagick;
+using A = DocumentFormat.OpenXml.Drawing;
+using P = DocumentFormat.OpenXml.Presentation;
+
+namespace PptxMcp.Tests.Services;
+
+///
+/// Service-level tests for OptimizeImages (Issue #85 — Image optimization).
+/// Validates image downscaling, format conversion, JPEG recompression, and result structure.
+///
+[Trait("Category", "Unit")]
+public class ImageOptimizationTests : PptxTestBase
+{
+ // ──────────────────────────────────────────────────────────
+ // 1. No images — returns ImagesProcessed=0, Success=true
+ // ──────────────────────────────────────────────────────────
+
+ [Fact]
+ public void OptimizeImages_NoImages_ReturnsEmpty()
+ {
+ var path = CreateMinimalPptx("No Images");
+
+ var result = Service.OptimizeImages(path);
+
+ Assert.True(result.Success);
+ Assert.Equal(0, result.ImagesProcessed);
+ Assert.Equal(0, result.ImagesSkipped);
+ Assert.Equal(0, result.TotalBytesBefore);
+ Assert.Equal(0, result.TotalBytesAfter);
+ Assert.Equal(0, result.TotalBytesSaved);
+ Assert.Empty(result.OptimizedImages);
+ }
+
+ // ──────────────────────────────────────────────────────────
+ // 2. Small image — skips optimization when already optimal
+ // ──────────────────────────────────────────────────────────
+
+ [Fact]
+ public void OptimizeImages_SmallImage_SkipsOptimization()
+ {
+ // Create a small PNG image (100x100) displayed at larger size (2x2 inches)
+ var path = CreatePptxWithImage(
+ width: 100,
+ height: 100,
+ format: MagickFormat.Png,
+ displayWidthEmu: Emu.Inches2,
+ displayHeightEmu: Emu.Inches2);
+
+ var result = Service.OptimizeImages(path, targetDpi: 150);
+
+ Assert.True(result.Success);
+ // Image is already smaller than display size at 150 DPI, so should be skipped
+ Assert.Equal(0, result.ImagesProcessed);
+ Assert.True(result.ImagesSkipped > 0);
+ Assert.Single(result.OptimizedImages);
+ Assert.Equal(0, result.OptimizedImages[0].BytesSaved);
+ Assert.Contains("skipped", result.OptimizedImages[0].Action, StringComparison.OrdinalIgnoreCase);
+ }
+
+ // ──────────────────────────────────────────────────────────
+ // 3. JPEG recompression — verifies quality reduction
+ // ──────────────────────────────────────────────────────────
+
+ [Fact]
+ public void OptimizeImages_JpegRecompression_ReducesFileSize()
+ {
+ // Create a large JPEG at 100% quality with enough content to see compression
+ var path = CreatePptxWithImage(
+ width: 2000,
+ height: 1500,
+ format: MagickFormat.Jpeg,
+ jpegQuality: 100,
+ displayWidthEmu: Emu.Inches4,
+ displayHeightEmu: Emu.Inches3);
+
+ var result = Service.OptimizeImages(path, targetDpi: 150, jpegQuality: 85);
+
+ Assert.True(result.Success);
+ Assert.True(result.ImagesProcessed > 0);
+ Assert.True(result.TotalBytesSaved > 0);
+ Assert.Single(result.OptimizedImages);
+
+ var optimizedImage = result.OptimizedImages[0];
+ Assert.True(optimizedImage.BytesSaved > 0);
+ Assert.Contains("recompressed", optimizedImage.Action);
+ Assert.Equal("Jpeg", optimizedImage.OriginalFormat);
+ Assert.Equal("Jpeg", optimizedImage.OptimizedFormat);
+ Assert.True(optimizedImage.OptimizedSizeBytes < optimizedImage.OriginalSizeBytes);
+ }
+
+ // ──────────────────────────────────────────────────────────
+ // 4. File not found — proper error handling
+ // ──────────────────────────────────────────────────────────
+
+ [Fact]
+ public void OptimizeImages_FileNotFound_ReturnsError()
+ {
+ var nonExistentPath = Path.Join(Path.GetTempPath(), "nonexistent-" + Guid.NewGuid() + ".pptx");
+
+ var exception = Assert.Throws(() => Service.OptimizeImages(nonExistentPath));
+ Assert.Contains(nonExistentPath, exception.Message);
+ }
+
+ // ──────────────────────────────────────────────────────────
+ // 5. Result structure — all fields populated correctly
+ // ──────────────────────────────────────────────────────────
+
+ [Fact]
+ public void OptimizeImages_ResultStructure_AllFieldsPopulated()
+ {
+ var path = CreatePptxWithImage(
+ width: 1500,
+ height: 1000,
+ format: MagickFormat.Jpeg,
+ jpegQuality: 100,
+ displayWidthEmu: Emu.Inches3,
+ displayHeightEmu: Emu.Inches2);
+
+ var result = Service.OptimizeImages(path);
+
+ // Verify all top-level fields are present
+ Assert.True(result.Success);
+ Assert.NotNull(result.FilePath);
+ Assert.Equal(path, result.FilePath);
+ Assert.True(result.ImagesProcessed >= 0);
+ Assert.True(result.ImagesSkipped >= 0);
+ Assert.True(result.TotalBytesBefore >= 0);
+ Assert.True(result.TotalBytesAfter >= 0);
+ Assert.True(result.TotalBytesSaved >= 0);
+ Assert.NotNull(result.OptimizedImages);
+ Assert.NotEmpty(result.OptimizedImages);
+ Assert.NotNull(result.Validation);
+ Assert.NotNull(result.Message);
+ Assert.False(string.IsNullOrWhiteSpace(result.Message));
+
+ // Verify OptimizedImageInfo fields
+ var imageInfo = result.OptimizedImages[0];
+ Assert.NotNull(imageInfo.ImagePath);
+ Assert.NotNull(imageInfo.OriginalFormat);
+ Assert.NotNull(imageInfo.OptimizedFormat);
+ Assert.True(imageInfo.OriginalWidth > 0);
+ Assert.True(imageInfo.OriginalHeight > 0);
+ Assert.True(imageInfo.OptimizedWidth > 0);
+ Assert.True(imageInfo.OptimizedHeight > 0);
+ Assert.True(imageInfo.OriginalSizeBytes > 0);
+ Assert.True(imageInfo.OptimizedSizeBytes > 0);
+ Assert.NotNull(imageInfo.Action);
+
+ // Verify validation status
+ Assert.True(result.Validation.ErrorsBefore >= 0);
+ Assert.True(result.Validation.ErrorsAfter >= 0);
+ }
+
+ // ──────────────────────────────────────────────────────────
+ // 6. Custom parameters — targetDpi, jpegQuality work
+ // ──────────────────────────────────────────────────────────
+
+ [Fact]
+ public void OptimizeImages_CustomParameters_AppliedCorrectly()
+ {
+ // Create large image that will be downscaled
+ var path = CreatePptxWithImage(
+ width: 3000,
+ height: 2000,
+ format: MagickFormat.Jpeg,
+ jpegQuality: 100,
+ displayWidthEmu: Emu.Inches3, // 3 inches
+ displayHeightEmu: Emu.Inches2); // 2 inches
+
+ // At 300 DPI, 3 inches = 900 pixels, 2 inches = 600 pixels
+ // Image is 3000x2000, so should be downscaled
+ var result = Service.OptimizeImages(path, targetDpi: 300, jpegQuality: 90);
+
+ Assert.True(result.Success);
+ Assert.True(result.ImagesProcessed > 0);
+
+ var optimizedImage = result.OptimizedImages[0];
+ Assert.True(optimizedImage.OptimizedWidth < optimizedImage.OriginalWidth);
+ Assert.True(optimizedImage.OptimizedHeight < optimizedImage.OriginalHeight);
+ Assert.Contains("downscaled", optimizedImage.Action);
+ }
+
+ // ──────────────────────────────────────────────────────────
+ // 7. Format conversion — BMP to PNG
+ // ──────────────────────────────────────────────────────────
+
+ [Fact]
+ public void OptimizeImages_BmpToPng_ConvertsFormat()
+ {
+ var path = CreatePptxWithImage(
+ width: 800,
+ height: 600,
+ format: MagickFormat.Bmp,
+ displayWidthEmu: Emu.Inches3,
+ displayHeightEmu: Emu.Inches2);
+
+ var result = Service.OptimizeImages(path, convertFormats: true);
+
+ Assert.True(result.Success);
+ Assert.True(result.ImagesProcessed > 0);
+
+ var optimizedImage = result.OptimizedImages[0];
+ Assert.Equal("Bmp", optimizedImage.OriginalFormat);
+ Assert.Equal("Png", optimizedImage.OptimizedFormat);
+ Assert.Contains("converted", optimizedImage.Action);
+ Assert.True(optimizedImage.BytesSaved > 0);
+ }
+
+ // ──────────────────────────────────────────────────────────
+ // 8. Downscaling large image — reduces dimensions
+ // ──────────────────────────────────────────────────────────
+
+ [Fact]
+ public void OptimizeImages_LargeImage_Downscales()
+ {
+ // Create very large image (4000x3000) displayed at 2x1.5 inches
+ var path = CreatePptxWithImage(
+ width: 4000,
+ height: 3000,
+ format: MagickFormat.Png,
+ displayWidthEmu: Emu.Inches2,
+ displayHeightEmu: Emu.Inches1_5);
+
+ var result = Service.OptimizeImages(path, targetDpi: 150);
+
+ Assert.True(result.Success);
+ Assert.True(result.ImagesProcessed > 0);
+
+ var optimizedImage = result.OptimizedImages[0];
+ // At 150 DPI: 2 inches = 300px, 1.5 inches = 225px
+ // Image should be downscaled from 4000x3000 to approximately 300x225
+ Assert.True(optimizedImage.OptimizedWidth < optimizedImage.OriginalWidth);
+ Assert.True(optimizedImage.OptimizedHeight < optimizedImage.OriginalHeight);
+ Assert.True(optimizedImage.OptimizedWidth <= 350); // Allow some margin
+ Assert.True(optimizedImage.OptimizedHeight <= 275);
+ Assert.Contains("downscaled", optimizedImage.Action);
+ Assert.True(optimizedImage.BytesSaved > 0);
+ }
+
+ // ──────────────────────────────────────────────────────────
+ // 9. ConvertFormats disabled — BMP stays BMP
+ // ──────────────────────────────────────────────────────────
+
+ [Fact]
+ public void OptimizeImages_ConvertFormatsDisabled_SkipsConversion()
+ {
+ var path = CreatePptxWithImage(
+ width: 500,
+ height: 400,
+ format: MagickFormat.Bmp,
+ displayWidthEmu: Emu.Inches2,
+ displayHeightEmu: Emu.Inches1_5);
+
+ var result = Service.OptimizeImages(path, convertFormats: false);
+
+ Assert.True(result.Success);
+ // BMP without downscaling or conversion should be skipped
+ var optimizedImage = result.OptimizedImages[0];
+ Assert.Equal("Bmp", optimizedImage.OriginalFormat);
+ Assert.Equal("Bmp", optimizedImage.OptimizedFormat);
+ }
+
+ // ──────────────────────────────────────────────────────────
+ // 10. Multiple images — processes all
+ // ──────────────────────────────────────────────────────────
+
+ [Fact]
+ public void OptimizeImages_MultipleImages_ProcessesAll()
+ {
+ var path = CreatePptxWithMultipleImages();
+
+ var result = Service.OptimizeImages(path);
+
+ Assert.True(result.Success);
+ // Should process 3 images total
+ Assert.Equal(3, result.OptimizedImages.Count);
+ Assert.True(result.ImagesProcessed + result.ImagesSkipped == 3);
+ Assert.True(result.TotalBytesBefore > 0);
+ }
+
+ // ──────────────────────────────────────────────────────────
+ // Helpers — create PPTX fixtures with images
+ // ──────────────────────────────────────────────────────────
+
+ ///
+ /// Creates a PPTX with a single slide containing one image with specified properties.
+ ///
+ private string CreatePptxWithImage(
+ int width,
+ int height,
+ MagickFormat format,
+ long displayWidthEmu,
+ long displayHeightEmu,
+ int jpegQuality = 100)
+ {
+ var path = Path.Join(Path.GetTempPath(), Path.GetRandomFileName() + ".pptx");
+ TrackTempFile(path);
+
+ using var doc = PresentationDocument.Create(path, PresentationDocumentType.Presentation);
+ var presentationPart = doc.AddPresentationPart();
+ var (slideMasterPart, slideLayoutPart) = CreateMinimalMasterAndLayout(presentationPart);
+
+ var slidePart = presentationPart.AddNewPart();
+ slidePart.AddPart(slideLayoutPart);
+
+ // Generate test image with ImageMagick
+ var imageBytes = CreateTestImageBytes(width, height, format, jpegQuality);
+
+ // Add image part based on format
+ ImagePart imagePart = format switch
+ {
+ MagickFormat.Jpeg => slidePart.AddImagePart(ImagePartType.Jpeg),
+ MagickFormat.Png => slidePart.AddImagePart(ImagePartType.Png),
+ MagickFormat.Bmp => slidePart.AddImagePart(ImagePartType.Bmp),
+ MagickFormat.Tiff or MagickFormat.Tiff64 => slidePart.AddImagePart(ImagePartType.Tiff),
+ _ => slidePart.AddImagePart(ImagePartType.Png)
+ };
+ using (var ms = new MemoryStream(imageBytes))
+ imagePart.FeedData(ms);
+
+ var relId = slidePart.GetIdOfPart(imagePart);
+ slidePart.Slide = CreateSlideWithPicture(relId, displayWidthEmu, displayHeightEmu);
+
+ var slideIdList = new SlideIdList(
+ new SlideId
+ {
+ Id = 256,
+ RelationshipId = presentationPart.GetIdOfPart(slidePart)
+ });
+
+ FinalizePresentationPart(presentationPart, slideIdList, slideMasterPart);
+ return path;
+ }
+
+ ///
+ /// Creates a PPTX with 3 slides, each with a different image.
+ ///
+ private string CreatePptxWithMultipleImages()
+ {
+ var path = Path.Join(Path.GetTempPath(), Path.GetRandomFileName() + ".pptx");
+ TrackTempFile(path);
+
+ using var doc = PresentationDocument.Create(path, PresentationDocumentType.Presentation);
+ var presentationPart = doc.AddPresentationPart();
+ var (slideMasterPart, slideLayoutPart) = CreateMinimalMasterAndLayout(presentationPart);
+
+ var slideIdList = new SlideIdList();
+ uint slideId = 256;
+
+ // Image 1: Large JPEG
+ var slidePart1 = presentationPart.AddNewPart();
+ slidePart1.AddPart(slideLayoutPart);
+ var image1Bytes = CreateTestImageBytes(2000, 1500, MagickFormat.Jpeg, 100);
+ var imagePart1 = slidePart1.AddImagePart(ImagePartType.Jpeg);
+ using (var ms = new MemoryStream(image1Bytes))
+ imagePart1.FeedData(ms);
+ slidePart1.Slide = CreateSlideWithPicture(slidePart1.GetIdOfPart(imagePart1), Emu.Inches3, Emu.Inches2);
+ slideIdList.Append(new SlideId { Id = slideId++, RelationshipId = presentationPart.GetIdOfPart(slidePart1) });
+
+ // Image 2: BMP to convert
+ var slidePart2 = presentationPart.AddNewPart();
+ slidePart2.AddPart(slideLayoutPart);
+ var image2Bytes = CreateTestImageBytes(800, 600, MagickFormat.Bmp, 100);
+ var imagePart2 = slidePart2.AddImagePart(ImagePartType.Bmp);
+ using (var ms = new MemoryStream(image2Bytes))
+ imagePart2.FeedData(ms);
+ slidePart2.Slide = CreateSlideWithPicture(slidePart2.GetIdOfPart(imagePart2), Emu.Inches2, Emu.Inches1_5);
+ slideIdList.Append(new SlideId { Id = slideId++, RelationshipId = presentationPart.GetIdOfPart(slidePart2) });
+
+ // Image 3: Small PNG (should be skipped)
+ var slidePart3 = presentationPart.AddNewPart();
+ slidePart3.AddPart(slideLayoutPart);
+ var image3Bytes = CreateTestImageBytes(100, 100, MagickFormat.Png, 100);
+ var imagePart3 = slidePart3.AddImagePart(ImagePartType.Png);
+ using (var ms = new MemoryStream(image3Bytes))
+ imagePart3.FeedData(ms);
+ slidePart3.Slide = CreateSlideWithPicture(slidePart3.GetIdOfPart(imagePart3), Emu.Inches2, Emu.Inches2);
+ slideIdList.Append(new SlideId { Id = slideId++, RelationshipId = presentationPart.GetIdOfPart(slidePart3) });
+
+ FinalizePresentationPart(presentationPart, slideIdList, slideMasterPart);
+ return path;
+ }
+
+ ///
+ /// Creates a test image using ImageMagick with specified properties.
+ /// Returns a byte array instead of a stream to avoid disposal issues.
+ ///
+ private static byte[] CreateTestImageBytes(int width, int height, MagickFormat format, int jpegQuality)
+ {
+ using var image = new MagickImage(MagickColors.Blue, (uint)width, (uint)height);
+
+ // Add some variation to make the image compressible
+ using var overlay = new MagickImage(MagickColors.Yellow, (uint)width / 4, (uint)height / 4);
+ image.Composite(overlay, (int)(width * 0.25), (int)(height * 0.25), CompositeOperator.Over);
+
+ image.Format = format;
+ if (format == MagickFormat.Jpeg)
+ {
+ image.Quality = (uint)jpegQuality;
+ }
+
+ return image.ToByteArray();
+ }
+
+ ///
+ /// Creates a test image using ImageMagick with specified properties.
+ ///
+ private static MemoryStream CreateTestImage(int width, int height, MagickFormat format, int jpegQuality)
+ {
+ var bytes = CreateTestImageBytes(width, height, format, jpegQuality);
+ return new MemoryStream(bytes);
+ }
+
+ // ── Shared fixture helpers ──────────────────────────────
+
+ private static (SlideMasterPart Master, SlideLayoutPart Layout) CreateMinimalMasterAndLayout(
+ PresentationPart presentationPart)
+ {
+ var slideMasterPart = presentationPart.AddNewPart();
+ var slideLayoutPart = slideMasterPart.AddNewPart();
+
+ slideLayoutPart.SlideLayout = new SlideLayout(
+ new CommonSlideData(
+ new ShapeTree(
+ new P.NonVisualGroupShapeProperties(
+ new P.NonVisualDrawingProperties { Id = 1, Name = string.Empty },
+ new P.NonVisualGroupShapeDrawingProperties(),
+ new ApplicationNonVisualDrawingProperties()),
+ new GroupShapeProperties(new A.TransformGroup()))),
+ new ColorMapOverride(new A.MasterColorMapping()))
+ { Type = SlideLayoutValues.Title };
+ slideLayoutPart.SlideLayout.CommonSlideData!.Name = "Title Slide";
+ slideLayoutPart.AddPart(slideMasterPart);
+
+ slideMasterPart.SlideMaster = new SlideMaster(
+ new CommonSlideData(
+ new ShapeTree(
+ new P.NonVisualGroupShapeProperties(
+ new P.NonVisualDrawingProperties { Id = 1, Name = string.Empty },
+ new P.NonVisualGroupShapeDrawingProperties(),
+ new ApplicationNonVisualDrawingProperties()),
+ new GroupShapeProperties(new A.TransformGroup()))),
+ new P.ColorMap
+ {
+ Background1 = A.ColorSchemeIndexValues.Light1,
+ Text1 = A.ColorSchemeIndexValues.Dark1,
+ Background2 = A.ColorSchemeIndexValues.Light2,
+ Text2 = A.ColorSchemeIndexValues.Dark2,
+ Accent1 = A.ColorSchemeIndexValues.Accent1,
+ Accent2 = A.ColorSchemeIndexValues.Accent2,
+ Accent3 = A.ColorSchemeIndexValues.Accent3,
+ Accent4 = A.ColorSchemeIndexValues.Accent4,
+ Accent5 = A.ColorSchemeIndexValues.Accent5,
+ Accent6 = A.ColorSchemeIndexValues.Accent6,
+ Hyperlink = A.ColorSchemeIndexValues.Hyperlink,
+ FollowedHyperlink = A.ColorSchemeIndexValues.FollowedHyperlink
+ },
+ new SlideLayoutIdList(
+ new SlideLayoutId
+ {
+ Id = 2049,
+ RelationshipId = slideMasterPart.GetIdOfPart(slideLayoutPart)
+ }));
+
+ return (slideMasterPart, slideLayoutPart);
+ }
+
+ private static void FinalizePresentationPart(PresentationPart presentationPart,
+ SlideIdList slideIdList, SlideMasterPart slideMasterPart)
+ {
+ var slideMasterIdList = new SlideMasterIdList(
+ new SlideMasterId
+ {
+ Id = 2147483648U,
+ RelationshipId = presentationPart.GetIdOfPart(slideMasterPart)
+ });
+
+ presentationPart.Presentation = new Presentation(
+ slideIdList,
+ new SlideSize { Cx = 9144000, Cy = 6858000, Type = SlideSizeValues.Screen4x3 },
+ new NotesSize { Cx = 6858000, Cy = 9144000 });
+
+ presentationPart.Presentation.InsertAt(slideMasterIdList, 0);
+ presentationPart.Presentation.Save();
+ }
+
+ private static Slide CreateSlideWithPicture(string imageRelId, long widthEmu, long heightEmu)
+ {
+ return new Slide(
+ new CommonSlideData(
+ new ShapeTree(
+ new P.NonVisualGroupShapeProperties(
+ new P.NonVisualDrawingProperties { Id = 1, Name = string.Empty },
+ new P.NonVisualGroupShapeDrawingProperties(),
+ new ApplicationNonVisualDrawingProperties()),
+ new GroupShapeProperties(new A.TransformGroup()),
+ new P.Picture(
+ new P.NonVisualPictureProperties(
+ new P.NonVisualDrawingProperties { Id = 2, Name = "Image 1" },
+ new P.NonVisualPictureDrawingProperties(
+ new A.PictureLocks { NoChangeAspect = true }),
+ new ApplicationNonVisualDrawingProperties()),
+ new P.BlipFill(
+ new A.Blip { Embed = imageRelId },
+ new A.Stretch(new A.FillRectangle())),
+ new P.ShapeProperties(
+ new A.Transform2D(
+ new A.Offset { X = Emu.OneInch, Y = Emu.OneInch },
+ new A.Extents { Cx = widthEmu, Cy = heightEmu }),
+ new A.PresetGeometry(new A.AdjustValueList())
+ { Preset = A.ShapeTypeValues.Rectangle })))));
+ }
+}