diff --git a/src/UglyToad.PdfPig.Tests/ContentTests/ResourceStoreColorSpaceCacheTests.cs b/src/UglyToad.PdfPig.Tests/ContentTests/ResourceStoreColorSpaceCacheTests.cs new file mode 100644 index 000000000..f49bcc347 --- /dev/null +++ b/src/UglyToad.PdfPig.Tests/ContentTests/ResourceStoreColorSpaceCacheTests.cs @@ -0,0 +1,193 @@ +namespace UglyToad.PdfPig.Tests.ContentTests +{ + using System.Collections.Generic; + using PdfPig.Content; + using PdfPig.Core; + using PdfPig.Graphics.Colors; + using PdfPig.PdfFonts; + using PdfPig.Tokens; + using UglyToad.PdfPig.Tests.Tokens; + using Xunit; + + public class ResourceStoreColorSpaceCacheTests + { + private sealed class NoOpFontFactory : IFontFactory + { + public IFont Get(DictionaryToken dictionary) => null!; + } + + private static ResourceStore BuildStore(TestPdfTokenScanner scanner) + { + return new ResourceStore( + scanner, + new NoOpFontFactory(), + new TestFilterProvider(), + new ParsingOptions + { + UseLenientParsing = true, + SkipMissingFonts = true, + }); + } + + private static ArrayToken CreateSeparationArray() + { + // [ /Separation /MySpot /DeviceRGB << Type2 tint function >> ] + var tintFunction = new DictionaryToken(new Dictionary + { + { NameToken.FunctionType, new NumericToken(2) }, + { NameToken.Domain, new ArrayToken(new IToken[] { new NumericToken(0), new NumericToken(1) }) }, + { NameToken.C0, new ArrayToken(new IToken[] { new NumericToken(0), new NumericToken(0), new NumericToken(0) }) }, + { NameToken.C1, new ArrayToken(new IToken[] { new NumericToken(1), new NumericToken(0), new NumericToken(0) }) }, + { NameToken.N, new NumericToken(1) } + }); + + return new ArrayToken(new IToken[] + { + NameToken.Separation, + NameToken.Create("MySpot"), + NameToken.Devicergb, + tintFunction + }); + } + + private static DictionaryToken CreateShadingLikeDictionary(IndirectReference colorSpaceReference) + { + return new DictionaryToken(new Dictionary + { + { NameToken.ShadingType, new NumericToken(2) }, + { NameToken.ColorSpace, new IndirectReferenceToken(colorSpaceReference) } + }); + } + + [Fact] + public void SharedIndirectColorSpaceIsParsedOnce() + { + var scanner = new TestPdfTokenScanner(); + var reference = new IndirectReference(12, 0); + scanner.Objects[reference] = new ObjectToken(XrefLocation.File(0), reference, CreateSeparationArray()); + + var store = BuildStore(scanner); + store.LoadResourceDictionary(new DictionaryToken(new Dictionary())); + + // Two different consumers (e.g. two shadings) each referencing '/ColorSpace 12 0 R'. + var details1 = store.GetColorSpaceDetails(NameToken.Separation, CreateShadingLikeDictionary(reference)); + var details2 = store.GetColorSpaceDetails(NameToken.Separation, CreateShadingLikeDictionary(reference)); + + Assert.IsType(details1); + Assert.Same(details1, details2); + } + + [Fact] + public void DifferentIndirectColorSpacesAreParsedSeparately() + { + var scanner = new TestPdfTokenScanner(); + var reference1 = new IndirectReference(12, 0); + var reference2 = new IndirectReference(13, 0); + scanner.Objects[reference1] = new ObjectToken(XrefLocation.File(0), reference1, CreateSeparationArray()); + scanner.Objects[reference2] = new ObjectToken(XrefLocation.File(0), reference2, CreateSeparationArray()); + + var store = BuildStore(scanner); + store.LoadResourceDictionary(new DictionaryToken(new Dictionary())); + + var details1 = store.GetColorSpaceDetails(NameToken.Separation, CreateShadingLikeDictionary(reference1)); + var details2 = store.GetColorSpaceDetails(NameToken.Separation, CreateShadingLikeDictionary(reference2)); + + Assert.NotSame(details1, details2); + } + + [Fact] + public void CacheIsClearedWhenResourceDictionaryChanges() + { + var scanner = new TestPdfTokenScanner(); + var reference = new IndirectReference(12, 0); + scanner.Objects[reference] = new ObjectToken(XrefLocation.File(0), reference, CreateSeparationArray()); + + var store = BuildStore(scanner); + + store.LoadResourceDictionary(new DictionaryToken(new Dictionary())); + var details1 = store.GetColorSpaceDetails(NameToken.Separation, CreateShadingLikeDictionary(reference)); + store.UnloadResourceDictionary(); + + // Default* substitutes in another resource scope can change the parse result, so the cache + // must not survive the scope change. + store.LoadResourceDictionary(new DictionaryToken(new Dictionary())); + var details2 = store.GetColorSpaceDetails(NameToken.Separation, CreateShadingLikeDictionary(reference)); + + Assert.NotSame(details1, details2); + } + + [Fact] + public void FilteredShadingStreamDictionaryIsCached() + { + // Shading types 4 to 7 are streams whose dictionaries carry a /Filter entry (e.g. FlateDecode). + // Only CCITTFaxDecode influences colour space parsing, so these must still hit the cache. + var scanner = new TestPdfTokenScanner(); + var reference = new IndirectReference(12, 0); + scanner.Objects[reference] = new ObjectToken(XrefLocation.File(0), reference, CreateSeparationArray()); + + var store = BuildStore(scanner); + store.LoadResourceDictionary(new DictionaryToken(new Dictionary())); + + DictionaryToken CreateStreamShadingDictionary() => new DictionaryToken(new Dictionary + { + { NameToken.ShadingType, new NumericToken(7) }, + { NameToken.Filter, NameToken.FlateDecode }, + { NameToken.Length, new NumericToken(256) }, + { NameToken.ColorSpace, new IndirectReferenceToken(reference) } + }); + + var details1 = store.GetColorSpaceDetails(NameToken.Separation, CreateStreamShadingDictionary()); + var details2 = store.GetColorSpaceDetails(NameToken.Separation, CreateStreamShadingDictionary()); + + Assert.IsType(details1); + Assert.Same(details1, details2); + } + + [Fact] + public void EqualInlineColorSpaceArraysShareOneInstance() + { + // The colour space definition is repeated inline (no indirect reference): value-based token + // equality still allows the definitions to share a single parsed instance. + var scanner = new TestPdfTokenScanner(); + var store = BuildStore(scanner); + store.LoadResourceDictionary(new DictionaryToken(new Dictionary())); + + DictionaryToken CreateInlineDictionary() => new DictionaryToken(new Dictionary + { + { NameToken.ShadingType, new NumericToken(2) }, + { NameToken.ColorSpace, CreateSeparationArray() } + }); + + var details1 = store.GetColorSpaceDetails(NameToken.Separation, CreateInlineDictionary()); + var details2 = store.GetColorSpaceDetails(NameToken.Separation, CreateInlineDictionary()); + + Assert.IsType(details1); + Assert.Same(details1, details2); + } + + [Fact] + public void ImageMaskDictionaryBypassesCache() + { + var scanner = new TestPdfTokenScanner(); + var reference = new IndirectReference(12, 0); + scanner.Objects[reference] = new ObjectToken(XrefLocation.File(0), reference, CreateSeparationArray()); + + var store = BuildStore(scanner); + store.LoadResourceDictionary(new DictionaryToken(new Dictionary())); + + // A stencil mask referencing the same colour space object parses to a stencil, which must not + // be cached under the reference and returned to non-mask consumers. + var maskDictionary = new DictionaryToken(new Dictionary + { + { NameToken.ImageMask, BooleanToken.True }, + { NameToken.ColorSpace, new IndirectReferenceToken(reference) } + }); + + var maskDetails = store.GetColorSpaceDetails(NameToken.Separation, maskDictionary); + var plainDetails = store.GetColorSpaceDetails(NameToken.Separation, CreateShadingLikeDictionary(reference)); + + Assert.IsType(maskDetails); + Assert.IsType(plainDetails); + } + } +} diff --git a/src/UglyToad.PdfPig.Tests/Integration/ColorSpaceCacheTests.cs b/src/UglyToad.PdfPig.Tests/Integration/ColorSpaceCacheTests.cs new file mode 100644 index 000000000..432bd4241 --- /dev/null +++ b/src/UglyToad.PdfPig.Tests/Integration/ColorSpaceCacheTests.cs @@ -0,0 +1,56 @@ +namespace UglyToad.PdfPig.Tests.Integration +{ + using System.Collections.Generic; + using PdfPig.Content; + using PdfPig.Filters; + using PdfPig.Graphics.Colors; + using PdfPig.PdfFonts; + using PdfPig.Tokens; + + public class ColorSpaceCacheTests + { + private sealed class NoOpFontFactory : IFontFactory + { + public IFont Get(DictionaryToken dictionary) => null!; + } + + [Fact] + public void ShadingsSharingColorSpaceObjectShareOneInstance() + { + // ColorIssue.pdf contains nine shadings; eight are ShadingType 7 streams (so their + // dictionaries carry /Filter /FlateDecode) which all reference '/ColorSpace 8 0 R', + // a six-colorant DeviceN colour space. They must share a single parsed instance. + using var document = PdfDocument.Open(IntegrationHelpers.GetDocumentPath("ColorIssue.pdf")); + + var page = document.GetPage(1); + var scanner = document.Structure.TokenScanner; + + Assert.True(page.Dictionary.TryGet(NameToken.Resources, scanner, out DictionaryToken resources)); + + var store = new ResourceStore( + scanner, + new NoOpFontFactory(), + new FilterProviderWithLookup(DefaultFilterProvider.Instance), + new ParsingOptions + { + UseLenientParsing = true, + SkipMissingFonts = true, + }); + + store.LoadResourceDictionary(resources); + + var deviceNColorSpaces = new List(); + for (var i = 0; i <= 8; i++) + { + var shading = store.GetShading(NameToken.Create($"Sh{i}")); + if (shading.ColorSpace is DeviceNColorSpaceDetails) + { + deviceNColorSpaces.Add(shading.ColorSpace); + } + } + + Assert.Equal(8, deviceNColorSpaces.Count); + Assert.All(deviceNColorSpaces, cs => Assert.Same(deviceNColorSpaces[0], cs)); + } + } +} diff --git a/src/UglyToad.PdfPig.Tests/Integration/Documents/ColorIssue.pdf b/src/UglyToad.PdfPig.Tests/Integration/Documents/ColorIssue.pdf new file mode 100644 index 000000000..aedb67367 Binary files /dev/null and b/src/UglyToad.PdfPig.Tests/Integration/Documents/ColorIssue.pdf differ diff --git a/src/UglyToad.PdfPig/Content/ResourceStore.cs b/src/UglyToad.PdfPig/Content/ResourceStore.cs index e4ea659ac..a65347ec2 100644 --- a/src/UglyToad.PdfPig/Content/ResourceStore.cs +++ b/src/UglyToad.PdfPig/Content/ResourceStore.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; + using System.Linq; using Core; using Graphics.Colors; using Parser.Parts; @@ -28,6 +29,7 @@ internal sealed class ResourceStore : IResourceStore private readonly StackDictionary namedColorSpaces = new StackDictionary(); private readonly Dictionary loadedNamedColorSpaceDetails = new Dictionary(); + private readonly Dictionary<(NameToken? Name, IToken ColorSpace), ColorSpaceDetails> loadedColorSpaceDetailsCache = new Dictionary<(NameToken?, IToken), ColorSpaceDetails>(); private readonly Dictionary markedContentProperties = new Dictionary(); @@ -59,6 +61,7 @@ public void LoadResourceDictionary(DictionaryToken resourceDictionary) { lastLoadedFont = (null, null); loadedNamedColorSpaceDetails.Clear(); + loadedColorSpaceDetailsCache.Clear(); namedColorSpaces.Push(); currentFontState.Push(); @@ -195,6 +198,7 @@ public void UnloadResourceDictionary() { lastLoadedFont = (null, null); loadedNamedColorSpaceDetails.Clear(); + loadedColorSpaceDetailsCache.Clear(); currentFontState.Pop(); currentXObjectState.Pop(); namedColorSpaces.Pop(); @@ -305,9 +309,65 @@ public bool TryGetNamedColorSpace(NameToken? name, out ResourceColorSpace namedT } public ColorSpaceDetails GetColorSpaceDetails(NameToken? name, DictionaryToken? dictionary) - { + { dictionary ??= new DictionaryToken(new Dictionary()); - + + if (!TryGetCacheColorSpaceDefinition(dictionary, out IToken? colorSpaceToken)) + { + return GetColorSpaceDetailsInternal(name, dictionary); + } + + var key = (name, colorSpaceToken); + if (loadedColorSpaceDetailsCache.TryGetValue(key, out var cached)) + { + return cached; + } + + var parsed = GetColorSpaceDetailsInternal(name, dictionary); + loadedColorSpaceDetailsCache[key] = parsed; + return parsed; + } + + private bool TryGetCacheColorSpaceDefinition(DictionaryToken dictionary, [NotNullWhen(true)] out IToken? colorSpaceToken) + { + colorSpaceToken = null; + + // While a DefaultGray/RGB/CMYK substitute is being resolved the same colour space object can + // legitimately parse to a different result, so bypass the cache entirely. + if (isResolvingDefaultSubstitute) + { + return false; + } + + // We rely on the color space definition for caching. + if (!dictionary.TryGet(NameToken.ColorSpace, out colorSpaceToken) && + !dictionary.TryGet(NameToken.Cs, out colorSpaceToken)) + { + return false; + } + + // We do not cache stencil-mask color spaces as they do not rely on color space definition. + // Stencil color spaces are created when the dictionary contains `ImageMask` or `Im` or if + // a filter is CcittFaxDecodeFilter. + if (dictionary.ContainsKey(NameToken.ImageMask) || dictionary.ContainsKey(NameToken.Im)) + { + return false; + } + + if ((dictionary.ContainsKey(NameToken.Filter) || dictionary.ContainsKey(NameToken.F)) && + filterProvider.GetFilters(dictionary, scanner).OfType().Any()) + { + return false; + } + + // NB: If the colorSpaceToken is an indirect reference, we do not resolve it. + // This could change, fine for now + + return true; + } + + private ColorSpaceDetails GetColorSpaceDetailsInternal(NameToken? name, DictionaryToken dictionary) + { // Null color space for images if (name is null) {