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,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, IToken>
{
{
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, IToken>
{
{
NameToken.ColorSpace,
new DictionaryToken(new Dictionary<NameToken, IToken>
{
{ NameToken.DefaultRgb, defaultRgbArray },
})
},
});

var store = BuildStore();
store.LoadResourceDictionary(resources);

var details = store.GetColorSpaceDetails(
NameToken.Devicergb,
new DictionaryToken(new Dictionary<NameToken, IToken>()));

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, IToken>
{
{
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, IToken>
{
{
NameToken.ColorSpace,
new DictionaryToken(new Dictionary<NameToken, IToken>
{
{ 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<NameToken, IToken>()));

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, IToken>
{
{
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, IToken>
{
{
NameToken.ColorSpace,
new DictionaryToken(new Dictionary<NameToken, IToken>
{
{ 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, IToken>
{
{ NameToken.ColorSpace, indexedArray },
});

var details = store.GetColorSpaceDetails(NameToken.Indexed, imageDictionary);

var indexed = Assert.IsType<IndexedColorSpaceDetails>(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, IToken>
{
{
NameToken.ColorSpace,
new DictionaryToken(new Dictionary<NameToken, IToken>
{
{ 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, IToken>
{
{
NameToken.ColorSpace,
new DictionaryToken(new Dictionary<NameToken, IToken>
{
{ 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, IToken>
{
{
NameToken.ColorSpace,
new DictionaryToken(new Dictionary<NameToken, IToken>
{
{ NameToken.DefaultRgb, NameToken.Pattern },
})
},
});

var store = BuildStore();
store.LoadResourceDictionary(resources);

Assert.Same(DeviceRgbColorSpaceDetails.Instance, store.GetDeviceColorSpaceDetails(ColorSpace.DeviceRGB));
}
}
}
9 changes: 9 additions & 0 deletions src/UglyToad.PdfPig/Content/IResourceStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ public interface IResourceStore
/// </summary>
ColorSpaceDetails GetColorSpaceDetails(NameToken? name, DictionaryToken? dictionary);

/// <summary>
/// Get the colour space details for a device colour space selected directly (for example by the
/// <c>g</c> / <c>rg</c> / <c>k</c> operators), applying the <c>DefaultGray</c> / <c>DefaultRGB</c> /
/// <c>DefaultCMYK</c> 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.
/// </summary>
ColorSpaceDetails GetDeviceColorSpaceDetails(ColorSpace deviceColorSpace);

/// <summary>
/// Get the marked content properties dictionary corresponding to the name.
/// </summary>
Expand Down
71 changes: 63 additions & 8 deletions src/UglyToad.PdfPig/Content/ResourceStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down
Loading
Loading