From 7bdef2f75cd318e8e4fc3cb96015e7abebe35f49 Mon Sep 17 00:00:00 2001 From: Gerard Gunnewijk Date: Thu, 23 Apr 2026 22:41:32 +0200 Subject: [PATCH 1/4] Fixed #87. CID text bytes could contain 0D, which was unescaped in a string literal, and a parser would parse it to 0A, which in the reported font is capital G. Which when subsetting and not used was not available, resulting in a .undef glyph being shown. Fixed by both escaping more characters in string literals, and using hex-string for CID text in content streams. --- .../Content/ContentStream.cs | 6 +- .../Generation/PdfStream.cs | 82 +++++++--- .../Content/ContentStreamTests.cs | 119 ++++++++++++++ .../Generation/PdfStreamTests.cs | 145 ++++++++++++++++++ 4 files changed, 331 insertions(+), 21 deletions(-) create mode 100644 tests/Synercoding.FileFormats.Pdf.Tests/Content/ContentStreamTests.cs diff --git a/src/Synercoding.FileFormats.Pdf/Content/ContentStream.cs b/src/Synercoding.FileFormats.Pdf/Content/ContentStream.cs index eab9d1b..2c67cc4 100644 --- a/src/Synercoding.FileFormats.Pdf/Content/ContentStream.cs +++ b/src/Synercoding.FileFormats.Pdf/Content/ContentStream.cs @@ -234,7 +234,7 @@ public ContentStream EndText() public ContentStream ShowTextTj(byte[] line) { InnerStream - .WriteStringLiteral(line) + .WriteStringHex(line) .Space() .Write("Tj") .NewLine(); @@ -250,7 +250,7 @@ public ContentStream ShowTextTj(byte[] line) public ContentStream MoveNextLineShowText(byte[] line) { InnerStream - .WriteStringLiteral(line) + .WriteStringHex(line) .Space() .Write("'") .NewLine(); @@ -285,7 +285,7 @@ public ContentStream MoveNextLineShowText(byte[] line, double wordSpacing, doubl .Space() .Write(characterSpacing) .Space() - .WriteStringLiteral(line) + .WriteStringHex(line) .Space() .Write("\"") .NewLine(); diff --git a/src/Synercoding.FileFormats.Pdf/Generation/PdfStream.cs b/src/Synercoding.FileFormats.Pdf/Generation/PdfStream.cs index e6cbd12..378ffb6 100644 --- a/src/Synercoding.FileFormats.Pdf/Generation/PdfStream.cs +++ b/src/Synercoding.FileFormats.Pdf/Generation/PdfStream.cs @@ -259,16 +259,7 @@ internal PdfStream WriteStringLiteral(string value) : [.. Encoding.UTF8.Preamble, .. Encoding.UTF8.GetBytes(value)]; foreach (var b in bytes) - { - if (b == '(') - Write('\\').Write('('); - else if (b == ')') - Write('\\').Write(')'); - else if (b == '\\') - Write('\\').Write('\\'); - else - Write(b); - } + _writeLiteralByte(b); WriteByte(0x29); // ) @@ -285,22 +276,77 @@ internal PdfStream WriteStringLiteral(byte[] encodedString) WriteByte(0x28); // ( foreach (var b in encodedString) + _writeLiteralByte(b); + + WriteByte(0x29); // ) + + return this; + } + + /// + /// Write an encoded byte sequence to the stream as a PDF hexadecimal string. + /// + /// + /// Hexadecimal strings (ISO 32000-1 §7.3.4.3) are the correct container for arbitrary + /// binary data such as CID-encoded show-text operands: they have no escape rules and + /// no end-of-line normalisation, so every byte round-trips exactly. + /// + /// The bytes to write. + /// The to support chaining operations. + internal PdfStream WriteStringHex(byte[] encodedString) + { + WriteByte(0x3C); // < + + Span pair = stackalloc byte[2]; + foreach (var b in encodedString) + { + pair[0] = _hexNibble(b >> 4); + pair[1] = _hexNibble(b & 0x0F); + Write(pair); + } + + WriteByte(0x3E); // > + + return this; + } + + private void _writeLiteralByte(byte b) + { + switch (b) { - if (b == '(') + case (byte)'(': Write('\\').Write('('); - else if (b == ')') + break; + case (byte)')': Write('\\').Write(')'); - else if (b == '\\') + break; + case (byte)'\\': Write('\\').Write('\\'); - else + break; + case 0x0A: + Write('\\').Write('n'); + break; + case 0x0D: + Write('\\').Write('r'); + break; + case 0x09: + Write('\\').Write('t'); + break; + case 0x08: + Write('\\').Write('b'); + break; + case 0x0C: + Write('\\').Write('f'); + break; + default: WriteByte(b); + break; } - - WriteByte(0x29); // ) - - return this; } + private static byte _hexNibble(int n) + => (byte)( n < 10 ? ( '0' + n ) : ( 'A' + n - 10 ) ); + /// /// Write an array of numbers to the pdf stream /// diff --git a/tests/Synercoding.FileFormats.Pdf.Tests/Content/ContentStreamTests.cs b/tests/Synercoding.FileFormats.Pdf.Tests/Content/ContentStreamTests.cs new file mode 100644 index 0000000..d2f44db --- /dev/null +++ b/tests/Synercoding.FileFormats.Pdf.Tests/Content/ContentStreamTests.cs @@ -0,0 +1,119 @@ +using Synercoding.FileFormats.Pdf.Content; +using Synercoding.FileFormats.Pdf.Generation; +using Synercoding.FileFormats.Pdf.Generation.Internal; +using Synercoding.FileFormats.Pdf.Primitives; +using System.Text; + +namespace Synercoding.FileFormats.Pdf.Tests.Content; + +/// +/// Regression tests for issue #87 — CID-encoded show-text operands must survive the +/// serialiser unchanged. When literal strings were used, bytes containing 0x0D were +/// normalised to 0x0A by the PDF parser (ISO 32000-1 §7.3.4.2), which silently +/// shifted the CID lookup and produced wrong or missing glyphs. +/// +public class ContentStreamTests : IDisposable +{ + private readonly TableBuilder _tableBuilder; + private readonly CachedResources _cachedResources; + private readonly PageResources _pageResources; + private readonly ContentStream _contentStream; + + public ContentStreamTests() + { + _tableBuilder = new TableBuilder(); + _cachedResources = new CachedResources(_tableBuilder); + _pageResources = new PageResources(_tableBuilder, _cachedResources); + _contentStream = new ContentStream(_tableBuilder.ReserveId(), _pageResources); + } + + public void Dispose() + { + _contentStream.Dispose(); + _pageResources.Dispose(); + } + + [Fact] + public void ShowTextTj_GlyphId0x000D_WritesHexString() + { + // Reproduces the original issue #87 case: Source Sans Pro capital 'J' + // maps to glyph id 13 (0x000D). The bytes must round-trip verbatim. + _contentStream.ShowTextTj(new byte[] { 0x00, 0x0D }); + + var written = Encoding.ASCII.GetString(_contentStream.InnerStream.ToStreamObject().RawData); + + Assert.Contains("<000D>", written); + Assert.Contains("Tj", written); + } + + [Fact] + public void ShowTextTj_GlyphIdWithCarriageReturnInHighByte_WritesHexString() + { + _contentStream.ShowTextTj(new byte[] { 0x0D, 0x42 }); + + var written = Encoding.ASCII.GetString(_contentStream.InnerStream.ToStreamObject().RawData); + + Assert.Contains("<0D42>", written); + } + + [Fact] + public void ShowTextTj_ConsecutiveCidsForming0D0A_PreservesAlignment() + { + // A literal string would collapse 0D 0A to a single 0x0A, shifting + // alignment for every subsequent 2-byte CID from that point on. + _contentStream.ShowTextTj(new byte[] { 0x01, 0x0D, 0x0A, 0x02 }); + + var written = Encoding.ASCII.GetString(_contentStream.InnerStream.ToStreamObject().RawData); + + Assert.Contains("<010D0A02>", written); + } + + [Fact] + public void ShowTextTj_BytesMatchingLiteralDelimiters_AreEmittedAsHex() + { + // A glyph id whose byte encoding contains '(' / ')' / '\' was previously + // escaped for a literal string; hex strings write them verbatim. + _contentStream.ShowTextTj(new byte[] { 0x28, 0x29, 0x5C }); + + var written = Encoding.ASCII.GetString(_contentStream.InnerStream.ToStreamObject().RawData); + + Assert.Contains("<28295C>", written); + Assert.DoesNotContain("\\(", written); + Assert.DoesNotContain("\\)", written); + Assert.DoesNotContain("\\\\", written); + } + + [Fact] + public void ShowTextTj_DoesNotEmitLiteralStringDelimiters() + { + // Regression guard: the operand must no longer be wrapped in ( … ). + _contentStream.ShowTextTj(new byte[] { 0x00, 0x0D }); + + var rawData = _contentStream.InnerStream.ToStreamObject().RawData; + + Assert.DoesNotContain((byte)'(', rawData); + Assert.DoesNotContain((byte)')', rawData); + } + + [Fact] + public void MoveNextLineShowText_GlyphId0x000D_WritesHexString() + { + _contentStream.MoveNextLineShowText(new byte[] { 0x00, 0x0D }); + + var written = Encoding.ASCII.GetString(_contentStream.InnerStream.ToStreamObject().RawData); + + Assert.Contains("<000D>", written); + Assert.Contains("'", written); + } + + [Fact] + public void MoveNextLineShowText_WithSpacing_WritesHexString() + { + _contentStream.MoveNextLineShowText(new byte[] { 0x00, 0x0D }, wordSpacing: 1.0, characterSpacing: 2.0); + + var written = Encoding.ASCII.GetString(_contentStream.InnerStream.ToStreamObject().RawData); + + Assert.Contains("<000D>", written); + Assert.Contains("\"", written); + } +} diff --git a/tests/Synercoding.FileFormats.Pdf.Tests/Generation/PdfStreamTests.cs b/tests/Synercoding.FileFormats.Pdf.Tests/Generation/PdfStreamTests.cs index 533b840..1aa256a 100644 --- a/tests/Synercoding.FileFormats.Pdf.Tests/Generation/PdfStreamTests.cs +++ b/tests/Synercoding.FileFormats.Pdf.Tests/Generation/PdfStreamTests.cs @@ -266,6 +266,151 @@ public void Test_ToStreamObject_WithThreeFilters_ReturnsStreamObjectWithFilterAr Assert.Equal(thirdEncoded, streamObject.RawData); } + // Regression tests for issue #87 — the literal-string escape table and the + // hex-string fallback used for CID-encoded show-text operands. + + [Fact] + public void WriteStringHex_EmptyArray_WritesEmptyAngleBrackets() + { + using var memoryStream = new MemoryStream(); + var pdfStream = new PdfStream(memoryStream); + + pdfStream.WriteStringHex(Array.Empty()); + + Assert.Equal(new byte[] { 0x3C, 0x3E }, memoryStream.ToArray()); + } + + [Fact] + public void WriteStringHex_GlyphId0x000D_PreservesCarriageReturn() + { + // Issue #87: Source Sans Pro capital 'J' has glyph id 13 (0x000D). + // Previously this was written as a literal string, where the parser + // would silently normalise 0x0D to 0x0A and the consumer would look + // up CID 10 instead of 13. + using var memoryStream = new MemoryStream(); + var pdfStream = new PdfStream(memoryStream); + + pdfStream.WriteStringHex(new byte[] { 0x00, 0x0D }); + + Assert.Equal("<000D>", Encoding.ASCII.GetString(memoryStream.ToArray())); + } + + [Fact] + public void WriteStringHex_GlyphIdWithCarriageReturnInHighByte_IsPreserved() + { + using var memoryStream = new MemoryStream(); + var pdfStream = new PdfStream(memoryStream); + + pdfStream.WriteStringHex(new byte[] { 0x0D, 0x42 }); + + Assert.Equal("<0D42>", Encoding.ASCII.GetString(memoryStream.ToArray())); + } + + [Fact] + public void WriteStringHex_ConsecutiveCidsForming0D0A_PreservesAlignment() + { + // A literal string would collapse the 0D 0A pair into a single 0x0A, + // shifting alignment for every subsequent 2-byte CID. A hex string + // round-trips every byte. + using var memoryStream = new MemoryStream(); + var pdfStream = new PdfStream(memoryStream); + + pdfStream.WriteStringHex(new byte[] { 0x01, 0x0D, 0x0A, 0x02 }); + + Assert.Equal("<010D0A02>", Encoding.ASCII.GetString(memoryStream.ToArray())); + } + + [Fact] + public void WriteStringHex_BytesThatWouldBeEscapedInLiteral_AreWrittenRaw() + { + using var memoryStream = new MemoryStream(); + var pdfStream = new PdfStream(memoryStream); + + pdfStream.WriteStringHex(new byte[] { 0x28, 0x29, 0x5C }); + + Assert.Equal("<28295C>", Encoding.ASCII.GetString(memoryStream.ToArray())); + } + + [Fact] + public void WriteStringHex_AllBytes_RoundTripExactly() + { + var input = new byte[256]; + for (int i = 0; i < 256; i++) + input[i] = (byte)i; + + using var memoryStream = new MemoryStream(); + var pdfStream = new PdfStream(memoryStream); + + pdfStream.WriteStringHex(input); + + var output = Encoding.ASCII.GetString(memoryStream.ToArray()); + Assert.StartsWith("<", output); + Assert.EndsWith(">", output); + Assert.Equal(( input.Length * 2 ) + 2, output.Length); + + // Parse the hex back and verify every byte round-trips. + var hex = output[1..^1]; + var roundTripped = new byte[input.Length]; + for (int i = 0; i < input.Length; i++) + roundTripped[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16); + Assert.Equal(input, roundTripped); + } + + [Fact] + public void WriteStringLiteral_String_EscapesCarriageReturn() + { + using var memoryStream = new MemoryStream(); + var pdfStream = new PdfStream(memoryStream); + + pdfStream.WriteStringLiteral("a\rb"); + + Assert.Equal("(a\\rb)", Encoding.ASCII.GetString(memoryStream.ToArray())); + } + + [Fact] + public void WriteStringLiteral_String_EscapesLineFeed() + { + using var memoryStream = new MemoryStream(); + var pdfStream = new PdfStream(memoryStream); + + pdfStream.WriteStringLiteral("a\nb"); + + Assert.Equal("(a\\nb)", Encoding.ASCII.GetString(memoryStream.ToArray())); + } + + [Fact] + public void WriteStringLiteral_String_EscapesTab() + { + using var memoryStream = new MemoryStream(); + var pdfStream = new PdfStream(memoryStream); + + pdfStream.WriteStringLiteral("a\tb"); + + Assert.Equal("(a\\tb)", Encoding.ASCII.GetString(memoryStream.ToArray())); + } + + [Fact] + public void WriteStringLiteral_String_StillEscapesParenthesesAndBackslash() + { + using var memoryStream = new MemoryStream(); + var pdfStream = new PdfStream(memoryStream); + + pdfStream.WriteStringLiteral("(a)\\b"); + + Assert.Equal("(\\(a\\)\\\\b)", Encoding.ASCII.GetString(memoryStream.ToArray())); + } + + [Fact] + public void WriteStringLiteral_Bytes_EscapesControlCharacters() + { + using var memoryStream = new MemoryStream(); + var pdfStream = new PdfStream(memoryStream); + + pdfStream.WriteStringLiteral(new byte[] { 0x0D, 0x0A, 0x09, 0x08, 0x0C }); + + Assert.Equal("(\\r\\n\\t\\b\\f)", Encoding.ASCII.GetString(memoryStream.ToArray())); + } + private class PassThroughFilterStub : IStreamFilter { public PdfName Name => PdfName.Get("PassThrough"); From 59b8a54fd34a46c639fd7a21eabdf4c4d8da883b Mon Sep 17 00:00:00 2001 From: Gerard Gunnewijk Date: Mon, 20 Apr 2026 15:29:20 +0200 Subject: [PATCH 2/4] Change GrayScaleMethod from enum to delegate to allow users more control over separation images. --- .../Program.cs | 2 +- .../Content/GrayScaleMethod.cs | 79 +++++++++++++++++-- .../Content/PdfImage.cs | 36 ++------- .../Generation/PageResources.cs | 3 +- src/Synercoding.FileFormats.Pdf/PdfWriter.cs | 6 +- .../Generation/PageResourcesTests.cs | 2 +- 6 files changed, 86 insertions(+), 42 deletions(-) diff --git a/samples/Synercoding.FileFormats.Pdf.ConsoleTester/Program.cs b/samples/Synercoding.FileFormats.Pdf.ConsoleTester/Program.cs index 7b0b755..4e627a6 100644 --- a/samples/Synercoding.FileFormats.Pdf.ConsoleTester/Program.cs +++ b/samples/Synercoding.FileFormats.Pdf.ConsoleTester/Program.cs @@ -294,7 +294,7 @@ private static void _writePdf(string fileName, bool enableSubsetting = true) using (var pantherSixImage = SixLabors.ImageSharp.Image.Load(pantherPngStream)) { var pantherImg = writer.AddImage(pantherSixImage); - var transparentPanther = writer.AddSeparationImage(pantherSixImage, new Separation(PdfName.Get("White"), PredefinedColors.Yellow), GrayScaleMethod.AlphaChannel, [0, 1]); + var transparentPanther = writer.AddSeparationImage(pantherSixImage, new Separation(PdfName.Get("White"), PredefinedColors.Yellow), GrayScaleMethods.AlphaChannel, [0, 1]); writer.AddPage(page => { diff --git a/src/Synercoding.FileFormats.Pdf/Content/GrayScaleMethod.cs b/src/Synercoding.FileFormats.Pdf/Content/GrayScaleMethod.cs index 0d9597f..176fd31 100644 --- a/src/Synercoding.FileFormats.Pdf/Content/GrayScaleMethod.cs +++ b/src/Synercoding.FileFormats.Pdf/Content/GrayScaleMethod.cs @@ -1,36 +1,99 @@ +using SixLabors.ImageSharp.PixelFormats; + namespace Synercoding.FileFormats.Pdf.Content; /// /// What method is used to generate a 1 component grayscale pixel byte array /// -public enum GrayScaleMethod +public static class GrayScaleMethods { /// /// Use the red channel /// - RedChannel, + public static byte RedChannel(ref Rgba32 pixel) => pixel.R; + /// + /// Use the red channel + /// + public static byte RedChannel(ref Rgb24 pixel) => pixel.R; + /// + /// Use the green channel + /// + public static byte GreenChannel(ref Rgba32 pixel) => pixel.G; /// /// Use the green channel /// - GreenChannel, + public static byte GreenChannel(ref Rgb24 pixel) => pixel.G; /// /// Use the blue channel /// - BlueChannel, + public static byte BlueChannel(ref Rgba32 pixel) => pixel.B; + /// + /// Use the blue channel + /// + public static byte BlueChannel(ref Rgb24 pixel) => pixel.B; /// /// Use the alpha channel /// - AlphaChannel, + public static byte AlphaChannel(ref Rgba32 pixel) => pixel.A; + /// + /// Use the average of the Red, Green and Blue channels. + /// + public static byte AverageOfRGBChannels(ref Rgba32 pixel) => (byte)( ( pixel.R + pixel.G + pixel.B ) / 3 ); /// /// Use the average of the Red, Green and Blue channels. /// - AverageOfRGBChannels, + public static byte AverageOfRGBChannels(ref Rgb24 pixel) => (byte)( ( pixel.R + pixel.G + pixel.B ) / 3 ); /// /// The constants defined by ITU-R BT.601 are 0.299 red + 0.587 green + 0.114 blue. /// - BT601, + public static byte BT601(ref Rgba32 pixel) => (byte)( ( pixel.R * 0.299 ) + ( pixel.G * 0.587 ) + ( pixel.B * 0.114 ) ); + /// + /// The constants defined by ITU-R BT.601 are 0.299 red + 0.587 green + 0.114 blue. + /// + public static byte BT601(ref Rgb24 pixel) => (byte)( ( pixel.R * 0.299 ) + ( pixel.G * 0.587 ) + ( pixel.B * 0.114 ) ); /// /// The constants defined by ITU-R BT.709 are 0.2126 red + 0.7152 green + 0.0722 blue. /// - BT709, + public static byte BT709(ref Rgba32 pixel) => (byte)( ( pixel.R * 0.2126 ) + ( pixel.G * 0.7152 ) + ( pixel.B * 0.0722 ) ); + /// + /// The constants defined by ITU-R BT.709 are 0.2126 red + 0.7152 green + 0.0722 blue. + /// + public static byte BT709(ref Rgb24 pixel) => (byte)( ( pixel.R * 0.2126 ) + ( pixel.G * 0.7152 ) + ( pixel.B * 0.0722 ) ); + + /// + /// Create a threshold method from an initial method. The threshold method will return the lowValue if the initial method returns a value less than the threshold, and the highValue otherwise. + /// + /// The initial method to use. + /// The threshold value. + /// The value to return if the initial method returns a value less than the threshold. + /// The value to return if the initial method returns a value greater than or equal to the threshold. + /// The threshold method. + public static GrayScaleMethod32 Threshold(GrayScaleMethod32 initialMethod, byte threshold, byte lowValue = 0x00, byte highValue = 0xFF) + { + return (ref Rgba32 pixel) => + { + var value = initialMethod(ref pixel); + return value < threshold ? lowValue : highValue; + }; + } + + /// + /// Create a threshold method from an initial method. The threshold method will return the lowValue if the initial method returns a value less than the threshold, and the highValue otherwise. + /// + /// The initial method to use. + /// The threshold value. + /// The value to return if the initial method returns a value less than the threshold. + /// The value to return if the initial method returns a value greater than or equal to the threshold. + /// The threshold method. + public static GrayScaleMethod24 Threshold(GrayScaleMethod24 initialMethod, byte threshold, byte lowValue = 0x00, byte highValue = 0xFF) + { + return (ref Rgb24 pixel) => + { + var value = initialMethod(ref pixel); + return value < threshold ? lowValue : highValue; + }; + } } + +public delegate byte GrayScaleMethod32(ref Rgba32 pixel); +public delegate byte GrayScaleMethod24(ref Rgb24 pixel); diff --git a/src/Synercoding.FileFormats.Pdf/Content/PdfImage.cs b/src/Synercoding.FileFormats.Pdf/Content/PdfImage.cs index e727991..6f704db 100644 --- a/src/Synercoding.FileFormats.Pdf/Content/PdfImage.cs +++ b/src/Synercoding.FileFormats.Pdf/Content/PdfImage.cs @@ -151,14 +151,14 @@ internal static PdfImage Get(TableBuilder tableBuilder, Image image) internal static PdfImage Get(TableBuilder tableBuilder, Image image) => new PdfImage(tableBuilder.ReserveId(), _encodeToJpg(image), image.Width, image.Height, DeviceRGB.Instance, null, null, (PdfNames.DCTDecode, null)); - internal static PdfImage GetSeparation(TableBuilder tableBuilder, Image image, Separation separation, GrayScaleMethod grayScaleMethod, double[]? decodeArray = null) + internal static PdfImage GetSeparation(TableBuilder tableBuilder, Image image, Separation separation, GrayScaleMethod24 grayScaleMethod, double[]? decodeArray = null) { using var grayScaleStream = AsGrayScaleByteStream(image, grayScaleMethod); return new PdfImage(tableBuilder.ReserveId(), _flateEncode(grayScaleStream), image.Width, image.Height, separation, null, decodeArray, (PdfNames.FlateDecode, null)); } - internal static PdfImage GetSeparation(TableBuilder tableBuilder, Image image, Separation separation, GrayScaleMethod grayScaleMethod, double[]? decodeArray = null) + internal static PdfImage GetSeparation(TableBuilder tableBuilder, Image image, Separation separation, GrayScaleMethod32 grayScaleMethod, double[]? decodeArray = null) { using var grayScaleStream = AsGrayScaleByteStream(image, grayScaleMethod); @@ -170,7 +170,7 @@ internal static PdfImage GetSeparation(TableBuilder tableBuilder, Image if (!_hasTransparancy(image)) return null; - using var grayScaleStream = AsGrayScaleByteStream(image, GrayScaleMethod.AlphaChannel); + using var grayScaleStream = AsGrayScaleByteStream(image, GrayScaleMethods.AlphaChannel); return new PdfImage(tableBuilder.ReserveId(), _flateEncode(grayScaleStream), image.Width, image.Height, DeviceGray.Instance, null, null, (PdfNames.FlateDecode, null)); } @@ -214,7 +214,7 @@ private static Stream _flateEncode(MemoryStream stream) return new MemoryStream(bytes); } - internal static MemoryStream AsGrayScaleByteStream(Image image, GrayScaleMethod grayScaleMethod) + internal static MemoryStream AsGrayScaleByteStream(Image image, GrayScaleMethod32 grayScaleMethod) { var byteStream = new MemoryStream(); @@ -231,17 +231,7 @@ internal static MemoryStream AsGrayScaleByteStream(Image image, GrayScal // Get a reference to the pixel at position x ref Rgba32 pixel = ref pixelRow[x]; - var pixelValue = grayScaleMethod switch - { - GrayScaleMethod.AlphaChannel => pixel.A, - GrayScaleMethod.RedChannel => pixel.R, - GrayScaleMethod.GreenChannel => pixel.G, - GrayScaleMethod.BlueChannel => pixel.B, - GrayScaleMethod.AverageOfRGBChannels => (byte)( ( pixel.R + pixel.G + pixel.B ) / 3 ), - GrayScaleMethod.BT601 => (byte)( ( pixel.R * 0.299 ) + ( pixel.G * 0.587 ) + ( pixel.B * 0.114 ) ), - GrayScaleMethod.BT709 => (byte)( ( pixel.R * 0.2126 ) + ( pixel.G * 0.7152 ) + ( pixel.B * 0.0722 ) ), - _ => throw new NotImplementedException() - }; + var pixelValue = grayScaleMethod(ref pixel); byteStream.WriteByte(pixelValue); } @@ -253,11 +243,8 @@ internal static MemoryStream AsGrayScaleByteStream(Image image, GrayScal return byteStream; } - internal static MemoryStream AsGrayScaleByteStream(Image image, GrayScaleMethod grayScaleMethod) + internal static MemoryStream AsGrayScaleByteStream(Image image, GrayScaleMethod24 grayScaleMethod) { - if (grayScaleMethod == GrayScaleMethod.AlphaChannel) - throw new ArgumentException($"Can not use alpha channel for images of pixel format {nameof(Rgb24)}.", nameof(grayScaleMethod)); - var byteStream = new MemoryStream(); image.ProcessPixelRows(accessor => @@ -273,16 +260,7 @@ internal static MemoryStream AsGrayScaleByteStream(Image image, GrayScale // Get a reference to the pixel at position x ref Rgb24 pixel = ref pixelRow[x]; - var pixelValue = grayScaleMethod switch - { - GrayScaleMethod.RedChannel => pixel.R, - GrayScaleMethod.GreenChannel => pixel.G, - GrayScaleMethod.BlueChannel => pixel.B, - GrayScaleMethod.AverageOfRGBChannels => (byte)( ( pixel.R + pixel.G + pixel.B ) / 3 ), - GrayScaleMethod.BT601 => (byte)( ( pixel.R * 0.299 ) + ( pixel.G * 0.587 ) + ( pixel.B * 0.114 ) ), - GrayScaleMethod.BT709 => (byte)( ( pixel.R * 0.2126 ) + ( pixel.G * 0.7152 ) + ( pixel.B * 0.0722 ) ), - _ => throw new NotImplementedException() - }; + var pixelValue = grayScaleMethod(ref pixel); byteStream.WriteByte(pixelValue); } diff --git a/src/Synercoding.FileFormats.Pdf/Generation/PageResources.cs b/src/Synercoding.FileFormats.Pdf/Generation/PageResources.cs index 386b304..340d40e 100644 --- a/src/Synercoding.FileFormats.Pdf/Generation/PageResources.cs +++ b/src/Synercoding.FileFormats.Pdf/Generation/PageResources.cs @@ -69,8 +69,9 @@ public PdfName AddJpgUnsafe(Stream jpgStream, int originalWidth, int originalHei return Add(pdfImage); } - public PdfName Add(SixLabors.ImageSharp.Image image, Separation separation, GrayScaleMethod grayScaleMethod = GrayScaleMethod.AverageOfRGBChannels) + public PdfName Add(SixLabors.ImageSharp.Image image, Separation separation, GrayScaleMethod32? grayScaleMethod = null) { + grayScaleMethod ??= GrayScaleMethods.AverageOfRGBChannels; var pdfImage = PdfImage.GetSeparation(_tableBuilder, image, separation, grayScaleMethod); return Add(pdfImage); diff --git a/src/Synercoding.FileFormats.Pdf/PdfWriter.cs b/src/Synercoding.FileFormats.Pdf/PdfWriter.cs index fe43230..0c5312a 100644 --- a/src/Synercoding.FileFormats.Pdf/PdfWriter.cs +++ b/src/Synercoding.FileFormats.Pdf/PdfWriter.cs @@ -169,8 +169,9 @@ public PdfImage AddImage(SixLabors.ImageSharp.Image image) /// The method to convert to grayscale. /// Optional decode array for the image. /// The added PDF image. - public PdfImage AddSeparationImage(SixLabors.ImageSharp.Image image, Separation separation, GrayScaleMethod grayScaleMethod = GrayScaleMethod.AverageOfRGBChannels, double[]? decodeArray = null) + public PdfImage AddSeparationImage(SixLabors.ImageSharp.Image image, Separation separation, GrayScaleMethod32? grayScaleMethod = null, double[]? decodeArray = null) { + grayScaleMethod ??= GrayScaleMethods.AverageOfRGBChannels; var pdfImage = PdfImage.GetSeparation(_objectWriter.TableBuiler, image, separation, grayScaleMethod, decodeArray); _objectWriter.Write(pdfImage.ToStreamObject(_cachedResources)); @@ -193,8 +194,9 @@ public PdfImage AddSeparationImage(SixLabors.ImageSharp.Image image, Sep /// The method to convert to grayscale. /// Optional decode array for the image. /// The added PDF image. - public PdfImage AddSeparationImage(SixLabors.ImageSharp.Image image, Separation separation, GrayScaleMethod grayScaleMethod = GrayScaleMethod.AverageOfRGBChannels, double[]? decodeArray = null) + public PdfImage AddSeparationImage(SixLabors.ImageSharp.Image image, Separation separation, GrayScaleMethod24? grayScaleMethod = null, double[]? decodeArray = null) { + grayScaleMethod ??= GrayScaleMethods.AverageOfRGBChannels; var pdfImage = PdfImage.GetSeparation(_objectWriter.TableBuiler, image, separation, grayScaleMethod, decodeArray); _objectWriter.Write(pdfImage.ToStreamObject(_cachedResources)); diff --git a/tests/Synercoding.FileFormats.Pdf.Tests/Generation/PageResourcesTests.cs b/tests/Synercoding.FileFormats.Pdf.Tests/Generation/PageResourcesTests.cs index 7af998f..4728e6d 100644 --- a/tests/Synercoding.FileFormats.Pdf.Tests/Generation/PageResourcesTests.cs +++ b/tests/Synercoding.FileFormats.Pdf.Tests/Generation/PageResourcesTests.cs @@ -283,7 +283,7 @@ public void Test_Add_ImageSharp_WithSeparation() var separation = new Separation(PdfName.Get("SpotColor"), new RgbColor(0, 1, 0)); // Green // Act - var name = _pageResources.Add(image, separation, GrayScaleMethod.AverageOfRGBChannels); + var name = _pageResources.Add(image, separation, GrayScaleMethods.AverageOfRGBChannels); // Assert Assert.NotNull(name); From f944d610e2efc27490ac30ad34b79915a5fb0ae1 Mon Sep 17 00:00:00 2001 From: Gerard Gunnewijk Date: Mon, 20 Apr 2026 15:37:23 +0200 Subject: [PATCH 3/4] Fixed missing xml comments --- .../Content/GrayScaleMethod.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/Synercoding.FileFormats.Pdf/Content/GrayScaleMethod.cs b/src/Synercoding.FileFormats.Pdf/Content/GrayScaleMethod.cs index 176fd31..5b5cd15 100644 --- a/src/Synercoding.FileFormats.Pdf/Content/GrayScaleMethod.cs +++ b/src/Synercoding.FileFormats.Pdf/Content/GrayScaleMethod.cs @@ -95,5 +95,16 @@ public static GrayScaleMethod24 Threshold(GrayScaleMethod24 initialMethod, byte } } +/// +/// Represents a method to convert a pixel into a single byte. +/// +/// The pixel to convert +/// The byte value the pixel represents. public delegate byte GrayScaleMethod32(ref Rgba32 pixel); + +/// +/// Represents a method to convert a pixel into a single byte. +/// +/// The pixel to convert +/// The byte value the pixel represents. public delegate byte GrayScaleMethod24(ref Rgb24 pixel); From e064097cc487524f1184f643d3d77a705ef3e1d7 Mon Sep 17 00:00:00 2001 From: Gerard Gunnewijk Date: Thu, 23 Apr 2026 23:28:33 +0200 Subject: [PATCH 4/4] Updated release notes (#89) --- src/Synercoding.FileFormats.Pdf/PackageDetails.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Synercoding.FileFormats.Pdf/PackageDetails.props b/src/Synercoding.FileFormats.Pdf/PackageDetails.props index c4d3879..66c138d 100644 --- a/src/Synercoding.FileFormats.Pdf/PackageDetails.props +++ b/src/Synercoding.FileFormats.Pdf/PackageDetails.props @@ -10,7 +10,7 @@ Synercoding.FileFormats.Pdf Synercoding.FileFormats.Pdf Contains classes which makes it easy to quickly create a pdf file. - Complete rewrite to add support for fonts, and with an read/edit future in mind. + Changed GrayScaleMethod from an enum to a delegate, giving users full control over how separation images are converted to grayscale (breaking change). Fixed issue #87 where CID text bytes containing 0x0D were incorrectly unescaped in string literals, causing parsers to misread them as 0x0A and render the wrong glyph; string literals now escape more characters and CID text in content streams is written as hex strings. README.md LICENSE