diff --git a/src/UglyToad.PdfPig.Tests/ContentTests/ResourceStoreDefaultColorSpaceTests.cs b/src/UglyToad.PdfPig.Tests/ContentTests/ResourceStoreDefaultColorSpaceTests.cs new file mode 100644 index 000000000..86a703d41 --- /dev/null +++ b/src/UglyToad.PdfPig.Tests/ContentTests/ResourceStoreDefaultColorSpaceTests.cs @@ -0,0 +1,259 @@ +namespace UglyToad.PdfPig.Tests.ContentTests +{ + using System.Collections.Generic; + using PdfPig.Content; + using PdfPig.Graphics.Colors; + using PdfPig.PdfFonts; + using PdfPig.Tokens; + using UglyToad.PdfPig.Tests.Tokens; + using Xunit; + + public class ResourceStoreDefaultColorSpaceTests + { + private sealed class NoOpFontFactory : IFontFactory + { + public IFont Get(DictionaryToken dictionary) => null!; + } + + private static ResourceStore BuildStore() + { + return new ResourceStore( + new TestPdfTokenScanner(), + new NoOpFontFactory(), + new TestFilterProvider(), + new ParsingOptions + { + UseLenientParsing = true, + SkipMissingFonts = true, + }); + } + + [Fact] + public void DeviceRgbRequest_WithDefaultRgbInResources_UsesDefaultRgb() + { + // Resources/ColorSpace/DefaultRGB -> [ /CalRGB << /WhitePoint [0.9505 1 1.089] >> ] + var calRgbDict = new DictionaryToken(new Dictionary + { + { + NameToken.WhitePoint, + new ArrayToken(new IToken[] + { + new NumericToken(0.9505), + new NumericToken(1.0), + new NumericToken(1.089), + }) + }, + }); + var defaultRgbArray = new ArrayToken(new IToken[] { NameToken.Calrgb, calRgbDict }); + + var resources = new DictionaryToken(new Dictionary + { + { + NameToken.ColorSpace, + new DictionaryToken(new Dictionary + { + { NameToken.DefaultRgb, defaultRgbArray }, + }) + }, + }); + + var store = BuildStore(); + store.LoadResourceDictionary(resources); + + var details = store.GetColorSpaceDetails( + NameToken.Devicergb, + new DictionaryToken(new Dictionary())); + + Assert.Equal(ColorSpace.CalRGB, details.Type); + } + + [Fact] + public void GetDeviceColorSpaceDetails_WithDefaultRgbInResources_UsesDefaultRgb() + { + // The g/rg/k operators select a device colour space directly; per 8.6.5.6 the matching + // Default* substitution must still apply (it takes precedence over any output intent). + var calRgbDict = new DictionaryToken(new Dictionary + { + { + NameToken.WhitePoint, + new ArrayToken(new IToken[] + { + new NumericToken(0.9505), + new NumericToken(1.0), + new NumericToken(1.089), + }) + }, + }); + var defaultRgbArray = new ArrayToken(new IToken[] { NameToken.Calrgb, calRgbDict }); + + var resources = new DictionaryToken(new Dictionary + { + { + NameToken.ColorSpace, + new DictionaryToken(new Dictionary + { + { NameToken.DefaultRgb, defaultRgbArray }, + }) + }, + }); + + var store = BuildStore(); + store.LoadResourceDictionary(resources); + + var details = store.GetDeviceColorSpaceDetails(ColorSpace.DeviceRGB); + + Assert.Equal(ColorSpace.CalRGB, details.Type); + } + + [Fact] + public void GetDeviceColorSpaceDetails_WithoutDefault_ReturnsDeviceColorSpace() + { + var store = BuildStore(); + store.LoadResourceDictionary(new DictionaryToken(new Dictionary())); + + Assert.Same(DeviceGrayColorSpaceDetails.Instance, store.GetDeviceColorSpaceDetails(ColorSpace.DeviceGray)); + Assert.Same(DeviceRgbColorSpaceDetails.Instance, store.GetDeviceColorSpaceDetails(ColorSpace.DeviceRGB)); + Assert.Same(DeviceCmykColorSpaceDetails.Instance, store.GetDeviceColorSpaceDetails(ColorSpace.DeviceCMYK)); + } + + [Fact] + public void IndexedBase_WithDefaultRgbInResources_UsesDefaultRgb() + { + // 8.6.5.6: the base colour space of an Indexed space, when it is a device colour space, must + // be replaced by the corresponding Default* colour space. + var calRgbDict = new DictionaryToken(new Dictionary + { + { + NameToken.WhitePoint, + new ArrayToken(new IToken[] + { + new NumericToken(0.9505), + new NumericToken(1.0), + new NumericToken(1.089), + }) + }, + }); + var defaultRgbArray = new ArrayToken(new IToken[] { NameToken.Calrgb, calRgbDict }); + + var resources = new DictionaryToken(new Dictionary + { + { + NameToken.ColorSpace, + new DictionaryToken(new Dictionary + { + { NameToken.DefaultRgb, defaultRgbArray }, + }) + }, + }); + + var store = BuildStore(); + store.LoadResourceDictionary(resources); + + // [ /Indexed /DeviceRGB 1 <000000FFFFFF> ] : 2 entries of 3 RGB components. + var indexedArray = new ArrayToken(new IToken[] + { + NameToken.Indexed, + NameToken.Devicergb, + new NumericToken(1), + new StringToken("ÿÿÿ"), + }); + var imageDictionary = new DictionaryToken(new Dictionary + { + { NameToken.ColorSpace, indexedArray }, + }); + + var details = store.GetColorSpaceDetails(NameToken.Indexed, imageDictionary); + + var indexed = Assert.IsType(details); + Assert.Equal(ColorSpace.CalRGB, indexed.BaseColorSpace.Type); + } + + [Fact] + public void GetDeviceColorSpaceDetails_WithIndexedDefaultRgb_ReturnsDeviceColorSpace() + { + // 8.6.5.6: any colour space other than a Lab, Indexed, or Pattern colour space may be used as a + // default colour space. An Indexed DefaultRGB is therefore invalid and must be ignored, leaving + // the device colour space in place. + // DefaultRGB -> [ /Indexed /DeviceGray 1 <00FF> ] + var defaultRgbArray = new ArrayToken(new IToken[] + { + NameToken.Indexed, + NameToken.Devicegray, + new NumericToken(1), + new StringToken("ÿ"), + }); + + var resources = new DictionaryToken(new Dictionary + { + { + NameToken.ColorSpace, + new DictionaryToken(new Dictionary + { + { NameToken.DefaultRgb, defaultRgbArray }, + }) + }, + }); + + var store = BuildStore(); + store.LoadResourceDictionary(resources); + + Assert.Same(DeviceRgbColorSpaceDetails.Instance, store.GetDeviceColorSpaceDetails(ColorSpace.DeviceRGB)); + } + + [Fact] + public void GetDeviceColorSpaceDetails_WithSelfReferentialIndexedDefaultRgb_ReturnsDeviceColorSpace() + { + // Regression: a self-referential default - /DefaultRGB defined as an Indexed space whose base + // is /DeviceRGB - must not recurse forever. Resolving DeviceRGB resolves the Indexed default, + // whose base resolves DeviceRGB again; the re-entrancy guard breaks the loop and the Indexed + // default is rejected, leaving the device colour space. + // DefaultRGB -> [ /Indexed /DeviceRGB 1 <000000FFFFFF> ] + var defaultRgbArray = new ArrayToken(new IToken[] + { + NameToken.Indexed, + NameToken.Devicergb, + new NumericToken(1), + new StringToken("ÿÿÿ"), + }); + + var resources = new DictionaryToken(new Dictionary + { + { + NameToken.ColorSpace, + new DictionaryToken(new Dictionary + { + { NameToken.DefaultRgb, defaultRgbArray }, + }) + }, + }); + + var store = BuildStore(); + store.LoadResourceDictionary(resources); + + Assert.Same(DeviceRgbColorSpaceDetails.Instance, store.GetDeviceColorSpaceDetails(ColorSpace.DeviceRGB)); + } + + [Fact] + public void GetDeviceColorSpaceDetails_WithPatternDefaultRgb_ReturnsDeviceColorSpace() + { + // 8.6.5.6: a Pattern colour space may not be used as a default colour space, so a Pattern + // DefaultRGB must be ignored, leaving the device colour space in place. + // DefaultRGB -> /Pattern + var resources = new DictionaryToken(new Dictionary + { + { + NameToken.ColorSpace, + new DictionaryToken(new Dictionary + { + { NameToken.DefaultRgb, NameToken.Pattern }, + }) + }, + }); + + var store = BuildStore(); + store.LoadResourceDictionary(resources); + + Assert.Same(DeviceRgbColorSpaceDetails.Instance, store.GetDeviceColorSpaceDetails(ColorSpace.DeviceRGB)); + } + } +} diff --git a/src/UglyToad.PdfPig/Content/IResourceStore.cs b/src/UglyToad.PdfPig/Content/IResourceStore.cs index cfb348834..2927a680d 100644 --- a/src/UglyToad.PdfPig/Content/IResourceStore.cs +++ b/src/UglyToad.PdfPig/Content/IResourceStore.cs @@ -52,6 +52,15 @@ public interface IResourceStore /// ColorSpaceDetails GetColorSpaceDetails(NameToken? name, DictionaryToken? dictionary); + /// + /// Get the colour space details for a device colour space selected directly (for example by the + /// g / rg / k operators), applying the DefaultGray / DefaultRGB / + /// DefaultCMYK substitution from the current resource dictionary when present (PDF 2.0, + /// 8.6.5.6 "Default colour spaces"). Returns the device colour space itself when no matching + /// default colour space is defined. + /// + ColorSpaceDetails GetDeviceColorSpaceDetails(ColorSpace deviceColorSpace); + /// /// Get the marked content properties dictionary corresponding to the name. /// diff --git a/src/UglyToad.PdfPig/Content/ResourceStore.cs b/src/UglyToad.PdfPig/Content/ResourceStore.cs index 898f83eb1..eff61b805 100644 --- a/src/UglyToad.PdfPig/Content/ResourceStore.cs +++ b/src/UglyToad.PdfPig/Content/ResourceStore.cs @@ -12,7 +12,7 @@ using Filters; using Util; - internal class ResourceStore : IResourceStore + internal sealed class ResourceStore : IResourceStore { private readonly IPdfTokenScanner scanner; private readonly IFontFactory fontFactory; @@ -55,7 +55,7 @@ public void LoadResourceDictionary(DictionaryToken resourceDictionary) namedColorSpaces.Push(); currentFontState.Push(); - currentXObjectState.Push(); + currentXObjectState.Push(); extendedGraphicsStates.Push(); if (resourceDictionary.TryGet(NameToken.Font, out var fontBase)) @@ -307,10 +307,14 @@ public ColorSpaceDetails GetColorSpaceDetails(NameToken? name, DictionaryToken? return ColorSpaceDetailsParser.GetColorSpaceDetails(null, dictionary, scanner, this, filterProvider); } - if (name.TryMapToColorSpace(out ColorSpace colorspaceActual)) + if (name.TryMapToColorSpace(out ColorSpace colorSpaceActual)) { - // TODO - We need to find a way to store profile that have an actual dictionary, e.g. ICC profiles - without parsing them again - return ColorSpaceDetailsParser.GetColorSpaceDetails(colorspaceActual, dictionary, scanner, this, filterProvider); + if (TryGetDefaultSubstitute(colorSpaceActual, out NameToken? substituteName)) + { + return GetColorSpaceDetails(substituteName, dictionary); + } + + return ColorSpaceDetailsParser.GetColorSpaceDetails(colorSpaceActual, dictionary, scanner, this, filterProvider); } // Named color spaces @@ -326,7 +330,8 @@ public ColorSpaceDetails GetColorSpaceDetails(NameToken? name, DictionaryToken? { return ColorSpaceDetailsParser.GetColorSpaceDetails(mapped, dictionary, scanner, this, filterProvider); } - else if (namedColorSpace.Data is ArrayToken array) + + if (namedColorSpace.Data is ArrayToken array) { var csd = ColorSpaceDetailsParser.GetColorSpaceDetails(mapped, dictionary.With(NameToken.ColorSpace, array), scanner, this, filterProvider); loadedNamedColorSpaceDetails[name] = csd; @@ -337,6 +342,56 @@ public ColorSpaceDetails GetColorSpaceDetails(NameToken? name, DictionaryToken? throw new InvalidOperationException($"Could not find color space for token '{name}'."); } + public ColorSpaceDetails GetDeviceColorSpaceDetails(ColorSpace deviceColorSpace) + { + // 8.6.5.6: a directly selected device colour space is remapped to its DefaultGray/RGB/CMYK + // substitute when one is present in the current resources. Otherwise return the device + // space singleton. + // Any colour space other than a Lab, Indexed, or Pattern colour space may be used as a default + // colour space and it should be compatible with the original device colour space. + // We reject those families up-front from the substitute's own definition, before resolving its + // details. Besides being cheaper, this prevents infinite recursion for a self-referential default + // such as /DefaultRGB defined as [ /Indexed /DeviceRGB ... ], whose base would otherwise resolve + // this same device colour space again. + if (TryGetDefaultSubstitute(deviceColorSpace, out NameToken? substituteName) && + TryGetNamedColorSpace(substituteName, out ResourceColorSpace substitute) && + substitute.Name.TryMapToColorSpace(out ColorSpace substituteColorSpace) && + substituteColorSpace is not ColorSpace.Lab and not ColorSpace.Indexed and not ColorSpace.Pattern) + { + return GetColorSpaceDetails(substituteName, null); + } + + return deviceColorSpace switch + { + ColorSpace.DeviceGray => DeviceGrayColorSpaceDetails.Instance, + ColorSpace.DeviceRGB => DeviceRgbColorSpaceDetails.Instance, + ColorSpace.DeviceCMYK => DeviceCmykColorSpaceDetails.Instance, + _ => throw new ArgumentOutOfRangeException(nameof(deviceColorSpace), + deviceColorSpace, + "Expected a device colour space (DeviceGray, DeviceRGB or DeviceCMYK).") + }; + } + + private bool TryGetDefaultSubstitute(ColorSpace requested, [NotNullWhen(true)] out NameToken? substituteName) + { + NameToken? candidate = requested switch + { + ColorSpace.DeviceGray => NameToken.DefaultGray, + ColorSpace.DeviceRGB => NameToken.DefaultRgb, + ColorSpace.DeviceCMYK => NameToken.DefaultCmyk, + _ => null + }; + + if (candidate is not null && namedColorSpaces.TryGetValue(candidate, out _)) + { + substituteName = candidate; + return true; + } + + substituteName = null; + return false; + } + public bool TryGetXObject(NameToken name, [NotNullWhen(true)] out StreamToken? stream) { stream = null; @@ -351,11 +406,11 @@ public bool TryGetXObject(NameToken name, [NotNullWhen(true)] out StreamToken? s public DictionaryToken? GetExtendedGraphicsStateDictionary(NameToken name) { if (parsingOptions.UseLenientParsing) - { + { if (extendedGraphicsStates.TryGetValue(name, out var dictToken)) { return dictToken; - } + } parsingOptions.Logger.Error($"The graphic state dictionary does not contain the key '{name}'."); return null; diff --git a/src/UglyToad.PdfPig/Graphics/ColorSpaceContext.cs b/src/UglyToad.PdfPig/Graphics/ColorSpaceContext.cs index 7c8ee22b5..2a6398a69 100644 --- a/src/UglyToad.PdfPig/Graphics/ColorSpaceContext.cs +++ b/src/UglyToad.PdfPig/Graphics/ColorSpaceContext.cs @@ -40,7 +40,7 @@ public void SetStrokingColor(IReadOnlyList operands, NameToken? patternN return; } - if (patternName != null && CurrentStrokingColorSpace.Type == ColorSpace.Pattern) + if (patternName is not null && CurrentStrokingColorSpace.Type == ColorSpace.Pattern) { currentStateFunc().CurrentStrokingColor = ((PatternColorSpaceDetails)CurrentStrokingColorSpace).GetColor(patternName); // TODO - use operands values for Uncoloured Tiling Patterns @@ -53,20 +53,17 @@ public void SetStrokingColor(IReadOnlyList operands, NameToken? patternN public void SetStrokingColorGray(double gray) { - CurrentStrokingColorSpace = DeviceGrayColorSpaceDetails.Instance; - currentStateFunc().CurrentStrokingColor = CurrentStrokingColorSpace.GetColor(gray); + SetDeviceColor(ColorSpace.DeviceGray, [gray], stroking: true); } public void SetStrokingColorRgb(double r, double g, double b) { - CurrentStrokingColorSpace = DeviceRgbColorSpaceDetails.Instance; - currentStateFunc().CurrentStrokingColor = CurrentStrokingColorSpace.GetColor(r, g, b); + SetDeviceColor(ColorSpace.DeviceRGB, [r, g, b], stroking: true); } public void SetStrokingColorCmyk(double c, double m, double y, double k) { - CurrentStrokingColorSpace = DeviceCmykColorSpaceDetails.Instance; - currentStateFunc().CurrentStrokingColor = CurrentStrokingColorSpace.GetColor(c, m, y, k); + SetDeviceColor(ColorSpace.DeviceCMYK, [c, m, y, k], stroking: true); } public void SetNonStrokingColorspace(NameToken colorspace, DictionaryToken? dictionary = null) @@ -87,7 +84,7 @@ public void SetNonStrokingColor(IReadOnlyList operands, NameToken? patte return; } - if (patternName != null && CurrentNonStrokingColorSpace.Type == ColorSpace.Pattern) + if (patternName is not null && CurrentNonStrokingColorSpace.Type == ColorSpace.Pattern) { currentStateFunc().CurrentNonStrokingColor = ((PatternColorSpaceDetails)CurrentNonStrokingColorSpace).GetColor(patternName); // TODO - use operands values for Uncoloured Tiling Patterns @@ -100,20 +97,42 @@ public void SetNonStrokingColor(IReadOnlyList operands, NameToken? patte public void SetNonStrokingColorGray(double gray) { - CurrentNonStrokingColorSpace = DeviceGrayColorSpaceDetails.Instance; - currentStateFunc().CurrentNonStrokingColor = CurrentNonStrokingColorSpace.GetColor(gray); + SetDeviceColor(ColorSpace.DeviceGray, [gray], stroking: false); } public void SetNonStrokingColorRgb(double r, double g, double b) { - CurrentNonStrokingColorSpace = DeviceRgbColorSpaceDetails.Instance; - currentStateFunc().CurrentNonStrokingColor = CurrentNonStrokingColorSpace.GetColor(r, g, b); + SetDeviceColor(ColorSpace.DeviceRGB, [r, g, b], stroking: false); } public void SetNonStrokingColorCmyk(double c, double m, double y, double k) { - CurrentNonStrokingColorSpace = DeviceCmykColorSpaceDetails.Instance; - currentStateFunc().CurrentNonStrokingColor = CurrentNonStrokingColorSpace.GetColor(c, m, y, k); + SetDeviceColor(ColorSpace.DeviceCMYK, [c, m, y, k], stroking: false); + } + + /// + /// Set a colour selected directly through a device colour operator (g/rg/k + /// and their stroking variants). Per 8.6.5.6, "Default colour spaces", the device colour space is + /// first remapped to the corresponding DefaultGray/DefaultRGB/DefaultCMYK space + /// when one is defined in the current resource dictionary; otherwise the device space is used as-is. + /// + private void SetDeviceColor(ColorSpace deviceColorSpace, ReadOnlySpan values, bool stroking) + { + var colorSpace = resourceStore.GetDeviceColorSpaceDetails(deviceColorSpace); + var state = currentStateFunc(); + + IColor color = colorSpace.GetColor(values.ToArray()); + + if (stroking) + { + CurrentStrokingColorSpace = colorSpace; + state.CurrentStrokingColor = color; + } + else + { + CurrentNonStrokingColorSpace = colorSpace; + state.CurrentNonStrokingColor = color; + } } public IColorSpaceContext DeepClone() diff --git a/src/UglyToad.PdfPig/Util/ColorSpaceDetailsParser.cs b/src/UglyToad.PdfPig/Util/ColorSpaceDetailsParser.cs index a59148acc..da4d8e8b2 100644 --- a/src/UglyToad.PdfPig/Util/ColorSpaceDetailsParser.cs +++ b/src/UglyToad.PdfPig/Util/ColorSpaceDetailsParser.cs @@ -482,6 +482,15 @@ private static ColorSpaceDetails GetSecondaryColorSpace(IToken csToken, if (DirectObjectFinder.TryGet(csToken, scanner, out NameToken? alternateNameToken) && ColorSpaceMapper.TryMap(alternateNameToken, resourceStore, out var baseColorSpaceName)) { + // 8.6.5.6: when a special colour space is based on an underlying device colour space, the + // DefaultGray/DefaultRGB/DefaultCMYK substitution shall be used in place of that device + // space. This applies to the base of an Indexed space, the alternate of a Separation/DeviceN + // space and the underlying space of a Pattern - all of which are resolved here. + if (baseColorSpaceName is ColorSpace.DeviceGray or ColorSpace.DeviceRGB or ColorSpace.DeviceCMYK) + { + return resourceStore.GetDeviceColorSpaceDetails(baseColorSpaceName); + } + return GetColorSpaceDetails( baseColorSpaceName, dictionary,