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,103 @@
namespace UglyToad.PdfPig.Tests.Graphics.Colors
{
using PdfPig.Graphics.Colors;

/// <summary>
/// Indexed colour-table bytes must decode to the base colour space's native component
/// ranges (ISO 32000-2, 8.6.6.3). Lab is the space where this matters: L* is [0, 100] and
/// a*/b* come from the /Range entry, so decoding table bytes to [0, 1] (correct for device
/// spaces) renders Lab entries near-black. These tests cover all three decode entry points:
/// GetColor, GetRgb and Process.
/// </summary>
public class IndexedLabColorSpaceDetailsTests
{
private static readonly double[] D50WhitePoint = [0.9505, 1.0, 1.089];

/// <summary>
/// Two-entry table over Lab (default /Range [-100 100 -100 100]):
/// index 0 = black (L*=0, a*=b*=~0), index 1 = white (L*=100, a*=b*=~0).
/// A byte of 0x80 decodes to -100 + (128/255)*200 = ~0.4 on the a*/b* axes.
/// </summary>
private static IndexedColorSpaceDetails CreateIndexedOverLab(double[]? range = null)
{
var lab = new LabColorSpaceDetails(D50WhitePoint, null, range);
return new IndexedColorSpaceDetails(lab, 1, [0x00, 0x80, 0x80, 0xFF, 0x80, 0x80]);
}

[Fact]
public void GetColor_WhiteLabTableEntry_IsNearWhite()
{
var indexed = CreateIndexedOverLab();

var (r, g, b) = indexed.GetColor(1).ToRGBValues();

// Without range decoding L* becomes 1.0 (of 100) and this renders near-black.
Assert.True(r > 0.9 && g > 0.9 && b > 0.9, $"Expected near-white but got ({r}, {g}, {b}).");
}

[Fact]
public void GetColor_BlackLabTableEntry_IsNearBlack()
{
var indexed = CreateIndexedOverLab();

var (r, g, b) = indexed.GetColor(0).ToRGBValues();

Assert.True(r < 0.1 && g < 0.1 && b < 0.1, $"Expected near-black but got ({r}, {g}, {b}).");
}

[Fact]
public void GetRgb_WhiteLabTableEntry_IsNearWhite()
{
var indexed = CreateIndexedOverLab();

indexed.GetRgb([1.0], out double r, out double g, out double b);

Assert.True(r > 0.9 && g > 0.9 && b > 0.9, $"Expected near-white but got ({r}, {g}, {b}).");
}

[Fact]
public void Process_WhiteLabTableEntry_IsNearWhite()
{
var indexed = CreateIndexedOverLab();

double[] rgb = indexed.Process(1);

Assert.Equal(3, rgb.Length);
Assert.True(rgb[0] > 0.9 && rgb[1] > 0.9 && rgb[2] > 0.9,
$"Expected near-white but got ({rgb[0]}, {rgb[1]}, {rgb[2]}).");
}

[Fact]
public void AllThreePaths_DecodeIdentically()
{
var indexed = CreateIndexedOverLab();

var (cr, cg, cb) = indexed.GetColor(1).ToRGBValues();
indexed.GetRgb([1.0], out double rr, out double rg, out double rb);
double[] p = indexed.Process(1);

Assert.Equal(cr, rr, 12);
Assert.Equal(cg, rg, 12);
Assert.Equal(cb, rb, 12);
Assert.Equal(cr, p[0], 12);
Assert.Equal(cg, p[1], 12);
Assert.Equal(cb, p[2], 12);
}

[Fact]
public void GetColor_HonoursCustomRangeEntry()
{
// With /Range [0 0 0 0] the a*/b* axes are pinned to zero, so a mid-grey table
// entry decodes to a pure neutral grey: r = g = b exactly (any a*/b* tint would
// break the equality).
var lab = new LabColorSpaceDetails(D50WhitePoint, null, [0.0, 0.0, 0.0, 0.0]);
var indexed = new IndexedColorSpaceDetails(lab, 0, [0x80, 0x00, 0xFF]);

var (r, g, b) = indexed.GetColor(0).ToRGBValues();

Assert.Equal(r, g, 12);
Assert.Equal(g, b, 12);
Assert.InRange(r, 0.3, 0.7);
}
}
}
176 changes: 72 additions & 104 deletions src/UglyToad.PdfPig/Graphics/Colors/ColorSpaceDetails.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,24 @@ protected internal ColorSpaceDetails(ColorSpace type)
/// </summary>
internal abstract Span<byte> Transform(Span<byte> decoded);

