Add early support for Stencil masking, rename SoftMaskImage property into MaskImage and make sure IsInlineImage is true for InlineImage
Some checks failed
Build and test / build (push) Has been cancelled
Build and test [MacOS] / build (push) Has been cancelled
Run Integration Tests / build (push) Has been cancelled

This commit is contained in:
BobLd 2025-05-11 15:42:51 +01:00
parent 0bed135bad
commit 4dab2ef239
11 changed files with 183 additions and 71 deletions

View File

@ -3,7 +3,7 @@
using SkiaSharp; using SkiaSharp;
using System.Linq; using System.Linq;
public class SoftMaskTests public class MaskTests
{ {
[Fact] [Fact]
public void PigProductionHandbook() public void PigProductionHandbook()
@ -17,7 +17,7 @@
var images = page.GetImages().ToArray(); var images = page.GetImages().ToArray();
var image1 = images[1]; var image1 = images[1];
Assert.NotNull(image1.SoftMaskImage); Assert.NotNull(image1.MaskImage);
Assert.True(image1.TryGetPng(out var png1)); Assert.True(image1.TryGetPng(out var png1));
using (var skImage1 = SKImage.FromEncodedData(png1)) using (var skImage1 = SKImage.FromEncodedData(png1))
using (var skBitmap1 = SKBitmap.FromImage(skImage1)) using (var skBitmap1 = SKBitmap.FromImage(skImage1))
@ -27,7 +27,7 @@
} }
var image2 = images[2]; var image2 = images[2];
Assert.NotNull(image2.SoftMaskImage); Assert.NotNull(image2.MaskImage);
Assert.True(image2.TryGetPng(out var png2)); Assert.True(image2.TryGetPng(out var png2));
using (var skImage2 = SKImage.FromEncodedData(png2)) using (var skImage2 = SKImage.FromEncodedData(png2))
using (var skBitmap2 = SKBitmap.FromImage(skImage2)) 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
}
}
} }
} }

View File

@ -80,6 +80,7 @@
"UglyToad.PdfPig.Content.InlineImage", "UglyToad.PdfPig.Content.InlineImage",
"UglyToad.PdfPig.Content.IPageFactory`1", "UglyToad.PdfPig.Content.IPageFactory`1",
"UglyToad.PdfPig.Content.IPdfImage", "UglyToad.PdfPig.Content.IPdfImage",
"UglyToad.PdfPig.Content.PdfImageExtensions",
"UglyToad.PdfPig.Content.IResourceStore", "UglyToad.PdfPig.Content.IResourceStore",
"UglyToad.PdfPig.Content.Letter", "UglyToad.PdfPig.Content.Letter",
"UglyToad.PdfPig.Content.MarkedContentElement", "UglyToad.PdfPig.Content.MarkedContentElement",

View File

@ -37,7 +37,7 @@
public ReadOnlyMemory<byte> DecodedBytes { get; set; } public ReadOnlyMemory<byte> DecodedBytes { get; set; }
public IPdfImage? SoftMaskImage { get; } public IPdfImage? MaskImage { get; }
public bool TryGetBytesAsMemory(out ReadOnlyMemory<byte> bytes) public bool TryGetBytesAsMemory(out ReadOnlyMemory<byte> bytes)
{ {

View File

@ -95,9 +95,10 @@
ColorSpaceDetails? ColorSpaceDetails { get; } ColorSpaceDetails? ColorSpaceDetails { get; }
/// <summary> /// <summary>
/// Soft-mask image. /// The image mask.
/// <para>Either a Soft-mask or a Stencil mask.</para>
/// </summary> /// </summary>
IPdfImage? SoftMaskImage { get; } IPdfImage? MaskImage { get; }
/// <summary> /// <summary>
/// Get the decoded memory of the image if applicable. For JPEG images and some other types the /// Get the decoded memory of the image if applicable. For JPEG images and some other types the

View File

@ -58,7 +58,7 @@
public ColorSpaceDetails ColorSpaceDetails { get; } public ColorSpaceDetails ColorSpaceDetails { get; }
/// <inheritdoc /> /// <inheritdoc />
public IPdfImage? SoftMaskImage { get; } public IPdfImage? MaskImage { get; }
/// <summary> /// <summary>
/// Create a new <see cref="InlineImage"/>. /// Create a new <see cref="InlineImage"/>.
@ -78,6 +78,7 @@
ColorSpaceDetails colorSpaceDetails, ColorSpaceDetails colorSpaceDetails,
IPdfImage? softMaskImage) IPdfImage? softMaskImage)
{ {
IsInlineImage = true;
Bounds = bounds; Bounds = bounds;
WidthInSamples = widthInSamples; WidthInSamples = widthInSamples;
HeightInSamples = heightInSamples; HeightInSamples = heightInSamples;
@ -114,7 +115,7 @@
return b; return b;
}) : null; }) : null;
SoftMaskImage = softMaskImage; MaskImage = softMaskImage;
} }
/// <inheritdoc /> /// <inheritdoc />

