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)