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 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
}
}
}
}

View File

@ -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",

View File

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

View File

@ -95,9 +95,10 @@
ColorSpaceDetails? ColorSpaceDetails { get; }
/// <summary>
/// Soft-mask image.
/// The image mask.
/// <para>Either a Soft-mask or a Stencil mask.</para>
/// </summary>
IPdfImage? SoftMaskImage { get; }
IPdfImage? MaskImage { get; }
/// <summary>
/// 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; }
/// <inheritdoc />
public IPdfImage? SoftMaskImage { get; }
public IPdfImage? MaskImage { get; }
/// <summary>
/// Create a new <see cref="InlineImage"/>.
@ -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;
}
/// <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>
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>
/// The type of the ColorSpace.
/// </summary>
@ -39,10 +45,11 @@
/// <summary>
/// Create a new <see cref="ColorSpaceDetails"/>.
/// </summary>
protected internal ColorSpaceDetails(ColorSpace type)
protected internal ColorSpaceDetails(ColorSpace type, bool isStencil = false)
{
Type = type;
BaseType = type;
IsStencil = isStencil;
}
/// <summary>
@ -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);
}
/// <inheritdoc/>
@ -310,11 +317,15 @@
/// </summary>
public ReadOnlySpan<byte> ColorTable => colorTable;
public IndexedColorSpaceDetails(ColorSpaceDetails baseColorSpaceDetails, byte hiVal, byte[] colorTable)
: this(baseColorSpaceDetails, hiVal, colorTable, false)
{ }
/// <summary>
/// Create a new <see cref="IndexedColorSpaceDetails"/>.
/// </summary>
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<byte> result = new byte[input.Length * 3];
var i = 0;
foreach (var x in input)
{
for (var j = 0; j < 3; ++j)
Span<byte> 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<byte> result = new byte[input.Length * 4];
var i = 0;
foreach (var x in input)
{
for (var j = 0; j < 4; ++j)
Span<byte> 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<byte> 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<byte> 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;

View File

@ -11,6 +11,19 @@
{
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))
{
return false;
@ -74,9 +87,24 @@
var numberOfComponents = image.ColorSpaceDetails!.BaseNumberOfColorComponents;
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))
{
@ -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);
}

View File

@ -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<ReadOnlyMemory<byte>>(() => streamToken.Decode(filterProvider, pdfScanner))
var decodedBytes = supportsFilters ? new Lazy<ReadOnlyMemory<byte>>(() => xObject.Stream.Decode(filterProvider, pdfScanner))
: null;
var decode = Array.Empty<double>();

View File

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