/// <summary>
/// Decode raw 8-bit encoded component samples (e.g. an Indexed colour space's colour-table
/// entry) into this colour space's native component ranges, writing in place into
/// <paramref name="destination"/>. Per ISO 32000-2 (PDF 2.0) 8.6.6.3 each byte decodes to
/// min + (byte / 255) × (max − min) of the component's range; for device and most CIE
/// spaces this is [0, 1], which this default implements. Spaces with other native ranges
/// (e.g. Lab) override this.
/// </summary>
/// <param name="raw">The encoded 8-bit component samples.</param>
/// <param name="destination">Receives the decoded component values. Must be at least as long as <paramref name="raw"/>.</param>
internal virtual void DecodeRawComponents(ReadOnlySpan<byte> raw, Span<double> destination)
{
for (int i = 0; i < raw.Length; i++)
{
destination[i] = raw[i] / 255.0;
}
}

/// <summary>
/// Convert to byte.
/// </summary>
Expand Down Expand Up @@ -399,11 +417,24 @@ private byte ClampColorIndex(double value)
return rounded >= HiVal ? HiVal : (byte)rounded;
}

/// <inheritdoc/>
internal override double[] Process(params double[] values)
/// <summary>
/// Decode colour-table bytes to the base colour space's component ranges.
/// ISO 32000-2 (PDF 2.0) 8.6.6.3: the colour table data is interpreted as
/// component values in the base space, i.e. each byte decodes to
/// min + (byte / 255) × (max − min) of that component's range. Device and
/// most CIE spaces use [0, 1]; Lab uses L* ∈ [0, 100] and a*/b* from the
/// /Range entry — feeding Lab a [0, 1] L* renders near-black.
/// </summary>
/// <remarks>
/// The decode rule lives on the base colour space (<see cref="ColorSpaceDetails.DecodeRawComponents"/>);
/// this writes the decoded components in place into <paramref name="destination"/>, whose
/// length selects the table entry width (the base space's component count).
/// </remarks>
private void DecodeTableEntry(byte index, Span<double> destination)
{
var csBytes = UnwrapIndexedColorSpaceBytes([ClampColorIndex(values[0])]);
return BaseColorSpace.Process(ScaleIndexedComponents(csBytes));
BaseColorSpace.DecodeRawComponents(
ColorTable.Slice(index * destination.Length, destination.Length),
destination);
}

/// <inheritdoc/>
Expand All @@ -416,44 +447,18 @@ public override IColor GetColor(params double[] values)

return cache.GetOrAdd(values[0], v =>
{
var csBytes = UnwrapIndexedColorSpaceBytes([ClampColorIndex(v)]);
return BaseColorSpace.GetColor(ScaleIndexedComponents(csBytes));
var components = new double[BaseColorSpace.NumberOfColorComponents];
DecodeTableEntry(ClampColorIndex(v), components);
return BaseColorSpace.GetColor(components);
});
}

/// <summary>
/// Decode colour-table bytes to the base colour space's component ranges.
/// ISO 32000-2 (PDF 2.0) 8.6.6.3: the colour table data is interpreted as
/// component values in the base space, i.e. each byte decodes to
/// min + (byte / 255) × (max − min) of that component's range. Device and
/// most CIE spaces use [0, 1]; Lab uses L* ∈ [0, 100] and a*/b* from the
/// /Range entry — feeding Lab a [0, 1] L* renders near-black.
/// </summary>
private double[] ScaleIndexedComponents(Span<byte> csBytes)
/// <inheritdoc/>
internal override double[] Process(params double[] values)
{
var scaled = new double[csBytes.Length];

if (BaseColorSpace is LabColorSpaceDetails labBase)
{
for (int i = 0; i < csBytes.Length; i++)
{
double unit = csBytes[i] / 255.0;
scaled[i] = (i % 3) switch
{
0 => unit * 100.0, // L*: [0, 100]
1 => labBase.Matrix[0] + unit * (labBase.Matrix[1] - labBase.Matrix[0]), // a*: /Range
_ => labBase.Matrix[2] + unit * (labBase.Matrix[3] - labBase.Matrix[2]), // b*: /Range
};
}
return scaled;
}

for (int i = 0; i < csBytes.Length; i++)
{
scaled[i] = csBytes[i] / 255.0;
}

return scaled;
var components = new double[BaseColorSpace.NumberOfColorComponents];
DecodeTableEntry(ClampColorIndex(values[0]), components);
return BaseColorSpace.Process(components);
}

