diff --git a/src/UglyToad.PdfPig.Tests/Integration/SoftMaskTests.cs b/src/UglyToad.PdfPig.Tests/Integration/SoftMaskTests.cs new file mode 100644 index 000000000..3a1b310e7 --- /dev/null +++ b/src/UglyToad.PdfPig.Tests/Integration/SoftMaskTests.cs @@ -0,0 +1,41 @@ +namespace UglyToad.PdfPig.Tests.Integration +{ + using SkiaSharp; + using System.Linq; + + public class SoftMaskTests + { + [Fact] + public void PigProductionHandbook() + { + var path = IntegrationHelpers.GetDocumentPath("Pig Production Handbook.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.SoftMaskImage); + 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); + } + + var image2 = images[2]; + Assert.NotNull(image2.SoftMaskImage); + Assert.True(image2.TryGetPng(out var png2)); + using (var skImage2 = SKImage.FromEncodedData(png2)) + using (var skBitmap2 = SKBitmap.FromImage(skImage2)) + { + var pixel = skBitmap2.GetPixel(0, 0); + Assert.Equal(0, pixel.Alpha); + } + } + } + } +} diff --git a/src/UglyToad.PdfPig.Tests/PublicApiScannerTests.cs b/src/UglyToad.PdfPig.Tests/PublicApiScannerTests.cs index c98592526..06850720c 100644 --- a/src/UglyToad.PdfPig.Tests/PublicApiScannerTests.cs +++ b/src/UglyToad.PdfPig.Tests/PublicApiScannerTests.cs @@ -161,6 +161,7 @@ public void OnlyExposedApiIsPublic() "UglyToad.PdfPig.Graphics.Colors.LatticeFormGouraudShading", "UglyToad.PdfPig.Graphics.Colors.CoonsPatchMeshesShading", "UglyToad.PdfPig.Graphics.Colors.TensorProductPatchMeshesShading", + "UglyToad.PdfPig.Graphics.Core.BlendMode", "UglyToad.PdfPig.Graphics.Core.LineCapStyle", "UglyToad.PdfPig.Graphics.Core.LineDashPattern", "UglyToad.PdfPig.Graphics.Core.LineJoinStyle", @@ -245,6 +246,8 @@ public void OnlyExposedApiIsPublic() "UglyToad.PdfPig.Graphics.Operations.TextState.Type3SetGlyphWidth", "UglyToad.PdfPig.Graphics.Operations.TextState.Type3SetGlyphWidthAndBoundingBox", "UglyToad.PdfPig.Graphics.PerformantRectangleTransformer", + "UglyToad.PdfPig.Graphics.SoftMask", + "UglyToad.PdfPig.Graphics.SoftMaskType", "UglyToad.PdfPig.Graphics.TextMatrices", "UglyToad.PdfPig.Graphics.XObjectContentRecord", "UglyToad.PdfPig.Images.ColorSpaceDetailsByteConverter", diff --git a/src/UglyToad.PdfPig.Tests/TestPdfImage.cs b/src/UglyToad.PdfPig.Tests/TestPdfImage.cs index aba9c7268..eba65dae4 100644 --- a/src/UglyToad.PdfPig.Tests/TestPdfImage.cs +++ b/src/UglyToad.PdfPig.Tests/TestPdfImage.cs @@ -37,6 +37,8 @@ public class TestPdfImage : IPdfImage public ReadOnlyMemory DecodedBytes { get; set; } + public IPdfImage? SoftMaskImage { get; } + public bool TryGetBytesAsMemory(out ReadOnlyMemory bytes) { bytes = DecodedBytes; diff --git a/src/UglyToad.PdfPig/Content/IPdfImage.cs b/src/UglyToad.PdfPig/Content/IPdfImage.cs index 544ea3e76..4902c106a 100644 --- a/src/UglyToad.PdfPig/Content/IPdfImage.cs +++ b/src/UglyToad.PdfPig/Content/IPdfImage.cs @@ -94,6 +94,11 @@ public interface IPdfImage /// ColorSpaceDetails? ColorSpaceDetails { get; } + /// + /// Soft-mask image. + /// + IPdfImage? SoftMaskImage { get; } + /// /// Get the decoded memory of the image if applicable. For JPEG images and some other types the /// should be used directly. diff --git a/src/UglyToad.PdfPig/Content/InlineImage.cs b/src/UglyToad.PdfPig/Content/InlineImage.cs index b13bf56c3..ee5e613e7 100644 --- a/src/UglyToad.PdfPig/Content/InlineImage.cs +++ b/src/UglyToad.PdfPig/Content/InlineImage.cs @@ -55,12 +55,19 @@ public class InlineImage : IPdfImage public ReadOnlySpan RawBytes => RawMemory.Span; /// - public ColorSpaceDetails ColorSpaceDetails { get; } + public ColorSpaceDetails ColorSpaceDetails { get; } + + /// + public IPdfImage? SoftMaskImage { get; } /// /// Create a new . /// - internal InlineImage(PdfRectangle bounds, int widthInSamples, int heightInSamples, int bitsPerComponent, bool isImageMask, + internal InlineImage(PdfRectangle bounds, + int widthInSamples, + int heightInSamples, + int bitsPerComponent, + bool isImageMask, RenderingIntent renderingIntent, bool interpolate, IReadOnlyList decode, @@ -68,7 +75,8 @@ internal InlineImage(PdfRectangle bounds, int widthInSamples, int heightInSample ILookupFilterProvider filterProvider, IReadOnlyList filterNames, DictionaryToken streamDictionary, - ColorSpaceDetails colorSpaceDetails) + ColorSpaceDetails colorSpaceDetails, + IPdfImage? softMaskImage) { Bounds = bounds; WidthInSamples = widthInSamples; @@ -104,7 +112,9 @@ internal InlineImage(PdfRectangle bounds, int widthInSamples, int heightInSample } return b; - }) : null; + }) : null; + + SoftMaskImage = softMaskImage; } /// diff --git a/src/UglyToad.PdfPig/Graphics/BaseStreamProcessor.cs b/src/UglyToad.PdfPig/Graphics/BaseStreamProcessor.cs index 563a31d05..43758be41 100644 --- a/src/UglyToad.PdfPig/Graphics/BaseStreamProcessor.cs +++ b/src/UglyToad.PdfPig/Graphics/BaseStreamProcessor.cs @@ -495,22 +495,19 @@ protected virtual void ProcessFormXObject(StreamToken formStream, NameToken xObj $"Invalid Transparency Group XObject, '{NameToken.S}' token is not set or not equal to '{NameToken.Transparency}'."); } - /* blend mode - * A conforming reader shall implicitly reset this parameter to its initial value at the beginning of execution of a - * transparency group XObject (see 11.6.6, "Transparency Group XObjects"). Initial value: Normal. - */ - //startState.BlendMode = BlendMode.Normal; - - /* soft mask - * A conforming reader shall implicitly reset this parameter implicitly reset to its initial value at the beginning - * of execution of a transparency group XObject (see 11.6.6, "Transparency Group XObjects"). Initial value: None. - */ - // TODO - - /* alpha constant - * A conforming reader shall implicitly reset this parameter to its initial value at the beginning of execution of a - * transparency group XObject (see 11.6.6, "Transparency Group XObjects"). Initial value: 1.0. - */ + // Blend mode + // A conforming reader shall implicitly reset this parameter to its initial value at the beginning of execution of a + // transparency group XObject (see 11.6.6, "Transparency Group XObjects"). Initial value: Normal. + startState.BlendMode = BlendMode.Normal; + + // Soft mask + // A conforming reader shall implicitly reset this parameter implicitly reset to its initial value at the beginning + // of execution of a transparency group XObject (see 11.6.6, "Transparency Group XObjects"). Initial value: None. + startState.SoftMask = null; + + // Alpha constant + // A conforming reader shall implicitly reset this parameter to its initial value at the beginning of execution of a + // transparency group XObject (see 11.6.6, "Transparency Group XObjects"). Initial value: 1.0. startState.AlphaConstantNonStroking = 1.0; startState.AlphaConstantStroking = 1.0; @@ -765,6 +762,49 @@ public virtual void SetNamedGraphicsState(NameToken stateName) // (see Section 6.5.4, “Automatic Stroke Adjustment”). currentGraphicsState.StrokeAdjustment = saToken.Data; } + + // (PDF 1.4, array is deprecated in PDF 2.0) The current blend mode that shall be + // used in the transparent imaging model (see 11.3.5, "Blend mode"). A PDF reader + // shall implicitly reset this parameter to its initial value at the (array is + // deprecated beginning of execution of a transparency group XObject + // (see 11.6.6, in PDF 2.0) "Transparency group XObjects"). The value shall be + // either a name object, designating one of the standard blend modes listed in + // "Table 134 — Standard separable blend modes" and "Table 135 — Standard + // non-separable blend modes" in 11.3.5, "Blend mode", or an array of such names. + // In the latter case, the PDF reader shall use the first blend mode in the array + // that it recognises (or Normal if it recognises none of them). + // + // Initial value: Normal. + if (state.TryGet(NameToken.Bm, PdfScanner, out NameToken? bmToken)) + { + currentGraphicsState.BlendMode = bmToken.Data.ToBlendMode() ?? BlendMode.Normal; + } + else if (state.TryGet(NameToken.Bm, PdfScanner, out ArrayToken? bmArrayToken)) + { + // The PDF reader shall use the first blend mode in the array that it + // recognises (or Normal if it recognises none of them). + + currentGraphicsState.BlendMode = BlendMode.Normal; + + foreach (var token in bmArrayToken.Data.OfType()) + { + var bm = token.Data.ToBlendMode(); + if (bm.HasValue) + { + currentGraphicsState.BlendMode = bm.Value; + break; + } + } + } + + if (state.TryGet(NameToken.Smask, PdfScanner, out NameToken? smToken) && smToken.Equals(NameToken.None)) + { + currentGraphicsState.SoftMask = null; + } + else if (state.TryGet(NameToken.Smask, PdfScanner, out DictionaryToken? smDictToken)) + { + currentGraphicsState.SoftMask = SoftMask.Parse(smDictToken, PdfScanner, FilterProvider); + } } /// diff --git a/src/UglyToad.PdfPig/Graphics/Core/BlendMode.cs b/src/UglyToad.PdfPig/Graphics/Core/BlendMode.cs new file mode 100644 index 000000000..b6ab1d984 --- /dev/null +++ b/src/UglyToad.PdfPig/Graphics/Core/BlendMode.cs @@ -0,0 +1,65 @@ +namespace UglyToad.PdfPig.Graphics.Core +{ + /// + /// The blend mode. + /// + public enum BlendMode : byte + { + // 11.3.5.2 Separable blend modes + + /// + /// Default. + /// Same as Compatible. + /// + Normal = 0, + Multiply = 1, + Screen = 2, + Darken = 3, + Lighten = 4, + ColorDodge = 5, + ColorBurn = 6, + HardLight = 7, + SoftLight = 8, + Overlay = 9, + Difference = 10, + Exclusion = 11, + + // 11.3.5.3 Non-separable blend modes + Hue = 12, + Saturation = 13, + Color = 14, + Luminosity = 15 + } + + internal static class BlendModeExtensions + { + public static BlendMode? ToBlendMode(this string s) + { + return s switch + { + // 11.3.5.2 Separable blend modes + "Normal" => BlendMode.Normal, + "Compatible" => BlendMode.Normal, + "Multiply" => BlendMode.Multiply, + "Screen" => BlendMode.Screen, + "Darken" => BlendMode.Darken, + "Lighten" => BlendMode.Lighten, + "ColorDodge" => BlendMode.ColorDodge, + "ColorBurn" => BlendMode.ColorBurn, + "HardLight" => BlendMode.HardLight, + "SoftLight" => BlendMode.SoftLight, + "Overlay" => BlendMode.Overlay, + "Difference" => BlendMode.Difference, + "Exclusion" => BlendMode.Exclusion, + + // 11.3.5.3 Non-separable blend modes + "Hue" => BlendMode.Hue, + "Saturation" => BlendMode.Saturation, + "Color" => BlendMode.Color, + "Luminosity" => BlendMode.Luminosity, + + _ => null + }; + } + } +} diff --git a/src/UglyToad.PdfPig/Graphics/CurrentGraphicsState.cs b/src/UglyToad.PdfPig/Graphics/CurrentGraphicsState.cs index ad8a13bcc..20a1e3587 100644 --- a/src/UglyToad.PdfPig/Graphics/CurrentGraphicsState.cs +++ b/src/UglyToad.PdfPig/Graphics/CurrentGraphicsState.cs @@ -71,10 +71,18 @@ public class CurrentGraphicsState : IDeepCloneable public double AlphaConstantNonStroking { get; set; } = 1; /// - /// Should soft mask and alpha constant values be interpreted as shape () or opacity () values? + /// Should soft mask and alpha constant values be interpreted as shape + /// () or opacity () values? /// public bool AlphaSource { get; set; } = false; + /// + /// A soft-mask dictionary specifying the mask shape or mask opacity values + /// that shall be used in the transparent imaging model, or the name None if + /// no such mask is specified. + /// + public SoftMask SoftMask { get; set; } + /// /// Maps positions from user coordinates to device coordinates. /// @@ -95,6 +103,11 @@ public class CurrentGraphicsState : IDeepCloneable /// public IColor CurrentNonStrokingColor { get; set; } + /// + /// The current blend mode. + /// + public BlendMode BlendMode { get; set; } = BlendMode.Normal; + #region Device Dependent /// @@ -151,6 +164,8 @@ public CurrentGraphicsState DeepClone() CurrentNonStrokingColor = CurrentNonStrokingColor, CurrentClippingPath = CurrentClippingPath, ColorSpaceContext = ColorSpaceContext?.DeepClone(), + BlendMode = BlendMode, + SoftMask = SoftMask }; } } diff --git a/src/UglyToad.PdfPig/Graphics/InlineImageBuilder.cs b/src/UglyToad.PdfPig/Graphics/InlineImageBuilder.cs index 87287b1d7..25ab8af1a 100644 --- a/src/UglyToad.PdfPig/Graphics/InlineImageBuilder.cs +++ b/src/UglyToad.PdfPig/Graphics/InlineImageBuilder.cs @@ -1,14 +1,18 @@ namespace UglyToad.PdfPig.Graphics { using System; + using System.Collections; using System.Collections.Generic; using System.Linq; + using System.Xml.Linq; using Content; using Core; using Filters; using PdfPig.Core; using Tokenization.Scanner; using Tokens; + using UglyToad.PdfPig.Graphics.Colors; + using UglyToad.PdfPig.XObjects; /// /// Inline Image Builder. @@ -49,9 +53,34 @@ internal InlineImage CreateInlineImage( var isMask = maskToken?.Data == true; var bitsPerComponent = GetByKeys(NameToken.BitsPerComponent, NameToken.Bpc, !isMask)?.Int ?? 1; - NameToken? colorSpaceName = null; + var imgDic = new DictionaryToken(Properties ?? new Dictionary()); + + XObjectImage? softMaskImage = null; + if (imgDic.TryGet(NameToken.Smask, tokenScanner, out StreamToken? sMaskToken)) + { + if (!sMaskToken.StreamDictionary.TryGet(NameToken.Subtype, out NameToken softMaskSubType) || !softMaskSubType.Equals(NameToken.Image)) + { + throw new Exception("The SMask dictionary does not contain a 'Subtype' entry, or its value is not 'Image'."); + } + + if (!sMaskToken.StreamDictionary.TryGet(NameToken.ColorSpace, out NameToken softMaskColorSpace) || !softMaskColorSpace.Equals(NameToken.Devicegray)) + { + throw new Exception("The SMask dictionary does not contain a 'ColorSpace' entry, or its value is not 'Devicegray'."); + } + + if (sMaskToken.StreamDictionary.ContainsKey(NameToken.Mask) || sMaskToken.StreamDictionary.ContainsKey(NameToken.Smask)) + { + throw new Exception("The SMask dictionary contains a 'Mask' or 'Smask' entry."); + } + + XObjectContentRecord softMaskImageRecord = new XObjectContentRecord(XObjectType.Image, sMaskToken, TransformationMatrix.Identity, + defaultRenderingIntent, DeviceGrayColorSpaceDetails.Instance); + + softMaskImage = XObjectFactory.ReadImage(softMaskImageRecord, tokenScanner, filterProvider, resourceStore); + } + if (!isMask) { colorSpaceName = GetByKeys(NameToken.ColorSpace, NameToken.Cs, false); @@ -74,8 +103,6 @@ internal InlineImage CreateInlineImage( } } - var imgDic = new DictionaryToken(Properties ?? new Dictionary()); - var details = resourceStore.GetColorSpaceDetails(colorSpaceName, imgDic); var renderingIntent = GetByKeys(NameToken.Intent, null, false)?.Data?.ToRenderingIntent() ?? defaultRenderingIntent; @@ -106,7 +133,7 @@ internal InlineImage CreateInlineImage( return new InlineImage(bounds, width, height, bitsPerComponent, isMask, renderingIntent, interpolate, decode, Bytes, - filterProvider, filterNames, imgDic, details); + filterProvider, filterNames, imgDic, details, softMaskImage); } #nullable disable diff --git a/src/UglyToad.PdfPig/Graphics/SoftMask.cs b/src/UglyToad.PdfPig/Graphics/SoftMask.cs new file mode 100644 index 000000000..eb575e639 --- /dev/null +++ b/src/UglyToad.PdfPig/Graphics/SoftMask.cs @@ -0,0 +1,166 @@ +namespace UglyToad.PdfPig.Graphics +{ + using System; + using System.Linq; + using UglyToad.PdfPig.Filters; + using UglyToad.PdfPig.Functions; + using UglyToad.PdfPig.Tokenization.Scanner; + using UglyToad.PdfPig.Tokens; + using UglyToad.PdfPig.Util; + + /// + /// Soft Mask. + /// + public sealed class SoftMask + { + /// + /// (Required) A subtype specifying the method that shall be used in deriving the mask + /// values from the transparency group specified by the G entry: + /// + /// Alpha - The group's computed alpha shall be used, disregarding its colour. + /// Luminosity - The group's computed colour shall be converted to a single-component luminosity value. + /// + /// + public SoftMaskType Subtype { get; private set; } + + /// + /// (Required) A transparency group XObject that shall be used as the source of alpha + /// or colour values for deriving the mask. If the subtype S is Luminosity, the group + /// attributes dictionary shall contain a CS entry defining the colour space in which + /// the compositing computation is to be performed. + /// + public StreamToken TransparencyGroup { get; private set; } + + /// + /// (Optional) An array of component values specifying the colour that shall be used + /// as the backdrop against which to composite the transparency group XObject G. + /// This entry shall be consulted only if the subtype S is Luminosity. + /// The array shall consist of n numbers, where n is the number of components in the + /// colour space specified by the CS entry in the group attributes dictionary. + /// + /// Default value: the colour space’s initial value, representing black. + /// + /// + public double[]? BC { get; private set; } + + /// + /// (Optional) A function object (see 7.10, "Functions") specifying the transfer + /// function that shall be used in deriving the mask values.The function shall + /// accept one input, the computed group alpha or luminosity (depending on the + /// value of the subtype S), and shall return one output, the resulting mask + /// value.The input shall be in the range 0.0 to 1.0. The computed output shall + /// be in the range 0.0 to 1.0; if it falls outside this range, it shall be forced + /// to the nearest valid value.The name Identity may be specified in place of a + /// function object to designate the identity function. + /// + /// Default value: Identity. + /// + /// + public PdfFunction? TransferFunction { get; private set; } + + internal static SoftMask Parse(DictionaryToken dictionaryToken, IPdfTokenScanner pdfTokenScanner, ILookupFilterProvider filterProvider) + { + if (dictionaryToken == null) + { + throw new ArgumentNullException(nameof(dictionaryToken)); + } + + var softMask = new SoftMask(); + + if (!dictionaryToken.TryGet(NameToken.S, pdfTokenScanner, out NameToken? s)) + { + /* + * (Required) A subtype specifying the method that shall be used in deriving + * the mask values from the transparency group specified by the G entry: Alpha + * The group’s computed alpha shall be used, disregarding its colour (see 11.5.2, + * "Deriving a soft mask from group alpha"). Luminosity The group’s computed + * colour shall be converted to a single-component luminosity value (see 11.5.3, + * "Deriving a soft mask from group luminosity"). + */ + throw new Exception($"Missing soft-mask dictionary '{NameToken.S}' entry."); + } + + if (s.Equals(NameToken.Luminosity)) + { + softMask.Subtype = SoftMaskType.Luminosity; + } + else if (s.Equals(NameToken.Alpha)) + { + softMask.Subtype = SoftMaskType.Alpha; + } + else + { + throw new Exception($"Invalid soft-mask Subtype '{s}' entry."); + } + + if (!dictionaryToken.TryGet(NameToken.G, pdfTokenScanner, out StreamToken g)) + { + /* + * (Required) A transparency group XObject (see 11.6.6, "Transparency group + * XObjects") that shall be used as the source of alpha or colour values for + * deriving the mask. If the subtype S is Luminosity, the group attributes + * dictionary shall contain a CS entry defining the colour space in which + * the compositing computation is to be performed. + */ + throw new Exception($"Missing soft-mask dictionary '{NameToken.G}' entry."); + } + + softMask.TransparencyGroup = g; + + if (dictionaryToken.TryGet(NameToken.Bc, pdfTokenScanner, out ArrayToken bc)) + { + /* + * (Optional) An array of component values specifying the colour that shall + * be used as the backdrop against which to composite the transparency group + * XObject G. This entry shall be consulted only if the subtype S is Luminosity. + * The array shall consist of n numbers, where n is the number of components in + * the colour space specified by the CS entry in the group attributes dictionary + * (see 11.6.6, "Transparency group XObjects"). Default value: the colour space’s + * initial value, representing black. + */ + softMask.BC = bc.Data.OfType().Select(x => x.Data).ToArray(); + } + + if (dictionaryToken.TryGet(NameToken.Tr, pdfTokenScanner, out NameToken trName)) + { + /* + * (Optional) A function object (see 7.10, "Functions") specifying the transfer + * function that shall be used in deriving the mask values. The function shall + * accept one input, the computed group alpha or luminosity (depending on the + * value of the subtype S), and shall return one output, the resulting mask + * value. The input shall be in the range 0.0 to 1.0. The computed output shall + * be in the range 0.0 to 1.0; if it falls outside this range, it shall be forced + * to the nearest valid value. The name Identity may be specified in place of a + * function object to designate the identity function. Default value: Identity + */ + if (!trName.Equals(NameToken.Identity)) + { + throw new Exception($"Invalid transfer function name '{trName}' entry, should be '{NameToken.Identity}'."); + } + } + else if (dictionaryToken.TryGet(NameToken.Tr, pdfTokenScanner, out IToken? trFunction)) + { + softMask.TransferFunction = PdfFunctionParser.Create(trFunction, pdfTokenScanner, filterProvider); + } + + return softMask; + } + } + + /// + /// The soft mask type. + /// Alpha or Luminosity. + /// + public enum SoftMaskType : byte + { + /// + /// Alpha - The group's computed alpha shall be used, disregarding its colour. + /// + Alpha = 0, + + /// + /// Luminosity - The group's computed colour shall be converted to a single-component luminosity value. + /// + Luminosity = 1 + } +} diff --git a/src/UglyToad.PdfPig/Images/Png/PngFromPdfImageFactory.cs b/src/UglyToad.PdfPig/Images/Png/PngFromPdfImageFactory.cs index 74eb53990..f95c4765c 100644 --- a/src/UglyToad.PdfPig/Images/Png/PngFromPdfImageFactory.cs +++ b/src/UglyToad.PdfPig/Images/Png/PngFromPdfImageFactory.cs @@ -6,7 +6,51 @@ using UglyToad.PdfPig.Core; internal static class PngFromPdfImageFactory - { + { + private static bool TryGenerateSoftMask(IPdfImage image, [NotNullWhen(true)] out ReadOnlySpan bytes) + { + bytes = ReadOnlySpan.Empty; + + if (!image.TryGetBytesAsMemory(out var imageMemory)) + { + return false; + } + + try + { + bytes = ColorSpaceDetailsByteConverter.Convert(image.ColorSpaceDetails!, + imageMemory.Span, + image.BitsPerComponent, + image.WidthInSamples, + image.HeightInSamples); + return IsCorrectlySized(image, bytes); + } + catch (Exception) + { + // ignored. + } + + return false; + } + + private static bool IsCorrectlySized(IPdfImage image, ReadOnlySpan bytesPure) + { + var numberOfComponents = image.ColorSpaceDetails!.BaseNumberOfColorComponents; + var requiredSize = (image.WidthInSamples * image.HeightInSamples * numberOfComponents); + + var actualSize = bytesPure.Length; + + return bytesPure.Length == requiredSize || + // Spec, p. 37: "...error if the stream contains too much data, with the exception that + // there may be an extra end-of-line marker..." + (actualSize == requiredSize + 1 && bytesPure[actualSize - 1] == ReadHelper.AsciiLineFeed) || + (actualSize == requiredSize + 1 && bytesPure[actualSize - 1] == ReadHelper.AsciiCarriageReturn) || + // The combination of a CARRIAGE RETURN followed immediately by a LINE FEED is treated as one EOL marker. + (actualSize == requiredSize + 2 && + bytesPure[actualSize - 2] == ReadHelper.AsciiCarriageReturn && + bytesPure[actualSize - 1] == ReadHelper.AsciiLineFeed); + } + public static bool TryGenerate(IPdfImage image, [NotNullWhen(true)] out byte[]? bytes) { bytes = null; @@ -29,24 +73,12 @@ public static bool TryGenerate(IPdfImage image, [NotNullWhen(true)] out byte[]? var numberOfComponents = image.ColorSpaceDetails!.BaseNumberOfColorComponents; - var is3Byte = numberOfComponents == 3; + ReadOnlySpan softMask = null; + bool isSoftMask = image.SoftMaskImage is not null && TryGenerateSoftMask(image.SoftMaskImage, out softMask); - var builder = PngBuilder.Create(image.WidthInSamples, image.HeightInSamples, false); - - var requiredSize = (image.WidthInSamples * image.HeightInSamples * numberOfComponents); - - var actualSize = bytesPure.Length; - var isCorrectlySized = bytesPure.Length == requiredSize || - // Spec, p. 37: "...error if the stream contains too much data, with the exception that - // there may be an extra end-of-line marker..." - (actualSize == requiredSize + 1 && bytesPure[actualSize - 1] == ReadHelper.AsciiLineFeed) || - (actualSize == requiredSize + 1 && bytesPure[actualSize - 1] == ReadHelper.AsciiCarriageReturn) || - // The combination of a CARRIAGE RETURN followed immediately by a LINE FEED is treated as one EOL marker. - (actualSize == requiredSize + 2 && - bytesPure[actualSize - 2] == ReadHelper.AsciiCarriageReturn && - bytesPure[actualSize - 1] == ReadHelper.AsciiLineFeed); + var builder = PngBuilder.Create(image.WidthInSamples, image.HeightInSamples, isSoftMask); - if (!isCorrectlySized) + if (!IsCorrectlySized(image, bytesPure)) { return false; } @@ -54,6 +86,7 @@ public static bool TryGenerate(IPdfImage image, [NotNullWhen(true)] out byte[]? if (image.ColorSpaceDetails.BaseType == ColorSpace.DeviceCMYK || numberOfComponents == 4) { int i = 0; + int sm = 0; for (int col = 0; col < image.HeightInSamples; col++) { for (int row = 0; row < image.WidthInSamples; row++) @@ -65,6 +98,7 @@ public static bool TryGenerate(IPdfImage image, [NotNullWhen(true)] out byte[]? * B = 255 × (1-Y) × (1-K) */ + byte a = isSoftMask ? softMask[sm++] : byte.MaxValue; double c = (bytesPure[i++] / 255d); double m = (bytesPure[i++] / 255d); double y = (bytesPure[i++] / 255d); @@ -73,18 +107,20 @@ public static bool TryGenerate(IPdfImage image, [NotNullWhen(true)] out byte[]? var g = (byte)(255 * (1 - m) * (1 - k)); var b = (byte)(255 * (1 - y) * (1 - k)); - builder.SetPixel(r, g, b, row, col); + builder.SetPixel(new Pixel(r, g, b, a, false), row, col); } } } - else if (is3Byte) + else if (numberOfComponents == 3) { int i = 0; + int sm = 0; for (int col = 0; col < image.HeightInSamples; col++) { for (int row = 0; row < image.WidthInSamples; row++) { - builder.SetPixel(bytesPure[i++], bytesPure[i++], bytesPure[i++], row, col); + byte a = isSoftMask ? softMask[sm++] : byte.MaxValue; + builder.SetPixel(new Pixel(bytesPure[i++], bytesPure[i++], bytesPure[i++], a, false), row, col); } } } @@ -95,8 +131,9 @@ public static bool TryGenerate(IPdfImage image, [NotNullWhen(true)] out byte[]? { for (int row = 0; row < image.WidthInSamples; row++) { + byte a = isSoftMask ? softMask[i] : byte.MaxValue; byte pixel = bytesPure[i++]; - builder.SetPixel(pixel, pixel, pixel, row, col); + 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 b8415f4da..aea5dc80b 100644 --- a/src/UglyToad.PdfPig/XObjects/XObjectFactory.cs +++ b/src/UglyToad.PdfPig/XObjects/XObjectFactory.cs @@ -1,55 +1,81 @@ -namespace UglyToad.PdfPig.XObjects -{ - using System; - using System.Linq; - using Content; - using Core; - using Filters; - using Graphics; - using Graphics.Colors; - using Graphics.Core; +namespace UglyToad.PdfPig.XObjects +{ + using System; + using System.Linq; + using Content; + using Core; + using Filters; + using Graphics; + using Graphics.Colors; + using Graphics.Core; using Images; - using Tokenization.Scanner; + using Tokenization.Scanner; using Tokens; using Util; - - /// - /// External Object (XObject) factory. - /// - public static class XObjectFactory + + /// + /// External Object (XObject) factory. + /// + public static class XObjectFactory { - /// - /// Read the XObject image. - /// + /// + /// Read the XObject image. + /// public static XObjectImage ReadImage(XObjectContentRecord xObject, IPdfTokenScanner pdfScanner, - ILookupFilterProvider filterProvider, - IResourceStore resourceStore) - { - if (xObject is null) - { - throw new ArgumentNullException(nameof(xObject)); - } - - if (xObject.Type != XObjectType.Image) - { - throw new InvalidOperationException($"Cannot create an image from an XObject with type: {xObject.Type}."); + ILookupFilterProvider filterProvider, + IResourceStore resourceStore) + { + if (xObject is null) + { + throw new ArgumentNullException(nameof(xObject)); + } + + if (xObject.Type != XObjectType.Image) + { + throw new InvalidOperationException($"Cannot create an image from an XObject with type: {xObject.Type}."); } var dictionary = xObject.Stream.StreamDictionary.Resolve(pdfScanner); - - var bounds = xObject.AppliedTransformation.Transform(new PdfRectangle(new PdfPoint(0, 0), new PdfPoint(1, 1))); - + + var bounds = xObject.AppliedTransformation.Transform(new PdfRectangle(new PdfPoint(0, 0), new PdfPoint(1, 1))); + var width = dictionary.GetInt(NameToken.Width); var height = dictionary.GetInt(NameToken.Height); - + var isImageMask = dictionary.TryGet(NameToken.ImageMask, out BooleanToken isMaskToken) && isMaskToken.Data; - - var isJpxDecode = dictionary.TryGet(NameToken.Filter, out NameToken filterName) && filterName.Equals(NameToken.JpxDecode); - + + XObjectImage? softMaskImage = null; + if (dictionary.TryGet(NameToken.Smask, pdfScanner, out StreamToken? sMaskToken)) + { + if (!sMaskToken.StreamDictionary.TryGet(NameToken.Subtype, out NameToken softMaskSubType) || !softMaskSubType.Equals(NameToken.Image)) + { + throw new Exception("The SMask dictionary does not contain a 'Subtype' entry, or its value is not 'Image'."); + } + + if (!sMaskToken.StreamDictionary.TryGet(NameToken.ColorSpace, out NameToken softMaskColorSpace) || !softMaskColorSpace.Equals(NameToken.Devicegray)) + { + throw new Exception("The SMask dictionary does not contain a 'ColorSpace' entry, or its value is not 'Devicegray'."); + } + + if (sMaskToken.StreamDictionary.ContainsKey(NameToken.Mask) || sMaskToken.StreamDictionary.ContainsKey(NameToken.Smask)) + { + 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); + + softMaskImage = ReadImage(softMaskImageRecord, pdfScanner, filterProvider, resourceStore); + } + + var isJpxDecode = dictionary.TryGet(NameToken.Filter, out NameToken filterName) && filterName.Equals(NameToken.JpxDecode); + int bitsPerComponent; if (isImageMask) - { + { bitsPerComponent = 1; } else @@ -74,17 +100,17 @@ public static XObjectImage ReadImage(XObjectContentRecord xObject, { throw new PdfDocumentFormatException($"No bits per component defined for image: {dictionary}."); } - + bitsPerComponent = bitsPerComponentToken.Int; } - } - - var intent = xObject.DefaultRenderingIntent; - if (dictionary.TryGet(NameToken.Intent, out NameToken renderingIntentToken)) - { - intent = renderingIntentToken.Data.ToRenderingIntent(); - } - + } + + var intent = xObject.DefaultRenderingIntent; + if (dictionary.TryGet(NameToken.Intent, out NameToken renderingIntentToken)) + { + intent = renderingIntentToken.Data.ToRenderingIntent(); + } + var interpolate = dictionary.TryGet(NameToken.Interpolate, out BooleanToken? interpolateToken) && interpolateToken.Data; @@ -99,55 +125,56 @@ public static XObjectImage ReadImage(XObjectContentRecord xObject, } } - var streamToken = new StreamToken(dictionary, xObject.Stream.Data); - + var streamToken = new StreamToken(dictionary, xObject.Stream.Data); + var decodedBytes = supportsFilters ? new Lazy>(() => streamToken.Decode(filterProvider, pdfScanner)) - : null; - - var decode = Array.Empty(); + : null; + + var decode = Array.Empty(); if (dictionary.TryGet(NameToken.Decode, out ArrayToken decodeArrayToken)) - { - decode = decodeArrayToken.Data.OfType() - .Select(x => x.Double) - .ToArray(); - } + { + decode = decodeArrayToken.Data.OfType() + .Select(x => x.Double) + .ToArray(); + } - ColorSpaceDetails? details = null; - if (!isImageMask) - { + ColorSpaceDetails? details = null; + if (!isImageMask) + { if (dictionary.TryGet(NameToken.ColorSpace, out NameToken? colorSpaceNameToken)) { - details = resourceStore.GetColorSpaceDetails(colorSpaceNameToken, dictionary); - } + details = resourceStore.GetColorSpaceDetails(colorSpaceNameToken, dictionary); + } else if (dictionary.TryGet(NameToken.ColorSpace, out ArrayToken? colorSpaceArrayToken) - && colorSpaceArrayToken.Length > 0 && colorSpaceArrayToken.Data[0] is NameToken firstColorSpaceName) + && colorSpaceArrayToken.Length > 0 && colorSpaceArrayToken.Data[0] is NameToken firstColorSpaceName) { details = resourceStore.GetColorSpaceDetails(firstColorSpaceName, dictionary); - } - else if (!isJpxDecode) - { - details = xObject.DefaultColorSpace; } - } + else if (!isJpxDecode) + { + details = xObject.DefaultColorSpace; + } + } else { details = resourceStore.GetColorSpaceDetails(null, dictionary); - } - - return new XObjectImage( - bounds, - width, - height, - bitsPerComponent, - isJpxDecode, - isImageMask, - intent, - interpolate, - decode, - dictionary, - xObject.Stream.Data, - decodedBytes, - details); - } - } -} + } + + return new XObjectImage( + bounds, + width, + height, + bitsPerComponent, + isJpxDecode, + isImageMask, + intent, + interpolate, + decode, + dictionary, + xObject.Stream.Data, + decodedBytes, + details, + softMaskImage); + } + } +} diff --git a/src/UglyToad.PdfPig/XObjects/XObjectImage.cs b/src/UglyToad.PdfPig/XObjects/XObjectImage.cs index 09ebf4e79..29ee3bdff 100644 --- a/src/UglyToad.PdfPig/XObjects/XObjectImage.cs +++ b/src/UglyToad.PdfPig/XObjects/XObjectImage.cs @@ -64,6 +64,9 @@ public class XObjectImage : IPdfImage /// public ColorSpaceDetails? ColorSpaceDetails { get; } + /// + public IPdfImage? SoftMaskImage { get; } + /// /// Creates a new . /// @@ -79,7 +82,8 @@ internal XObjectImage(PdfRectangle bounds, DictionaryToken imageDictionary, ReadOnlyMemory rawMemory, Lazy>? bytes, - ColorSpaceDetails? colorSpaceDetails) + ColorSpaceDetails? colorSpaceDetails, + IPdfImage? softMaskImage) { Bounds = bounds; WidthInSamples = widthInSamples; @@ -94,6 +98,7 @@ internal XObjectImage(PdfRectangle bounds, RawMemory = rawMemory; ColorSpaceDetails = colorSpaceDetails; memoryFactory = bytes; + SoftMaskImage = softMaskImage; } /// diff --git a/src/UglyToad.PdfPig/XObjects/XObjectType.cs b/src/UglyToad.PdfPig/XObjects/XObjectType.cs index 56e9e8814..553b70d29 100644 --- a/src/UglyToad.PdfPig/XObjects/XObjectType.cs +++ b/src/UglyToad.PdfPig/XObjects/XObjectType.cs @@ -3,7 +3,7 @@ /// /// XObject type. /// - public enum XObjectType + public enum XObjectType : byte { /// /// Image.