diff --git a/src/UglyToad.PdfPig.Tests/Integration/Documents/MOZILLA-LINK-3264-0.pdf b/src/UglyToad.PdfPig.Tests/Integration/Documents/MOZILLA-LINK-3264-0.pdf new file mode 100644 index 00000000..b671f183 Binary files /dev/null and b/src/UglyToad.PdfPig.Tests/Integration/Documents/MOZILLA-LINK-3264-0.pdf differ diff --git a/src/UglyToad.PdfPig.Tests/Integration/SoftMaskTests.cs b/src/UglyToad.PdfPig.Tests/Integration/MaskTests.cs similarity index 50% rename from src/UglyToad.PdfPig.Tests/Integration/SoftMaskTests.cs rename to src/UglyToad.PdfPig.Tests/Integration/MaskTests.cs index 3a1b310e..e34a64b1 100644 --- a/src/UglyToad.PdfPig.Tests/Integration/SoftMaskTests.cs +++ b/src/UglyToad.PdfPig.Tests/Integration/MaskTests.cs @@ -3,7 +3,7 @@ using SkiaSharp; using System.Linq; - public class SoftMaskTests + public class MaskTests { [Fact] public void PigProductionHandbook() @@ -17,7 +17,7 @@ var images = page.GetImages().ToArray(); var image1 = images[1]; - Assert.NotNull(image1.SoftMaskImage); + Assert.NotNull(image1.MaskImage); Assert.True(image1.TryGetPng(out var png1)); using (var skImage1 = SKImage.FromEncodedData(png1)) using (var skBitmap1 = SKBitmap.FromImage(skImage1)) @@ -27,7 +27,7 @@ } var image2 = images[2]; - Assert.NotNull(image2.SoftMaskImage); + Assert.NotNull(image2.MaskImage); Assert.True(image2.TryGetPng(out var png2)); using (var skImage2 = SKImage.FromEncodedData(png2)) using (var skBitmap2 = SKBitmap.FromImage(skImage2)) @@ -37,5 +37,37 @@ } } } + + [Fact] + public void MOZILLA_LINK_3264_0() + { + var path = IntegrationHelpers.GetDocumentPath("MOZILLA-LINK-3264-0.pdf"); + + using (var document = PdfDocument.Open(path, new ParsingOptions() { UseLenientParsing = true, SkipMissingFonts = true })) + { + var page = document.GetPage(1); + + var images = page.GetImages().ToArray(); + + var image1 = images[1]; + Assert.NotNull(image1.MaskImage); + Assert.True(image1.TryGetPng(out var png1)); + using (var skImage1 = SKImage.FromEncodedData(png1)) + using (var skBitmap1 = SKBitmap.FromImage(skImage1)) + { + var pixel = skBitmap1.GetPixel(0, 0); + Assert.Equal(0, pixel.Alpha); + } + + page = document.GetPage(2); + + images = page.GetImages().ToArray(); + + var image2 = images[1]; + Assert.NotNull(image2.MaskImage); + Assert.True(image2.TryGetPng(out var png2)); + // TODO - Check alpha value + } + } } } diff --git a/src/UglyToad.PdfPig.Tests/PublicApiScannerTests.cs b/src/UglyToad.PdfPig.Tests/PublicApiScannerTests.cs index 06850720..52ac1775 100644 --- a/src/UglyToad.PdfPig.Tests/PublicApiScannerTests.cs +++ b/src/UglyToad.PdfPig.Tests/PublicApiScannerTests.cs @@ -80,6 +80,7 @@ "UglyToad.PdfPig.Content.InlineImage", "UglyToad.PdfPig.Content.IPageFactory`1", "UglyToad.PdfPig.Content.IPdfImage", + "UglyToad.PdfPig.Content.PdfImageExtensions", "UglyToad.PdfPig.Content.IResourceStore", "UglyToad.PdfPig.Content.Letter", "UglyToad.PdfPig.Content.MarkedContentElement", diff --git a/src/UglyToad.PdfPig.Tests/TestPdfImage.cs b/src/UglyToad.PdfPig.Tests/TestPdfImage.cs index eba65dae..d90b6409 100644 --- a/src/UglyToad.PdfPig.Tests/TestPdfImage.cs +++ b/src/UglyToad.PdfPig.Tests/TestPdfImage.cs @@ -37,7 +37,7 @@ public ReadOnlyMemory DecodedBytes { get; set; } - public IPdfImage? SoftMaskImage { get; } + public IPdfImage? MaskImage { get; } public bool TryGetBytesAsMemory(out ReadOnlyMemory bytes) { diff --git a/src/UglyToad.PdfPig/Content/IPdfImage.cs b/src/UglyToad.PdfPig/Content/IPdfImage.cs index 4902c106..b0d02218 100644 --- a/src/UglyToad.PdfPig/Content/IPdfImage.cs +++ b/src/UglyToad.PdfPig/Content/IPdfImage.cs @@ -95,9 +95,10 @@ ColorSpaceDetails? ColorSpaceDetails { get; } /// - /// Soft-mask image. + /// The image mask. + /// Either a Soft-mask or a Stencil mask. /// - IPdfImage? SoftMaskImage { get; } + IPdfImage? MaskImage { get; } /// /// Get the decoded memory of the image if applicable. For JPEG images and some other types the diff --git a/src/UglyToad.PdfPig/Content/InlineImage.cs b/src/UglyToad.PdfPig/Content/InlineImage.cs index ee5e613e..f427f597 100644 --- a/src/UglyToad.PdfPig/Content/InlineImage.cs +++ b/src/UglyToad.PdfPig/Content/InlineImage.cs @@ -58,7 +58,7 @@ public ColorSpaceDetails ColorSpaceDetails { get; } /// - public IPdfImage? SoftMaskImage { get; } + public IPdfImage? MaskImage { get; } /// /// Create a new . @@ -78,6 +78,7 @@ ColorSpaceDetails colorSpaceDetails, IPdfImage? softMaskImage) { + IsInlineImage = true; Bounds = bounds; WidthInSamples = widthInSamples; HeightInSamples = heightInSamples; @@ -114,7 +115,7 @@ return b; }) : null; - SoftMaskImage = softMaskImage; + MaskImage = softMaskImage; } /// diff --git a/src/UglyToad.PdfPig/Content/PdfImageExtensions.cs b/src/UglyToad.PdfPig/Content/PdfImageExtensions.cs new file mode 100644 index 00000000..952d214d --- /dev/null +++ b/src/UglyToad.PdfPig/Content/PdfImageExtensions.cs @@ -0,0 +1,22 @@ +namespace UglyToad.PdfPig.Content +{ + /// + /// Pdf image extensions. + /// + public static class PdfImageExtensions + { + /// + /// true if the image colors needs to be reversed based on the Decode array and color space. false otherwise. + /// + public static bool NeedsReverseDecode(this IPdfImage pdfImage) + { + if (pdfImage.ColorSpaceDetails?.IsStencil == true) + { + // Stencil color space already takes care of reversing. + return false; + } + + return pdfImage.Decode.Count >= 2 && pdfImage.Decode[0] == 1 && pdfImage.Decode[1] == 0; + } + } +} diff --git a/src/UglyToad.PdfPig/Graphics/Colors/ColorSpaceDetails.cs b/src/UglyToad.PdfPig/Graphics/Colors/ColorSpaceDetails.cs index a6c05968..dcbcc982 100644 --- a/src/UglyToad.PdfPig/Graphics/Colors/ColorSpaceDetails.cs +++ b/src/UglyToad.PdfPig/Graphics/Colors/ColorSpaceDetails.cs @@ -15,6 +15,12 @@ /// public abstract class ColorSpaceDetails { + /// + /// Is the color space a stencil indexed color space. + /// Stencil color spaces take care of inverting colors based on the Decode array. + /// + public bool IsStencil { get; } + /// /// The type of the ColorSpace. /// @@ -39,10 +45,11 @@ /// /// Create a new . /// - protected internal ColorSpaceDetails(ColorSpace type) + protected internal ColorSpaceDetails(ColorSpace type, bool isStencil = false) { Type = type; BaseType = type; + IsStencil = isStencil; } /// @@ -279,7 +286,7 @@ internal static ColorSpaceDetails Stencil(ColorSpaceDetails colorSpaceDetails, double[] decode) { var blackIsOne = decode.Length >= 2 && decode[0] == 1 && decode[1] == 0; - return new IndexedColorSpaceDetails(colorSpaceDetails, 1, blackIsOne ? [255, 0] : [0, 255]); + return new IndexedColorSpaceDetails(colorSpaceDetails, 1, blackIsOne ? [255, 0] : [0, 255], true); } /// @@ -310,11 +317,15 @@ /// public ReadOnlySpan ColorTable => colorTable; + public IndexedColorSpaceDetails(ColorSpaceDetails baseColorSpaceDetails, byte hiVal, byte[] colorTable) + : this(baseColorSpaceDetails, hiVal, colorTable, false) + { } + /// /// Create a new . /// - public IndexedColorSpaceDetails(ColorSpaceDetails baseColorSpaceDetails, byte hiVal, byte[] colorTable) - : base(ColorSpace.Indexed) + private IndexedColorSpaceDetails(ColorSpaceDetails baseColorSpaceDetails, byte hiVal, byte[] colorTable, bool isStencil) + : base(ColorSpace.Indexed, isStencil) { BaseColorSpace = baseColorSpaceDetails ?? throw new ArgumentNullException(nameof(baseColorSpaceDetails)); HiVal = hiVal; @@ -367,56 +378,40 @@ case ColorSpace.DeviceRGB: case ColorSpace.CalRGB: case ColorSpace.Lab: - { - Span result = new byte[input.Length * 3]; - var i = 0; - foreach (var x in input) { - for (var j = 0; j < 3; ++j) + Span result = new byte[input.Length * 3]; + var i = 0; + foreach (var x in input) { - result[i++] = ColorTable[x * 3 + j]; + for (var j = 0; j < 3; ++j) + { + result[i++] = ColorTable[x * 3 + j]; + } } - } - return result; - } + return result; + } case ColorSpace.DeviceCMYK: - { - Span result = new byte[input.Length * 4]; - var i = 0; - foreach (var x in input) { - for (var j = 0; j < 4; ++j) + Span result = new byte[input.Length * 4]; + var i = 0; + foreach (var x in input) { - result[i++] = ColorTable[x * 4 + j]; + for (var j = 0; j < 4; ++j) + { + result[i++] = ColorTable[x * 4 + j]; + } } - } - return result; - } + return result; + } case ColorSpace.DeviceGray: case ColorSpace.CalGray: case ColorSpace.Separation: - { - for (var i = 0; i < input.Length; ++i) { - ref byte b = ref input[i]; - b = ColorTable[b]; - } - - return input; - } - - case ColorSpace.DeviceN: - case ColorSpace.ICCBased: - { - int i = 0; - if (BaseColorSpace.NumberOfColorComponents == 1) - { - // In place - for (i = 0; i < input.Length; ++i) + for (var i = 0; i < input.Length; ++i) { ref byte b = ref input[i]; b = ColorTable[b]; @@ -425,17 +420,33 @@ return input; } - Span result = new byte[input.Length * BaseColorSpace.NumberOfColorComponents]; - foreach (var x in input) + case ColorSpace.DeviceN: + case ColorSpace.ICCBased: { - for (var j = 0; j < BaseColorSpace.NumberOfColorComponents; ++j) + int i = 0; + if (BaseColorSpace.NumberOfColorComponents == 1) { - result[i++] = ColorTable[x * BaseColorSpace.NumberOfColorComponents + j]; - } - } + // In place + for (i = 0; i < input.Length; ++i) + { + ref byte b = ref input[i]; + b = ColorTable[b]; + } - return result; - } + return input; + } + + Span result = new byte[input.Length * BaseColorSpace.NumberOfColorComponents]; + foreach (var x in input) + { + for (var j = 0; j < BaseColorSpace.NumberOfColorComponents; ++j) + { + result[i++] = ColorTable[x * BaseColorSpace.NumberOfColorComponents + j]; + } + } + + return result; + } } return input; diff --git a/src/UglyToad.PdfPig/Images/Png/PngFromPdfImageFactory.cs b/src/UglyToad.PdfPig/Images/Png/PngFromPdfImageFactory.cs index f95c4765..fdfa9aaa 100644 --- a/src/UglyToad.PdfPig/Images/Png/PngFromPdfImageFactory.cs +++ b/src/UglyToad.PdfPig/Images/Png/PngFromPdfImageFactory.cs @@ -9,7 +9,20 @@ { private static bool TryGenerateSoftMask(IPdfImage image, [NotNullWhen(true)] out ReadOnlySpan bytes) { - bytes = ReadOnlySpan.Empty; + bytes = ReadOnlySpan.Empty; + + if (image.MaskImage is null) + { + return false; + } + + // Because we cannot resize images directly in PdfPig, we only + // apply the mask if it has the same size as the image + if (image.HeightInSamples != image.MaskImage.HeightInSamples || + image.WidthInSamples != image.MaskImage.WidthInSamples) + { + return false; + } if (!image.TryGetBytesAsMemory(out var imageMemory)) { @@ -74,9 +87,24 @@ var numberOfComponents = image.ColorSpaceDetails!.BaseNumberOfColorComponents; ReadOnlySpan softMask = null; - bool isSoftMask = image.SoftMaskImage is not null && TryGenerateSoftMask(image.SoftMaskImage, out softMask); - - var builder = PngBuilder.Create(image.WidthInSamples, image.HeightInSamples, isSoftMask); + + bool hasMask = TryGenerateSoftMask(image, out softMask); + Func getAlphaChannel = _ => byte.MaxValue; + + if (hasMask) + { + byte[] softMaskBytes = softMask.ToArray(); + if (image.MaskImage!.NeedsReverseDecode()) + { + getAlphaChannel = i => Convert.ToByte(255 - softMaskBytes[i]); + } + else + { + getAlphaChannel = i => softMaskBytes[i]; + } + } + + var builder = PngBuilder.Create(image.WidthInSamples, image.HeightInSamples, hasMask); if (!IsCorrectlySized(image, bytesPure)) { @@ -98,7 +126,7 @@ * B = 255 × (1-Y) × (1-K) */ - byte a = isSoftMask ? softMask[sm++] : byte.MaxValue; + byte a = getAlphaChannel(sm++); double c = (bytesPure[i++] / 255d); double m = (bytesPure[i++] / 255d); double y = (bytesPure[i++] / 255d); @@ -119,7 +147,7 @@ { for (int row = 0; row < image.WidthInSamples; row++) { - byte a = isSoftMask ? softMask[sm++] : byte.MaxValue; + byte a = getAlphaChannel(sm++); builder.SetPixel(new Pixel(bytesPure[i++], bytesPure[i++], bytesPure[i++], a, false), row, col); } } @@ -131,7 +159,7 @@ { for (int row = 0; row < image.WidthInSamples; row++) { - byte a = isSoftMask ? softMask[i] : byte.MaxValue; + byte a = getAlphaChannel(i); byte pixel = bytesPure[i++]; builder.SetPixel(new Pixel(pixel, pixel, pixel, a, false), row, col); } diff --git a/src/UglyToad.PdfPig/XObjects/XObjectFactory.cs b/src/UglyToad.PdfPig/XObjects/XObjectFactory.cs index aea5dc80..48e08d0d 100644 --- a/src/UglyToad.PdfPig/XObjects/XObjectFactory.cs +++ b/src/UglyToad.PdfPig/XObjects/XObjectFactory.cs @@ -63,13 +63,31 @@ throw new Exception("The SMask dictionary contains a 'Mask' or 'Smask' entry."); } - var renderingIntent = xObject.DefaultRenderingIntent; // Ignored - - XObjectContentRecord softMaskImageRecord = new XObjectContentRecord(XObjectType.Image, sMaskToken, TransformationMatrix.Identity, - renderingIntent, DeviceGrayColorSpaceDetails.Instance); + XObjectContentRecord softMaskImageRecord = new XObjectContentRecord(XObjectType.Image, + sMaskToken, + TransformationMatrix.Identity, + xObject.DefaultRenderingIntent, // Ignored + DeviceGrayColorSpaceDetails.Instance); softMaskImage = ReadImage(softMaskImageRecord, pdfScanner, filterProvider, resourceStore); } + else if (dictionary.TryGet(NameToken.Mask, out StreamToken maskStream)) + { + if (maskStream.StreamDictionary.ContainsKey(NameToken.ColorSpace)) + { + throw new Exception("The SMask dictionary contains a 'ColorSpace'."); + } + + // Stencil masking + XObjectContentRecord maskImageRecord = new XObjectContentRecord(XObjectType.Image, + maskStream, + TransformationMatrix.Identity, + xObject.DefaultRenderingIntent, + null); + + softMaskImage = ReadImage(maskImageRecord, pdfScanner, filterProvider, resourceStore); + System.Diagnostics.Debug.Assert(softMaskImage.ColorSpaceDetails?.IsStencil == true); + } var isJpxDecode = dictionary.TryGet(NameToken.Filter, out NameToken filterName) && filterName.Equals(NameToken.JpxDecode); @@ -125,9 +143,7 @@ } } - var streamToken = new StreamToken(dictionary, xObject.Stream.Data); - - var decodedBytes = supportsFilters ? new Lazy>(() => streamToken.Decode(filterProvider, pdfScanner)) + var decodedBytes = supportsFilters ? new Lazy>(() => xObject.Stream.Decode(filterProvider, pdfScanner)) : null; var decode = Array.Empty(); diff --git a/src/UglyToad.PdfPig/XObjects/XObjectImage.cs b/src/UglyToad.PdfPig/XObjects/XObjectImage.cs index 29ee3bdf..967e0796 100644 --- a/src/UglyToad.PdfPig/XObjects/XObjectImage.cs +++ b/src/UglyToad.PdfPig/XObjects/XObjectImage.cs @@ -65,7 +65,7 @@ public ColorSpaceDetails? ColorSpaceDetails { get; } /// - public IPdfImage? SoftMaskImage { get; } + public IPdfImage? MaskImage { get; } /// /// Creates a new . @@ -98,7 +98,7 @@ RawMemory = rawMemory; ColorSpaceDetails = colorSpaceDetails; memoryFactory = bytes; - SoftMaskImage = softMaskImage; + MaskImage = softMaskImage; } ///