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 }))))); + } +}