View File

@ -0,0 +1,22 @@
namespace UglyToad.PdfPig.Content
{
/// <summary>
/// Pdf image extensions.
/// </summary>
public static class PdfImageExtensions
{
/// <summary>
/// <c>true</c> if the image colors needs to be reversed based on the Decode array and color space. <c>false</c> otherwise.
/// </summary>
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;
}
}
}

View File

@ -15,6 +15,12 @@
/// </summary> /// </summary>
public abstract class ColorSpaceDetails public abstract class ColorSpaceDetails
{ {
/// <summary>
/// Is the color space a stencil indexed color space.
/// <para>Stencil color spaces take care of inverting colors based on the Decode array.</para>
/// </summary>
public bool IsStencil { get; }
/// <summary> /// <summary>
/// The type of the ColorSpace. /// The type of the ColorSpace.
/// </summary> /// </summary>
@ -39,10 +45,11 @@
/// <summary> /// <summary>
/// Create a new <see cref="ColorSpaceDetails"/>. /// Create a new <see cref="ColorSpaceDetails"/>.
/// </summary> /// </summary>
protected internal ColorSpaceDetails(ColorSpace type) protected internal ColorSpaceDetails(ColorSpace type, bool isStencil = false)
{ {
Type = type; Type = type;
BaseType = type; BaseType = type;
IsStencil = isStencil;
} }
/// <summary> /// <summary>
@ -279,7 +286,7 @@
internal static ColorSpaceDetails Stencil(ColorSpaceDetails colorSpaceDetails, double[] decode) internal static ColorSpaceDetails Stencil(ColorSpaceDetails colorSpaceDetails, double[] decode)
{ {
var blackIsOne = decode.Length >= 2 && decode[0] == 1 && decode[1] == 0; 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);
} }
/// <inheritdoc/> /// <inheritdoc/>
@ -310,11 +317,15 @@
/// </summary> /// </summary>
public ReadOnlySpan<byte> ColorTable => colorTable; public ReadOnlySpan<byte> ColorTable => colorTable;
public IndexedColorSpaceDetails(ColorSpaceDetails baseColorSpaceDetails, byte hiVal, byte[] colorTable)
: this(baseColorSpaceDetails, hiVal, colorTable, false)
{ }
/// <summary> /// <summary>
/// Create a new <see cref="IndexedColorSpaceDetails"/>. /// Create a new <see cref="IndexedColorSpaceDetails"/>.
/// </summary> /// </summary>
public IndexedColorSpaceDetails(ColorSpaceDetails baseColorSpaceDetails, byte hiVal, byte[] colorTable) private IndexedColorSpaceDetails(ColorSpaceDetails baseColorSpaceDetails, byte hiVal, byte[] colorTable, bool isStencil)
: base(ColorSpace.Indexed) : base(ColorSpace.Indexed, isStencil)
{ {
BaseColorSpace = baseColorSpaceDetails ?? throw new ArgumentNullException(nameof(baseColorSpaceDetails)); BaseColorSpace = baseColorSpaceDetails ?? throw new ArgumentNullException(nameof(baseColorSpaceDetails));
HiVal = hiVal; HiVal = hiVal;

View File

@ -11,6 +11,19 @@
{ {
bytes = ReadOnlySpan<byte>.Empty; bytes = ReadOnlySpan<byte>.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)) if (!image.TryGetBytesAsMemory(out var imageMemory))
{ {
return false; return false;
@ -74,9 +87,24 @@
var numberOfComponents = image.ColorSpaceDetails!.BaseNumberOfColorComponents; var numberOfComponents = image.ColorSpaceDetails!.BaseNumberOfColorComponents;
ReadOnlySpan<byte> softMask = null; ReadOnlySpan<byte> 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<int, byte> 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)) if (!IsCorrectlySized(image, bytesPure))
{ {
@ -98,7 +126,7 @@
* B = 255 × (1-Y) × (1-K) * B = 255 × (1-Y) × (1-K)
*/ */
byte a = isSoftMask ? softMask[sm++] : byte.MaxValue; byte a = getAlphaChannel(sm++);
double c = (bytesPure[i++] / 255d); double c = (bytesPure[i++] / 255d);
double m = (bytesPure[i++] / 255d); double m = (bytesPure[i++] / 255d);
double y = (bytesPure[i++] / 255d); double y = (bytesPure[i++] / 255d);
@ -119,7 +147,7 @@
{ {
for (int row = 0; row < image.WidthInSamples; row++) 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); 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++) for (int row = 0; row < image.WidthInSamples; row++)
{ {
byte a = isSoftMask ? softMask[i] : byte.MaxValue; byte a = getAlphaChannel(i);
byte pixel = bytesPure[i++]; byte pixel = bytesPure[i++];
builder.SetPixel(new Pixel(pixel, pixel, pixel, a, false), row, col); builder.SetPixel(new Pixel(pixel, pixel, pixel, a, false), row, col);
} }

View File

@ -63,13 +63,31 @@
throw new Exception("The SMask dictionary contains a 'Mask' or 'Smask' entry."); throw new Exception("The SMask dictionary contains a 'Mask' or 'Smask' entry.");
} }
var renderingIntent = xObject.DefaultRenderingIntent; // Ignored XObjectContentRecord softMaskImageRecord = new XObjectContentRecord(XObjectType.Image,
sMaskToken,
XObjectContentRecord softMaskImageRecord = new XObjectContentRecord(XObjectType.Image, sMaskToken, TransformationMatrix.Identity, TransformationMatrix.Identity,
renderingIntent, DeviceGrayColorSpaceDetails.Instance); xObject.DefaultRenderingIntent, // Ignored
DeviceGrayColorSpaceDetails.Instance);
softMaskImage = ReadImage(softMaskImageRecord, pdfScanner, filterProvider, resourceStore); 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); 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<ReadOnlyMemory<byte>>(() => xObject.Stream.Decode(filterProvider, pdfScanner))
var decodedBytes = supportsFilters ? new Lazy<ReadOnlyMemory<byte>>(() => streamToken.Decode(filterProvider, pdfScanner))
: null; : null;
var decode = Array.Empty<double>(); var decode = Array.Empty<double>();

View File

@ -65,7 +65,7 @@
public ColorSpaceDetails? ColorSpaceDetails { get; } public ColorSpaceDetails? ColorSpaceDetails { get; }
/// <inheritdoc /> /// <inheritdoc />
public IPdfImage? SoftMaskImage { get; } public IPdfImage? MaskImage { get; }
/// <summary> /// <summary>
/// Creates a new <see cref="XObjectImage"/>. /// Creates a new <see cref="XObjectImage"/>.
@ -98,7 +98,7 @@
RawMemory = rawMemory; RawMemory = rawMemory;
ColorSpaceDetails = colorSpaceDetails; ColorSpaceDetails = colorSpaceDetails;
memoryFactory = bytes; memoryFactory = bytes;
SoftMaskImage = softMaskImage; MaskImage = softMaskImage;
} }
/// <inheritdoc /> /// <inheritdoc />