diff --git a/src/UglyToad.PdfPig.Tests/Graphics/Colors/IndexedLabColorSpaceDetailsTests.cs b/src/UglyToad.PdfPig.Tests/Graphics/Colors/IndexedLabColorSpaceDetailsTests.cs
new file mode 100644
index 000000000..c794457a4
--- /dev/null
+++ b/src/UglyToad.PdfPig.Tests/Graphics/Colors/IndexedLabColorSpaceDetailsTests.cs
@@ -0,0 +1,103 @@
+namespace UglyToad.PdfPig.Tests.Graphics.Colors
+{
+ using PdfPig.Graphics.Colors;
+
+ ///
+ /// 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.
+ ///
+ public class IndexedLabColorSpaceDetailsTests
+ {
+ private static readonly double[] D50WhitePoint = [0.9505, 1.0, 1.089];
+
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/UglyToad.PdfPig/Graphics/Colors/ColorSpaceDetails.cs b/src/UglyToad.PdfPig/Graphics/Colors/ColorSpaceDetails.cs
index cb99b1ea8..ea022991a 100644
--- a/src/UglyToad.PdfPig/Graphics/Colors/ColorSpaceDetails.cs
+++ b/src/UglyToad.PdfPig/Graphics/Colors/ColorSpaceDetails.cs
@@ -75,6 +75,24 @@ protected internal ColorSpaceDetails(ColorSpace type)
///
internal abstract Span Transform(Span decoded);
+ ///
+ /// 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
+ /// . 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.
+ ///
+ /// The encoded 8-bit component samples.
+ /// Receives the decoded component values. Must be at least as long as .
+ internal virtual void DecodeRawComponents(ReadOnlySpan raw, Span destination)
+ {
+ for (int i = 0; i < raw.Length; i++)
+ {
+ destination[i] = raw[i] / 255.0;
+ }
+ }
+
///
/// Convert to byte.
///
@@ -399,11 +417,24 @@ private byte ClampColorIndex(double value)
return rounded >= HiVal ? HiVal : (byte)rounded;
}
- ///
- internal override double[] Process(params double[] values)
+ ///
+ /// 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.
+ ///
+ ///
+ /// The decode rule lives on the base colour space ();
+ /// this writes the decoded components in place into , whose
+ /// length selects the table entry width (the base space's component count).
+ ///
+ private void DecodeTableEntry(byte index, Span destination)
{
- var csBytes = UnwrapIndexedColorSpaceBytes([ClampColorIndex(values[0])]);
- return BaseColorSpace.Process(ScaleIndexedComponents(csBytes));
+ BaseColorSpace.DecodeRawComponents(
+ ColorTable.Slice(index * destination.Length, destination.Length),
+ destination);
}
///
@@ -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);
});
}
- ///
- /// 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.
- ///
- private double[] ScaleIndexedComponents(Span csBytes)
+ ///
+ 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 UnwrapIndexedColorSpaceBytes(Span input)
@@ -564,72 +569,14 @@ public override IColor GetInitializeColor()
///
public override void GetRgb(ReadOnlySpan 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 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 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 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);
}
///
@@ -1355,6 +1302,27 @@ internal override Span Transform(Span decoded)
return transformed;
}
+ ///
+ ///
+ ///
+ /// 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 ().
+ ///
+ ///
+ internal override void DecodeRawComponents(ReadOnlySpan raw, Span 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)