Skip to content
Draft
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
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
namespace UglyToad.PdfPig.Tests.Graphics.Colors
{
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using UglyToad.PdfPig.Graphics.Colors;
using UglyToad.PdfPig.Graphics.Colors.Icc;
using UglyToad.PdfPig.Graphics.Core;
using Xunit;

public class ICCBasedColorSpaceDetailsTests
{
private sealed class StubTransform : IIccTransform
{
private readonly (double r, double g, double b) fixedOut;

public StubTransform(int components, (double r, double g, double b) fixedOut)
{
NumberOfComponents = components;
this.fixedOut = fixedOut;
}

public int NumberOfComponents { get; }

public (double r, double g, double b) ToRgb(ReadOnlySpan<double> values) => fixedOut;

public void Transform(ReadOnlySpan<byte> src, Span<byte> dstRgb)
{
int pixels = src.Length / NumberOfComponents;
for (int p = 0; p < pixels; p++)
{
dstRgb[p * 3] = (byte)Math.Round(fixedOut.r * 255);
dstRgb[p * 3 + 1] = (byte)Math.Round(fixedOut.g * 255);
dstRgb[p * 3 + 2] = (byte)Math.Round(fixedOut.b * 255);
}
}
}

private sealed class StubProfile : IIccProfile
{
private readonly Dictionary<RenderingIntent, IIccTransform> transforms;

public StubProfile(int components, Dictionary<RenderingIntent, IIccTransform> transforms)
{
NumberOfComponents = components;
this.transforms = transforms;
}

public int NumberOfComponents { get; }

public bool TryGetTransform(RenderingIntent intent, [NotNullWhen(true)] out IIccTransform? transform)
{
if (transforms.TryGetValue(intent, out var t))
{
transform = t;
return true;
}
transform = null;
return false;
}
}

private sealed class StubService : IIccProfileService
{
private readonly IIccProfile? profile;

public StubService(IIccProfile? profile) { this.profile = profile; }

public bool TryGetProfile(Memory<byte> profileBytes, int numberOfColorComponents,
[NotNullWhen(true)] out IIccProfile? profile)
{
profile = this.profile;
return profile is not null;
}
}

[Fact]
public void WithoutService_FallsBackToAlternateColorSpace()
{
var details = new ICCBasedColorSpaceDetails(
numberOfColorComponents: 3,
alternateColorSpaceDetails: DeviceRgbColorSpaceDetails.Instance,
range: null,
metadata: null,
profile: new byte[] { 1, 2, 3 },
iccProfileService: null);

Assert.Equal(ColorSpace.DeviceRGB, details.BaseType);
Assert.Equal(3, details.BaseNumberOfColorComponents);
Assert.Null(details.IccProfile);
Assert.Null(details.GetTransform(RenderingIntent.RelativeColorimetric));

var (r, g, b) = details.GetColor(0.5, 0.5, 0.5).ToRGBValues();
Assert.Equal(0.5, r);
Assert.Equal(0.5, g);
Assert.Equal(0.5, b);
}

[Fact]
public void WithService_BaseTypeIsDeviceRgbAndComponentsIsThree()
{
var profile = new StubProfile(4, new Dictionary<RenderingIntent, IIccTransform>
{
[RenderingIntent.RelativeColorimetric] = new StubTransform(4, (0, 0, 0)),
});

var details = new ICCBasedColorSpaceDetails(
numberOfColorComponents: 4,
alternateColorSpaceDetails: DeviceCmykColorSpaceDetails.Instance,
range: null,
metadata: null,
profile: new byte[] { 0x01 },
iccProfileService: new StubService(profile));

Assert.Equal(ColorSpace.DeviceRGB, details.BaseType);
Assert.Equal(3, details.BaseNumberOfColorComponents);
Assert.NotNull(details.IccProfile);
Assert.NotNull(details.GetTransform(RenderingIntent.RelativeColorimetric));
}

[Fact]
public void WithService_GetColorWithoutIntent_UsesRelativeColorimetric()
{
var profile = new StubProfile(4, new Dictionary<RenderingIntent, IIccTransform>
{
[RenderingIntent.RelativeColorimetric] = new StubTransform(4, (0.25, 0.5, 0.75)),
[RenderingIntent.Perceptual] = new StubTransform(4, (0.10, 0.10, 0.10)),
});

var details = new ICCBasedColorSpaceDetails(4, DeviceCmykColorSpaceDetails.Instance,
null, null, new byte[] { 0xAB }, new StubService(profile));

var (r, g, b) = details.GetColor(0.1, 0.2, 0.3, 0.4).ToRGBValues();
Assert.Equal(0.25, r);
Assert.Equal(0.50, g);
Assert.Equal(0.75, b);
}

[Fact]
public void WithService_GetColorWithIntent_RoutesThroughThatIntent()
{
var profile = new StubProfile(4, new Dictionary<RenderingIntent, IIccTransform>
{
[RenderingIntent.RelativeColorimetric] = new StubTransform(4, (0.25, 0.5, 0.75)),
[RenderingIntent.Perceptual] = new StubTransform(4, (0.10, 0.10, 0.10)),
});

var details = new ICCBasedColorSpaceDetails(4, DeviceCmykColorSpaceDetails.Instance,
null, null, new byte[] { 0xAB }, new StubService(profile));

var (r, g, b) = details.GetColor(new double[] { 0.1, 0.2, 0.3, 0.4 },
RenderingIntent.Perceptual).ToRGBValues();

Assert.Equal(0.10, r);
Assert.Equal(0.10, g);
Assert.Equal(0.10, b);
}

[Fact]
public void WithService_TransformIntentOverloadProducesIntentSpecificBuffer()
{
var profile = new StubProfile(4, new Dictionary<RenderingIntent, IIccTransform>
{
[RenderingIntent.RelativeColorimetric] = new StubTransform(4, (1.0, 0.0, 0.0)),
[RenderingIntent.Saturation] = new StubTransform(4, (0.0, 1.0, 0.0)),
});

var details = new ICCBasedColorSpaceDetails(4, DeviceCmykColorSpaceDetails.Instance,
null, null, new byte[] { 0xCD }, new StubService(profile));

Span<byte> input = stackalloc byte[8] { 10, 20, 30, 40, 50, 60, 70, 80 };

// Default intent (RelativeColorimetric) → red.
var def = details.Transform(input);
Assert.Equal(255, def[0]); Assert.Equal(0, def[1]); Assert.Equal(0, def[2]);

// Explicit Saturation → green.
var sat = details.Transform(input, RenderingIntent.Saturation);
Assert.Equal(0, sat[0]); Assert.Equal(255, sat[1]); Assert.Equal(0, sat[2]);
}

[Fact]
public void IndexedWithIccBase_TransformWithIntent_RoutesThroughThatIntent()
{
// /Indexed [/ICCBased ...] palette image with 2 entries:
// index 0 -> CMYK (0.1, 0.2, 0.3, 0.4)
// index 1 -> CMYK (0.5, 0.6, 0.7, 0.8)
// ICC profile stub maps any input to a fixed RGB per intent.
var profile = new StubProfile(4, new Dictionary<RenderingIntent, IIccTransform>
{
[RenderingIntent.RelativeColorimetric] = new StubTransform(4, (1.0, 0.0, 0.0)), // red
[RenderingIntent.Saturation] = new StubTransform(4, (0.0, 1.0, 0.0)), // green
});

var iccBase = new ICCBasedColorSpaceDetails(4, DeviceCmykColorSpaceDetails.Instance,
null, null, new byte[] { 0x01 }, new StubService(profile));

// 2-entry CMYK palette (8 bytes).
byte[] colorTable =
[
(byte)(0.1 * 255), (byte)(0.2 * 255), (byte)(0.3 * 255), (byte)(0.4 * 255),
(byte)(0.5 * 255), (byte)(0.6 * 255), (byte)(0.7 * 255), (byte)(0.8 * 255),
];
var indexed = new IndexedColorSpaceDetails(iccBase, hiVal: 1, colorTable: colorTable);

// Image bytes: 3 pixels of index 0, 1, 0.
Span<byte> input = stackalloc byte[3] { 0, 1, 0 };

// Default intent -> red.
var def = indexed.Transform(input.ToArray());
Assert.Equal(9, def.Length); // 3 pixels * 3 bytes RGB
Assert.Equal(255, def[0]); Assert.Equal(0, def[1]); Assert.Equal(0, def[2]);

// Saturation intent -> green. THIS is the case my first refactor missed:
// ColorSpaceDetailsByteConverter -> Indexed.Transform -> BaseColorSpace.Transform
// used to drop the intent at the Indexed boundary.
var sat = ((ColorSpaceDetails)indexed).Transform(input.ToArray(), RenderingIntent.Saturation);
Assert.Equal(9, sat.Length);
Assert.Equal(0, sat[0]); Assert.Equal(255, sat[1]); Assert.Equal(0, sat[2]);
}

[Fact]
public void WithService_GetTransformFallsBackToRelativeColorimetricForUnsupportedIntent()
{
// Profile only supports RelativeColorimetric; ask for Perceptual.
var profile = new StubProfile(3, new Dictionary<RenderingIntent, IIccTransform>
{
[RenderingIntent.RelativeColorimetric] = new StubTransform(3, (0.4, 0.5, 0.6)),
});

var details = new ICCBasedColorSpaceDetails(3, DeviceRgbColorSpaceDetails.Instance,
null, null, new byte[] { 0x99 }, new StubService(profile));

// Direct GetTransform: returns null for unsupported intent.
Assert.Null(details.GetTransform(RenderingIntent.Perceptual));

// GetColor with unsupported intent: falls back to RelativeColorimetric internally.
var (r, g, b) = details.GetColor(new double[] { 0.1, 0.2, 0.3 },
RenderingIntent.Perceptual).ToRGBValues();
Assert.Equal(0.4, r);
Assert.Equal(0.5, g);
Assert.Equal(0.6, b);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,9 @@ public void CanGeneratePngFromICCBasedImageData()
numberOfColorComponents: 3,
alternateColorSpaceDetails: DeviceRgbColorSpaceDetails.Instance,
range: new List<double> { 0, 1, 0, 1, 0, 1 },
metadata: null),
metadata: null,
profile: Memory<byte>.Empty,
iccProfileService: null),
DecodedBytes = decodedBytes,
WidthInSamples = 1,
HeightInSamples = 1,
Expand All @@ -215,7 +217,9 @@ public void AlternateColorSpaceDetailsIsCurrentlyUsedInPdfPigWhenGeneratingPngsF
numberOfColorComponents: 3,
alternateColorSpaceDetails: DeviceRgbColorSpaceDetails.Instance,
range: new List<double> { 0, 1, 0, 1, 0, 1 },
metadata: null),
metadata: null,
profile: Memory<byte>.Empty,
iccProfileService: null),
DecodedBytes = decodedBytes,
WidthInSamples = 1,
HeightInSamples = 1,
Expand Down
5 changes: 5 additions & 0 deletions src/UglyToad.PdfPig.Tests/PublicApiScannerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,11 @@ public void OnlyExposedApiIsPublic()
"UglyToad.PdfPig.Graphics.Colors.DeviceCmykColorSpaceDetails",
"UglyToad.PdfPig.Graphics.Colors.DeviceNColorSpaceDetails",
"UglyToad.PdfPig.Graphics.Colors.ICCBasedColorSpaceDetails",
"UglyToad.PdfPig.Graphics.Colors.Icc.IIccProfile",
"UglyToad.PdfPig.Graphics.Colors.Icc.IIccProfileService",
"UglyToad.PdfPig.Graphics.Colors.Icc.IIccTransform",
"UglyToad.PdfPig.Graphics.Colors.Icc.OutputIntent",
"UglyToad.PdfPig.Graphics.Colors.Icc.IccProfileReference",
"UglyToad.PdfPig.Graphics.Colors.IndexedColorSpaceDetails",
"UglyToad.PdfPig.Graphics.Colors.LabColorSpaceDetails",
"UglyToad.PdfPig.Graphics.Colors.PatternColorSpaceDetails",
Expand Down
30 changes: 30 additions & 0 deletions src/UglyToad.PdfPig/Content/IResourceStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Graphics.Colors;
using PdfFonts;
using Tokens;
using UglyToad.PdfPig.Graphics.Colors.Icc;