internal Span<byte> UnwrapIndexedColorSpaceBytes(Span<byte> input)
Expand Down Expand Up @@ -564,72 +569,14 @@ public override IColor GetInitializeColor()
/// <inheritdoc/>
public override void GetRgb(ReadOnlySpan<double> values, out double r, out double g, out double b)
{
// Look up the index into the colour table and dispatch to the base colour space.
// Base color spaces have at most 4 components for our supported types.
byte index = (byte)values[0];
Span<double> buffer = stackalloc double[4];
int components;
switch (BaseType)
{
case ColorSpace.DeviceRGB:
case ColorSpace.CalRGB:
case ColorSpace.Lab:
components = 3;
for (int j = 0; j < 3; j++)
{
buffer[j] = colorTable[index * 3 + j] / 255.0;
}

break;
case ColorSpace.DeviceCMYK:
components = 4;
for (int j = 0; j < 4; j++)
{
buffer[j] = colorTable[index * 4 + j] / 255.0;
}

break;
case ColorSpace.DeviceGray:
case ColorSpace.CalGray:
case ColorSpace.Separation:
components = 1;
buffer[0] = colorTable[index] / 255.0;
break;
case ColorSpace.DeviceN:
case ColorSpace.ICCBased:
components = BaseColorSpace.NumberOfColorComponents;
if (components == 1)
{
buffer[0] = colorTable[index] / 255.0;
}
else
{
if (components > buffer.Length)
{
Span<double> big = components <= 128 ? stackalloc double[components] : new double[components];
for (int j = 0; j < components; j++)
{
big[j] = colorTable[index * components + j] / 255.0;
}

BaseColorSpace.GetRgb(big, out r, out g, out b);
return;
}

for (int j = 0; j < components; j++)
{
buffer[j] = colorTable[index * components + j] / 255.0;
}
}

break;
default:
components = 1;
buffer[0] = values[0];
break;
}

BaseColorSpace.GetRgb(buffer.Slice(0, components), out r, out g, out b);
// Look up the index into the colour table and let the base colour space decode its
// own table bytes into its native component ranges.
byte index = ClampColorIndex(values[0]);
int components = BaseColorSpace.NumberOfColorComponents;
Span<double> buffer = components <= 16 ? stackalloc double[16] : new double[components];
buffer = buffer.Slice(0, components);
DecodeTableEntry(index, buffer);
BaseColorSpace.GetRgb(buffer, out r, out g, out b);
}

/// <summary>
Expand Down Expand Up @@ -1355,6 +1302,27 @@ internal override Span<byte> Transform(Span<byte> decoded)
return transformed;
}

/// <summary>
/// <inheritdoc/>
/// <para>
/// Lab components do not decode to [0, 1]: L* decodes to [0, 100] and a*/b* decode to
/// the ranges given by the colour space's Range entry (<see cref="Matrix"/>).
/// </para>
/// </summary>
internal override void DecodeRawComponents(ReadOnlySpan<byte> raw, Span<double> destination)
{
for (int i = 0; i < raw.Length; i++)
{
double unit = raw[i] / 255.0;
destination[i] = (i % 3) switch
{
0 => unit * 100.0, // L*: [0, 100]
1 => Matrix[0] + unit * (Matrix[1] - Matrix[0]), // a*: Range
_ => Matrix[2] + unit * (Matrix[3] - Matrix[2]), // b*: Range
};
}
}

private static double g(double x)
{
if (x > 6.0 / 29.0)
Expand Down
Loading