Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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, IToken>
{
{ 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, IToken>
{
{ 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<NameToken, IToken>()));

// 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<SeparationColorSpaceDetails>(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<NameToken, IToken>()));

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<NameToken, IToken>()));
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<NameToken, IToken>()));
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<NameToken, IToken>()));

DictionaryToken CreateStreamShadingDictionary() => new DictionaryToken(new Dictionary<NameToken, IToken>
{
{ 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<SeparationColorSpaceDetails>(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<NameToken, IToken>()));

DictionaryToken CreateInlineDictionary() => new DictionaryToken(new Dictionary<NameToken, IToken>
{
{ NameToken.ShadingType, new NumericToken(2) },
{ NameToken.ColorSpace, CreateSeparationArray() }
});

var details1 = store.GetColorSpaceDetails(NameToken.Separation, CreateInlineDictionary());
var details2 = store.GetColorSpaceDetails(NameToken.Separation, CreateInlineDictionary());

Assert.IsType<SeparationColorSpaceDetails>(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<NameToken, IToken>()));

// 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, IToken>
{
{ 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<IndexedColorSpaceDetails>(maskDetails);
Assert.IsType<SeparationColorSpaceDetails>(plainDetails);
}
}
}
56 changes: 56 additions & 0 deletions src/UglyToad.PdfPig.Tests/Integration/ColorSpaceCacheTests.cs
Original file line number Diff line number Diff line change
@@ -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<ColorSpaceDetails>();
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));
}
}
}
Binary file not shown.
64 changes: 62 additions & 2 deletions src/UglyToad.PdfPig/Content/ResourceStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,6 +29,7 @@ internal sealed class ResourceStore : IResourceStore

private readonly StackDictionary<NameToken, ResourceColorSpace> namedColorSpaces = new StackDictionary<NameToken, ResourceColorSpace>();
private readonly Dictionary<NameToken, ColorSpaceDetails> loadedNamedColorSpaceDetails = new Dictionary<NameToken, ColorSpaceDetails>();
private readonly Dictionary<(NameToken? Name, IToken ColorSpace), ColorSpaceDetails> loadedColorSpaceDetailsCache = new Dictionary<(NameToken?, IToken), ColorSpaceDetails>();

private readonly Dictionary<NameToken, DictionaryToken> markedContentProperties = new Dictionary<NameToken, DictionaryToken>();

Expand Down Expand Up @@ -59,6 +61,7 @@ public void LoadResourceDictionary(DictionaryToken resourceDictionary)
{
lastLoadedFont = (null, null);
loadedNamedColorSpaceDetails.Clear();
loadedColorSpaceDetailsCache.Clear();

namedColorSpaces.Push();
currentFontState.Push();
Expand Down Expand Up @@ -195,6 +198,7 @@ public void UnloadResourceDictionary()
{
lastLoadedFont = (null, null);
loadedNamedColorSpaceDetails.Clear();
loadedColorSpaceDetailsCache.Clear();
currentFontState.Pop();
currentXObjectState.Pop();
namedColorSpaces.Pop();
Expand Down Expand Up @@ -305,9 +309,65 @@ public bool TryGetNamedColorSpace(NameToken? name, out ResourceColorSpace namedT
}

public ColorSpaceDetails GetColorSpaceDetails(NameToken? name, DictionaryToken? dictionary)
{
{
dictionary ??= new DictionaryToken(new Dictionary<NameToken, IToken>());


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<CcittFaxDecodeFilter>().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)
{
Expand Down
Loading