/// <summary>
/// Resource store.
Expand Down Expand Up @@ -52,6 +53,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 All @@ -66,5 +76,25 @@ public interface IResourceStore
/// Get the shading corresponding to the name.
/// </summary>
Shading GetShading(NameToken name);

/// <summary>
/// The configured ICC profile service (from <see cref="ParsingOptions.IccProfileService"/>),
/// or <c>null</c> when ICC-based color spaces should fall back to their alternate color space.
/// </summary>
IIccProfileService? IccProfileService { get; }

/// <summary>
/// The document catalog's output intent ICC profile (document scope), resolved from the catalog's
/// <c>/OutputIntents</c> array (<c>/DestOutputProfile</c>), or <c>null</c> when the document declares
/// no usable output intent (or no <see cref="IccProfileService"/> is configured / output-intent
/// colour management is disabled). Used to colour-manage the device colour spaces (DeviceCMYK /
/// DeviceRGB / DeviceGray) per PDF/X semantics.
/// <para>
/// A page may override it with its own page-level <c>/OutputIntents</c> (PDF 2.0, Table 31); that
/// override is page-scoped and is resolved per page onto the graphics state
/// (<c>CurrentGraphicsState.OutputIntent</c>), with this document-level value as the fallback.
/// </para>
/// </summary>
OutputIntent? OutputIntent { get; }
}
}
Loading
Loading