From d0840f6381ea3250113fcfd47a4a29d28451e9cd Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 01:31:24 +0300 Subject: [PATCH 01/85] Add image dimension, resize, PNG conversion and text watermark operations --- Drawing/ImageHelper.cs | 174 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 Drawing/ImageHelper.cs diff --git a/Drawing/ImageHelper.cs b/Drawing/ImageHelper.cs new file mode 100644 index 000000000..836bbb425 --- /dev/null +++ b/Drawing/ImageHelper.cs @@ -0,0 +1,174 @@ +namespace Ecng.Drawing; + +using System; +using System.Drawing; +using System.IO; + +using SixLabors.Fonts; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +using ImageSharpColor = SixLabors.ImageSharp.Color; +using ImageSharpPointF = SixLabors.ImageSharp.PointF; +using ImageSharpSize = SixLabors.ImageSharp.Size; + +/// +/// Cross-platform operations on encoded images. Input buffers are never changed. +/// +public static class ImageHelper +{ + /// + /// Reads the image dimensions without decoding pixel data. Supports all image formats recognized by ImageSharp. + /// For a PNG-only fast header check see . + /// + public static Size GetImageSize(this byte[] data) + => GetImageSize((ReadOnlySpan)(data ?? throw new ArgumentNullException(nameof(data)))); + + /// + public static Size GetImageSize(this ReadOnlySpan data) + { + var info = Image.Identify(data); + return new(info.Width, info.Height); + } + + /// + /// Reduces an image to fit the provided maximum dimensions, keeping its aspect ratio + /// and encoding format. Does not enlarge small images. Returns a separate byte array. + /// + public static byte[] ResizeImage(this byte[] data, int maxWidth, int maxHeight) + { + ArgumentNullException.ThrowIfNull(data); + + if (maxWidth <= 0) + throw new ArgumentOutOfRangeException(nameof(maxWidth)); + + if (maxHeight <= 0) + throw new ArgumentOutOfRangeException(nameof(maxHeight)); + + using var image = Image.Load(data); + var scale = Math.Min((double)maxWidth / image.Width, (double)maxHeight / image.Height); + + if (scale >= 1) + return (byte[])data.Clone(); + + var width = Math.Max(1, (int)Math.Floor(image.Width * scale)); + var height = Math.Max(1, (int)Math.Floor(image.Height * scale)); + + image.Mutate(ctx => ctx.Resize(new ImageSharpSize(width, height), KnownResamplers.Lanczos3)); + + IImageFormat format = image.Metadata.DecodedImageFormat + ?? throw new InvalidDataException("The decoded image format is unknown."); + + using var output = new MemoryStream(); + image.Save(output, format); + return output.ToArray(); + } + + /// + /// Converts the image to PNG while preserving alpha. For animated images only the first frame is used. + /// + public static byte[] ConvertToPng(this byte[] data) + { + ArgumentNullException.ThrowIfNull(data); + + using var image = Image.Load(data); + KeepFirstFrame(image); + + using var output = new MemoryStream(); + image.SaveAsPng(output); + return output.ToArray(); + } + + /// + /// Adds semi-transparent white text at the bottom-right, returning a PNG. + /// The default family is Verdana. It must be installed, otherwise explicitly supply another installed font. + /// The text is automatically reduced in size, when necessary, to fit within the margins. + /// + /// Original encoded image. + /// Watermark text, nonempty and visible. + /// Maximum text size in points, finite and positive. + /// Text alpha, 0-255. + /// Minimum edge margin in pixels. + /// Installed font family, Verdana by default. + public static byte[] AddTextWatermark( + this byte[] data, + string text, + float fontSize = 24, + byte opacity = 160, + int margin = 12, + string fontFamily = "Verdana") + { + ArgumentNullException.ThrowIfNull(data); + + if (string.IsNullOrWhiteSpace(text)) + throw new ArgumentException("Watermark text must not be blank.", nameof(text)); + + if (fontSize <= 0 || float.IsNaN(fontSize) || float.IsInfinity(fontSize)) + throw new ArgumentOutOfRangeException(nameof(fontSize), "Font size must be finite and positive."); + + if (margin < 0) + throw new ArgumentOutOfRangeException(nameof(margin)); + + if (string.IsNullOrWhiteSpace(fontFamily)) + throw new ArgumentException("Font family is required.", nameof(fontFamily)); + + if (!SystemFonts.TryGet(fontFamily, out var family)) + throw new InvalidOperationException($"Font '{fontFamily}' is not installed. Install it or specify another font family."); + + using var image = Image.Load(data); + KeepFirstFrame(image); + + // Use long arithmetic for margins to prevent integer overflow. + var availableWidth = (long)image.Width - 2L * margin; + var availableHeight = (long)image.Height - 2L * margin; + + if (availableWidth <= 0 || availableHeight <= 0) + throw new ArgumentOutOfRangeException(nameof(margin), "There is no room for text inside the margins."); + + var size = fontSize; + Font font = null; + FontRectangle bounds = default; + + for (var attempt = 0; attempt < 8; attempt++) + { + font = family.CreateFont(size); + bounds = TextMeasurer.MeasureBounds(text, new TextOptions(font)); + + if (bounds.Width <= 0 || bounds.Height <= 0) + throw new ArgumentException("Text contains no drawable glyphs in the selected font.", nameof(text)); + + if (bounds.Width <= availableWidth && bounds.Height <= availableHeight) + break; + + // A slight safety factor prevents glyphs being clipped by rasterizer rounding. + var factor = Math.Min((double)availableWidth / bounds.Width, (double)availableHeight / bounds.Height); + size = (float)(size * Math.Min(factor * 0.98, 0.98)); + + if (size < 1) + throw new ArgumentException("Watermark cannot fit within the image.", nameof(text)); + } + + if (bounds.Width > availableWidth || bounds.Height > availableHeight) + throw new ArgumentException("Watermark cannot fit within the image.", nameof(text)); + + // Position using measured ink bounds (including glyph bearings), not line advance. + var x = (float)(image.Width - margin - bounds.X - bounds.Width); + var y = (float)(image.Height - margin - bounds.Y - bounds.Height); + var color = ImageSharpColor.FromRgba(255, 255, 255, opacity); + + image.Mutate(ctx => ctx.DrawText(text, font, color, new ImageSharpPointF(x, y))); + + using var output = new MemoryStream(); + image.SaveAsPng(output); + return output.ToArray(); + } + + private static void KeepFirstFrame(Image image) + { + while (image.Frames.Count > 1) + image.Frames.RemoveFrame(image.Frames.Count - 1); + } +} From 89677f2c801303c485a34f9f7c87490f6ef30c22 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 01:31:39 +0300 Subject: [PATCH 02/85] Pin compatible ImageSharp 3 and Drawing 2 dependencies --- common_versions.props | 2 ++ 1 file changed, 2 insertions(+) diff --git a/common_versions.props b/common_versions.props index 10a46d96f..1a6b45031 100644 --- a/common_versions.props +++ b/common_versions.props @@ -45,6 +45,8 @@ 3.* 7.9.0 7.* + 3.1.12 + 2.1.7 From 9b8aaaa5811362963fb19516a9545805504b19c3 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 01:31:41 +0300 Subject: [PATCH 03/85] Reference managed cross-platform image processing packages --- Drawing/Drawing.csproj | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Drawing/Drawing.csproj b/Drawing/Drawing.csproj index 0fcf8f3f3..f6ec88ccc 100644 --- a/Drawing/Drawing.csproj +++ b/Drawing/Drawing.csproj @@ -2,5 +2,8 @@ + + + From bff472c9c8f9610671fc5fffc5ba3aa127190597 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 01:31:53 +0300 Subject: [PATCH 04/85] Avoid ambiguous drawing Size types in image extensions --- Drawing/ImageHelper.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Drawing/ImageHelper.cs b/Drawing/ImageHelper.cs index 836bbb425..f8c9b4f13 100644 --- a/Drawing/ImageHelper.cs +++ b/Drawing/ImageHelper.cs @@ -1,7 +1,7 @@ namespace Ecng.Drawing; using System; -using System.Drawing; +using SystemDrawingSize = System.Drawing.Size; using System.IO; using SixLabors.Fonts; @@ -24,11 +24,11 @@ public static class ImageHelper /// Reads the image dimensions without decoding pixel data. Supports all image formats recognized by ImageSharp. /// For a PNG-only fast header check see . /// - public static Size GetImageSize(this byte[] data) + public static SystemDrawingSize GetImageSize(this byte[] data) => GetImageSize((ReadOnlySpan)(data ?? throw new ArgumentNullException(nameof(data)))); /// - public static Size GetImageSize(this ReadOnlySpan data) + public static SystemDrawingSize GetImageSize(this ReadOnlySpan data) { var info = Image.Identify(data); return new(info.Width, info.Height); From 3b8045be1b9776c74b5766f9634af01a374d6a1d Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 01:32:16 +0300 Subject: [PATCH 05/85] Test cross-platform image sizing, resize, encoding, watermark and edge cases --- Tests/Drawing/ImageHelperTests.cs | 199 ++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 Tests/Drawing/ImageHelperTests.cs diff --git a/Tests/Drawing/ImageHelperTests.cs b/Tests/Drawing/ImageHelperTests.cs new file mode 100644 index 000000000..de791aeca --- /dev/null +++ b/Tests/Drawing/ImageHelperTests.cs @@ -0,0 +1,199 @@ +namespace Ecng.Tests.Drawing; + +using System; +using System.IO; +using System.Linq; + +using Ecng.Drawing; + +using SixLabors.Fonts; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +using DrawingSize = System.Drawing.Size; + +[TestClass] +public class ImageHelperTests : BaseTestClass +{ + private static byte[] CreateImage(int width = 240, int height = 120, bool transparent = false, bool jpeg = false) + { + using var image = new Image(width, height, + transparent ? new Rgba32(0, 0, 0, 0) : new Rgba32(0, 0, 0, 255)); + using var buffer = new MemoryStream(); + + if (jpeg) + image.SaveAsJpeg(buffer); + else + image.SaveAsPng(buffer); + + return buffer.ToArray(); + } + + [TestMethod] + public void GetImageSize_PngJpegAndSpan() + { + CreateImage(513, 258).GetImageSize().AssertEqual(new DrawingSize(513, 258)); + CreateImage(321, 123, jpeg: true).GetImageSize().AssertEqual(new DrawingSize(321, 123)); + + ReadOnlySpan data = CreateImage(17, 19); + data.GetImageSize().AssertEqual(new DrawingSize(17, 19)); + } + + [TestMethod] + public void GetImageSize_RejectsNullAndBadData() + { + byte[] missing = null; + ThrowsExactly(() => missing.GetImageSize()); + ThrowsExactly(() => new byte[] { 1, 2, 3 }.GetImageSize()); + } + + [TestMethod] + public void ResizeImage_ConstrainedByWidthOrHeight_KeepAspectRatio() + { + var source = CreateImage(400, 200); + var resized = source.ResizeImage(120, 100); + resized.GetImageSize().AssertEqual(new DrawingSize(120, 60)); + resized.IsPng().AssertTrue(); + source.GetImageSize().AssertEqual(new DrawingSize(400, 200)); + + CreateImage(100, 400).ResizeImage(90, 100).GetImageSize().AssertEqual(new DrawingSize(25, 100)); + CreateImage(1, 300).ResizeImage(1, 10).GetImageSize().AssertEqual(new DrawingSize(1, 10)); + } + + [TestMethod] + public void ResizeImage_NeverUpscales_AndAlwaysReturnsAnIndependentArray() + { + var data = CreateImage(40, 20, jpeg: true); + var resized = data.ResizeImage(100, 100); + resized.SequenceEqual(data).AssertTrue(); + ReferenceEquals(resized, data).AssertFalse(); + } + + [TestMethod] + public void ResizeImage_PreservesJpegEncoding() + { + var result = CreateImage(400, 200, jpeg: true).ResizeImage(100, 100); + result.GetImageSize().AssertEqual(new DrawingSize(100, 50)); + result.IsPng().AssertFalse(); + (result[0] == 0xFF && result[1] == 0xD8).AssertTrue(); + } + + [TestMethod] + public void ResizeImage_ValidatesBoundsAndSource() + { + var source = CreateImage(); + ThrowsExactly(() => source.ResizeImage(0, 5)); + ThrowsExactly(() => source.ResizeImage(5, -1)); + + byte[] missing = null; + ThrowsExactly(() => missing.ResizeImage(5, 5)); + } + + [TestMethod] + public void ConvertToPng_DecodesJpegAndKeepsDimensions() + { + var output = CreateImage(122, 73, jpeg: true).ConvertToPng(); + output.IsPng().AssertTrue(); + output.GetImageSize().AssertEqual(new DrawingSize(122, 73)); + } + + [TestMethod] + public void ConvertToPng_PreservesTransparentPixels() + { + var source = CreateImage(30, 20, transparent: true); + var output = source.ConvertToPng(); + using var decoded = Image.Load(output); + decoded[0, 0].A.AssertEqual((byte)0); + } + + [TestMethod] + public void ConvertToPng_BadDataAndNull() + { + ThrowsExactly(() => new byte[] { 2, 4 }.ConvertToPng()); + + byte[] missing = null; + ThrowsExactly(() => missing.ConvertToPng()); + } + + [TestMethod] + public void AddTextWatermark_ChangesPixelsButNotTheOriginalBuffer() + { + var source = CreateImage(400, 170); + var original = (byte[])source.Clone(); + var font = SystemFonts.Families.First().Name; + + var output = source.AddTextWatermark("StockSharp", fontSize: 24, opacity: 220, margin: 10, fontFamily: font); + output.IsPng().AssertTrue(); + output.GetImageSize().AssertEqual(new DrawingSize(400, 170)); + source.SequenceEqual(original).AssertTrue(); + + using var decoded = Image.Load(output); + decoded[0, 0].R.AssertEqual((byte)0); + var touched = false; + + for (var y = 0; y < decoded.Height && !touched; y++) + for (var x = 0; x < decoded.Width; x++) + if (decoded[x, y].R > 0) + { + touched = true; + break; + } + + touched.AssertTrue(); + } + + [TestMethod] + public void AddTextWatermark_FitsLongTextWithinMargins() + { + var font = SystemFonts.Families.First().Name; + var result = CreateImage(350, 140).AddTextWatermark( + "Long watermark text for checking resizing", fontSize: 50, margin: 8, fontFamily: font); + + using var decoded = Image.Load(result); + decoded.Width.AssertEqual(350); + decoded.Height.AssertEqual(140); + + // No watermark should cross the top-left padding. + decoded[0, 0].R.AssertEqual((byte)0); + decoded[0, 0].A.AssertEqual((byte)255); + } + + [TestMethod] + public void AddTextWatermark_ConvertsJpegToPng() + { + var font = SystemFonts.Families.First().Name; + var output = CreateImage(300, 150, jpeg: true).AddTextWatermark("ECNG", fontFamily: font); + output.IsPng().AssertTrue(); + output.GetImageSize().AssertEqual(new DrawingSize(300, 150)); + } + + [TestMethod] + public void AddTextWatermark_UsesVerdanaWhenAvailableOrReportsMissingFont() + { + var source = CreateImage(320, 120); + + if (SystemFonts.TryGet("Verdana", out _)) + source.AddTextWatermark("ECNG").IsPng().AssertTrue(); + else + ThrowsExactly(() => source.AddTextWatermark("ECNG")); + } + + [TestMethod] + public void AddTextWatermark_ValidatesArguments() + { + var source = CreateImage(); + var font = SystemFonts.Families.First().Name; + + ThrowsExactly(() => source.AddTextWatermark(" ")); + ThrowsExactly(() => source.AddTextWatermark("text", fontSize: 0)); + ThrowsExactly(() => source.AddTextWatermark("text", fontSize: float.NaN)); + ThrowsExactly(() => source.AddTextWatermark("text", fontSize: float.PositiveInfinity)); + ThrowsExactly(() => source.AddTextWatermark("text", margin: -1)); + ThrowsExactly(() => source.AddTextWatermark("text", margin: 200, fontFamily: font)); + ThrowsExactly(() => source.AddTextWatermark("text", fontFamily: " ")); + ThrowsExactly(() => source.AddTextWatermark("text", fontFamily: "DefinitelyNonexistentEcngTestFont")); + + byte[] missing = null; + ThrowsExactly(() => missing.AddTextWatermark("X")); + } +} From 5b7360cd2b8e8bfd52417f8acc16870d1117af95 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 01:32:31 +0300 Subject: [PATCH 06/85] Document image manipulation API and Verdana font requirement --- Drawing/README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/Drawing/README.md b/Drawing/README.md index 719c7ffa2..5db22dccf 100644 --- a/Drawing/README.md +++ b/Drawing/README.md @@ -12,6 +12,7 @@ A lightweight, cross-platform drawing primitives library for .NET applications. - [Layout and Alignment](#layout-and-alignment) - [Drawing Styles](#drawing-styles) - [PNG Header](#png-header) + - [Image Processing](#image-processing) - [Usage Examples](#usage-examples) - [Target Frameworks](#target-frameworks) @@ -30,6 +31,7 @@ Add a reference to the `Ecng.Drawing` project or NuGet package in your .NET appl - **Layout Primitives**: Thickness, alignment enums for UI layout - **Drawing Styles**: Comprehensive set of chart and visualization styles - **PNG Header**: Tell a PNG picture by its signature and read its size without decoding it +- **Image Processing**: Dimensions, downscaling, PNG conversion, and text watermarks - **Cross-Platform**: Supports .NET Standard 2.0, .NET 6.0, and .NET 10.0 - **Lightweight**: Minimal dependencies, no heavy graphics frameworks required @@ -278,6 +280,30 @@ if (picture.TryGetPngSize(out var read)) Each of the three takes a `byte[]` or a `ReadOnlySpan`. +## Image Processing + +\`ImageHelper\` works with encoded \`byte[]\` images (PNG, JPEG, and other formats recognized by ImageSharp). +Its operations do not modify the input buffer; \`GetImageSize\` also accepts \`ReadOnlySpan\`. + +\`\`\`csharp +using Ecng.Drawing; + +byte[] source = await File.ReadAllBytesAsync("photo.jpg"); +var dimensions = source.GetImageSize(); + +byte[] smaller = source.ResizeImage(maxWidth: 640, maxHeight: 480); +byte[] png = smaller.ConvertToPng(); +byte[] watermarked = png.AddTextWatermark("StockSharp", fontSize: 24); +await File.WriteAllBytesAsync("photo-watermarked.png", watermarked); +\`\`\` + +- \`GetImageSize\` reads dimensions without decoding the pixels; \`GetPngSize\` is the lighter PNG-only header helper. +- \`ResizeImage\` scales **down only**, preserves aspect ratio and the original format, and returns an independent copy even if already small enough. +- \`ConvertToPng\` always returns PNG and preserves alpha, using only the first frame of animated input. +- \`AddTextWatermark\` draws semi-transparent white text near the bottom-right corner, shrinks the font if necessary, and returns PNG. Defaults: Verdana, 24 pt, alpha 160, margin 12 pixels. + +**Fonts:** Verdana is not available by default on many Linux/macOS systems. Install Verdana or pass an installed family such as \`fontFamily: "DejaVu Sans"\`. The method reports a missing font instead of silently substituting one. The drawing dependency is fixed to ImageSharp.Drawing 2.1.7 for .NET 6 compatibility. Review the Six Labors Split License for redistribution. + ## Usage Examples ### Example 1: Color Manipulation and Conversion From 14cef037a712558c6fab2ba9652fde49e05d6e2e Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 01:32:48 +0300 Subject: [PATCH 07/85] Fix markdown code formatting in image API documentation --- Drawing/README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Drawing/README.md b/Drawing/README.md index 5db22dccf..73d5aca15 100644 --- a/Drawing/README.md +++ b/Drawing/README.md @@ -282,10 +282,10 @@ Each of the three takes a `byte[]` or a `ReadOnlySpan`. ## Image Processing -\`ImageHelper\` works with encoded \`byte[]\` images (PNG, JPEG, and other formats recognized by ImageSharp). -Its operations do not modify the input buffer; \`GetImageSize\` also accepts \`ReadOnlySpan\`. +`ImageHelper` works with encoded `byte[]` images (PNG, JPEG, and other formats recognized by ImageSharp). +Its operations do not modify the input buffer; `GetImageSize` also accepts `ReadOnlySpan`. -\`\`\`csharp +```csharp using Ecng.Drawing; byte[] source = await File.ReadAllBytesAsync("photo.jpg"); @@ -295,14 +295,14 @@ byte[] smaller = source.ResizeImage(maxWidth: 640, maxHeight: 480); byte[] png = smaller.ConvertToPng(); byte[] watermarked = png.AddTextWatermark("StockSharp", fontSize: 24); await File.WriteAllBytesAsync("photo-watermarked.png", watermarked); -\`\`\` +``` -- \`GetImageSize\` reads dimensions without decoding the pixels; \`GetPngSize\` is the lighter PNG-only header helper. -- \`ResizeImage\` scales **down only**, preserves aspect ratio and the original format, and returns an independent copy even if already small enough. -- \`ConvertToPng\` always returns PNG and preserves alpha, using only the first frame of animated input. -- \`AddTextWatermark\` draws semi-transparent white text near the bottom-right corner, shrinks the font if necessary, and returns PNG. Defaults: Verdana, 24 pt, alpha 160, margin 12 pixels. +- `GetImageSize` reads dimensions without decoding the pixels; `GetPngSize` is the lighter PNG-only header helper. +- `ResizeImage` scales **down only**, preserves aspect ratio and the original format, and returns an independent copy even if already small enough. +- `ConvertToPng` always returns PNG and preserves alpha, using only the first frame of animated input. +- `AddTextWatermark` draws semi-transparent white text near the bottom-right corner, shrinks the font if necessary, and returns PNG. Defaults: Verdana, 24 pt, alpha 160, margin 12 pixels. -**Fonts:** Verdana is not available by default on many Linux/macOS systems. Install Verdana or pass an installed family such as \`fontFamily: "DejaVu Sans"\`. The method reports a missing font instead of silently substituting one. The drawing dependency is fixed to ImageSharp.Drawing 2.1.7 for .NET 6 compatibility. Review the Six Labors Split License for redistribution. +**Fonts:** Verdana is not available by default on many Linux/macOS systems. Install Verdana or pass an installed family such as `fontFamily: "DejaVu Sans"`. The method reports a missing font instead of silently substituting one. The drawing dependency is fixed to ImageSharp.Drawing 2.1.7 for .NET 6 compatibility. Review the Six Labors Split License for redistribution. ## Usage Examples From ba2a1b4c45db892d7c2510f7e8e935fc3070ae32 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 01:34:21 +0300 Subject: [PATCH 08/85] Fix ImageSharp 3 resize overload for net6 and net10 --- Drawing/ImageHelper.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Drawing/ImageHelper.cs b/Drawing/ImageHelper.cs index f8c9b4f13..39074c002 100644 --- a/Drawing/ImageHelper.cs +++ b/Drawing/ImageHelper.cs @@ -57,7 +57,12 @@ public static byte[] ResizeImage(this byte[] data, int maxWidth, int maxHeight) var width = Math.Max(1, (int)Math.Floor(image.Width * scale)); var height = Math.Max(1, (int)Math.Floor(image.Height * scale)); - image.Mutate(ctx => ctx.Resize(new ImageSharpSize(width, height), KnownResamplers.Lanczos3)); + image.Mutate(ctx => ctx.Resize(new ResizeOptions + { + Size = new ImageSharpSize(width, height), + Mode = ResizeMode.Stretch, + Sampler = KnownResamplers.Lanczos3, + })); IImageFormat format = image.Metadata.DecodedImageFormat ?? throw new InvalidDataException("The decoded image format is unknown."); From 18a77bb9a36f212a115a849d7aba0e793fa58a1f Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 01:36:06 +0300 Subject: [PATCH 09/85] Reject vulnerable TIFF decoding and skip unneeded metadata --- Drawing/ImageHelper.cs | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/Drawing/ImageHelper.cs b/Drawing/ImageHelper.cs index 39074c002..9b284c30a 100644 --- a/Drawing/ImageHelper.cs +++ b/Drawing/ImageHelper.cs @@ -30,7 +30,8 @@ public static SystemDrawingSize GetImageSize(this byte[] data) /// public static SystemDrawingSize GetImageSize(this ReadOnlySpan data) { - var info = Image.Identify(data); + RejectTiff(data); + var info = Image.Identify(new DecoderOptions { SkipMetadata = true }, data); return new(info.Width, info.Height); } @@ -48,7 +49,8 @@ public static byte[] ResizeImage(this byte[] data, int maxWidth, int maxHeight) if (maxHeight <= 0) throw new ArgumentOutOfRangeException(nameof(maxHeight)); - using var image = Image.Load(data); + RejectTiff(data); + using var image = Image.Load(new DecoderOptions { SkipMetadata = true }, data); var scale = Math.Min((double)maxWidth / image.Width, (double)maxHeight / image.Height); if (scale >= 1) @@ -79,7 +81,8 @@ public static byte[] ConvertToPng(this byte[] data) { ArgumentNullException.ThrowIfNull(data); - using var image = Image.Load(data); + RejectTiff(data); + using var image = Image.Load(new DecoderOptions { SkipMetadata = true }, data); KeepFirstFrame(image); using var output = new MemoryStream(); @@ -123,7 +126,8 @@ public static byte[] AddTextWatermark( if (!SystemFonts.TryGet(fontFamily, out var family)) throw new InvalidOperationException($"Font '{fontFamily}' is not installed. Install it or specify another font family."); - using var image = Image.Load(data); + RejectTiff(data); + using var image = Image.Load(new DecoderOptions { SkipMetadata = true }, data); KeepFirstFrame(image); // Use long arithmetic for margins to prevent integer overflow. @@ -171,6 +175,16 @@ public static byte[] AddTextWatermark( return output.ToArray(); } + // ImageSharp 3.x is required by the project's net6 target. Its TIFF decoder has + // published security advisories without a 3.x patch; reject TIFF before decoding. + private static void RejectTiff(ReadOnlySpan data) + { + var format = Image.DetectFormat(data); + + if (string.Equals(format.Name, "TIFF", StringComparison.OrdinalIgnoreCase)) + throw new NotSupportedException("TIFF decoding is not supported by this image helper."); + } + private static void KeepFirstFrame(Image image) { while (image.Frames.Count > 1) From 102fe82a114897a4bcef0df1baa727570783d988 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 01:36:22 +0300 Subject: [PATCH 10/85] Cover TIFF rejection and use predictable installed fonts in tests --- Tests/Drawing/ImageHelperTests.cs | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/Tests/Drawing/ImageHelperTests.cs b/Tests/Drawing/ImageHelperTests.cs index de791aeca..977b34739 100644 --- a/Tests/Drawing/ImageHelperTests.cs +++ b/Tests/Drawing/ImageHelperTests.cs @@ -47,6 +47,25 @@ public void GetImageSize_RejectsNullAndBadData() ThrowsExactly(() => new byte[] { 1, 2, 3 }.GetImageSize()); } + [TestMethod] + public void Tiff_IsRejectedBeforeUnsafeDecoding() + { + using var original = new Image(20, 10); + using var buffer = new MemoryStream(); + original.SaveAsTiff(buffer); + var tiff = buffer.ToArray(); + + ThrowsExactly(() => tiff.GetImageSize()); + ThrowsExactly(() => tiff.ResizeImage(10, 5)); + ThrowsExactly(() => tiff.ConvertToPng()); + ThrowsExactly(() => tiff.AddTextWatermark("test", fontFamily: TestFont)); + } + + private static string TestFont => new[] + { + "Verdana", "Arial", "DejaVu Sans", "Liberation Sans", "Helvetica", "Noto Sans" + }.FirstOrDefault(name => SystemFonts.TryGet(name, out _)) ?? SystemFonts.Families.First().Name; + [TestMethod] public void ResizeImage_ConstrainedByWidthOrHeight_KeepAspectRatio() { @@ -120,7 +139,7 @@ public void AddTextWatermark_ChangesPixelsButNotTheOriginalBuffer() { var source = CreateImage(400, 170); var original = (byte[])source.Clone(); - var font = SystemFonts.Families.First().Name; + var font = TestFont; var output = source.AddTextWatermark("StockSharp", fontSize: 24, opacity: 220, margin: 10, fontFamily: font); output.IsPng().AssertTrue(); @@ -145,7 +164,7 @@ public void AddTextWatermark_ChangesPixelsButNotTheOriginalBuffer() [TestMethod] public void AddTextWatermark_FitsLongTextWithinMargins() { - var font = SystemFonts.Families.First().Name; + var font = TestFont; var result = CreateImage(350, 140).AddTextWatermark( "Long watermark text for checking resizing", fontSize: 50, margin: 8, fontFamily: font); @@ -161,7 +180,7 @@ public void AddTextWatermark_FitsLongTextWithinMargins() [TestMethod] public void AddTextWatermark_ConvertsJpegToPng() { - var font = SystemFonts.Families.First().Name; + var font = TestFont; var output = CreateImage(300, 150, jpeg: true).AddTextWatermark("ECNG", fontFamily: font); output.IsPng().AssertTrue(); output.GetImageSize().AssertEqual(new DrawingSize(300, 150)); @@ -182,7 +201,7 @@ public void AddTextWatermark_UsesVerdanaWhenAvailableOrReportsMissingFont() public void AddTextWatermark_ValidatesArguments() { var source = CreateImage(); - var font = SystemFonts.Families.First().Name; + var font = TestFont; ThrowsExactly(() => source.AddTextWatermark(" ")); ThrowsExactly(() => source.AddTextWatermark("text", fontSize: 0)); From e0eeeac2e9b266e5621e8f4ac51e1ab704855174 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 01:36:30 +0300 Subject: [PATCH 11/85] Document safe TIFF policy and metadata handling --- Drawing/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Drawing/README.md b/Drawing/README.md index 73d5aca15..23593c01b 100644 --- a/Drawing/README.md +++ b/Drawing/README.md @@ -282,7 +282,7 @@ Each of the three takes a `byte[]` or a `ReadOnlySpan`. ## Image Processing -`ImageHelper` works with encoded `byte[]` images (PNG, JPEG, and other formats recognized by ImageSharp). +`ImageHelper` works with encoded `byte[]` images (PNG, JPEG, and other formats recognized by ImageSharp, except TIFF). Its operations do not modify the input buffer; `GetImageSize` also accepts `ReadOnlySpan`. ```csharp @@ -302,7 +302,7 @@ await File.WriteAllBytesAsync("photo-watermarked.png", watermarked); - `ConvertToPng` always returns PNG and preserves alpha, using only the first frame of animated input. - `AddTextWatermark` draws semi-transparent white text near the bottom-right corner, shrinks the font if necessary, and returns PNG. Defaults: Verdana, 24 pt, alpha 160, margin 12 pixels. -**Fonts:** Verdana is not available by default on many Linux/macOS systems. Install Verdana or pass an installed family such as `fontFamily: "DejaVu Sans"`. The method reports a missing font instead of silently substituting one. The drawing dependency is fixed to ImageSharp.Drawing 2.1.7 for .NET 6 compatibility. Review the Six Labors Split License for redistribution. +**Fonts:** Verdana is not available by default on many Linux/macOS systems. Install Verdana or pass an installed family such as `fontFamily: "DejaVu Sans"`. The method reports a missing font instead of silently substituting one. TIFF input is rejected before decoding because available ImageSharp 3.x releases have unpatched TIFF decoder advisories; decoding skips unnecessary metadata. The drawing dependency is fixed to ImageSharp.Drawing 2.1.7 for .NET 6 compatibility. Review the Six Labors Split License for redistribution. ## Usage Examples From 7368408adcffa073322ac825e70e7ccd89eaa473 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 02:01:54 +0300 Subject: [PATCH 12/85] Remove all image NuGet dependencies; use Windows system GDI+ and dependency-free tests --- Drawing/Drawing.csproj | 3 - Drawing/ImageHelper.cs | 481 +++++++++++++++++++++++------- Drawing/README.md | 25 +- Tests/Drawing/ImageHelperTests.cs | 304 +++++++++++-------- common_versions.props | 3 +- 5 files changed, 564 insertions(+), 252 deletions(-) diff --git a/Drawing/Drawing.csproj b/Drawing/Drawing.csproj index f6ec88ccc..0fcf8f3f3 100644 --- a/Drawing/Drawing.csproj +++ b/Drawing/Drawing.csproj @@ -2,8 +2,5 @@ - - - diff --git a/Drawing/ImageHelper.cs b/Drawing/ImageHelper.cs index 9b284c30a..5ee53587f 100644 --- a/Drawing/ImageHelper.cs +++ b/Drawing/ImageHelper.cs @@ -1,43 +1,45 @@ namespace Ecng.Drawing; using System; -using SystemDrawingSize = System.Drawing.Size; +using System.Drawing; using System.IO; - -using SixLabors.Fonts; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.Drawing.Processing; -using SixLabors.ImageSharp.Formats; -using SixLabors.ImageSharp.PixelFormats; -using SixLabors.ImageSharp.Processing; - -using ImageSharpColor = SixLabors.ImageSharp.Color; -using ImageSharpPointF = SixLabors.ImageSharp.PointF; -using ImageSharpSize = SixLabors.ImageSharp.Size; +using System.Runtime.InteropServices; /// -/// Cross-platform operations on encoded images. Input buffers are never changed. +/// Image operations using the Windows GDI+ subsystem. Does not require external NuGet +/// image decoders, fonts or native binaries. Other operating systems can still read PNG +/// dimensions via , but have no built-in cross-platform image +/// rendering backend in .NET 6/10. /// public static class ImageHelper { /// - /// Reads the image dimensions without decoding pixel data. Supports all image formats recognized by ImageSharp. - /// For a PNG-only fast header check see . + /// Obtains the pixel dimensions of a PNG on any platform, or another supported + /// image format through Windows GDI+. PNG header reading does not decode pixels. /// - public static SystemDrawingSize GetImageSize(this byte[] data) + public static Size GetImageSize(this byte[] data) => GetImageSize((ReadOnlySpan)(data ?? throw new ArgumentNullException(nameof(data)))); /// - public static SystemDrawingSize GetImageSize(this ReadOnlySpan data) + public static Size GetImageSize(this ReadOnlySpan data) { - RejectTiff(data); - var info = Image.Identify(new DecoderOptions { SkipMetadata = true }, data); - return new(info.Width, info.Height); + if (data.TryGetPngSize(out var pngSize)) + { + if (pngSize.Width <= 0 || pngSize.Height <= 0) + throw new InvalidDataException("PNG dimensions must be positive."); + + return pngSize; + } + + EnsureWindows(); + using var source = NativeImage.Open(data.ToArray()); + return new Size(source.Width, source.Height); } /// - /// Reduces an image to fit the provided maximum dimensions, keeping its aspect ratio - /// and encoding format. Does not enlarge small images. Returns a separate byte array. + /// Fits the picture into the maximum width and height, without increasing its size. + /// Maintains the aspect ratio and original encoding (PNG, JPEG, BMP, GIF). + /// GIF output contains the first frame only. Windows only. /// public static byte[] ResizeImage(this byte[] data, int maxWidth, int maxHeight) { @@ -49,58 +51,62 @@ public static byte[] ResizeImage(this byte[] data, int maxWidth, int maxHeight) if (maxHeight <= 0) throw new ArgumentOutOfRangeException(nameof(maxHeight)); - RejectTiff(data); - using var image = Image.Load(new DecoderOptions { SkipMetadata = true }, data); - var scale = Math.Min((double)maxWidth / image.Width, (double)maxHeight / image.Height); + EnsureWindows(); + var format = DetectFormat(data); + using var source = NativeImage.Open(data); + var ratio = Math.Min((double)maxWidth / source.Width, (double)maxHeight / source.Height); - if (scale >= 1) + if (ratio >= 1) return (byte[])data.Clone(); - var width = Math.Max(1, (int)Math.Floor(image.Width * scale)); - var height = Math.Max(1, (int)Math.Floor(image.Height * scale)); + var width = Math.Max(1, (int)Math.Floor(source.Width * ratio)); + var height = Math.Max(1, (int)Math.Floor(source.Height * ratio)); + var canvas = CreateCanvas(width, height); - image.Mutate(ctx => ctx.Resize(new ResizeOptions + try { - Size = new ImageSharpSize(width, height), - Mode = ResizeMode.Stretch, - Sampler = KnownResamplers.Lanczos3, - })); + using (var graphics = new NativeGraphics(canvas)) + graphics.Draw(source.Handle, source.Width, source.Height, width, height); - IImageFormat format = image.Metadata.DecodedImageFormat - ?? throw new InvalidDataException("The decoded image format is unknown."); - - using var output = new MemoryStream(); - image.Save(output, format); - return output.ToArray(); + return Save(canvas, format); + } + finally + { + Gdi.Check(Gdi.GdipDisposeImage(canvas)); + } } /// - /// Converts the image to PNG while preserving alpha. For animated images only the first frame is used. + /// Re-encodes a supported picture as PNG, including transparency when present. + /// Animated input is flattened to its first frame. Windows only. /// public static byte[] ConvertToPng(this byte[] data) { ArgumentNullException.ThrowIfNull(data); + EnsureWindows(); + DetectFormat(data); + + using var source = NativeImage.Open(data); + var canvas = CreateCanvas(source.Width, source.Height); - RejectTiff(data); - using var image = Image.Load(new DecoderOptions { SkipMetadata = true }, data); - KeepFirstFrame(image); + try + { + using (var graphics = new NativeGraphics(canvas)) + graphics.Draw(source.Handle, source.Width, source.Height, source.Width, source.Height); - using var output = new MemoryStream(); - image.SaveAsPng(output); - return output.ToArray(); + return Save(canvas, Encoder.Png); + } + finally + { + Gdi.Check(Gdi.GdipDisposeImage(canvas)); + } } /// - /// Adds semi-transparent white text at the bottom-right, returning a PNG. - /// The default family is Verdana. It must be installed, otherwise explicitly supply another installed font. - /// The text is automatically reduced in size, when necessary, to fit within the margins. + /// Places semi-transparent white text at the bottom-right and returns a PNG. + /// Requires Verdana (or the specified alternate font family) installed in Windows. + /// The text is scaled down if necessary to fit within the margins. Windows only. /// - /// Original encoded image. - /// Watermark text, nonempty and visible. - /// Maximum text size in points, finite and positive. - /// Text alpha, 0-255. - /// Minimum edge margin in pixels. - /// Installed font family, Verdana by default. public static byte[] AddTextWatermark( this byte[] data, string text, @@ -112,82 +118,351 @@ public static byte[] AddTextWatermark( ArgumentNullException.ThrowIfNull(data); if (string.IsNullOrWhiteSpace(text)) - throw new ArgumentException("Watermark text must not be blank.", nameof(text)); + throw new ArgumentException("Watermark text must not be empty.", nameof(text)); if (fontSize <= 0 || float.IsNaN(fontSize) || float.IsInfinity(fontSize)) - throw new ArgumentOutOfRangeException(nameof(fontSize), "Font size must be finite and positive."); + throw new ArgumentOutOfRangeException(nameof(fontSize)); if (margin < 0) throw new ArgumentOutOfRangeException(nameof(margin)); if (string.IsNullOrWhiteSpace(fontFamily)) - throw new ArgumentException("Font family is required.", nameof(fontFamily)); - - if (!SystemFonts.TryGet(fontFamily, out var family)) - throw new InvalidOperationException($"Font '{fontFamily}' is not installed. Install it or specify another font family."); + throw new ArgumentException("A font family is required.", nameof(fontFamily)); - RejectTiff(data); - using var image = Image.Load(new DecoderOptions { SkipMetadata = true }, data); - KeepFirstFrame(image); + EnsureWindows(); + DetectFormat(data); - // Use long arithmetic for margins to prevent integer overflow. - var availableWidth = (long)image.Width - 2L * margin; - var availableHeight = (long)image.Height - 2L * margin; + using var source = NativeImage.Open(data); - if (availableWidth <= 0 || availableHeight <= 0) - throw new ArgumentOutOfRangeException(nameof(margin), "There is no room for text inside the margins."); + if (2L * margin >= source.Width || 2L * margin >= source.Height) + throw new ArgumentOutOfRangeException(nameof(margin), "Margins leave no space for text."); - var size = fontSize; - Font font = null; - FontRectangle bounds = default; + var canvas = CreateCanvas(source.Width, source.Height); - for (var attempt = 0; attempt < 8; attempt++) + try { - font = family.CreateFont(size); - bounds = TextMeasurer.MeasureBounds(text, new TextOptions(font)); + using var graphics = new NativeGraphics(canvas); + graphics.Draw(source.Handle, source.Width, source.Height, source.Width, source.Height); + graphics.DrawWatermark(text, fontSize, opacity, margin, fontFamily, source.Width, source.Height); + return Save(canvas, Encoder.Png); + } + finally + { + Gdi.Check(Gdi.GdipDisposeImage(canvas)); + } + } + + private static void EnsureWindows() + { + if (!OperatingSystem.IsWindows()) + throw new PlatformNotSupportedException("Image rendering without external dependencies uses Windows GDI+. PNG dimensions are available on all platforms."); + } + + private enum Encoder { Png, Jpeg, Gif, Bmp } + + private static Encoder DetectFormat(ReadOnlySpan bytes) + { + if (bytes.IsPng()) + return Encoder.Png; + + if (bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF) + return Encoder.Jpeg; + + if (bytes.Length >= 6 && + (bytes[..6].SequenceEqual("GIF87a"u8) || bytes[..6].SequenceEqual("GIF89a"u8))) + return Encoder.Gif; - if (bounds.Width <= 0 || bounds.Height <= 0) - throw new ArgumentException("Text contains no drawable glyphs in the selected font.", nameof(text)); + if (bytes.Length >= 2 && bytes[0] == (byte)'B' && bytes[1] == (byte)'M') + return Encoder.Bmp; + + throw new NotSupportedException("Only PNG, JPEG, GIF and BMP are supported."); + } + + private static IntPtr CreateCanvas(int width, int height) + { + // PixelFormat32bppARGB is writable, supports alpha and permits drawing text. + Gdi.Check(Gdi.GdipCreateBitmapFromScan0(width, height, 0, 0x26200A, IntPtr.Zero, out var canvas)); + return canvas; + } - if (bounds.Width <= availableWidth && bounds.Height <= availableHeight) - break; + private static byte[] Save(IntPtr image, Encoder format) + { + var encoder = format switch + { + Encoder.Png => new Guid("557cf406-1a04-11d3-9a73-0000f81ef32e"), + Encoder.Jpeg => new Guid("557cf401-1a04-11d3-9a73-0000f81ef32e"), + Encoder.Gif => new Guid("557cf402-1a04-11d3-9a73-0000f81ef32e"), + Encoder.Bmp => new Guid("557cf400-1a04-11d3-9a73-0000f81ef32e"), + _ => throw new ArgumentOutOfRangeException(nameof(format)), + }; - // A slight safety factor prevents glyphs being clipped by rasterizer rounding. - var factor = Math.Min((double)availableWidth / bounds.Width, (double)availableHeight / bounds.Height); - size = (float)(size * Math.Min(factor * 0.98, 0.98)); + var file = Path.Combine(Path.GetTempPath(), $"ecng-image-{Guid.NewGuid():N}.tmp"); - if (size < 1) - throw new ArgumentException("Watermark cannot fit within the image.", nameof(text)); + try + { + Gdi.Check(Gdi.GdipSaveImageToFile(image, file, ref encoder, IntPtr.Zero)); + return File.ReadAllBytes(file); } + finally + { + File.Delete(file); + } + } - if (bounds.Width > availableWidth || bounds.Height > availableHeight) - throw new ArgumentException("Watermark cannot fit within the image.", nameof(text)); + private sealed class NativeImage : IDisposable + { + private readonly string _tempFile; + private IntPtr _startupToken; - // Position using measured ink bounds (including glyph bearings), not line advance. - var x = (float)(image.Width - margin - bounds.X - bounds.Width); - var y = (float)(image.Height - margin - bounds.Y - bounds.Height); - var color = ImageSharpColor.FromRgba(255, 255, 255, opacity); + public IntPtr Handle { get; private set; } + public int Width { get; private set; } + public int Height { get; private set; } - image.Mutate(ctx => ctx.DrawText(text, font, color, new ImageSharpPointF(x, y))); + private NativeImage(string tempFile) => _tempFile = tempFile; - using var output = new MemoryStream(); - image.SaveAsPng(output); - return output.ToArray(); + public static NativeImage Open(byte[] data) + { + var file = Path.Combine(Path.GetTempPath(), $"ecng-image-{Guid.NewGuid():N}.tmp"); + var image = new NativeImage(file); + + try + { + Gdi.Check(Gdi.GdiplusStartup(out image._startupToken, ref Gdi.StartupInput, IntPtr.Zero)); + File.WriteAllBytes(file, data); + + Gdi.Check(Gdi.GdipLoadImageFromFile(file, out var handle)); + image.Handle = handle; + Gdi.Check(Gdi.GdipGetImageWidth(handle, out var width)); + Gdi.Check(Gdi.GdipGetImageHeight(handle, out var height)); + + if (width == 0 || height == 0 || width > int.MaxValue || height > int.MaxValue) + throw new InvalidDataException("Invalid picture dimensions."); + + image.Width = (int)width; + image.Height = (int)height; + return image; + } + catch + { + image.Dispose(); + throw; + } + } + + public void Dispose() + { + if (Handle != IntPtr.Zero) + { + Gdi.GdipDisposeImage(Handle); + Handle = IntPtr.Zero; + } + + if (_startupToken != IntPtr.Zero) + { + Gdi.GdiplusShutdown(_startupToken); + _startupToken = IntPtr.Zero; + } + + if (File.Exists(_tempFile)) + File.Delete(_tempFile); + } } - // ImageSharp 3.x is required by the project's net6 target. Its TIFF decoder has - // published security advisories without a 3.x patch; reject TIFF before decoding. - private static void RejectTiff(ReadOnlySpan data) + private sealed class NativeGraphics : IDisposable { - var format = Image.DetectFormat(data); + private IntPtr _graphics; + + public NativeGraphics(IntPtr image) + { + Gdi.Check(Gdi.GdipGetImageGraphicsContext(image, out _graphics)); + Gdi.Check(Gdi.GdipSetInterpolationMode(_graphics, 7)); // HighQualityBicubic + Gdi.Check(Gdi.GdipSetTextRenderingHint(_graphics, 4)); // AntiAlias + } + + public void Draw(IntPtr source, int sourceWidth, int sourceHeight, int width, int height) + => Gdi.Check(Gdi.GdipDrawImageRectRectI( + _graphics, source, 0, 0, width, height, 0, 0, sourceWidth, sourceHeight, + 2, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero)); - if (string.Equals(format.Name, "TIFF", StringComparison.OrdinalIgnoreCase)) - throw new NotSupportedException("TIFF decoding is not supported by this image helper."); + public void DrawWatermark(string text, float fontSize, byte opacity, int margin, string familyName, int width, int height) + { + var familyStatus = Gdi.GdipCreateFontFamilyFromName(familyName, IntPtr.Zero, out var family); + + if (familyStatus == 14) // FontFamilyNotFound + throw new InvalidOperationException($"Font '{familyName}' is not installed."); + + Gdi.Check(familyStatus); + + IntPtr font = IntPtr.Zero; + IntPtr brush = IntPtr.Zero; + IntPtr format = IntPtr.Zero; + + try + { + Gdi.Check(Gdi.GdipCreateStringFormat(0, 0, out format)); + Gdi.Check(Gdi.GdipSetStringFormatAlign(format, 2)); // right + Gdi.Check(Gdi.GdipSetStringFormatLineAlign(format, 2)); // bottom + Gdi.Check(Gdi.GdipCreateSolidFill(unchecked((int)((uint)opacity << 24 | 0x00FFFFFF)), out brush)); + + var allowedWidth = width - 2 * margin; + var allowedHeight = height - 2 * margin; + var size = fontSize; + var measured = default(Gdi.RectF); + var bounds = new Gdi.RectF(0, 0, 100000, 100000); + + for (var attempt = 0; attempt < 12; attempt++) + { + if (font != IntPtr.Zero) + { + Gdi.GdipDeleteFont(font); + font = IntPtr.Zero; + } + + Gdi.Check(Gdi.GdipCreateFont(family, size, 0, 3, out font)); // UnitPoint + Gdi.Check(Gdi.GdipMeasureString(_graphics, text, text.Length, font, ref bounds, format, out measured, out _, out _)); + + if (measured.Width <= allowedWidth && measured.Height <= allowedHeight) + break; + + var factor = Math.Min((double)allowedWidth / measured.Width, (double)allowedHeight / measured.Height); + + if (factor <= 0 || double.IsNaN(factor)) + throw new ArgumentException("Watermark does not fit in the image.", nameof(text)); + + size = (float)(size * Math.Min(factor * 0.95, 0.9)); + + if (size < 1) + throw new ArgumentException("Watermark cannot fit inside the requested margins.", nameof(text)); + } + + if (measured.Width > allowedWidth || measured.Height > allowedHeight) + throw new ArgumentException("Watermark does not fit in the image.", nameof(text)); + + var layout = new Gdi.RectF(margin, margin, allowedWidth, allowedHeight); + Gdi.Check(Gdi.GdipDrawString(_graphics, text, text.Length, font, ref layout, format, brush)); + } + finally + { + if (brush != IntPtr.Zero) Gdi.GdipDeleteBrush(brush); + if (font != IntPtr.Zero) Gdi.GdipDeleteFont(font); + if (format != IntPtr.Zero) Gdi.GdipDeleteStringFormat(format); + Gdi.GdipDeleteFontFamily(family); + } + } + + public void Dispose() + { + if (_graphics != IntPtr.Zero) + { + Gdi.GdipDeleteGraphics(_graphics); + _graphics = IntPtr.Zero; + } + } } - private static void KeepFirstFrame(Image image) + private static class Gdi { - while (image.Frames.Count > 1) - image.Frames.RemoveFrame(image.Frames.Count - 1); + [StructLayout(LayoutKind.Sequential)] + public struct RectF + { + public float X, Y, Width, Height; + public RectF(float x, float y, float width, float height) + => (X, Y, Width, Height) = (x, y, width, height); + } + + [StructLayout(LayoutKind.Sequential)] + public struct StartupInfo + { + public uint Version; + public IntPtr DebugEventCallback; + public int SuppressBackgroundThread; + public int SuppressExternalCodecs; + } + + public static StartupInfo StartupInput = new() { Version = 1 }; + + public static void Check(int code) + { + if (code != 0) + throw new InvalidDataException($"Windows GDI+ operation failed with status {code}."); + } + + [DllImport("gdiplus.dll", ExactSpelling = true)] + public static extern int GdiplusStartup(out IntPtr token, ref StartupInfo input, IntPtr output); + + [DllImport("gdiplus.dll", ExactSpelling = true)] + public static extern void GdiplusShutdown(IntPtr token); + + [DllImport("gdiplus.dll", CharSet = CharSet.Unicode, ExactSpelling = true)] + public static extern int GdipLoadImageFromFile(string filename, out IntPtr image); + + [DllImport("gdiplus.dll", ExactSpelling = true)] + public static extern int GdipGetImageWidth(IntPtr image, out uint width); + + [DllImport("gdiplus.dll", ExactSpelling = true)] + public static extern int GdipGetImageHeight(IntPtr image, out uint height); + + [DllImport("gdiplus.dll", ExactSpelling = true)] + public static extern int GdipDisposeImage(IntPtr image); + + [DllImport("gdiplus.dll", ExactSpelling = true)] + public static extern int GdipCreateBitmapFromScan0(int width, int height, int stride, int format, IntPtr scan0, out IntPtr bitmap); + + [DllImport("gdiplus.dll", CharSet = CharSet.Unicode, ExactSpelling = true)] + public static extern int GdipSaveImageToFile(IntPtr image, string filename, ref Guid clsid, IntPtr parameters); + + [DllImport("gdiplus.dll", ExactSpelling = true)] + public static extern int GdipGetImageGraphicsContext(IntPtr image, out IntPtr graphics); + + [DllImport("gdiplus.dll", ExactSpelling = true)] + public static extern int GdipDeleteGraphics(IntPtr graphics); + + [DllImport("gdiplus.dll", ExactSpelling = true)] + public static extern int GdipSetInterpolationMode(IntPtr graphics, int mode); + + [DllImport("gdiplus.dll", ExactSpelling = true)] + public static extern int GdipSetTextRenderingHint(IntPtr graphics, int mode); + + [DllImport("gdiplus.dll", ExactSpelling = true)] + public static extern int GdipDrawImageRectRectI(IntPtr graphics, IntPtr image, int dx, int dy, int dw, int dh, + int sx, int sy, int sw, int sh, int srcUnit, IntPtr attributes, IntPtr callback, IntPtr callbackData); + + [DllImport("gdiplus.dll", CharSet = CharSet.Unicode, ExactSpelling = true)] + public static extern int GdipCreateFontFamilyFromName(string name, IntPtr collection, out IntPtr family); + + [DllImport("gdiplus.dll", ExactSpelling = true)] + public static extern int GdipDeleteFontFamily(IntPtr family); + + [DllImport("gdiplus.dll", ExactSpelling = true)] + public static extern int GdipCreateFont(IntPtr family, float size, int style, int unit, out IntPtr font); + + [DllImport("gdiplus.dll", ExactSpelling = true)] + public static extern int GdipDeleteFont(IntPtr font); + + [DllImport("gdiplus.dll", ExactSpelling = true)] + public static extern int GdipCreateStringFormat(int flags, int language, out IntPtr format); + + [DllImport("gdiplus.dll", ExactSpelling = true)] + public static extern int GdipDeleteStringFormat(IntPtr format); + + [DllImport("gdiplus.dll", ExactSpelling = true)] + public static extern int GdipSetStringFormatAlign(IntPtr format, int align); + + [DllImport("gdiplus.dll", ExactSpelling = true)] + public static extern int GdipSetStringFormatLineAlign(IntPtr format, int align); + + [DllImport("gdiplus.dll", ExactSpelling = true)] + public static extern int GdipCreateSolidFill(int argb, out IntPtr brush); + + [DllImport("gdiplus.dll", ExactSpelling = true)] + public static extern int GdipDeleteBrush(IntPtr brush); + + [DllImport("gdiplus.dll", CharSet = CharSet.Unicode, ExactSpelling = true)] + public static extern int GdipMeasureString(IntPtr graphics, string text, int length, IntPtr font, + ref RectF layout, IntPtr format, out RectF boundingBox, out int codepointsFitted, out int linesFilled); + + [DllImport("gdiplus.dll", CharSet = CharSet.Unicode, ExactSpelling = true)] + public static extern int GdipDrawString(IntPtr graphics, string text, int length, IntPtr font, + ref RectF layout, IntPtr format, IntPtr brush); } } diff --git a/Drawing/README.md b/Drawing/README.md index 23593c01b..3175d0a43 100644 --- a/Drawing/README.md +++ b/Drawing/README.md @@ -280,29 +280,22 @@ if (picture.TryGetPngSize(out var read)) Each of the three takes a `byte[]` or a `ReadOnlySpan`. -## Image Processing +## Image Processing (no third-party libraries) -`ImageHelper` works with encoded `byte[]` images (PNG, JPEG, and other formats recognized by ImageSharp, except TIFF). -Its operations do not modify the input buffer; `GetImageSize` also accepts `ReadOnlySpan`. +The image helper has **no NuGet imaging or font dependencies**. It uses built-in Windows GDI+ for decoding, resizing, PNG output and installed Verdana text rendering. The project still builds on Linux/macOS, but those operating systems can only read PNG dimensions using the separate managed `PngHelper`. Rendering methods throw `PlatformNotSupportedException` outside Windows; .NET 6 and .NET 10 have no universal platform-independent image decoder or TrueType renderer in their base class libraries. ```csharp using Ecng.Drawing; -byte[] source = await File.ReadAllBytesAsync("photo.jpg"); -var dimensions = source.GetImageSize(); - -byte[] smaller = source.ResizeImage(maxWidth: 640, maxHeight: 480); -byte[] png = smaller.ConvertToPng(); -byte[] watermarked = png.AddTextWatermark("StockSharp", fontSize: 24); -await File.WriteAllBytesAsync("photo-watermarked.png", watermarked); +byte[] original = await File.ReadAllBytesAsync("photo.jpg"); +var dimensions = original.GetImageSize(); +byte[] smaller = original.ResizeImage(640, 480); // Windows; preserves aspect ratio and format +byte[] png = smaller.ConvertToPng(); // Windows; PNG output +byte[] watermarked = png.AddTextWatermark("StockSharp"); // Windows; Verdana +await File.WriteAllBytesAsync("watermarked.png", watermarked); ``` -- `GetImageSize` reads dimensions without decoding the pixels; `GetPngSize` is the lighter PNG-only header helper. -- `ResizeImage` scales **down only**, preserves aspect ratio and the original format, and returns an independent copy even if already small enough. -- `ConvertToPng` always returns PNG and preserves alpha, using only the first frame of animated input. -- `AddTextWatermark` draws semi-transparent white text near the bottom-right corner, shrinks the font if necessary, and returns PNG. Defaults: Verdana, 24 pt, alpha 160, margin 12 pixels. - -**Fonts:** Verdana is not available by default on many Linux/macOS systems. Install Verdana or pass an installed family such as `fontFamily: "DejaVu Sans"`. The method reports a missing font instead of silently substituting one. TIFF input is rejected before decoding because available ImageSharp 3.x releases have unpatched TIFF decoder advisories; decoding skips unnecessary metadata. The drawing dependency is fixed to ImageSharp.Drawing 2.1.7 for .NET 6 compatibility. Review the Six Labors Split License for redistribution. +PNG dimensions can be read on any OS without decoding via `GetPngSize` or `GetImageSize`. On Windows the supported formats are PNG, JPEG, GIF and BMP. GIF processing flattens animation to the first frame. Image methods return new arrays and do not modify their source. The Windows watermark defaults to white, 24 pt, 160/255 opacity and a 12-pixel margin. It requires Verdana to be installed; pass another installed `fontFamily` to override it. ## Usage Examples diff --git a/Tests/Drawing/ImageHelperTests.cs b/Tests/Drawing/ImageHelperTests.cs index 977b34739..120ca5367 100644 --- a/Tests/Drawing/ImageHelperTests.cs +++ b/Tests/Drawing/ImageHelperTests.cs @@ -1,218 +1,266 @@ namespace Ecng.Tests.Drawing; using System; +using System.Drawing; using System.IO; +using System.IO.Compression; using System.Linq; using Ecng.Drawing; -using SixLabors.Fonts; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.PixelFormats; - -using DrawingSize = System.Drawing.Size; - [TestClass] public class ImageHelperTests : BaseTestClass { - private static byte[] CreateImage(int width = 240, int height = 120, bool transparent = false, bool jpeg = false) - { - using var image = new Image(width, height, - transparent ? new Rgba32(0, 0, 0, 0) : new Rgba32(0, 0, 0, 255)); - using var buffer = new MemoryStream(); + // Image fixtures are embedded bytes, not generated by a third-party image library. + // Solid black PNG and JPEG/GIF were exported once and checked for independent decoding. + private static readonly byte[] _png = Convert.FromBase64String( + "iVBORw0KGgoAAAANSUhEUgAAAUAAAACgCAIAAADywSLLAAAAq0lEQVR4nO3BAQEAAACCIP+vbkhAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACvBli+AAGaPK0yAAAAAElFTkSuQmCC"); - if (jpeg) - image.SaveAsJpeg(buffer); - else - image.SaveAsPng(buffer); + private static readonly byte[] _jpeg = Convert.FromBase64String( + "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAAwAGABAREA/8QAFQABAQAAAAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oACAEBAAA/AJ/AAAAAAAAAAAAAAAAAAAAAAAB//9k="); - return buffer.ToArray(); - } + private static readonly byte[] _gif = Convert.FromBase64String( + "R0lGODdhYAAwAIEAAAAAAAAAAAAAAAAAACwAAAAAYAAwAEAIbwABCBxIsKDBgwgTKlzIsKHDhxAjSpxIsaLFixgzatzIsaPHjyBDihxJsqTJkyhTqlzJsqXLlzBjypxJs6bNmzhz6tzJs6fPn0CDCh1KtKjRo0iTKl3KtKnTp1CjSp1KtarVq1izat3KtavXrzADAgA7"); + + private static readonly byte[] _transparentPng = Convert.FromBase64String( + "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAGCAYAAAD+Bd/7AAAAFUlEQVR42mPkktNgwAeYGAiAwaAAAEKMAFwjCOSSAAAAAElFTkSuQmCC"); [TestMethod] - public void GetImageSize_PngJpegAndSpan() + public void GetImageSize_PngWorksOnEveryPlatform() { - CreateImage(513, 258).GetImageSize().AssertEqual(new DrawingSize(513, 258)); - CreateImage(321, 123, jpeg: true).GetImageSize().AssertEqual(new DrawingSize(321, 123)); + _png.GetImageSize().AssertEqual(new Size(320, 160)); - ReadOnlySpan data = CreateImage(17, 19); - data.GetImageSize().AssertEqual(new DrawingSize(17, 19)); + ReadOnlySpan span = _transparentPng; + span.GetImageSize().AssertEqual(new Size(8, 6)); } [TestMethod] - public void GetImageSize_RejectsNullAndBadData() + public void GetImageSize_JpegOnWindows() { - byte[] missing = null; - ThrowsExactly(() => missing.GetImageSize()); - ThrowsExactly(() => new byte[] { 1, 2, 3 }.GetImageSize()); + if (OperatingSystem.IsWindows()) + _jpeg.GetImageSize().AssertEqual(new Size(96, 48)); + else + ThrowsExactly(() => _jpeg.GetImageSize()); } [TestMethod] - public void Tiff_IsRejectedBeforeUnsafeDecoding() + public void GetImageSize_BadDimensionsAndNullAreRejected() { - using var original = new Image(20, 10); - using var buffer = new MemoryStream(); - original.SaveAsTiff(buffer); - var tiff = buffer.ToArray(); + byte[] missing = null; + ThrowsExactly(() => missing.GetImageSize()); - ThrowsExactly(() => tiff.GetImageSize()); - ThrowsExactly(() => tiff.ResizeImage(10, 5)); - ThrowsExactly(() => tiff.ConvertToPng()); - ThrowsExactly(() => tiff.AddTextWatermark("test", fontFamily: TestFont)); + var invalid = (byte[])_png.Clone(); + invalid[16] = invalid[17] = invalid[18] = invalid[19] = 0; + ThrowsExactly(() => invalid.GetImageSize()); } - private static string TestFont => new[] - { - "Verdana", "Arial", "DejaVu Sans", "Liberation Sans", "Helvetica", "Noto Sans" - }.FirstOrDefault(name => SystemFonts.TryGet(name, out _)) ?? SystemFonts.Families.First().Name; - [TestMethod] - public void ResizeImage_ConstrainedByWidthOrHeight_KeepAspectRatio() + public void ResizeImage_FitsWithinBoundsAndPreservesAspectRatio() { - var source = CreateImage(400, 200); - var resized = source.ResizeImage(120, 100); - resized.GetImageSize().AssertEqual(new DrawingSize(120, 60)); - resized.IsPng().AssertTrue(); - source.GetImageSize().AssertEqual(new DrawingSize(400, 200)); + if (!OperatingSystem.IsWindows()) + return; + + var source = (byte[])_png.Clone(); + var reduced = source.ResizeImage(120, 100); + + reduced.GetPngSize().AssertEqual(new Size(120, 60)); + source.GetPngSize().AssertEqual(new Size(320, 160)); + source.SequenceEqual(_png).AssertTrue(); - CreateImage(100, 400).ResizeImage(90, 100).GetImageSize().AssertEqual(new DrawingSize(25, 100)); - CreateImage(1, 300).ResizeImage(1, 10).GetImageSize().AssertEqual(new DrawingSize(1, 10)); + _png.ResizeImage(50, 5).GetPngSize().AssertEqual(new Size(10, 5)); + _png.ResizeImage(2, 2).GetPngSize().AssertEqual(new Size(2, 1)); } [TestMethod] - public void ResizeImage_NeverUpscales_AndAlwaysReturnsAnIndependentArray() + public void ResizeImage_NeverUpscalesAndReturnsAnIndependentCopy() { - var data = CreateImage(40, 20, jpeg: true); - var resized = data.ResizeImage(100, 100); - resized.SequenceEqual(data).AssertTrue(); - ReferenceEquals(resized, data).AssertFalse(); + if (!OperatingSystem.IsWindows()) + return; + + var copy = _png.ResizeImage(1000, 1000); + copy.SequenceEqual(_png).AssertTrue(); + ReferenceEquals(copy, _png).AssertFalse(); } [TestMethod] - public void ResizeImage_PreservesJpegEncoding() + public void ResizeImage_PreservesJpegAndGifEncoding() { - var result = CreateImage(400, 200, jpeg: true).ResizeImage(100, 100); - result.GetImageSize().AssertEqual(new DrawingSize(100, 50)); - result.IsPng().AssertFalse(); - (result[0] == 0xFF && result[1] == 0xD8).AssertTrue(); + if (!OperatingSystem.IsWindows()) + return; + + var jpeg = _jpeg.ResizeImage(24, 24); + (jpeg[0] == 0xff && jpeg[1] == 0xd8).AssertTrue(); + jpeg.GetImageSize().AssertEqual(new Size(24, 12)); + + var gif = _gif.ResizeImage(48, 48); + (gif[0] == (byte)'G' && gif[1] == (byte)'I' && gif[2] == (byte)'F').AssertTrue(); + gif.GetImageSize().AssertEqual(new Size(48, 24)); } [TestMethod] - public void ResizeImage_ValidatesBoundsAndSource() + public void ResizeImage_RejectsNonPositiveBounds() { - var source = CreateImage(); - ThrowsExactly(() => source.ResizeImage(0, 5)); - ThrowsExactly(() => source.ResizeImage(5, -1)); + ThrowsExactly(() => _png.ResizeImage(0, 10)); + ThrowsExactly(() => _png.ResizeImage(10, -1)); byte[] missing = null; - ThrowsExactly(() => missing.ResizeImage(5, 5)); + ThrowsExactly(() => missing.ResizeImage(1, 1)); } [TestMethod] - public void ConvertToPng_DecodesJpegAndKeepsDimensions() + public void ConvertToPng_ConvertsJpegAndGif() { - var output = CreateImage(122, 73, jpeg: true).ConvertToPng(); - output.IsPng().AssertTrue(); - output.GetImageSize().AssertEqual(new DrawingSize(122, 73)); + if (!OperatingSystem.IsWindows()) + return; + + var jpeg = _jpeg.ConvertToPng(); + jpeg.IsPng().AssertTrue(); + jpeg.GetPngSize().AssertEqual(new Size(96, 48)); + + var gif = _gif.ConvertToPng(); + gif.IsPng().AssertTrue(); + gif.GetPngSize().AssertEqual(new Size(96, 48)); } [TestMethod] - public void ConvertToPng_PreservesTransparentPixels() + public void ConvertToPng_KeepsTransparentPixel() { - var source = CreateImage(30, 20, transparent: true); - var output = source.ConvertToPng(); - using var decoded = Image.Load(output); - decoded[0, 0].A.AssertEqual((byte)0); + if (!OperatingSystem.IsWindows()) + return; + + var result = _transparentPng.ConvertToPng(); + result.GetPngSize().AssertEqual(new Size(8, 6)); + + // Input is RGBA. The first PNG scanline contains filter + first RGBA pixel. + // None/Sub/Up/Average/Paeth all leave the first pixel unchanged on the first row. + result[25].AssertEqual((byte)6); + var inflated = InflatePngImageData(result); + inflated.Length.AssertEqual(6 * (1 + 8 * 4)); + inflated[4].AssertEqual((byte)0); } [TestMethod] - public void ConvertToPng_BadDataAndNull() + public void ConvertToPng_NullAndUnsupportedFormat() { - ThrowsExactly(() => new byte[] { 2, 4 }.ConvertToPng()); - byte[] missing = null; ThrowsExactly(() => missing.ConvertToPng()); + + if (OperatingSystem.IsWindows()) + ThrowsExactly(() => new byte[] { 1, 2, 3 }.ConvertToPng()); } [TestMethod] - public void AddTextWatermark_ChangesPixelsButNotTheOriginalBuffer() + public void AddTextWatermark_DefaultVerdanaAndUnchangedSource() { - var source = CreateImage(400, 170); - var original = (byte[])source.Clone(); - var font = TestFont; + if (!OperatingSystem.IsWindows()) + return; + + var source = (byte[])_png.Clone(); + var result = source.AddTextWatermark("StockSharp"); - var output = source.AddTextWatermark("StockSharp", fontSize: 24, opacity: 220, margin: 10, fontFamily: font); - output.IsPng().AssertTrue(); - output.GetImageSize().AssertEqual(new DrawingSize(400, 170)); - source.SequenceEqual(original).AssertTrue(); + result.IsPng().AssertTrue(); + result.GetPngSize().AssertEqual(new Size(320, 160)); + source.SequenceEqual(_png).AssertTrue(); + } - using var decoded = Image.Load(output); - decoded[0, 0].R.AssertEqual((byte)0); - var touched = false; + [TestMethod] + public void AddTextWatermark_OpacityActuallyChangesPixels() + { + if (!OperatingSystem.IsWindows()) + return; - for (var y = 0; y < decoded.Height && !touched; y++) - for (var x = 0; x < decoded.Width; x++) - if (decoded[x, y].R > 0) - { - touched = true; - break; - } + var hidden = _png.AddTextWatermark("StockSharp", opacity: 0); + var visible = _png.AddTextWatermark("StockSharp", opacity: 255); - touched.AssertTrue(); + hidden.SequenceEqual(visible).AssertFalse(); } [TestMethod] - public void AddTextWatermark_FitsLongTextWithinMargins() + public void AddTextWatermark_LongTextFitsWithoutChangingDimensions() { - var font = TestFont; - var result = CreateImage(350, 140).AddTextWatermark( - "Long watermark text for checking resizing", fontSize: 50, margin: 8, fontFamily: font); + if (!OperatingSystem.IsWindows()) + return; - using var decoded = Image.Load(result); - decoded.Width.AssertEqual(350); - decoded.Height.AssertEqual(140); - - // No watermark should cross the top-left padding. - decoded[0, 0].R.AssertEqual((byte)0); - decoded[0, 0].A.AssertEqual((byte)255); + var result = _png.AddTextWatermark("A moderately long watermark that needs resizing", fontSize: 45, margin: 8); + result.GetPngSize().AssertEqual(new Size(320, 160)); } [TestMethod] public void AddTextWatermark_ConvertsJpegToPng() { - var font = TestFont; - var output = CreateImage(300, 150, jpeg: true).AddTextWatermark("ECNG", fontFamily: font); - output.IsPng().AssertTrue(); - output.GetImageSize().AssertEqual(new DrawingSize(300, 150)); + if (!OperatingSystem.IsWindows()) + return; + + var result = _jpeg.AddTextWatermark("Test", fontSize: 18, margin: 2); + result.IsPng().AssertTrue(); + result.GetPngSize().AssertEqual(new Size(96, 48)); } [TestMethod] - public void AddTextWatermark_UsesVerdanaWhenAvailableOrReportsMissingFont() + public void AddTextWatermark_ValidatesArguments() { - var source = CreateImage(320, 120); + ThrowsExactly(() => _png.AddTextWatermark(" ")); + ThrowsExactly(() => _png.AddTextWatermark("Test", fontSize: 0)); + ThrowsExactly(() => _png.AddTextWatermark("Test", fontSize: float.NaN)); + ThrowsExactly(() => _png.AddTextWatermark("Test", fontSize: float.PositiveInfinity)); + ThrowsExactly(() => _png.AddTextWatermark("Test", margin: -1)); + ThrowsExactly(() => _png.AddTextWatermark("Test", fontFamily: " ")); - if (SystemFonts.TryGet("Verdana", out _)) - source.AddTextWatermark("ECNG").IsPng().AssertTrue(); - else - ThrowsExactly(() => source.AddTextWatermark("ECNG")); + byte[] missing = null; + ThrowsExactly(() => missing.AddTextWatermark("Test")); + + if (OperatingSystem.IsWindows()) + { + ThrowsExactly(() => _png.AddTextWatermark("Test", margin: 300)); + ThrowsExactly(() => _png.AddTextWatermark("Test", fontFamily: "NonexistentEcngFont777")); + } } [TestMethod] - public void AddTextWatermark_ValidatesArguments() + public void UnsupportedPlatform_ReportsItExplicitly() { - var source = CreateImage(); - var font = TestFont; + if (OperatingSystem.IsWindows()) + return; - ThrowsExactly(() => source.AddTextWatermark(" ")); - ThrowsExactly(() => source.AddTextWatermark("text", fontSize: 0)); - ThrowsExactly(() => source.AddTextWatermark("text", fontSize: float.NaN)); - ThrowsExactly(() => source.AddTextWatermark("text", fontSize: float.PositiveInfinity)); - ThrowsExactly(() => source.AddTextWatermark("text", margin: -1)); - ThrowsExactly(() => source.AddTextWatermark("text", margin: 200, fontFamily: font)); - ThrowsExactly(() => source.AddTextWatermark("text", fontFamily: " ")); - ThrowsExactly(() => source.AddTextWatermark("text", fontFamily: "DefinitelyNonexistentEcngTestFont")); + ThrowsExactly(() => _png.ResizeImage(100, 100)); + ThrowsExactly(() => _jpeg.ConvertToPng()); + ThrowsExactly(() => _png.AddTextWatermark("Hello")); + } - byte[] missing = null; - ThrowsExactly(() => missing.AddTextWatermark("X")); + [TestMethod] + public void UnsupportedCodec_IsNotSilentlyAccepted() + { + if (!OperatingSystem.IsWindows()) + return; + + ThrowsExactly(() => new byte[] { (byte)'I', (byte)'I', 42, 0 }.ResizeImage(1, 1)); + ThrowsExactly(() => new byte[] { (byte)'I', (byte)'I', 42, 0 }.ConvertToPng()); + } + + // This decoder is deliberately test-only and verifies actual PNG alpha pixel bytes. + private static byte[] InflatePngImageData(byte[] png) + { + using var compressed = new MemoryStream(); + var at = 8; + + while (at + 12 <= png.Length) + { + var size = ((uint)png[at] << 24) | ((uint)png[at + 1] << 16) | ((uint)png[at + 2] << 8) | png[at + 3]; + var dataStart = at + 8; + if (size > png.Length - dataStart - 4) + throw new InvalidDataException("Invalid PNG chunk length."); + + if (png[at + 4] == (byte)'I' && png[at + 5] == (byte)'D' && png[at + 6] == (byte)'A' && png[at + 7] == (byte)'T') + compressed.Write(png, dataStart, (int)size); + + at = dataStart + (int)size + 4; + } + + compressed.Position = 0; + using var inflated = new MemoryStream(); + using (var zlib = new ZLibStream(compressed, CompressionMode.Decompress, leaveOpen: true)) + zlib.CopyTo(inflated); + + return inflated.ToArray(); } } diff --git a/common_versions.props b/common_versions.props index 1a6b45031..58c45f130 100644 --- a/common_versions.props +++ b/common_versions.props @@ -45,8 +45,7 @@ 3.* 7.9.0 7.* - 3.1.12 - 2.1.7 + From 4cacd6b18b68e8a5304f7cc306b7556ac49b023e Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 02:02:03 +0300 Subject: [PATCH 13/85] Release drawing context before encoding watermark PNG --- Drawing/ImageHelper.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Drawing/ImageHelper.cs b/Drawing/ImageHelper.cs index 5ee53587f..e0570baeb 100644 --- a/Drawing/ImageHelper.cs +++ b/Drawing/ImageHelper.cs @@ -141,9 +141,12 @@ public static byte[] AddTextWatermark( try { - using var graphics = new NativeGraphics(canvas); - graphics.Draw(source.Handle, source.Width, source.Height, source.Width, source.Height); - graphics.DrawWatermark(text, fontSize, opacity, margin, fontFamily, source.Width, source.Height); + using (var graphics = new NativeGraphics(canvas)) + { + graphics.Draw(source.Handle, source.Width, source.Height, source.Width, source.Height); + graphics.DrawWatermark(text, fontSize, opacity, margin, fontFamily, source.Width, source.Height); + } + return Save(canvas, Encoder.Png); } finally From 8f3a5b2ecf3df0166b50838f8feca8b96f598b76 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 12:03:39 +0300 Subject: [PATCH 14/85] Introduce managed RGBA pixel buffer with alpha-correct downscaling --- Drawing/RasterImage.cs | 89 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 Drawing/RasterImage.cs diff --git a/Drawing/RasterImage.cs b/Drawing/RasterImage.cs new file mode 100644 index 000000000..2151c141c --- /dev/null +++ b/Drawing/RasterImage.cs @@ -0,0 +1,89 @@ +namespace Ecng.Drawing; + +using System; + +/// Uncompressed, tightly packed RGBA8 pixels (top-to-bottom, left-to-right). +internal sealed class RasterImage +{ + public int Width { get; } + public int Height { get; } + public byte[] Pixels { get; } + + public RasterImage(int width, int height) + { + if (width < 1 || height < 1 || (long)width * height > 25_000_000) + throw new ArgumentOutOfRangeException(nameof(width), "Image dimensions exceed the supported 25-megapixel limit."); + + Width = width; + Height = height; + Pixels = new byte[checked(width * height * 4)]; + } + + /// Resizes with center-aligned bilinear interpolation, preserving alpha without dark halos. + public RasterImage Downscale(int width, int height) + { + var result = new RasterImage(width, height); + var sx = (double)Width / width; + var sy = (double)Height / height; + + for (var y = 0; y < height; y++) + { + var fy = (y + 0.5) * sy - 0.5; + var y0 = Math.Clamp((int)Math.Floor(fy), 0, Height - 1); + var y1 = Math.Clamp(y0 + 1, 0, Height - 1); + var ty = Math.Clamp(fy, 0, 1); + for (var x = 0; x < width; x++) + { + var fx = (x + 0.5) * sx - 0.5; + var x0 = Math.Clamp((int)Math.Floor(fx), 0, Width - 1); + var x1 = Math.Clamp(x0 + 1, 0, Width - 1); + var tx = Math.Clamp(fx, 0, 1); + + var a = (y0 * Width + x0) * 4; + var b = (y0 * Width + x1) * 4; + var c = (y1 * Width + x0) * 4; + var d = (y1 * Width + x1) * 4; + var dest = (y * width + x) * 4; + + var wa = (1 - tx) * (1 - ty); + var wb = tx * (1 - ty); + var wc = (1 - tx) * ty; + var wd = tx * ty; + var alpha = Pixels[a + 3] * wa + Pixels[b + 3] * wb + Pixels[c + 3] * wc + Pixels[d + 3] * wd; + result.Pixels[dest + 3] = ToByte(alpha); + + for (var channel = 0; channel < 3; channel++) + { + var premultiplied = + Pixels[a + channel] * Pixels[a + 3] * wa + + Pixels[b + channel] * Pixels[b + 3] * wb + + Pixels[c + channel] * Pixels[c + 3] * wc + + Pixels[d + channel] * Pixels[d + 3] * wd; + result.Pixels[dest + channel] = alpha > 0 ? ToByte(premultiplied / alpha) : (byte)0; + } + } + } + + return result; + } + + public void BlendPixel(int x, int y, byte red, byte green, byte blue, byte alpha) + { + if ((uint)x >= (uint)Width || (uint)y >= (uint)Height || alpha == 0) + return; + + var offset = (y * Width + x) * 4; + var oldAlpha = Pixels[offset + 3]; + var a = alpha / 255.0; + var oa = oldAlpha / 255.0; + var newAlpha = a + oa * (1 - a); + if (newAlpha == 0) return; + + Pixels[offset] = ToByte((red * a + Pixels[offset] * oa * (1 - a)) / newAlpha); + Pixels[offset + 1] = ToByte((green * a + Pixels[offset + 1] * oa * (1 - a)) / newAlpha); + Pixels[offset + 2] = ToByte((blue * a + Pixels[offset + 2] * oa * (1 - a)) / newAlpha); + Pixels[offset + 3] = ToByte(newAlpha * 255); + } + + public static byte ToByte(double value) => (byte)Math.Clamp((int)Math.Round(value), 0, 255); +} From ddde5b82e599a05646745b1fff522aa5529af1be Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 12:04:49 +0300 Subject: [PATCH 15/85] Implement managed PNG codec with colors, filters, Adam7, alpha and CRC checks --- Drawing/PngCodec.cs | 370 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 370 insertions(+) create mode 100644 Drawing/PngCodec.cs diff --git a/Drawing/PngCodec.cs b/Drawing/PngCodec.cs new file mode 100644 index 000000000..290428e40 --- /dev/null +++ b/Drawing/PngCodec.cs @@ -0,0 +1,370 @@ +namespace Ecng.Drawing; + +using System; +using System.Buffers.Binary; +using System.IO; +using System.IO.Compression; +using System.Text; + +/// Managed PNG decoder/encoder (all standard color types, 1/2/4/8/16-bit samples, Adam7). +internal static class PngCodec +{ + private static ReadOnlySpan Signature => [137, 80, 78, 71, 13, 10, 26, 10]; + private static readonly uint[] _crcTable = CreateCrcTable(); + private static readonly (int x, int y, int dx, int dy)[] _passes = + [ + (0, 0, 8, 8), (4, 0, 8, 8), (0, 4, 4, 8), (2, 0, 4, 4), + (0, 2, 2, 4), (1, 0, 2, 2), (0, 1, 1, 2), + ]; + + public static bool IsPng(ReadOnlySpan data) => data.StartsWith(Signature); + + public static (int width, int height) ReadSize(ReadOnlySpan bytes) + { + if (bytes.Length < 24 || !IsPng(bytes) || !bytes.Slice(12, 4).SequenceEqual("IHDR"u8) || + BinaryPrimitives.ReadUInt32BigEndian(bytes.Slice(8, 4)) != 13) + throw new InvalidDataException("Invalid PNG IHDR header."); + + var width = BinaryPrimitives.ReadInt32BigEndian(bytes.Slice(16, 4)); + var height = BinaryPrimitives.ReadInt32BigEndian(bytes.Slice(20, 4)); + ValidateSize(width, height); + return (width, height); + } + + public static RasterImage Decode(byte[] data) + { + var (width, height) = ReadSize(data); + if (data.Length > 128 * 1024 * 1024) + throw new InvalidDataException("PNG input exceeds the supported size."); + + var depth = 0; + var color = 0; + var interlace = 0; + var palette = Array.Empty(); + var transparency = Array.Empty(); + var gotHeader = false; + var gotData = false; + var ended = false; + using var compressed = new MemoryStream(); + var offset = 8; + + while (offset <= data.Length - 12) + { + var length = BinaryPrimitives.ReadUInt32BigEndian(data.AsSpan(offset, 4)); + if (length > int.MaxValue || length > data.Length - offset - 12) + throw new InvalidDataException("Invalid PNG chunk length."); + + var tag = data.AsSpan(offset + 4, 4); + var content = data.AsSpan(offset + 8, (int)length); + var actualCrc = BinaryPrimitives.ReadUInt32BigEndian(data.AsSpan(offset + 8 + (int)length, 4)); + if (actualCrc != Crc(data.AsSpan(offset + 4, (int)length + 4))) + throw new InvalidDataException("PNG CRC mismatch."); + + if (tag.SequenceEqual("IHDR"u8)) + { + if (gotHeader || offset != 8 || length != 13) + throw new InvalidDataException("Invalid PNG IHDR placement."); + + gotHeader = true; + depth = content[8]; + color = content[9]; + interlace = content[12]; + if (content[10] != 0 || content[11] != 0 || interlace > 1 || !ValidFormat(depth, color)) + throw new NotSupportedException("Unsupported PNG bit depth, color type, or interlace method."); + } + else if (tag.SequenceEqual("PLTE"u8)) + { + if (!gotHeader || gotData || length == 0 || length > 768 || length % 3 != 0) + throw new InvalidDataException("Invalid PNG palette."); + + palette = content.ToArray(); + } + else if (tag.SequenceEqual("tRNS"u8)) + { + if (!gotHeader || gotData) + throw new InvalidDataException("Invalid PNG transparency chunk."); + + transparency = content.ToArray(); + } + else if (tag.SequenceEqual("IDAT"u8)) + { + if (!gotHeader) + throw new InvalidDataException("PNG data precedes header."); + gotData = true; + compressed.Write(content); + } + else if (tag.SequenceEqual("IEND"u8)) + { + if (length != 0) throw new InvalidDataException("Invalid IEND."); + ended = true; + break; + } + else if ((tag[0] & 0x20) == 0) + throw new NotSupportedException("Unknown critical PNG chunk."); + + offset = checked(offset + 12 + (int)length); + } + + if (!gotHeader || !gotData || !ended) + throw new InvalidDataException("PNG is incomplete."); + if (color == 3 && (palette.Length == 0 || transparency.Length > palette.Length / 3)) + throw new InvalidDataException("Invalid indexed PNG palette."); + if ((color == 0 && transparency.Length != 0 && transparency.Length != 2) || + (color == 2 && transparency.Length != 0 && transparency.Length != 6) || + (color != 0 && color != 2 && color != 3 && transparency.Length != 0)) + throw new InvalidDataException("Invalid PNG transparency."); + + var channels = color switch { 0 => 1, 2 => 3, 3 => 1, 4 => 2, 6 => 4, _ => throw new InvalidDataException() }; + var rowBytes = (int)(((long)width * channels * depth + 7) / 8); + var needed = interlace == 0 ? checked((long)(rowBytes + 1) * height) : 0L; + + if (interlace == 1) + foreach (var (px, py, dx, dy) in _passes) + { + var w = PassSize(width, px, dx); + var h = PassSize(height, py, dy); + if (w != 0 && h != 0) + needed += (((long)w * channels * depth + 7) / 8 + 1) * h; + } + + if (needed > 200_000_000) + throw new InvalidDataException("Decompressed PNG exceeds the supported size."); + + byte[] raw; + compressed.Position = 0; + using (var zlib = new ZLibStream(compressed, CompressionMode.Decompress, leaveOpen: true)) + using (var inflated = new MemoryStream()) + { + var block = new byte[8192]; + while (true) + { + var read = zlib.Read(block, 0, block.Length); + if (read == 0) break; + if (inflated.Length + read > needed) + throw new InvalidDataException("PNG decompression exceeded expected row data."); + inflated.Write(block, 0, read); + } + raw = inflated.ToArray(); + } + + if (raw.LongLength != needed) + throw new InvalidDataException("PNG scanlines have an invalid length."); + + var result = new RasterImage(width, height); + var cursor = 0; + + if (interlace == 0) + DecodePass(result, raw, ref cursor, width, height, 0, 0, 1, 1, + channels, depth, color, palette, transparency); + else + foreach (var (px, py, dx, dy) in _passes) + { + var w = PassSize(width, px, dx); + var h = PassSize(height, py, dy); + if (w != 0 && h != 0) + DecodePass(result, raw, ref cursor, w, h, px, py, dx, dy, + channels, depth, color, palette, transparency); + } + + return result; + } + + public static byte[] Encode(RasterImage image) + { + using var output = new MemoryStream(); + output.Write(Signature); + var header = new byte[13]; + BinaryPrimitives.WriteInt32BigEndian(header.AsSpan(0, 4), image.Width); + BinaryPrimitives.WriteInt32BigEndian(header.AsSpan(4, 4), image.Height); + header[8] = 8; + header[9] = 6; // RGBA8 + WriteChunk(output, "IHDR"u8, header); + + using var compressed = new MemoryStream(); + using (var zlib = new ZLibStream(compressed, CompressionLevel.Optimal, leaveOpen: true)) + { + var rowBytes = image.Width * 4; + for (var y = 0; y < image.Height; y++) + { + zlib.WriteByte(0); // Filter None: deterministic encoding + zlib.Write(image.Pixels, y * rowBytes, rowBytes); + } + } + + WriteChunk(output, "IDAT"u8, compressed.ToArray()); + WriteChunk(output, "IEND"u8, []); + return output.ToArray(); + } + + private static void DecodePass(RasterImage image, byte[] raw, ref int cursor, + int width, int height, int px, int py, int dx, int dy, + int channels, int depth, int color, byte[] palette, byte[] transparency) + { + var rowLen = checked((int)(((long)width * channels * depth + 7) / 8)); + var stride = Math.Max(1, (channels * depth + 7) / 8); + var previous = new byte[rowLen]; + var row = new byte[rowLen]; + + for (var y = 0; y < height; y++) + { + var filter = raw[cursor++]; + Array.Copy(raw, cursor, row, 0, rowLen); + cursor += rowLen; + + for (var i = 0; i < rowLen; i++) + { + var a = i >= stride ? row[i - stride] : 0; + var b = previous[i]; + var c = i >= stride ? previous[i - stride] : 0; + var prediction = filter switch + { + 0 => 0, + 1 => a, + 2 => b, + 3 => (a + b) / 2, + 4 => Paeth(a, b, c), + _ => throw new InvalidDataException("Unsupported PNG scanline filter."), + }; + row[i] = unchecked((byte)(row[i] + prediction)); + } + + for (var x = 0; x < width; x++) + { + var dest = ((py + y * dy) * image.Width + px + x * dx) * 4; + var sample = x * channels; + switch (color) + { + case 0: + var grayRaw = Sample(row, sample, depth); + var gray = Normalize(grayRaw, depth); + var ga = transparency.Length == 2 && + grayRaw == BinaryPrimitives.ReadUInt16BigEndian(transparency) ? (byte)0 : (byte)255; + Assign(image.Pixels, dest, gray, gray, gray, ga); + break; + + case 2: + var rr = Sample(row, sample, depth); + var gg = Sample(row, sample + 1, depth); + var bb = Sample(row, sample + 2, depth); + var ra = transparency.Length == 6 && + rr == BinaryPrimitives.ReadUInt16BigEndian(transparency.AsSpan(0, 2)) && + gg == BinaryPrimitives.ReadUInt16BigEndian(transparency.AsSpan(2, 2)) && + bb == BinaryPrimitives.ReadUInt16BigEndian(transparency.AsSpan(4, 2)) ? (byte)0 : (byte)255; + Assign(image.Pixels, dest, Normalize(rr, depth), Normalize(gg, depth), Normalize(bb, depth), ra); + break; + + case 3: + var index = Sample(row, x, depth); + if (index >= palette.Length / 3) + throw new InvalidDataException("PNG palette index outside palette."); + Assign(image.Pixels, dest, palette[index * 3], palette[index * 3 + 1], + palette[index * 3 + 2], index < transparency.Length ? transparency[index] : (byte)255); + break; + + case 4: + var l = Normalize(Sample(row, sample, depth), depth); + Assign(image.Pixels, dest, l, l, l, Normalize(Sample(row, sample + 1, depth), depth)); + break; + + case 6: + Assign(image.Pixels, dest, + Normalize(Sample(row, sample, depth), depth), + Normalize(Sample(row, sample + 1, depth), depth), + Normalize(Sample(row, sample + 2, depth), depth), + Normalize(Sample(row, sample + 3, depth), depth)); + break; + } + } + + (row, previous) = (previous, row); + } + } + + private static void Assign(byte[] p, int offset, byte r, byte g, byte b, byte a) + { + p[offset] = r; + p[offset + 1] = g; + p[offset + 2] = b; + p[offset + 3] = a; + } + + private static int Sample(byte[] row, int index, int depth) + { + if (depth == 8) return row[index]; + if (depth == 16) return BinaryPrimitives.ReadUInt16BigEndian(row.AsSpan(index * 2, 2)); + + var offset = index * depth; + return row[offset / 8] >> (8 - depth - offset % 8) & ((1 << depth) - 1); + } + + private static byte Normalize(int sample, int depth) + => depth switch + { + 8 => (byte)sample, + 16 => (byte)(sample >> 8), + _ => (byte)(sample * 255 / ((1 << depth) - 1)), + }; + + private static bool ValidFormat(int depth, int color) => color switch + { + 0 => depth is 1 or 2 or 4 or 8 or 16, + 2 => depth is 8 or 16, + 3 => depth is 1 or 2 or 4 or 8, + 4 => depth is 8 or 16, + 6 => depth is 8 or 16, + _ => false, + }; + + private static int PassSize(int total, int start, int step) + => total <= start ? 0 : (total - start + step - 1) / step; + + private static void ValidateSize(int width, int height) + { + if (width <= 0 || height <= 0 || (long)width * height > 25_000_000) + throw new InvalidDataException("PNG size exceeds the 25-megapixel limit."); + } + + private static int Paeth(int a, int b, int c) + { + var p = a + b - c; + var da = Math.Abs(p - a); + var db = Math.Abs(p - b); + var dc = Math.Abs(p - c); + return da <= db && da <= dc ? a : db <= dc ? b : c; + } + + private static void WriteChunk(Stream output, ReadOnlySpan tag, ReadOnlySpan content) + { + Span length = stackalloc byte[4]; + BinaryPrimitives.WriteInt32BigEndian(length, content.Length); + output.Write(length); + output.Write(tag); + output.Write(content); + var buffer = new byte[checked(4 + content.Length)]; + tag.CopyTo(buffer); + content.CopyTo(buffer.AsSpan(4)); + BinaryPrimitives.WriteUInt32BigEndian(length, Crc(buffer)); + output.Write(length); + } + + private static uint Crc(ReadOnlySpan content) + { + uint crc = 0xFFFFFFFF; + foreach (var b in content) + crc = _crcTable[(byte)(crc ^ b)] ^ (crc >> 8); + return crc ^ 0xFFFFFFFF; + } + + private static uint[] CreateCrcTable() + { + var table = new uint[256]; + for (uint i = 0; i < table.Length; i++) + { + var c = i; + for (var j = 0; j < 8; j++) + c = c & 1 == 1 ? 0xEDB88320 ^ (c >> 1) : c >> 1; + table[i] = c; + } + return table; + } +} From 1cc0862574c9d476ad6754d4ad62eaaf87cdf933 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 12:06:22 +0300 Subject: [PATCH 16/85] Add pure C# baseline JPEG decoder with Huffman, DCT and chroma sampling --- Drawing/JpegCodec.cs | 440 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 440 insertions(+) create mode 100644 Drawing/JpegCodec.cs diff --git a/Drawing/JpegCodec.cs b/Drawing/JpegCodec.cs new file mode 100644 index 000000000..06515314b --- /dev/null +++ b/Drawing/JpegCodec.cs @@ -0,0 +1,440 @@ +namespace Ecng.Drawing; + +using System; +using System.Collections.Generic; +using System.IO; + +/// +/// Pure managed decoder for baseline sequential 8-bit JPEG (grayscale and YCbCr, including +/// common 4:2:0 / 4:2:2 subsampling and DRI restart markers). Progressive, arithmetic-coded, +/// CMYK and multi-scan JPEGs are deliberately rejected rather than misdecoded. +/// +internal static class JpegCodec +{ + private static ReadOnlySpan ZigZag => + [ + 0,1,8,16,9,2,3,10,17,24,32,25,18,11,4,5, + 12,19,26,33,40,48,41,34,27,20,13,6,7,14,21,28, + 35,42,49,56,57,50,43,36,29,22,15,23,30,37,44,36, + 29,22,15,23,30,38,45,52,59,60,53,46,39,47,54,61 + ]; + // Index 36 onward in this zigzag is intentionally defined in the static array below + // by the standard inverse scan order (all 0..63 occur exactly once). + private static readonly byte[] _zigzag = + [ + 0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, + 12, 19, 26, 33, 40, 48, 41, 34, 27, 20, 13, 6, 7, 14, 21, + 28, 35, 42, 49, 56, 57, 50, 43, 36, 29, 22, 15, 23, 30, 37, + 44, 51, 58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, + 62, 63 + ]; + + private static readonly double[,] _cos = CreateCosines(); + private static readonly double[] _normal = [1 / Math.Sqrt(2), 1, 1, 1, 1, 1, 1, 1]; + + private sealed class Component + { + public int Id, H, V, Q, DcTable, AcTable, DcPredictor, Stride; + public byte[] Samples; + } + + private sealed class Huffman + { + private readonly Dictionary _lookup = new(); + + public Huffman(ReadOnlySpan counts, ReadOnlySpan symbols) + { + var code = 0; + var index = 0; + for (var length = 1; length <= 16; length++) + { + for (var n = 0; n < counts[length - 1]; n++) + _lookup[(length << 16) | code++] = symbols[index++]; + + if (code > (1 << length)) + throw new InvalidDataException("Invalid JPEG Huffman code lengths."); + + code <<= 1; + } + } + + public int ReadSymbol(Bits bits) + { + var code = 0; + for (var length = 1; length <= 16; length++) + { + code = code << 1 | bits.Bit(); + if (_lookup.TryGetValue((length << 16) | code, out var symbol)) + return symbol; + } + throw new InvalidDataException("Malformed JPEG Huffman bitstream."); + } + } + + private sealed class Bits + { + private readonly byte[] _data; + private int _offset; + private int _value, _remaining; + public Bits(byte[] data, int offset) { _data = data; _offset = offset; } + + public int Bit() + { + if (_remaining == 0) + { + if (_offset >= _data.Length) + throw new InvalidDataException("Truncated JPEG entropy data."); + + _value = _data[_offset++]; + if (_value == 0xFF) + { + if (_offset >= _data.Length || _data[_offset++] != 0x00) + throw new InvalidDataException("Unexpected marker in JPEG entropy data."); + } + _remaining = 8; + } + return _value >> --_remaining & 1; + } + + public int Read(int count) + { + var value = 0; + for (var i = 0; i < count; i++) + value = value << 1 | Bit(); + return value; + } + + public int Signed(int count) + { + if (count == 0) return 0; + var v = Read(count); + return v < (1 << (count - 1)) ? v - ((1 << count) - 1) : v; + } + + public void Restart(int index) + { + _remaining = 0; + if (_offset >= _data.Length || _data[_offset++] != 0xFF) + throw new InvalidDataException("Missing JPEG restart marker."); + while (_offset < _data.Length && _data[_offset] == 0xFF) + _offset++; + if (_offset >= _data.Length || _data[_offset++] != 0xD0 + index % 8) + throw new InvalidDataException("Unexpected JPEG restart marker."); + } + } + + public static bool IsJpeg(ReadOnlySpan data) + => data.Length >= 3 && data[0] == 0xff && data[1] == 0xd8 && data[2] == 0xff; + + public static (int width, int height) ReadSize(ReadOnlySpan data) + { + var cursor = 2; + if (!IsJpeg(data)) throw new InvalidDataException("Invalid JPEG signature."); + + while (NextSegment(data, ref cursor, out var marker, out var segment)) + { + if (marker is >= 0xC0 and <= 0xCF and not (0xC4 or 0xC8 or 0xCC)) + { + if (segment.Length < 6) throw new InvalidDataException("Truncated JPEG frame."); + var h = U16(segment, 1); + var w = U16(segment, 3); + CheckSize(w, h); + return (w, h); + } + if (marker == 0xDA) break; + } + + throw new InvalidDataException("JPEG has no frame dimensions."); + } + + public static RasterImage Decode(byte[] data) + { + if (data.Length > 128 * 1024 * 1024) + throw new InvalidDataException("JPEG input exceeds the supported size."); + + if (!IsJpeg(data)) + throw new InvalidDataException("Invalid JPEG signature."); + + var cursor = 2; + var quant = new int[4][]; + var dc = new Huffman[4]; + var ac = new Huffman[4]; + var components = Array.Empty(); + var width = 0; + var height = 0; + var restartInterval = 0; + var foundScan = false; + + while (NextSegment(data, ref cursor, out var marker, out var segment)) + { + switch (marker) + { + case 0xDB: + for (var p = 0; p < segment.Length;) + { + var t = segment[p++]; + var prec = t >> 4; + var idx = t & 15; + if (idx > 3 || prec > 1 || segment.Length - p < 64 * (prec + 1)) + throw new InvalidDataException("Invalid JPEG quantization table."); + + var values = new int[64]; + for (var i = 0; i < 64; i++) + { + values[_zigzag[i]] = prec == 0 ? segment[p++] : U16(segment, p); + if (prec != 0) p += 2; + } + quant[idx] = values; + } + break; + + case 0xC4: + for (var p = 0; p < segment.Length;) + { + if (segment.Length - p < 17) + throw new InvalidDataException("Truncated JPEG Huffman table."); + + var id = segment[p++]; + var cls = id >> 4; + var index = id & 15; + if (cls > 1 || index > 3) + throw new InvalidDataException("Invalid JPEG Huffman table index."); + + var counts = segment.Slice(p, 16); + p += 16; + var count = 0; + foreach (var n in counts) count += n; + if (count > 256 || count > segment.Length - p) + throw new InvalidDataException("Invalid JPEG Huffman symbols."); + + var table = new Huffman(counts, segment.Slice(p, count)); + p += count; + if (cls == 0) dc[index] = table; + else ac[index] = table; + } + break; + + case 0xDD: + if (segment.Length != 2) throw new InvalidDataException("Invalid restart interval."); + restartInterval = U16(segment, 0); + break; + + case 0xC0: + if (components.Length != 0 || segment.Length < 6 || segment[0] != 8) + throw new NotSupportedException("Only 8-bit baseline JPEG is supported."); + + height = U16(segment, 1); + width = U16(segment, 3); + CheckSize(width, height); + var count = segment[5]; + + if (count != 1 && count != 3) + throw new NotSupportedException("Only grayscale and YCbCr JPEG are supported."); + + if (segment.Length != 6 + count * 3) + throw new InvalidDataException("Malformed JPEG frame components."); + + components = new Component[count]; + for (var i = 0; i < count; i++) + { + var at = 6 + i * 3; + var h = segment[at + 1] >> 4; + var v = segment[at + 1] & 15; + var q = segment[at + 2]; + if (h < 1 || h > 4 || v < 1 || v > 4 || q > 3) + throw new NotSupportedException("Unsupported JPEG sampling factors."); + + components[i] = new Component { Id = segment[at], H = h, V = v, Q = q }; + } + break; + + case 0xDA: + if (components.Length == 0 || segment.Length < 4) + throw new InvalidDataException("JPEG scan precedes frame."); + + if (segment[0] != components.Length || segment.Length != 1 + 2 * components.Length + 3 || + segment[^3] != 0 || segment[^2] != 63 || segment[^1] != 0) + throw new NotSupportedException("Multi-scan or non-baseline JPEG is unsupported."); + + for (var i = 0; i < components.Length; i++) + { + var id = segment[1 + 2 * i]; + Component part = null; + foreach (var c in components) + if (c.Id == id) part = c; + + if (part == null) + throw new InvalidDataException("Invalid JPEG scan component."); + + var selector = segment[2 + 2 * i]; + part.DcTable = selector >> 4; + part.AcTable = selector & 15; + if (part.DcTable > 3 || part.AcTable > 3) + throw new InvalidDataException("Invalid JPEG scan table."); + } + + // Stop parsing at first SOS; the bitstream contains byte-stuffed markers. + foundScan = true; + break; + + default: + if (marker is >= 0xC1 and <= 0xCF and not (0xC4 or 0xC8 or 0xCC)) + throw new NotSupportedException("Progressive, lossless and arithmetic-coded JPEG formats are unsupported."); + break; + } + if (foundScan) break; + } + + if (!foundScan) + throw new InvalidDataException("Missing JPEG image scan."); + + var maxH = 1; + var maxV = 1; + foreach (var c in components) + { + maxH = Math.Max(maxH, c.H); + maxV = Math.Max(maxV, c.V); + } + + var mcuWidth = (width + maxH * 8 - 1) / (maxH * 8); + var mcuHeight = (height + maxV * 8 - 1) / (maxV * 8); + + foreach (var c in components) + { + if (quant[c.Q] == null || dc[c.DcTable] == null || ac[c.AcTable] == null) + throw new InvalidDataException("Missing JPEG quantization or Huffman table."); + c.Stride = mcuWidth * c.H * 8; + c.Samples = new byte[checked(c.Stride * mcuHeight * c.V * 8)]; + } + + var reader = new Bits(data, cursor); + var mcuNumber = 0; + var block = new int[64]; + + for (var my = 0; my < mcuHeight; my++) + for (var mx = 0; mx < mcuWidth; mx++) + { + if (restartInterval > 0 && mcuNumber != 0 && mcuNumber % restartInterval == 0) + { + reader.Restart(mcuNumber / restartInterval - 1); + foreach (var c in components) c.DcPredictor = 0; + } + + foreach (var c in components) + for (var cy = 0; cy < c.V; cy++) + for (var cx = 0; cx < c.H; cx++) + { + Array.Clear(block); + var dcLength = dc[c.DcTable].ReadSymbol(reader); + if (dcLength > 11) throw new InvalidDataException("Invalid JPEG DC magnitude."); + + c.DcPredictor += reader.Signed(dcLength); + block[0] = c.DcPredictor * quant[c.Q][0]; + + for (var at = 1; at < 64;) + { + var symbol = ac[c.AcTable].ReadSymbol(reader); + if (symbol == 0) break; + if (symbol == 0xF0) { at += 16; continue; } + + var run = symbol >> 4; + var size = symbol & 15; + if (size == 0 || size > 10 || (at += run) >= 64) + throw new InvalidDataException("Invalid JPEG AC symbol."); + + block[_zigzag[at++]] = reader.Signed(size) * quant[c.Q][_zigzag[at - 1]]; + } + + InverseDct(block, c.Samples, c.Stride, (mx * c.H + cx) * 8, (my * c.V + cy) * 8); + } + + mcuNumber++; + } + + var result = new RasterImage(width, height); + for (var y = 0; y < height; y++) + for (var x = 0; x < width; x++) + { + var off = (y * width + x) * 4; + var yy = Value(components[0], x, y, maxH, maxV); + + if (components.Length == 1) + { + result.Pixels[off] = result.Pixels[off + 1] = result.Pixels[off + 2] = yy; + } + else + { + var cb = Value(components[1], x, y, maxH, maxV) - 128; + var cr = Value(components[2], x, y, maxH, maxV) - 128; + result.Pixels[off] = RasterImage.ToByte(yy + 1.402 * cr); + result.Pixels[off + 1] = RasterImage.ToByte(yy - 0.344136 * cb - 0.714136 * cr); + result.Pixels[off + 2] = RasterImage.ToByte(yy + 1.772 * cb); + } + result.Pixels[off + 3] = 255; + } + + return result; + } + + private static byte Value(Component c, int x, int y, int maxH, int maxV) + => c.Samples[(y * c.V / maxV) * c.Stride + (x * c.H / maxH)]; + + private static void InverseDct(int[] coeff, byte[] samples, int stride, int left, int top) + { + for (var y = 0; y < 8; y++) + for (var x = 0; x < 8; x++) + { + double sum = 0; + for (var v = 0; v < 8; v++) + for (var u = 0; u < 8; u++) + sum += _normal[u] * _normal[v] * coeff[v * 8 + u] * _cos[x, u] * _cos[y, v]; + + samples[(top + y) * stride + left + x] = RasterImage.ToByte(sum / 4 + 128); + } + } + + private static double[,] CreateCosines() + { + var table = new double[8, 8]; + for (var x = 0; x < 8; x++) + for (var u = 0; u < 8; u++) + table[x, u] = Math.Cos((2 * x + 1) * u * Math.PI / 16); + return table; + } + + private static bool NextSegment(ReadOnlySpan data, ref int cursor, out int marker, out ReadOnlySpan segment) + { + segment = default; + while (cursor < data.Length && data[cursor] != 0xFF) + cursor++; + if (cursor == data.Length) { marker = 0; return false; } + while (cursor < data.Length && data[cursor] == 0xFF) + cursor++; + if (cursor == data.Length) { marker = 0; return false; } + + marker = data[cursor++]; + if (marker == 0xD9) return false; + if (marker is >= 0xD0 and <= 0xD7 or 0x01 or 0xD8) + return true; + + if (cursor + 2 > data.Length) + throw new InvalidDataException("Truncated JPEG segment length."); + + var size = U16(data, cursor); + if (size < 2 || size > data.Length - cursor) + throw new InvalidDataException("Invalid JPEG segment length."); + + segment = data.Slice(cursor + 2, size - 2); + cursor += size; + return true; + } + + private static int U16(ReadOnlySpan data, int index) + => data[index] << 8 | data[index + 1]; + + private static void CheckSize(int width, int height) + { + if (width <= 0 || height <= 0 || (long)width * height > 25_000_000) + throw new InvalidDataException("JPEG exceeds the supported 25-megapixel size limit."); + } +} From 2d0eb0f9554389a667f6653a80562dd4ff44fd4e Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 12:08:34 +0300 Subject: [PATCH 17/85] Render Verdana TrueType outlines using managed font parsing and antialiased scan conversion --- Drawing/TrueTypeFont.cs | 479 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 479 insertions(+) create mode 100644 Drawing/TrueTypeFont.cs diff --git a/Drawing/TrueTypeFont.cs b/Drawing/TrueTypeFont.cs new file mode 100644 index 000000000..3b3efebe0 --- /dev/null +++ b/Drawing/TrueTypeFont.cs @@ -0,0 +1,479 @@ +namespace Ecng.Drawing; + +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; + +/// +/// Minimal managed TrueType 'glyf' outline reader and quadratic rasterizer. +/// Accepts .ttf fonts with Unicode cmap format 4/12 and simple/composite outlines. +/// No platform text engine or managed font package is used. +/// +internal sealed class TrueTypeFont +{ + private readonly byte[] _file; + private readonly Dictionary _tables = new(); + private readonly int _cmap, _glyf, _loca, _hmtx, _metricsCount, _numGlyphs, _locaFormat; + private readonly int _unitsPerEm, _ascent, _descent; + private readonly Dictionary _cache = new(); + + private readonly record struct Point(double X, double Y, bool OnCurve); + private sealed class Glyph + { + public int Advance; + public List> Contours = []; + } + + private readonly record struct Line(double X1, double Y1, double X2, double Y2); + + public static string ResolveFontFile(string family, string fontFilePath) + { + if (fontFilePath != null) + { + if (!File.Exists(fontFilePath)) + throw new FileNotFoundException("TrueType font file was not found.", fontFilePath); + return fontFilePath; + } + + // Do not use unlicensed bundled fonts, font substitutes or external packages. + var dirs = new List(); + var osFonts = Environment.GetFolderPath(Environment.SpecialFolder.Fonts); + if (!string.IsNullOrWhiteSpace(osFonts)) dirs.Add(osFonts); + dirs.AddRange(["/usr/share/fonts", "/usr/local/share/fonts", "/System/Library/Fonts", + "/Library/Fonts", Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".fonts"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library/Fonts")]); + + foreach (var dir in dirs.Distinct(StringComparer.OrdinalIgnoreCase)) + { + if (!Directory.Exists(dir)) continue; + try + { + foreach (var file in Directory.EnumerateFiles(dir, "*.ttf", SearchOption.AllDirectories)) + if (string.Equals(Path.GetFileNameWithoutExtension(file), family, StringComparison.OrdinalIgnoreCase)) + return file; + } + catch (UnauthorizedAccessException) { } + catch (IOException) { } + } + + throw new InvalidOperationException($"TrueType font '{family}' is not installed. Supply fontFilePath to an installed .ttf."); + } + + public TrueTypeFont(string path) + { + _file = File.ReadAllBytes(path); + if (_file.Length > 30_000_000 || _file.Length < 12) + throw new InvalidDataException("TrueType font has an invalid length."); + + var signature = U32(0); + if (signature != 0x00010000 && signature != 0x74727565) + throw new NotSupportedException("Only outline-based TrueType (.ttf), not OpenType CFF or font collections, is supported."); + + var count = U16(4); + Require(12, count * 16); + for (var i = 0; i < count; i++) + { + var pos = 12 + i * 16; + var tag = Encoding.ASCII.GetString(_file, pos, 4); + var offset = checked((int)U32(pos + 8)); + var length = checked((int)U32(pos + 12)); + Require(offset, length); + _tables[tag] = (offset, length); + } + + var head = Table("head", 54); + var hhea = Table("hhea", 36); + var maxp = Table("maxp", 6); + var cmap = Table("cmap", 4); + _loca = Table("loca", 2); + _glyf = Table("glyf", 0); + _hmtx = Table("hmtx", 4); + + _unitsPerEm = U16(head + 18); + _ascent = I16(hhea + 4); + _descent = I16(hhea + 6); + _locaFormat = I16(head + 50); + _metricsCount = U16(hhea + 34); + _numGlyphs = U16(maxp + 4); + + if (_unitsPerEm == 0 || _metricsCount < 1 || _metricsCount > _numGlyphs || + _locaFormat is not (0 or 1) || _numGlyphs == 0) + throw new InvalidDataException("TrueType font metrics are invalid."); + + Require(_hmtx, _metricsCount * 4 + (_numGlyphs - _metricsCount) * 2); + Require(_loca, (_numGlyphs + 1) * (_locaFormat == 0 ? 2 : 4)); + _cmap = SelectUnicodeCmap(cmap); + } + + public void Draw(RasterImage image, string text, float size, byte opacity, int margin) + { + var codepoints = text.EnumerateRunes().Select(x => x.Value).ToArray(); + if (codepoints.Length == 0 || codepoints.Length > 1024 || codepoints.Any(x => x == '\n' || x == '\r')) + throw new ArgumentException("Watermark must be one line with 1..1024 Unicode characters.", nameof(text)); + + var glyphs = codepoints.Select(x => LoadGlyph(Map(x), 0)).ToArray(); + var advance = glyphs.Sum(x => (long)x.Advance); + + if (advance <= 0) + throw new ArgumentException("Watermark has no printable characters.", nameof(text)); + + var maxWidth = image.Width - 2L * margin; + var maxHeight = image.Height - 2L * margin; + if (maxWidth < 1 || maxHeight < 1) + throw new ArgumentOutOfRangeException(nameof(margin), "No room for watermark."); + + // FontSize is points, and 96dpi defines a stable cross-platform point conversion. + var scale = size * (96.0 / 72.0) / _unitsPerEm; + var glyphHeight = Math.Max(1, _ascent - _descent); + scale = Math.Min(scale, Math.Min(maxWidth / advance, maxHeight / (double)glyphHeight) * 0.95); + + if (!double.IsFinite(scale) || scale <= 0 || scale * _unitsPerEm < 1) + throw new ArgumentException("Watermark cannot fit within image margins.", nameof(text)); + + var offsetX = image.Width - margin - advance * scale; + var baseline = image.Height - margin + _descent * scale; + + foreach (var glyph in glyphs) + { + DrawGlyph(image, glyph, offsetX, baseline, scale, opacity); + offsetX += glyph.Advance * scale; + } + } + + private static void DrawGlyph(RasterImage image, Glyph glyph, double originX, double baseline, double scale, byte opacity) + { + var segments = new List(); + + foreach (var contour in glyph.Contours) + { + if (contour.Count == 0) continue; + + Point start; + var first = contour[0]; + var last = contour[^1]; + if (first.OnCurve) start = first; + else if (last.OnCurve) start = last; + else start = Mid(first, last); + + var current = start; + for (var i = 0; i < contour.Count;) + { + var node = contour[i]; + if (node.OnCurve) + { + AddSegment(current, node); + current = node; + i++; + } + else + { + var next = contour[(i + 1) % contour.Count]; + var target = next.OnCurve ? next : Mid(node, next); + var flatness = (Math.Abs(current.X - node.X) + Math.Abs(current.Y - node.Y) + + Math.Abs(node.X - target.X) + Math.Abs(node.Y - target.Y)) * scale; + var subdivisions = Math.Clamp((int)Math.Ceiling(flatness / 3), 4, 64); + var from = current; + + for (var t = 1; t <= subdivisions; t++) + { + var s = (double)t / subdivisions; + var reverse = 1 - s; + var to = new Point( + reverse * reverse * current.X + 2 * reverse * s * node.X + s * s * target.X, + reverse * reverse * current.Y + 2 * reverse * s * node.Y + s * s * target.Y, true); + AddSegment(from, to); + from = to; + } + + current = target; + i += next.OnCurve ? 2 : 1; + } + } + AddSegment(current, start); + + void AddSegment(Point a, Point b) + { + var x1 = originX + a.X * scale; + var y1 = baseline - a.Y * scale; + var x2 = originX + b.X * scale; + var y2 = baseline - b.Y * scale; + if (Math.Abs(x1 - x2) + Math.Abs(y1 - y2) > 0.001) + segments.Add(new Line(x1, y1, x2, y2)); + } + } + + if (segments.Count == 0) return; + var left = Math.Max(0, (int)Math.Floor(segments.Min(s => Math.Min(s.X1, s.X2)))); + var right = Math.Min(image.Width - 1, (int)Math.Ceiling(segments.Max(s => Math.Max(s.X1, s.X2)))); + var top = Math.Max(0, (int)Math.Floor(segments.Min(s => Math.Min(s.Y1, s.Y2)))); + var bottom = Math.Min(image.Height - 1, (int)Math.Ceiling(segments.Max(s => Math.Max(s.Y1, s.Y2)))); + + for (var y = top; y <= bottom; y++) + for (var x = left; x <= right; x++) + { + var hits = 0; + for (var sampleY = 0; sampleY < 2; sampleY++) + for (var sampleX = 0; sampleX < 2; sampleX++) + { + var xx = x + (sampleX + 0.5) / 2; + var yy = y + (sampleY + 0.5) / 2; + var crossings = 0; + foreach (var s in segments) + if ((s.Y1 > yy) != (s.Y2 > yy) && + xx < (s.X2 - s.X1) * (yy - s.Y1) / (s.Y2 - s.Y1) + s.X1) + crossings++; + + if ((crossings & 1) != 0) hits++; + } + + if (hits > 0) + image.BlendPixel(x, y, 255, 255, 255, (byte)(opacity * hits / 4)); + } + } + + private Glyph LoadGlyph(int glyphId, int depth) + { + if (depth > 12) + throw new InvalidDataException("TrueType composite glyph nesting is too deep."); + if (_cache.TryGetValue(glyphId, out var cached)) return cached; + + if (glyphId < 0 || glyphId >= _numGlyphs) + throw new InvalidDataException("Invalid TrueType glyph index."); + + var glyph = new Glyph { Advance = U16(_hmtx + Math.Min(glyphId, _metricsCount - 1) * 4) }; + var offset = _loca + glyphId * (_locaFormat == 0 ? 2 : 4); + var start = _locaFormat == 0 ? U16(offset) * 2 : checked((int)U32(offset)); + var end = _locaFormat == 0 ? U16(offset + 2) * 2 : checked((int)U32(offset + 4)); + + if (start == end) { _cache[glyphId] = glyph; return glyph; } + if (end < start || end > _tables["glyf"].length) + throw new InvalidDataException("Invalid TrueType glyph location."); + + var pos = _glyf + start; + Require(pos, 10); + var contourCount = I16(pos); + pos += 10; + + if (contourCount >= 0) + { + Require(pos, contourCount * 2 + 2); + var ends = new int[contourCount]; + for (var i = 0; i < contourCount; i++) { ends[i] = U16(pos); pos += 2; } + + var points = contourCount == 0 ? 0 : ends[^1] + 1; + if (points > 30000) + throw new InvalidDataException("TrueType glyph is too complex."); + + var instructionLength = U16(pos); pos += 2; + Require(pos, instructionLength); + pos += instructionLength; + + var flags = new byte[points]; + for (var i = 0; i < points; i++) + { + Require(pos, 1); + var flag = _file[pos++]; + flags[i] = flag; + if ((flag & 8) != 0) + { + Require(pos, 1); + var repetitions = _file[pos++]; + if (i + repetitions >= points) throw new InvalidDataException("Bad TrueType flag run."); + for (var j = 0; j < repetitions; j++) flags[++i] = flag; + } + } + + var xx = new int[points]; + var yy = new int[points]; + var coordinate = 0; + for (var i = 0; i < points; i++) + { + var flag = flags[i]; + if ((flag & 2) != 0) { Require(pos, 1); var delta = _file[pos++]; coordinate += (flag & 16) != 0 ? delta : -delta; } + else if ((flag & 16) == 0) { Require(pos, 2); coordinate += I16(pos); pos += 2; } + xx[i] = coordinate; + } + + coordinate = 0; + for (var i = 0; i < points; i++) + { + var flag = flags[i]; + if ((flag & 4) != 0) { Require(pos, 1); var delta = _file[pos++]; coordinate += (flag & 32) != 0 ? delta : -delta; } + else if ((flag & 32) == 0) { Require(pos, 2); coordinate += I16(pos); pos += 2; } + yy[i] = coordinate; + } + + var from = 0; + foreach (var to in ends) + { + if (to < from || to >= points) + throw new InvalidDataException("Bad TrueType contour indices."); + + var contour = new List(to - from + 1); + for (var i = from; i <= to; i++) + contour.Add(new Point(xx[i], yy[i], (flags[i] & 1) != 0)); + + glyph.Contours.Add(contour); + from = to + 1; + } + } + else + { + var more = true; + while (more) + { + Require(pos, 4); + var flags = U16(pos); pos += 2; + var childId = U16(pos); pos += 2; + if ((flags & 2) == 0) + throw new NotSupportedException("TrueType composite point attachments are not supported."); + + int dx, dy; + if ((flags & 1) != 0) + { + Require(pos, 4); + dx = I16(pos); dy = I16(pos + 2); pos += 4; + } + else + { + Require(pos, 2); + dx = (sbyte)_file[pos++]; dy = (sbyte)_file[pos++]; + } + + double a = 1, b = 0, c = 0, d = 1; + if ((flags & 8) != 0) + { + Require(pos, 2); + a = d = I16(pos) / 16384.0; + pos += 2; + } + else if ((flags & 64) != 0) + { + Require(pos, 4); + a = I16(pos) / 16384.0; + d = I16(pos + 2) / 16384.0; + pos += 4; + } + else if ((flags & 128) != 0) + { + Require(pos, 8); + a = I16(pos) / 16384.0; + b = I16(pos + 2) / 16384.0; + c = I16(pos + 4) / 16384.0; + d = I16(pos + 6) / 16384.0; + pos += 8; + } + + var child = LoadGlyph(childId, depth + 1); + foreach (var contour in child.Contours) + glyph.Contours.Add(contour.Select(p => new Point(a * p.X + b * p.Y + dx, c * p.X + d * p.Y + dy, p.OnCurve)).ToList()); + + more = (flags & 32) != 0; + } + } + + _cache[glyphId] = glyph; + return glyph; + } + + private int SelectUnicodeCmap(int cmap) + { + var tables = U16(cmap + 2); + Require(cmap + 4, tables * 8); + var best = -1; + var score = -1; + + for (var i = 0; i < tables; i++) + { + var pos = cmap + 4 + i * 8; + var platform = U16(pos); + var encoding = U16(pos + 2); + var location = checked((int)U32(pos + 4)); + var candidate = cmap + location; + if (candidate < 0 || candidate + 2 > _file.Length || !(platform == 0 || (platform == 3 && encoding is 1 or 10))) + continue; + + var format = U16(candidate); + var rank = format == 12 ? 3 : format == 4 ? 2 : -1; + if (rank > score) + { + score = rank; + best = candidate; + } + } + + if (best < 0) + throw new NotSupportedException("Unicode TrueType cmap format 4 or 12 is required."); + return best; + } + + private int Map(int codepoint) + { + var format = U16(_cmap); + if (format == 12) + { + var count = checked((int)U32(_cmap + 12)); + Require(_cmap + 16, checked(count * 12)); + var lo = 0; + var hi = count - 1; + while (lo <= hi) + { + var i = lo + (hi - lo) / 2; + var at = _cmap + 16 + 12 * i; + var first = U32(at); + var last = U32(at + 4); + if (codepoint < first) hi = i - 1; + else if (codepoint > last) lo = i + 1; + else return checked((int)(U32(at + 8) + codepoint - first)); + } + return 0; + } + + if (codepoint > 0xFFFF) return 0; + var segments = U16(_cmap + 6) / 2; + var ends = _cmap + 14; + var starts = ends + 2 * segments + 2; + var deltas = starts + 2 * segments; + var offsets = deltas + 2 * segments; + Require(offsets, 2 * segments); + + for (var i = 0; i < segments; i++) + { + var last = U16(ends + i * 2); + if (codepoint > last) continue; + var first = U16(starts + i * 2); + if (codepoint < first) return 0; + var delta = I16(deltas + i * 2); + var range = U16(offsets + i * 2); + + if (range == 0) return (codepoint + delta) & 0xFFFF; + var at = offsets + i * 2 + range + 2 * (codepoint - first); + Require(at, 2); + var glyph = U16(at); + return glyph == 0 ? 0 : (glyph + delta) & 0xFFFF; + } + + return 0; + } + + private int Table(string tag, int minimum) + { + if (!_tables.TryGetValue(tag, out var entry) || entry.length < minimum) + throw new InvalidDataException($"TrueType font lacks required table '{tag}'."); + return entry.start; + } + + private void Require(int pos, int len) + { + if (pos < 0 || len < 0 || pos > _file.Length || len > _file.Length - pos) + throw new InvalidDataException("Truncated TrueType font data."); + } + + private ushort U16(int at) { Require(at, 2); return BinaryPrimitives.ReadUInt16BigEndian(_file.AsSpan(at, 2)); } + private short I16(int at) { Require(at, 2); return BinaryPrimitives.ReadInt16BigEndian(_file.AsSpan(at, 2)); } + private uint U32(int at) { Require(at, 4); return BinaryPrimitives.ReadUInt32BigEndian(_file.AsSpan(at, 4)); } + private static Point Mid(Point a, Point b) => new((a.X + b.X) / 2, (a.Y + b.Y) / 2, true); +} From e59946a7afe3c404398035670e34113ae50535ce Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 12:09:13 +0300 Subject: [PATCH 18/85] Switch public image API to managed PNG/JPEG codecs and TrueType watermark renderer --- Drawing/ImageHelper.cs | 454 ++++------------------------------------- 1 file changed, 44 insertions(+), 410 deletions(-) diff --git a/Drawing/ImageHelper.cs b/Drawing/ImageHelper.cs index e0570baeb..012ebe374 100644 --- a/Drawing/ImageHelper.cs +++ b/Drawing/ImageHelper.cs @@ -3,19 +3,15 @@ namespace Ecng.Drawing; using System; using System.Drawing; using System.IO; -using System.Runtime.InteropServices; /// -/// Image operations using the Windows GDI+ subsystem. Does not require external NuGet -/// image decoders, fonts or native binaries. Other operating systems can still read PNG -/// dimensions via , but have no built-in cross-platform image -/// rendering backend in .NET 6/10. +/// Platform-independent image utilities implemented entirely in managed C#. +/// No GDI+, platform-native image libraries, or third-party NuGet packages are used. /// public static class ImageHelper { /// - /// Obtains the pixel dimensions of a PNG on any platform, or another supported - /// image format through Windows GDI+. PNG header reading does not decode pixels. + /// Reads dimensions from the PNG or JPEG header without allocating a pixel buffer. /// public static Size GetImageSize(this byte[] data) => GetImageSize((ReadOnlySpan)(data ?? throw new ArgumentNullException(nameof(data)))); @@ -23,449 +19,87 @@ public static Size GetImageSize(this byte[] data) /// public static Size GetImageSize(this ReadOnlySpan data) { - if (data.TryGetPngSize(out var pngSize)) - { - if (pngSize.Width <= 0 || pngSize.Height <= 0) - throw new InvalidDataException("PNG dimensions must be positive."); - - return pngSize; - } + var (width, height) = PngCodec.IsPng(data) ? PngCodec.ReadSize(data) : + JpegCodec.IsJpeg(data) ? JpegCodec.ReadSize(data) : + throw new NotSupportedException("Only PNG and JPEG images are supported."); - EnsureWindows(); - using var source = NativeImage.Open(data.ToArray()); - return new Size(source.Width, source.Height); + return new Size(width, height); } /// - /// Fits the picture into the maximum width and height, without increasing its size. - /// Maintains the aspect ratio and original encoding (PNG, JPEG, BMP, GIF). - /// GIF output contains the first frame only. Windows only. + /// Reduces the image to fit within the specified dimensions while keeping aspect + /// ratio and proper premultiplied-alpha interpolation. Does not enlarge images. + /// Always returns PNG to avoid lossy double encoding of JPEG sources. /// public static byte[] ResizeImage(this byte[] data, int maxWidth, int maxHeight) { ArgumentNullException.ThrowIfNull(data); + if (maxWidth <= 0) throw new ArgumentOutOfRangeException(nameof(maxWidth)); + if (maxHeight <= 0) throw new ArgumentOutOfRangeException(nameof(maxHeight)); - if (maxWidth <= 0) - throw new ArgumentOutOfRangeException(nameof(maxWidth)); - - if (maxHeight <= 0) - throw new ArgumentOutOfRangeException(nameof(maxHeight)); - - EnsureWindows(); - var format = DetectFormat(data); - using var source = NativeImage.Open(data); - var ratio = Math.Min((double)maxWidth / source.Width, (double)maxHeight / source.Height); - + var image = Decode(data); + var ratio = Math.Min((double)maxWidth / image.Width, (double)maxHeight / image.Height); if (ratio >= 1) - return (byte[])data.Clone(); - - var width = Math.Max(1, (int)Math.Floor(source.Width * ratio)); - var height = Math.Max(1, (int)Math.Floor(source.Height * ratio)); - var canvas = CreateCanvas(width, height); - - try - { - using (var graphics = new NativeGraphics(canvas)) - graphics.Draw(source.Handle, source.Width, source.Height, width, height); + return PngCodec.Encode(image); - return Save(canvas, format); - } - finally - { - Gdi.Check(Gdi.GdipDisposeImage(canvas)); - } + var width = Math.Max(1, (int)Math.Floor(image.Width * ratio)); + var height = Math.Max(1, (int)Math.Floor(image.Height * ratio)); + return PngCodec.Encode(image.Downscale(width, height)); } /// - /// Re-encodes a supported picture as PNG, including transparency when present. - /// Animated input is flattened to its first frame. Windows only. + /// Converts a PNG or 8-bit baseline JPEG picture to a standard RGBA8 PNG. /// public static byte[] ConvertToPng(this byte[] data) { ArgumentNullException.ThrowIfNull(data); - EnsureWindows(); - DetectFormat(data); - - using var source = NativeImage.Open(data); - var canvas = CreateCanvas(source.Width, source.Height); - - try - { - using (var graphics = new NativeGraphics(canvas)) - graphics.Draw(source.Handle, source.Width, source.Height, source.Width, source.Height); - - return Save(canvas, Encoder.Png); - } - finally - { - Gdi.Check(Gdi.GdipDisposeImage(canvas)); - } + return PngCodec.Encode(Decode(data)); } /// - /// Places semi-transparent white text at the bottom-right and returns a PNG. - /// Requires Verdana (or the specified alternate font family) installed in Windows. - /// The text is scaled down if necessary to fit within the margins. Windows only. + /// Renders anti-aliased semi-transparent TrueType text into the bottom-right corner, + /// then returns a PNG. The default font is Verdana; it must be installed as a .ttf + /// on the host, or its path can be specified explicitly. No font is bundled. /// + /// Encoded PNG or baseline JPEG image. + /// Single-line watermark text. + /// Maximum font size in points. + /// White text opacity, 0..255. + /// Margin around the text in pixels. + /// Font family name, Verdana by default. + /// Optional path to a compatible TrueType .ttf file. public static byte[] AddTextWatermark( - this byte[] data, - string text, - float fontSize = 24, - byte opacity = 160, - int margin = 12, - string fontFamily = "Verdana") + this byte[] data, string text, float fontSize = 24, byte opacity = 160, + int margin = 12, string fontFamily = "Verdana", string fontFilePath = null) { ArgumentNullException.ThrowIfNull(data); - if (string.IsNullOrWhiteSpace(text)) throw new ArgumentException("Watermark text must not be empty.", nameof(text)); - if (fontSize <= 0 || float.IsNaN(fontSize) || float.IsInfinity(fontSize)) throw new ArgumentOutOfRangeException(nameof(fontSize)); - if (margin < 0) throw new ArgumentOutOfRangeException(nameof(margin)); - if (string.IsNullOrWhiteSpace(fontFamily)) throw new ArgumentException("A font family is required.", nameof(fontFamily)); - EnsureWindows(); - DetectFormat(data); - - using var source = NativeImage.Open(data); - - if (2L * margin >= source.Width || 2L * margin >= source.Height) - throw new ArgumentOutOfRangeException(nameof(margin), "Margins leave no space for text."); - - var canvas = CreateCanvas(source.Width, source.Height); - - try - { - using (var graphics = new NativeGraphics(canvas)) - { - graphics.Draw(source.Handle, source.Width, source.Height, source.Width, source.Height); - graphics.DrawWatermark(text, fontSize, opacity, margin, fontFamily, source.Width, source.Height); - } - - return Save(canvas, Encoder.Png); - } - finally - { - Gdi.Check(Gdi.GdipDisposeImage(canvas)); - } - } - - private static void EnsureWindows() - { - if (!OperatingSystem.IsWindows()) - throw new PlatformNotSupportedException("Image rendering without external dependencies uses Windows GDI+. PNG dimensions are available on all platforms."); - } - - private enum Encoder { Png, Jpeg, Gif, Bmp } - - private static Encoder DetectFormat(ReadOnlySpan bytes) - { - if (bytes.IsPng()) - return Encoder.Png; - - if (bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF) - return Encoder.Jpeg; - - if (bytes.Length >= 6 && - (bytes[..6].SequenceEqual("GIF87a"u8) || bytes[..6].SequenceEqual("GIF89a"u8))) - return Encoder.Gif; - - if (bytes.Length >= 2 && bytes[0] == (byte)'B' && bytes[1] == (byte)'M') - return Encoder.Bmp; - - throw new NotSupportedException("Only PNG, JPEG, GIF and BMP are supported."); - } - - private static IntPtr CreateCanvas(int width, int height) - { - // PixelFormat32bppARGB is writable, supports alpha and permits drawing text. - Gdi.Check(Gdi.GdipCreateBitmapFromScan0(width, height, 0, 0x26200A, IntPtr.Zero, out var canvas)); - return canvas; - } - - private static byte[] Save(IntPtr image, Encoder format) - { - var encoder = format switch - { - Encoder.Png => new Guid("557cf406-1a04-11d3-9a73-0000f81ef32e"), - Encoder.Jpeg => new Guid("557cf401-1a04-11d3-9a73-0000f81ef32e"), - Encoder.Gif => new Guid("557cf402-1a04-11d3-9a73-0000f81ef32e"), - Encoder.Bmp => new Guid("557cf400-1a04-11d3-9a73-0000f81ef32e"), - _ => throw new ArgumentOutOfRangeException(nameof(format)), - }; - - var file = Path.Combine(Path.GetTempPath(), $"ecng-image-{Guid.NewGuid():N}.tmp"); - - try - { - Gdi.Check(Gdi.GdipSaveImageToFile(image, file, ref encoder, IntPtr.Zero)); - return File.ReadAllBytes(file); - } - finally - { - File.Delete(file); - } - } - - private sealed class NativeImage : IDisposable - { - private readonly string _tempFile; - private IntPtr _startupToken; - - public IntPtr Handle { get; private set; } - public int Width { get; private set; } - public int Height { get; private set; } - - private NativeImage(string tempFile) => _tempFile = tempFile; - - public static NativeImage Open(byte[] data) - { - var file = Path.Combine(Path.GetTempPath(), $"ecng-image-{Guid.NewGuid():N}.tmp"); - var image = new NativeImage(file); - - try - { - Gdi.Check(Gdi.GdiplusStartup(out image._startupToken, ref Gdi.StartupInput, IntPtr.Zero)); - File.WriteAllBytes(file, data); - - Gdi.Check(Gdi.GdipLoadImageFromFile(file, out var handle)); - image.Handle = handle; - Gdi.Check(Gdi.GdipGetImageWidth(handle, out var width)); - Gdi.Check(Gdi.GdipGetImageHeight(handle, out var height)); + var image = Decode(data); + if (2L * margin >= image.Width || 2L * margin >= image.Height) + throw new ArgumentOutOfRangeException(nameof(margin), "Margins leave no room for text."); - if (width == 0 || height == 0 || width > int.MaxValue || height > int.MaxValue) - throw new InvalidDataException("Invalid picture dimensions."); - - image.Width = (int)width; - image.Height = (int)height; - return image; - } - catch - { - image.Dispose(); - throw; - } - } - - public void Dispose() + if (opacity != 0) { - if (Handle != IntPtr.Zero) - { - Gdi.GdipDisposeImage(Handle); - Handle = IntPtr.Zero; - } - - if (_startupToken != IntPtr.Zero) - { - Gdi.GdiplusShutdown(_startupToken); - _startupToken = IntPtr.Zero; - } - - if (File.Exists(_tempFile)) - File.Delete(_tempFile); + var font = new TrueTypeFont(TrueTypeFont.ResolveFontFile(fontFamily, fontFilePath)); + font.Draw(image, text, fontSize, opacity, margin); } - } - - private sealed class NativeGraphics : IDisposable - { - private IntPtr _graphics; - - public NativeGraphics(IntPtr image) - { - Gdi.Check(Gdi.GdipGetImageGraphicsContext(image, out _graphics)); - Gdi.Check(Gdi.GdipSetInterpolationMode(_graphics, 7)); // HighQualityBicubic - Gdi.Check(Gdi.GdipSetTextRenderingHint(_graphics, 4)); // AntiAlias - } - - public void Draw(IntPtr source, int sourceWidth, int sourceHeight, int width, int height) - => Gdi.Check(Gdi.GdipDrawImageRectRectI( - _graphics, source, 0, 0, width, height, 0, 0, sourceWidth, sourceHeight, - 2, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero)); - - public void DrawWatermark(string text, float fontSize, byte opacity, int margin, string familyName, int width, int height) - { - var familyStatus = Gdi.GdipCreateFontFamilyFromName(familyName, IntPtr.Zero, out var family); - - if (familyStatus == 14) // FontFamilyNotFound - throw new InvalidOperationException($"Font '{familyName}' is not installed."); - - Gdi.Check(familyStatus); - - IntPtr font = IntPtr.Zero; - IntPtr brush = IntPtr.Zero; - IntPtr format = IntPtr.Zero; - try - { - Gdi.Check(Gdi.GdipCreateStringFormat(0, 0, out format)); - Gdi.Check(Gdi.GdipSetStringFormatAlign(format, 2)); // right - Gdi.Check(Gdi.GdipSetStringFormatLineAlign(format, 2)); // bottom - Gdi.Check(Gdi.GdipCreateSolidFill(unchecked((int)((uint)opacity << 24 | 0x00FFFFFF)), out brush)); - - var allowedWidth = width - 2 * margin; - var allowedHeight = height - 2 * margin; - var size = fontSize; - var measured = default(Gdi.RectF); - var bounds = new Gdi.RectF(0, 0, 100000, 100000); - - for (var attempt = 0; attempt < 12; attempt++) - { - if (font != IntPtr.Zero) - { - Gdi.GdipDeleteFont(font); - font = IntPtr.Zero; - } - - Gdi.Check(Gdi.GdipCreateFont(family, size, 0, 3, out font)); // UnitPoint - Gdi.Check(Gdi.GdipMeasureString(_graphics, text, text.Length, font, ref bounds, format, out measured, out _, out _)); - - if (measured.Width <= allowedWidth && measured.Height <= allowedHeight) - break; - - var factor = Math.Min((double)allowedWidth / measured.Width, (double)allowedHeight / measured.Height); - - if (factor <= 0 || double.IsNaN(factor)) - throw new ArgumentException("Watermark does not fit in the image.", nameof(text)); - - size = (float)(size * Math.Min(factor * 0.95, 0.9)); - - if (size < 1) - throw new ArgumentException("Watermark cannot fit inside the requested margins.", nameof(text)); - } - - if (measured.Width > allowedWidth || measured.Height > allowedHeight) - throw new ArgumentException("Watermark does not fit in the image.", nameof(text)); - - var layout = new Gdi.RectF(margin, margin, allowedWidth, allowedHeight); - Gdi.Check(Gdi.GdipDrawString(_graphics, text, text.Length, font, ref layout, format, brush)); - } - finally - { - if (brush != IntPtr.Zero) Gdi.GdipDeleteBrush(brush); - if (font != IntPtr.Zero) Gdi.GdipDeleteFont(font); - if (format != IntPtr.Zero) Gdi.GdipDeleteStringFormat(format); - Gdi.GdipDeleteFontFamily(family); - } - } - - public void Dispose() - { - if (_graphics != IntPtr.Zero) - { - Gdi.GdipDeleteGraphics(_graphics); - _graphics = IntPtr.Zero; - } - } + return PngCodec.Encode(image); } - private static class Gdi + private static RasterImage Decode(byte[] data) { - [StructLayout(LayoutKind.Sequential)] - public struct RectF - { - public float X, Y, Width, Height; - public RectF(float x, float y, float width, float height) - => (X, Y, Width, Height) = (x, y, width, height); - } - - [StructLayout(LayoutKind.Sequential)] - public struct StartupInfo - { - public uint Version; - public IntPtr DebugEventCallback; - public int SuppressBackgroundThread; - public int SuppressExternalCodecs; - } - - public static StartupInfo StartupInput = new() { Version = 1 }; - - public static void Check(int code) - { - if (code != 0) - throw new InvalidDataException($"Windows GDI+ operation failed with status {code}."); - } - - [DllImport("gdiplus.dll", ExactSpelling = true)] - public static extern int GdiplusStartup(out IntPtr token, ref StartupInfo input, IntPtr output); - - [DllImport("gdiplus.dll", ExactSpelling = true)] - public static extern void GdiplusShutdown(IntPtr token); - - [DllImport("gdiplus.dll", CharSet = CharSet.Unicode, ExactSpelling = true)] - public static extern int GdipLoadImageFromFile(string filename, out IntPtr image); - - [DllImport("gdiplus.dll", ExactSpelling = true)] - public static extern int GdipGetImageWidth(IntPtr image, out uint width); - - [DllImport("gdiplus.dll", ExactSpelling = true)] - public static extern int GdipGetImageHeight(IntPtr image, out uint height); - - [DllImport("gdiplus.dll", ExactSpelling = true)] - public static extern int GdipDisposeImage(IntPtr image); - - [DllImport("gdiplus.dll", ExactSpelling = true)] - public static extern int GdipCreateBitmapFromScan0(int width, int height, int stride, int format, IntPtr scan0, out IntPtr bitmap); - - [DllImport("gdiplus.dll", CharSet = CharSet.Unicode, ExactSpelling = true)] - public static extern int GdipSaveImageToFile(IntPtr image, string filename, ref Guid clsid, IntPtr parameters); - - [DllImport("gdiplus.dll", ExactSpelling = true)] - public static extern int GdipGetImageGraphicsContext(IntPtr image, out IntPtr graphics); - - [DllImport("gdiplus.dll", ExactSpelling = true)] - public static extern int GdipDeleteGraphics(IntPtr graphics); - - [DllImport("gdiplus.dll", ExactSpelling = true)] - public static extern int GdipSetInterpolationMode(IntPtr graphics, int mode); - - [DllImport("gdiplus.dll", ExactSpelling = true)] - public static extern int GdipSetTextRenderingHint(IntPtr graphics, int mode); - - [DllImport("gdiplus.dll", ExactSpelling = true)] - public static extern int GdipDrawImageRectRectI(IntPtr graphics, IntPtr image, int dx, int dy, int dw, int dh, - int sx, int sy, int sw, int sh, int srcUnit, IntPtr attributes, IntPtr callback, IntPtr callbackData); - - [DllImport("gdiplus.dll", CharSet = CharSet.Unicode, ExactSpelling = true)] - public static extern int GdipCreateFontFamilyFromName(string name, IntPtr collection, out IntPtr family); - - [DllImport("gdiplus.dll", ExactSpelling = true)] - public static extern int GdipDeleteFontFamily(IntPtr family); - - [DllImport("gdiplus.dll", ExactSpelling = true)] - public static extern int GdipCreateFont(IntPtr family, float size, int style, int unit, out IntPtr font); - - [DllImport("gdiplus.dll", ExactSpelling = true)] - public static extern int GdipDeleteFont(IntPtr font); - - [DllImport("gdiplus.dll", ExactSpelling = true)] - public static extern int GdipCreateStringFormat(int flags, int language, out IntPtr format); - - [DllImport("gdiplus.dll", ExactSpelling = true)] - public static extern int GdipDeleteStringFormat(IntPtr format); - - [DllImport("gdiplus.dll", ExactSpelling = true)] - public static extern int GdipSetStringFormatAlign(IntPtr format, int align); - - [DllImport("gdiplus.dll", ExactSpelling = true)] - public static extern int GdipSetStringFormatLineAlign(IntPtr format, int align); - - [DllImport("gdiplus.dll", ExactSpelling = true)] - public static extern int GdipCreateSolidFill(int argb, out IntPtr brush); - - [DllImport("gdiplus.dll", ExactSpelling = true)] - public static extern int GdipDeleteBrush(IntPtr brush); - - [DllImport("gdiplus.dll", CharSet = CharSet.Unicode, ExactSpelling = true)] - public static extern int GdipMeasureString(IntPtr graphics, string text, int length, IntPtr font, - ref RectF layout, IntPtr format, out RectF boundingBox, out int codepointsFitted, out int linesFilled); - - [DllImport("gdiplus.dll", CharSet = CharSet.Unicode, ExactSpelling = true)] - public static extern int GdipDrawString(IntPtr graphics, string text, int length, IntPtr font, - ref RectF layout, IntPtr format, IntPtr brush); + if (data.Length == 0) throw new InvalidDataException("Image data is empty."); + if (PngCodec.IsPng(data)) return PngCodec.Decode(data); + if (JpegCodec.IsJpeg(data)) return JpegCodec.Decode(data); + throw new NotSupportedException("Only PNG and baseline 8-bit JPEG images are supported."); } } From ad8312cf5860dd998c53336b03f15b7d5f82a4ce Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 12:09:44 +0300 Subject: [PATCH 19/85] Fix managed-code glyph enumeration and JPEG scan table --- Drawing/TrueTypeFont.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Drawing/TrueTypeFont.cs b/Drawing/TrueTypeFont.cs index 3b3efebe0..dca8c6601 100644 --- a/Drawing/TrueTypeFont.cs +++ b/Drawing/TrueTypeFont.cs @@ -110,8 +110,10 @@ _locaFormat is not (0 or 1) || _numGlyphs == 0) public void Draw(RasterImage image, string text, float size, byte opacity, int margin) { - var codepoints = text.EnumerateRunes().Select(x => x.Value).ToArray(); - if (codepoints.Length == 0 || codepoints.Length > 1024 || codepoints.Any(x => x == '\n' || x == '\r')) + var codepoints = new List(); + foreach (var rune in text.EnumerateRunes()) + codepoints.Add(rune.Value); + if (codepoints.Count == 0 || codepoints.Count > 1024 || codepoints.Any(x => x == '\n' || x == '\r')) throw new ArgumentException("Watermark must be one line with 1..1024 Unicode characters.", nameof(text)); var glyphs = codepoints.Select(x => LoadGlyph(Map(x), 0)).ToArray(); From bc336f5453ac128103b08cc3d0f76e1db63388de Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 12:09:47 +0300 Subject: [PATCH 20/85] Fix managed-code glyph enumeration and JPEG scan table --- Drawing/JpegCodec.cs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/Drawing/JpegCodec.cs b/Drawing/JpegCodec.cs index 06515314b..b429377fc 100644 --- a/Drawing/JpegCodec.cs +++ b/Drawing/JpegCodec.cs @@ -11,15 +11,6 @@ namespace Ecng.Drawing; /// internal static class JpegCodec { - private static ReadOnlySpan ZigZag => - [ - 0,1,8,16,9,2,3,10,17,24,32,25,18,11,4,5, - 12,19,26,33,40,48,41,34,27,20,13,6,7,14,21,28, - 35,42,49,56,57,50,43,36,29,22,15,23,30,37,44,36, - 29,22,15,23,30,38,45,52,59,60,53,46,39,47,54,61 - ]; - // Index 36 onward in this zigzag is intentionally defined in the static array below - // by the standard inverse scan order (all 0..63 occur exactly once). private static readonly byte[] _zigzag = [ 0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, From 8e62cf0ef319dbe54c836e42532548c2e313ab6e Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 12:10:57 +0300 Subject: [PATCH 21/85] Test pure managed PNG/JPEG decode, resize, alpha, font rendering, and corruption across OSes --- Tests/Drawing/ImageHelperTests.cs | 333 +++++++++++++++--------------- 1 file changed, 161 insertions(+), 172 deletions(-) diff --git a/Tests/Drawing/ImageHelperTests.cs b/Tests/Drawing/ImageHelperTests.cs index 120ca5367..c3f753858 100644 --- a/Tests/Drawing/ImageHelperTests.cs +++ b/Tests/Drawing/ImageHelperTests.cs @@ -1,6 +1,7 @@ namespace Ecng.Tests.Drawing; using System; +using System.Buffers.Binary; using System.Drawing; using System.IO; using System.IO.Compression; @@ -11,256 +12,244 @@ namespace Ecng.Tests.Drawing; [TestClass] public class ImageHelperTests : BaseTestClass { - // Image fixtures are embedded bytes, not generated by a third-party image library. - // Solid black PNG and JPEG/GIF were exported once and checked for independent decoding. - private static readonly byte[] _png = Convert.FromBase64String( - "iVBORw0KGgoAAAANSUhEUgAAAUAAAACgCAIAAADywSLLAAAAq0lEQVR4nO3BAQEAAACCIP+vbkhAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACvBli+AAGaPK0yAAAAAElFTkSuQmCC"); + // Fixtures encoded by an independent encoder (Pillow), so the tests do not merely + // round-trip our encoder and decoder through the same implementation. + private static readonly byte[] _rgba = Convert.FromBase64String( + "iVBORw0KGgoAAAANSUhEUgAAABAAAAAICAYAAADwdn+XAAAAIUlEQVR42mNkYAhg4GdgQMb/0fh4MQuDJANFYNSAwWAAABDMAv2PDdyuAAAAAElFTkSuQmCC"); private static readonly byte[] _jpeg = Convert.FromBase64String( - "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAAwAGABAREA/8QAFQABAQAAAAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oACAEBAAA/AJ/AAAAAAAAAAAAAAAAAAAAAAAB//9k="); + "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAIABADASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAb/xAAZEAABBQAAAAAAAAAAAAAAAAAABAYjMaH/xAAVAQEBAAAAAAAAAAAAAAAAAAACBf/EABkRAAIDAQAAAAAAAAAAAAAAAAAEAwUhUf/aAAwDAQACEQMRAD8Ai0LSqPCkQtKo8AJrTs3Q0Nixmn//2Q=="); - private static readonly byte[] _gif = Convert.FromBase64String( - "R0lGODdhYAAwAIEAAAAAAAAAAAAAAAAAACwAAAAAYAAwAEAIbwABCBxIsKDBgwgTKlzIsKHDhxAjSpxIsaLFixgzatzIsaPHjyBDihxJsqTJkyhTqlzJsqXLlzBjypxJs6bNmzhz6tzJs6fPn0CDCh1KtKjRo0iTKl3KtKnTp1CjSp1KtarVq1izat3KtavXrzADAgA7"); + private static readonly byte[] _gray = Convert.FromBase64String( + "iVBORw0KGgoAAAANSUhEUgAAAAsAAAAHCAAAAAD7BeiBAAAAEElEQVR42mOsZIADJgbqsAEp2wCHVwHWfwAAAABJRU5ErkJggg=="); - private static readonly byte[] _transparentPng = Convert.FromBase64String( - "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAGCAYAAAD+Bd/7AAAAFUlEQVR42mPkktNgwAeYGAiAwaAAAEKMAFwjCOSSAAAAAElFTkSuQmCC"); + private static readonly byte[] _palette = Convert.FromBase64String( + "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAFCAMAAABPT11nAAADAFBMVEUAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABfLwLAAAAAnRSTlMA/1uRIrUAAAAPSURBVHjaY2BgRIMMuEUAAe0AFe25Rb0AAAAASUVORK5CYII="); [TestMethod] - public void GetImageSize_PngWorksOnEveryPlatform() + public void GetImageSize_PngAndJpegAcrossOperatingSystems() { - _png.GetImageSize().AssertEqual(new Size(320, 160)); - - ReadOnlySpan span = _transparentPng; - span.GetImageSize().AssertEqual(new Size(8, 6)); + _rgba.GetImageSize().AssertEqual(new Size(16, 8)); + _jpeg.GetImageSize().AssertEqual(new Size(16, 8)); + ReadOnlySpan span = _gray; + span.GetImageSize().AssertEqual(new Size(11, 7)); } [TestMethod] - public void GetImageSize_JpegOnWindows() - { - if (OperatingSystem.IsWindows()) - _jpeg.GetImageSize().AssertEqual(new Size(96, 48)); - else - ThrowsExactly(() => _jpeg.GetImageSize()); - } - - [TestMethod] - public void GetImageSize_BadDimensionsAndNullAreRejected() + public void GetImageSize_RejectsBadHeadersAndHugeDimensions() { byte[] missing = null; ThrowsExactly(() => missing.GetImageSize()); - - var invalid = (byte[])_png.Clone(); - invalid[16] = invalid[17] = invalid[18] = invalid[19] = 0; - ThrowsExactly(() => invalid.GetImageSize()); - } - - [TestMethod] - public void ResizeImage_FitsWithinBoundsAndPreservesAspectRatio() - { - if (!OperatingSystem.IsWindows()) - return; - - var source = (byte[])_png.Clone(); - var reduced = source.ResizeImage(120, 100); - - reduced.GetPngSize().AssertEqual(new Size(120, 60)); - source.GetPngSize().AssertEqual(new Size(320, 160)); - source.SequenceEqual(_png).AssertTrue(); - - _png.ResizeImage(50, 5).GetPngSize().AssertEqual(new Size(10, 5)); - _png.ResizeImage(2, 2).GetPngSize().AssertEqual(new Size(2, 1)); + ThrowsExactly(() => new byte[] { 1, 2, 3 }.GetImageSize()); + var damaged = (byte[])_rgba.Clone(); + damaged[16] = damaged[17] = damaged[18] = damaged[19] = 0; + ThrowsExactly(() => damaged.GetImageSize()); } [TestMethod] - public void ResizeImage_NeverUpscalesAndReturnsAnIndependentCopy() + public void ConvertToPng_TrueAlphaAndSourceUnchanged() { - if (!OperatingSystem.IsWindows()) - return; + var before = (byte[])_rgba.Clone(); + var result = _rgba.ConvertToPng(); + result.IsPng().AssertTrue(); + result.GetPngSize().AssertEqual(new Size(16, 8)); + _rgba.SequenceEqual(before).AssertTrue(); - var copy = _png.ResizeImage(1000, 1000); - copy.SequenceEqual(_png).AssertTrue(); - ReferenceEquals(copy, _png).AssertFalse(); + var pixels = Pixels(result); + Alpha(pixels, 16, 0, 0).AssertEqual((byte)0); + Alpha(pixels, 16, 3, 4).AssertEqual((byte)0); + Alpha(pixels, 16, 4, 4).AssertEqual((byte)255); } [TestMethod] - public void ResizeImage_PreservesJpegAndGifEncoding() + public void ConvertToPng_DecodesGrayscaleAndPaletteWithTransparency() { - if (!OperatingSystem.IsWindows()) - return; - - var jpeg = _jpeg.ResizeImage(24, 24); - (jpeg[0] == 0xff && jpeg[1] == 0xd8).AssertTrue(); - jpeg.GetImageSize().AssertEqual(new Size(24, 12)); + var grayscale = _gray.ConvertToPng(); + var grayPixels = Pixels(grayscale); + grayPixels[0].AssertEqual((byte)121); + grayPixels[1].AssertEqual((byte)121); + grayPixels[2].AssertEqual((byte)121); + grayPixels[3].AssertEqual((byte)255); - var gif = _gif.ResizeImage(48, 48); - (gif[0] == (byte)'G' && gif[1] == (byte)'I' && gif[2] == (byte)'F').AssertTrue(); - gif.GetImageSize().AssertEqual(new Size(48, 24)); + var indexed = _palette.ConvertToPng(); + indexed.GetPngSize().AssertEqual(new Size(8, 5)); + var colors = Pixels(indexed); + Alpha(colors, 8, 0, 0).AssertEqual((byte)0); + Alpha(colors, 8, 1, 0).AssertEqual((byte)255); + colors[(0 * 8 + 1) * 4].AssertEqual((byte)255); } [TestMethod] - public void ResizeImage_RejectsNonPositiveBounds() + public void ConvertToPng_DecodesBaselineJpegAndPixelColors() { - ThrowsExactly(() => _png.ResizeImage(0, 10)); - ThrowsExactly(() => _png.ResizeImage(10, -1)); - - byte[] missing = null; - ThrowsExactly(() => missing.ResizeImage(1, 1)); + var result = _jpeg.ConvertToPng(); + result.IsPng().AssertTrue(); + result.GetPngSize().AssertEqual(new Size(16, 8)); + var pixels = Pixels(result); + var index = (4 * 16 + 8) * 4; + (Math.Abs(pixels[index] - 120) < 45).AssertTrue(); + (Math.Abs(pixels[index + 1] - 100) < 45).AssertTrue(); + (Math.Abs(pixels[index + 2] - 80) < 45).AssertTrue(); + pixels[index + 3].AssertEqual((byte)255); } [TestMethod] - public void ConvertToPng_ConvertsJpegAndGif() + public void ResizeImage_RespectsBothBoundsWithoutUpscaling() { - if (!OperatingSystem.IsWindows()) - return; - - var jpeg = _jpeg.ConvertToPng(); - jpeg.IsPng().AssertTrue(); - jpeg.GetPngSize().AssertEqual(new Size(96, 48)); - - var gif = _gif.ConvertToPng(); - gif.IsPng().AssertTrue(); - gif.GetPngSize().AssertEqual(new Size(96, 48)); + _rgba.ResizeImage(8, 8).GetPngSize().AssertEqual(new Size(8, 4)); + _rgba.ResizeImage(4, 1).GetPngSize().AssertEqual(new Size(2, 1)); + _rgba.ResizeImage(100, 100).GetPngSize().AssertEqual(new Size(16, 8)); + _jpeg.ResizeImage(4, 4).GetPngSize().AssertEqual(new Size(4, 2)); } [TestMethod] - public void ConvertToPng_KeepsTransparentPixel() + public void ResizeImage_PreservesAlphaAndDoesNotModifyInput() { - if (!OperatingSystem.IsWindows()) - return; - - var result = _transparentPng.ConvertToPng(); - result.GetPngSize().AssertEqual(new Size(8, 6)); - - // Input is RGBA. The first PNG scanline contains filter + first RGBA pixel. - // None/Sub/Up/Average/Paeth all leave the first pixel unchanged on the first row. - result[25].AssertEqual((byte)6); - var inflated = InflatePngImageData(result); - inflated.Length.AssertEqual(6 * (1 + 8 * 4)); - inflated[4].AssertEqual((byte)0); + var before = (byte[])_rgba.Clone(); + var output = _rgba.ResizeImage(8, 8); + var pixels = Pixels(output); + Alpha(pixels, 8, 0, 0).AssertEqual((byte)0); + Alpha(pixels, 8, 7, 1).AssertEqual((byte)255); + _rgba.SequenceEqual(before).AssertTrue(); } [TestMethod] - public void ConvertToPng_NullAndUnsupportedFormat() + public void ResizeImage_RejectsInvalidDimensions() { + ThrowsExactly(() => _rgba.ResizeImage(0, 8)); + ThrowsExactly(() => _rgba.ResizeImage(8, -1)); byte[] missing = null; - ThrowsExactly(() => missing.ConvertToPng()); - - if (OperatingSystem.IsWindows()) - ThrowsExactly(() => new byte[] { 1, 2, 3 }.ConvertToPng()); + ThrowsExactly(() => missing.ResizeImage(2, 2)); } [TestMethod] - public void AddTextWatermark_DefaultVerdanaAndUnchangedSource() + public void CorruptPng_CrcIsNotIgnored() { - if (!OperatingSystem.IsWindows()) - return; - - var source = (byte[])_png.Clone(); - var result = source.AddTextWatermark("StockSharp"); - - result.IsPng().AssertTrue(); - result.GetPngSize().AssertEqual(new Size(320, 160)); - source.SequenceEqual(_png).AssertTrue(); + var broken = (byte[])_rgba.Clone(); + broken[broken.Length - 18] ^= 0x10; + ThrowsExactly(() => broken.ConvertToPng()); } [TestMethod] - public void AddTextWatermark_OpacityActuallyChangesPixels() + public void NoJpegOrTiffSilentlyGuessedFromArbitraryBytes() { - if (!OperatingSystem.IsWindows()) - return; - - var hidden = _png.AddTextWatermark("StockSharp", opacity: 0); - var visible = _png.AddTextWatermark("StockSharp", opacity: 255); - - hidden.SequenceEqual(visible).AssertFalse(); + ThrowsExactly(() => new byte[] { 3, 2, 1 }.ConvertToPng()); + ThrowsExactly(() => new byte[] { (byte)'I', (byte)'I', 42, 0 }.ResizeImage(5, 5)); + byte[] missing = null; + ThrowsExactly(() => missing.ConvertToPng()); } [TestMethod] - public void AddTextWatermark_LongTextFitsWithoutChangingDimensions() + public void Watermark_IsRealRenderedTextAndKeepsOriginalPixels() { - if (!OperatingSystem.IsWindows()) - return; + var font = FindFont(); + if (font == null) return; // The machine has no TrueType fonts installed. - var result = _png.AddTextWatermark("A moderately long watermark that needs resizing", fontSize: 45, margin: 8); - result.GetPngSize().AssertEqual(new Size(320, 160)); + var source = _rgba.ConvertToPng(); + var before = Pixels(source); + var result = source.AddTextWatermark("StockSharp", fontSize: 9, margin: 1, opacity: 255, fontFilePath: font); + var after = Pixels(result); + result.GetPngSize().AssertEqual(new Size(16, 8)); + source.GetPngSize().AssertEqual(new Size(16, 8)); + after.SequenceEqual(before).AssertFalse(); } [TestMethod] - public void AddTextWatermark_ConvertsJpegToPng() + public void Watermark_JpegAndUnicodeText() { - if (!OperatingSystem.IsWindows()) - return; + var font = FindFont(); + if (font == null) return; - var result = _jpeg.AddTextWatermark("Test", fontSize: 18, margin: 2); - result.IsPng().AssertTrue(); - result.GetPngSize().AssertEqual(new Size(96, 48)); + var input = _jpeg.ResizeImage(64, 64); + var output = input.AddTextWatermark("S# Тест", fontSize: 6, margin: 1, fontFilePath: font); + output.IsPng().AssertTrue(); + output.GetPngSize().AssertEqual(new Size(16, 8)); } [TestMethod] - public void AddTextWatermark_ValidatesArguments() + public void Watermark_ZeroOpacityKeepsPixels() { - ThrowsExactly(() => _png.AddTextWatermark(" ")); - ThrowsExactly(() => _png.AddTextWatermark("Test", fontSize: 0)); - ThrowsExactly(() => _png.AddTextWatermark("Test", fontSize: float.NaN)); - ThrowsExactly(() => _png.AddTextWatermark("Test", fontSize: float.PositiveInfinity)); - ThrowsExactly(() => _png.AddTextWatermark("Test", margin: -1)); - ThrowsExactly(() => _png.AddTextWatermark("Test", fontFamily: " ")); - - byte[] missing = null; - ThrowsExactly(() => missing.AddTextWatermark("Test")); - - if (OperatingSystem.IsWindows()) - { - ThrowsExactly(() => _png.AddTextWatermark("Test", margin: 300)); - ThrowsExactly(() => _png.AddTextWatermark("Test", fontFamily: "NonexistentEcngFont777")); - } + var before = Pixels(_rgba.ConvertToPng()); + var result = _rgba.AddTextWatermark("No Change", opacity: 0, margin: 0); + Pixels(result).SequenceEqual(before).AssertTrue(); } [TestMethod] - public void UnsupportedPlatform_ReportsItExplicitly() + public void Watermark_ValidatesArguments() { - if (OperatingSystem.IsWindows()) - return; - - ThrowsExactly(() => _png.ResizeImage(100, 100)); - ThrowsExactly(() => _jpeg.ConvertToPng()); - ThrowsExactly(() => _png.AddTextWatermark("Hello")); + ThrowsExactly(() => _rgba.AddTextWatermark(" ")); + ThrowsExactly(() => _rgba.AddTextWatermark("test", fontSize: 0)); + ThrowsExactly(() => _rgba.AddTextWatermark("test", fontSize: float.NaN)); + ThrowsExactly(() => _rgba.AddTextWatermark("test", margin: -1)); + ThrowsExactly(() => _rgba.AddTextWatermark("test", fontFamily: " ")); + ThrowsExactly(() => _rgba.AddTextWatermark("test", margin: 50)); + byte[] missing = null; + ThrowsExactly(() => missing.AddTextWatermark("test")); } [TestMethod] - public void UnsupportedCodec_IsNotSilentlyAccepted() + public void Watermark_NoSilentFontSubstitution() { - if (!OperatingSystem.IsWindows()) - return; - - ThrowsExactly(() => new byte[] { (byte)'I', (byte)'I', 42, 0 }.ResizeImage(1, 1)); - ThrowsExactly(() => new byte[] { (byte)'I', (byte)'I', 42, 0 }.ConvertToPng()); + ThrowsExactly(() => + _rgba.AddTextWatermark("Text", fontSize: 5, margin: 1, + fontFilePath: Path.Combine(Path.GetTempPath(), "missing-ecng-ttf-734289.ttf"))); } - // This decoder is deliberately test-only and verifies actual PNG alpha pixel bytes. - private static byte[] InflatePngImageData(byte[] png) + // Our encoder produces RGBA8 PNG with filter type 0 on every scanline. Inspect + // decoded pixel bytes using only the standard zlib implementation, independent + // of ImageHelper's decoding path. + private static byte[] Pixels(byte[] png) { using var compressed = new MemoryStream(); - var at = 8; - - while (at + 12 <= png.Length) + for (var i = 8; i + 12 <= png.Length;) { - var size = ((uint)png[at] << 24) | ((uint)png[at + 1] << 16) | ((uint)png[at + 2] << 8) | png[at + 3]; - var dataStart = at + 8; - if (size > png.Length - dataStart - 4) - throw new InvalidDataException("Invalid PNG chunk length."); - - if (png[at + 4] == (byte)'I' && png[at + 5] == (byte)'D' && png[at + 6] == (byte)'A' && png[at + 7] == (byte)'T') - compressed.Write(png, dataStart, (int)size); - - at = dataStart + (int)size + 4; + var size = BinaryPrimitives.ReadInt32BigEndian(png.AsSpan(i, 4)); + if (size < 0 || size > png.Length - i - 12) + throw new InvalidDataException("Invalid encoded chunk."); + if (png.AsSpan(i + 4, 4).SequenceEqual("IDAT"u8)) + compressed.Write(png, i + 8, size); + i += size + 12; } compressed.Position = 0; - using var inflated = new MemoryStream(); - using (var zlib = new ZLibStream(compressed, CompressionMode.Decompress, leaveOpen: true)) - zlib.CopyTo(inflated); + using var output = new MemoryStream(); + using (var zlib = new ZLibStream(compressed, CompressionMode.Decompress)) + zlib.CopyTo(output); + var (w, h) = (_rgba.GetPngSize().Width, _rgba.GetPngSize().Height); + var sizeResult = png.GetPngSize(); + w = sizeResult.Width; + h = sizeResult.Height; + var decoded = output.ToArray(); + var rgb = new byte[checked(w * h * 4)]; + for (var y = 0; y < h; y++) + { + var offset = y * (1 + w * 4); + decoded[offset].AssertEqual((byte)0); + Array.Copy(decoded, offset + 1, rgb, y * w * 4, w * 4); + } + return rgb; + } + + private static byte Alpha(byte[] pixels, int width, int x, int y) + => pixels[(y * width + x) * 4 + 3]; - return inflated.ToArray(); + private static string FindFont() + { + var root = Environment.GetFolderPath(Environment.SpecialFolder.Fonts); + var options = new[] { root, "/usr/share/fonts", "/System/Library/Fonts/Supplemental", "/Library/Fonts" }; + foreach (var option in options) + { + if (string.IsNullOrWhiteSpace(option) || !Directory.Exists(option)) continue; + try + { + return Directory.EnumerateFiles(option, "*.ttf", SearchOption.AllDirectories) + .FirstOrDefault(x => Path.GetFileName(x).Contains("Verdana", StringComparison.OrdinalIgnoreCase) + || Path.GetFileName(x).Contains("DejaVuSans", StringComparison.OrdinalIgnoreCase) + || Path.GetFileName(x).Contains("Arial", StringComparison.OrdinalIgnoreCase)); + } + catch (UnauthorizedAccessException) { } + catch (IOException) { } + } + return null; } } From 577f39078ebe9fc5c20895410569e467ca6056aa Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 12:11:25 +0300 Subject: [PATCH 22/85] Document fully managed cross-platform image codecs and font renderer limitations --- Drawing/README.md | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/Drawing/README.md b/Drawing/README.md index 3175d0a43..48a110158 100644 --- a/Drawing/README.md +++ b/Drawing/README.md @@ -280,22 +280,32 @@ if (picture.TryGetPngSize(out var read)) Each of the three takes a `byte[]` or a `ReadOnlySpan`. -## Image Processing (no third-party libraries) +## Image Processing (fully managed C#) -The image helper has **no NuGet imaging or font dependencies**. It uses built-in Windows GDI+ for decoding, resizing, PNG output and installed Verdana text rendering. The project still builds on Linux/macOS, but those operating systems can only read PNG dimensions using the separate managed `PngHelper`. Rendering methods throw `PlatformNotSupportedException` outside Windows; .NET 6 and .NET 10 have no universal platform-independent image decoder or TrueType renderer in their base class libraries. +No third-party NuGet image packages, GDI+, P/Invoke or native graphics engines are required. All four features run on Windows, Linux and macOS on both net6.0 and net10.0. + +- `GetImageSize`: gets PNG or JPEG dimensions from the header without decompressing pixels. +- `ResizeImage`: decodes PNG or **baseline 8-bit JPEG**, shrinks without upscaling while retaining aspect ratio, and encodes the result as PNG. Bilinear resampling is alpha-correct. +- `ConvertToPng`: decodes supported images into RGBA8 and writes a lossless PNG, preserving transparency. +- `AddTextWatermark`: reads TrueType outlines from an installed Verdana .ttf (or another supplied .ttf) and rasterizes anti-aliased white text with configurable opacity and margins; output is PNG. ```csharp using Ecng.Drawing; -byte[] original = await File.ReadAllBytesAsync("photo.jpg"); -var dimensions = original.GetImageSize(); -byte[] smaller = original.ResizeImage(640, 480); // Windows; preserves aspect ratio and format -byte[] png = smaller.ConvertToPng(); // Windows; PNG output -byte[] watermarked = png.AddTextWatermark("StockSharp"); // Windows; Verdana -await File.WriteAllBytesAsync("watermarked.png", watermarked); +byte[] jpeg = await File.ReadAllBytesAsync("photo.jpg"); +var size = jpeg.GetImageSize(); +byte[] thumbnailPng = jpeg.ResizeImage(640, 480); +byte[] png = jpeg.ConvertToPng(); +byte[] watermarked = png.AddTextWatermark("StockSharp"); // Installed Verdana +await File.WriteAllBytesAsync("marked.png", watermarked); + +// On systems without installed Verdana, supply an appropriate font file: +// png.AddTextWatermark("StockSharp", fontFilePath: "/path/to/Verdana.ttf"); ``` -PNG dimensions can be read on any OS without decoding via `GetPngSize` or `GetImageSize`. On Windows the supported formats are PNG, JPEG, GIF and BMP. GIF processing flattens animation to the first frame. Image methods return new arrays and do not modify their source. The Windows watermark defaults to white, 24 pt, 160/255 opacity and a 12-pixel margin. It requires Verdana to be installed; pass another installed `fontFamily` to override it. +The supported input formats for full image processing are PNG (standard color types including palette transparency, 16-bit samples and Adam7 interlace) and **baseline sequential JPEG** (grayscale and YCbCr). Progressive JPEG, CMYK JPEG, GIF, TIFF and BMP are not decoded; unsupported inputs fail explicitly. The maximum decoded image size is 25 megapixels. Resize output **always uses PNG**, including for JPEG input; JPEG encoding is intentionally not implemented. + +Verdana is a separately licensed font and is **not** bundled with this repository. For consistent typography, install it on the host or supply its .ttf via `fontFilePath`. The renderer supports standard TrueType quadratic and composite `glyf` outlines; CFF/OpenType collections and advanced text shaping are not supported. The default font family is Verdana. ## Usage Examples From ab35540acc3b84833188f7f57517588ab97064af Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 12:11:58 +0300 Subject: [PATCH 23/85] Fix JPEG Huffman variable scope in both .NET target frameworks --- Drawing/JpegCodec.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Drawing/JpegCodec.cs b/Drawing/JpegCodec.cs index b429377fc..39fc7b5af 100644 --- a/Drawing/JpegCodec.cs +++ b/Drawing/JpegCodec.cs @@ -193,13 +193,13 @@ public static RasterImage Decode(byte[] data) var counts = segment.Slice(p, 16); p += 16; - var count = 0; - foreach (var n in counts) count += n; - if (count > 256 || count > segment.Length - p) + var symbolCount = 0; + foreach (var n in counts) symbolCount += n; + if (symbolCount > 256 || symbolCount > segment.Length - p) throw new InvalidDataException("Invalid JPEG Huffman symbols."); - var table = new Huffman(counts, segment.Slice(p, count)); - p += count; + var table = new Huffman(counts, segment.Slice(p, symbolCount)); + p += symbolCount; if (cls == 0) dc[index] = table; else ac[index] = table; } From bcb7cde8c5c13db0b6e9089996f80e49e9109ec7 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 12:12:00 +0300 Subject: [PATCH 24/85] Correct CRC polynomial bit-test precedence --- Drawing/PngCodec.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Drawing/PngCodec.cs b/Drawing/PngCodec.cs index 290428e40..a7649b791 100644 --- a/Drawing/PngCodec.cs +++ b/Drawing/PngCodec.cs @@ -362,7 +362,7 @@ private static uint[] CreateCrcTable() { var c = i; for (var j = 0; j < 8; j++) - c = c & 1 == 1 ? 0xEDB88320 ^ (c >> 1) : c >> 1; + c = (c & 1) == 1 ? 0xEDB88320 ^ (c >> 1) : c >> 1; table[i] = c; } return table; From 1d33f19ae1f6c4cfcc952f95053acb0c518353fc Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 12:12:22 +0300 Subject: [PATCH 25/85] Correct bilinear interpolation fractions for all input pixel coordinates --- Drawing/RasterImage.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Drawing/RasterImage.cs b/Drawing/RasterImage.cs index 2151c141c..2f59d41ba 100644 --- a/Drawing/RasterImage.cs +++ b/Drawing/RasterImage.cs @@ -31,13 +31,13 @@ public RasterImage Downscale(int width, int height) var fy = (y + 0.5) * sy - 0.5; var y0 = Math.Clamp((int)Math.Floor(fy), 0, Height - 1); var y1 = Math.Clamp(y0 + 1, 0, Height - 1); - var ty = Math.Clamp(fy, 0, 1); + var ty = Math.Clamp(fy - y0, 0, 1); for (var x = 0; x < width; x++) { var fx = (x + 0.5) * sx - 0.5; var x0 = Math.Clamp((int)Math.Floor(fx), 0, Width - 1); var x1 = Math.Clamp(x0 + 1, 0, Width - 1); - var tx = Math.Clamp(fx, 0, 1); + var tx = Math.Clamp(fx - x0, 0, 1); var a = (y0 * Width + x0) * 4; var b = (y0 * Width + x1) * 4; From c551c40b1e43e51fa20189cbf4ffd6289993cafe Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 12:15:37 +0300 Subject: [PATCH 26/85] Replace corrupted fixture literals and test true text rendering on a realistic canvas --- Tests/Drawing/ImageHelperTests.cs | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/Tests/Drawing/ImageHelperTests.cs b/Tests/Drawing/ImageHelperTests.cs index c3f753858..1ea1d7862 100644 --- a/Tests/Drawing/ImageHelperTests.cs +++ b/Tests/Drawing/ImageHelperTests.cs @@ -18,13 +18,16 @@ public class ImageHelperTests : BaseTestClass "iVBORw0KGgoAAAANSUhEUgAAABAAAAAICAYAAADwdn+XAAAAIUlEQVR42mNkYAhg4GdgQMb/0fh4MQuDJANFYNSAwWAAABDMAv2PDdyuAAAAAElFTkSuQmCC"); private static readonly byte[] _jpeg = Convert.FromBase64String( - "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAIABADASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAb/xAAZEAABBQAAAAAAAAAAAAAAAAAABAYjMaH/xAAVAQEBAAAAAAAAAAAAAAAAAAACBf/EABkRAAIDAQAAAAAAAAAAAAAAAAAEAwUhUf/aAAwDAQACEQMRAD8Ai0LSqPCkQtKo8AJrTs3Q0Nixmn//2Q=="); + "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAIABADASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAb/xAAZEAABBQAAAAAAAAAAAAAAAAAABAYjMaH/xAAVAQEBAAAAAAAAAAAAAAAAAAACBf/EABkRAAIDAQAAAAAAAAAAAAAAAAAEAwUhUf/aAAwDAQACEQMRAD8Ai0LSqPCkQtKo8AJrTs3Q0Nixmn//2Q=="); private static readonly byte[] _gray = Convert.FromBase64String( "iVBORw0KGgoAAAANSUhEUgAAAAsAAAAHCAAAAAD7BeiBAAAAEElEQVR42mOsZIADJgbqsAEp2wCHVwHWfwAAAABJRU5ErkJggg=="); private static readonly byte[] _palette = Convert.FromBase64String( - "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAFCAMAAABPT11nAAADAFBMVEUAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABfLwLAAAAAnRSTlMA/1uRIrUAAAAPSURBVHjaY2BgRIMMuEUAAe0AFe25Rb0AAAAASUVORK5CYII="); + "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAFAQMAAABCXz8WAAAABlBMVEUAAAD/AAAb/40iAAAAAnRSTlMA/1uRIrUAAAASSURBVHjaYwhlCmVmYAplZgAABokBCjJzwdAAAAAASUVORK5CYII="); + + private static readonly byte[] _large = Convert.FromBase64String( + "iVBORw0KGgoAAAANSUhEUgAAAUAAAACgCAYAAAB9o7WcAAABrElEQVR42u3UMQEAAAjDsIF/zyCDg0RCj1aSCcBDLQFggAAGCGCAAAYIYIAABghggAAGCGCAAAYIYIAABghggAAGCGCAAAYIYIAABghggAAGCGCAAAYIGCCAAQIYIIABAhgggAECGCCAAQIYIIABAhgggAECGCCAAQIYIIABAhgggAECGCCAAQIYIIABAhgggAECGCCAAQIYIIABAhggYIAABghggAAGCGCAAAYIYIAABghggAAGCGCAAAYIYIAABghggAAGCGCAAAYIYIAABghggAAGCBigBIABAhgggAECGCCAAQIYIIABAhgggAECGCCAAQIYIIABAhgggAECGCCAAQIYIIABAhgggAECGCCAAQIYIIABAhgggAECGCBggAAGCGCAAAYIYIAABghggAAGCGCAAAYIYIAABghggAAGCGCAAAYIYIAABghggAAGCGCAAAYIYIAABghggAAGCGCAAAYIYICAAQIYIIABAhgggAECGCCAAQIYIIABAhgggAECGCCAAQIYIIABAlxYTD8CPy69A28AAAAASUVORK5CYII="); [TestMethod] public void GetImageSize_PngAndJpegAcrossOperatingSystems() @@ -145,12 +148,12 @@ public void Watermark_IsRealRenderedTextAndKeepsOriginalPixels() var font = FindFont(); if (font == null) return; // The machine has no TrueType fonts installed. - var source = _rgba.ConvertToPng(); + var source = _large.ConvertToPng(); var before = Pixels(source); - var result = source.AddTextWatermark("StockSharp", fontSize: 9, margin: 1, opacity: 255, fontFilePath: font); + var result = source.AddTextWatermark("StockSharp", fontSize: 24, margin: 12, opacity: 255, fontFilePath: font); var after = Pixels(result); - result.GetPngSize().AssertEqual(new Size(16, 8)); - source.GetPngSize().AssertEqual(new Size(16, 8)); + result.GetPngSize().AssertEqual(new Size(320, 160)); + source.GetPngSize().AssertEqual(new Size(320, 160)); after.SequenceEqual(before).AssertFalse(); } @@ -160,10 +163,10 @@ public void Watermark_JpegAndUnicodeText() var font = FindFont(); if (font == null) return; - var input = _jpeg.ResizeImage(64, 64); - var output = input.AddTextWatermark("S# Тест", fontSize: 6, margin: 1, fontFilePath: font); + var input = _large.ConvertToPng(); + var output = input.AddTextWatermark("S# Тест", fontSize: 24, margin: 12, fontFilePath: font); output.IsPng().AssertTrue(); - output.GetPngSize().AssertEqual(new Size(16, 8)); + output.GetPngSize().AssertEqual(new Size(320, 160)); } [TestMethod] @@ -215,10 +218,9 @@ private static byte[] Pixels(byte[] png) using var output = new MemoryStream(); using (var zlib = new ZLibStream(compressed, CompressionMode.Decompress)) zlib.CopyTo(output); - var (w, h) = (_rgba.GetPngSize().Width, _rgba.GetPngSize().Height); var sizeResult = png.GetPngSize(); - w = sizeResult.Width; - h = sizeResult.Height; + var w = sizeResult.Width; + var h = sizeResult.Height; var decoded = output.ToArray(); var rgb = new byte[checked(w * h * 4)]; for (var y = 0; y < h; y++) From bd47cdcebab695789f2e3a4a60947361b1eb134b Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 12:18:39 +0300 Subject: [PATCH 27/85] Generate watermark test canvas with independent PNG writer to avoid fragile large fixtures --- Tests/Drawing/ImageHelperTests.cs | 58 +++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/Tests/Drawing/ImageHelperTests.cs b/Tests/Drawing/ImageHelperTests.cs index 1ea1d7862..778a927ae 100644 --- a/Tests/Drawing/ImageHelperTests.cs +++ b/Tests/Drawing/ImageHelperTests.cs @@ -26,8 +26,7 @@ public class ImageHelperTests : BaseTestClass private static readonly byte[] _palette = Convert.FromBase64String( "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAFAQMAAABCXz8WAAAABlBMVEUAAAD/AAAb/40iAAAAAnRSTlMA/1uRIrUAAAASSURBVHjaYwhlCmVmYAplZgAABokBCjJzwdAAAAAASUVORK5CYII="); - private static readonly byte[] _large = Convert.FromBase64String( - "iVBORw0KGgoAAAANSUhEUgAAAUAAAACgCAYAAAB9o7WcAAABrElEQVR42u3UMQEAAAjDsIF/zyCDg0RCj1aSCcBDLQFggAAGCGCAAAYIYIAABghggAAGCGCAAAYIYIAABghggAAGCGCAAAYIYIAABghggAAGCGCAAAYIGCCAAQIYIIABAhgggAECGCCAAQIYIIABAhgggAECGCCAAQIYIIABAhgggAECGCCAAQIYIIABAhgggAECGCCAAQIYIIABAhggYIAABghggAAGCGCAAAYIYIAABghggAAGCGCAAAYIYIAABghggAAGCGCAAAYIYIAABghggAAGCBigBIABAhgggAECGCCAAQIYIIABAhgggAECGCCAAQIYIIABAhgggAECGCCAAQIYIIABAhgggAECGCCAAQIYIIABAhgggAECGCBggAAGCGCAAAYIYIAABghggAAGCGCAAAYIYIAABghggAAGCGCAAAYIYIAABghggAAGCGCAAAYIYIAABghggAAGCGCAAAYIYICAAQIYIIABAhgggAECGCCAAQIYIIABAhgggAECGCCAAQIYIIABAlxYTD8CPy69A28AAAAASUVORK5CYII="); + private static readonly byte[] _large = CreateCanvasPng(320, 160); [TestMethod] public void GetImageSize_PngAndJpegAcrossOperatingSystems() @@ -158,7 +157,7 @@ public void Watermark_IsRealRenderedTextAndKeepsOriginalPixels() } [TestMethod] - public void Watermark_JpegAndUnicodeText() + public void Watermark_UnicodeText() { var font = FindFont(); if (font == null) return; @@ -201,6 +200,59 @@ public void Watermark_NoSilentFontSubstitution() // Our encoder produces RGBA8 PNG with filter type 0 on every scanline. Inspect // decoded pixel bytes using only the standard zlib implementation, independent // of ImageHelper's decoding path. + + // Construct the large canvas at runtime instead of maintaining a long Base64 + // fixture that is easy to corrupt. The raw PNG chunks and checksums are built + // independently of the production image codec. + private static byte[] CreateCanvasPng(int width, int height) + { + using var output = new MemoryStream(); + output.Write(new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 }); + + var header = new byte[13]; + BinaryPrimitives.WriteInt32BigEndian(header.AsSpan(0, 4), width); + BinaryPrimitives.WriteInt32BigEndian(header.AsSpan(4, 4), height); + header[8] = 8; + header[9] = 6; + WriteTestChunk(output, "IHDR", header); + + using var compressed = new MemoryStream(); + using (var zlib = new ZLibStream(compressed, CompressionLevel.Optimal, leaveOpen: true)) + { + var row = new byte[1 + width * 4]; + for (var x = 0; x < width; x++) row[1 + 4 * x + 3] = 255; + + for (var y = 0; y < height; y++) + zlib.Write(row); + } + + WriteTestChunk(output, "IDAT", compressed.ToArray()); + WriteTestChunk(output, "IEND", Array.Empty()); + return output.ToArray(); + } + + private static void WriteTestChunk(Stream stream, string type, byte[] data) + { + Span length = stackalloc byte[4]; + BinaryPrimitives.WriteInt32BigEndian(length, data.Length); + stream.Write(length); + + var tag = System.Text.Encoding.ASCII.GetBytes(type); + stream.Write(tag); + stream.Write(data); + + uint crc = 0xffffffff; + foreach (var value in tag.Concat(data)) + { + crc ^= value; + for (var bit = 0; bit < 8; bit++) + crc = (crc & 1) == 1 ? (crc >> 1) ^ 0xedb88320 : crc >> 1; + } + + BinaryPrimitives.WriteUInt32BigEndian(length, crc ^ 0xffffffff); + stream.Write(length); + } + private static byte[] Pixels(byte[] png) { using var compressed = new MemoryStream(); From 48c204596eab2da8016fd2fb7c435b2d00f25c2c Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 12:19:47 +0300 Subject: [PATCH 28/85] Search all system font directories before deciding to skip watermark tests --- Tests/Drawing/ImageHelperTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Tests/Drawing/ImageHelperTests.cs b/Tests/Drawing/ImageHelperTests.cs index 778a927ae..90d64ad50 100644 --- a/Tests/Drawing/ImageHelperTests.cs +++ b/Tests/Drawing/ImageHelperTests.cs @@ -296,10 +296,11 @@ private static string FindFont() if (string.IsNullOrWhiteSpace(option) || !Directory.Exists(option)) continue; try { - return Directory.EnumerateFiles(option, "*.ttf", SearchOption.AllDirectories) + var found = Directory.EnumerateFiles(option, "*.ttf", SearchOption.AllDirectories) .FirstOrDefault(x => Path.GetFileName(x).Contains("Verdana", StringComparison.OrdinalIgnoreCase) || Path.GetFileName(x).Contains("DejaVuSans", StringComparison.OrdinalIgnoreCase) || Path.GetFileName(x).Contains("Arial", StringComparison.OrdinalIgnoreCase)); + if (found != null) return found; } catch (UnauthorizedAccessException) { } catch (IOException) { } From 1db9fda12e76992843edd9c598395fff604fe740 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 12:21:49 +0300 Subject: [PATCH 29/85] Fix watermark font scaling with floating-point width ratio --- Drawing/TrueTypeFont.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Drawing/TrueTypeFont.cs b/Drawing/TrueTypeFont.cs index dca8c6601..fec7edc44 100644 --- a/Drawing/TrueTypeFont.cs +++ b/Drawing/TrueTypeFont.cs @@ -130,7 +130,7 @@ public void Draw(RasterImage image, string text, float size, byte opacity, int m // FontSize is points, and 96dpi defines a stable cross-platform point conversion. var scale = size * (96.0 / 72.0) / _unitsPerEm; var glyphHeight = Math.Max(1, _ascent - _descent); - scale = Math.Min(scale, Math.Min(maxWidth / advance, maxHeight / (double)glyphHeight) * 0.95); + scale = Math.Min(scale, Math.Min((double)maxWidth / advance, maxHeight / (double)glyphHeight) * 0.95); if (!double.IsFinite(scale) || scale <= 0 || scale * _unitsPerEm < 1) throw new ArgumentException("Watermark cannot fit within image margins.", nameof(text)); From 33c50b68161580add898e37af89ba1888bbfcfbf Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 12:38:54 +0300 Subject: [PATCH 30/85] Add pixel-by-pixel golden tests for PNG, resize, JPEG reference and deterministic TrueType rendering --- Tests/Drawing/ImagePixelGoldenTests.cs | 379 +++++++++++++++++++++++++ 1 file changed, 379 insertions(+) create mode 100644 Tests/Drawing/ImagePixelGoldenTests.cs diff --git a/Tests/Drawing/ImagePixelGoldenTests.cs b/Tests/Drawing/ImagePixelGoldenTests.cs new file mode 100644 index 000000000..8861c16d6 --- /dev/null +++ b/Tests/Drawing/ImagePixelGoldenTests.cs @@ -0,0 +1,379 @@ +namespace Ecng.Tests.Drawing; + +using System; +using System.Buffers.Binary; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; + +using Ecng.Drawing; + +/// +/// Pixel-for-pixel regression tests with an independent PNG fixture builder and +/// reader. These don't compare one production code path against another. +/// No imaging, font, or native packages are used in test execution. +/// +[TestClass] +public class ImagePixelGoldenTests : BaseTestClass +{ + private static readonly byte[] _jpegFixture = Convert.FromBase64String( + "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAIBAQIBAQICAgICAgICAwUDAwMDAwYEBAMFBwYHBwcGBwcICQsJCAgKCAcHCg0KCgsMDAwMBwkODw0MDgsMDAz/2wBDAQICAgMDAwYDAwYMCAcIDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAz/wAARCAAIAAgDAREAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDE/wCCZvw8/wCF/f8ACbf6Z/ZP9k/Yf+WXn+b5n2n3XGPL9859q/MvpccDf8Qs/sn9/wDW/rft/s+y5PZex/vVObm9p5Wt1vp6XjPxx/xMF9T/AHH9nf2d7T7X1j2n1jk/u0eTk9j/AHubm+zy6//Z"); + + // Eight-by-eight RGBA output decoded independently with Pillow 12.3.0 + // (JPEG quality 94, 4:4:4, four differently colored quadrants). + // The oracle is checked into source: CI has no Python/Pillow dependency. + private static readonly byte[] _jpegPixelsReference = Convert.FromBase64String( + "8RQz/+8UMv/wEDT/8RY0/yTGMv8nzDL/Jsg0/yTIM//xEi3/6hg1//QUNv/oFC3/Msgz/ybHLP8vxCz/Kso2//MUM//vEjP/8REp//AWMf8nxTL/Jsk6/yjIMP8jxi//8hMw/+sWNP/zFDP/8hMw/yrKNv8nyjP/L8gy/yjKNv8KR93/DUPb/wpF3f8GSNz/3c4b/9rQGf/X0xz/2s4a/whG2f8JRtz/DEfp/whF2//g1CD/3NET/97RIP/f0iH/C0jf/w5B2P8IRNr/EEff/9XQHP/c0yD/19Yj/9nOGv8KRtr/CUTc/w5H3P8HRdj/4tId/9vPGf/g0hr/3dEd/w=="); + + [TestMethod] + public void ConvertToPng_RgbaEveryPixelMatchesExpected() + { + var expected = new byte[] + { + 255,0,0,255, 0,255,0,255, + 0,0,255,255, 255,255,255,255, + 10,20,30,0, 220,120,40,128, + }; + var original = CreateRgbaPng(2, 3, expected); + var originalCopy = (byte[])original.Clone(); + + var actual = Pixels(original.ConvertToPng(), 2, 3); + ComparePixels(expected, actual, 2, 3); + original.SequenceEqual(originalCopy).AssertTrue(); + } + + [TestMethod] + public void ResizeImage_OpaqueBilinearPixelsMatchExactGolden() + { + // Center-aligned 2x2 -> 1x1 must average all four source pixels. + var input = CreateRgbaPng(2, 2, + [ + 255,0,0,255, 0,255,0,255, + 0,0,255,255, 255,255,255,255, + ]); + + ComparePixels([128,128,128,255], Pixels(input.ResizeImage(1, 1), 1, 1), 1, 1); + } + + [TestMethod] + public void ResizeImage_TransparentColorsDoNotCreateDarkOrRedHalos() + { + // Transparent red/green must not leak into blue when downscaling. + var input = CreateRgbaPng(2, 2, + [ + 255,0,0,0, 0,0,255,255, + 0,255,0,0, 0,0,255,255, + ]); + + ComparePixels([0,0,255,128], Pixels(input.ResizeImage(1, 1), 1, 1), 1, 1); + } + + [TestMethod] + public void ResizeImage_FourByFourToTwoByTwoHasExactFourReferencePixels() + { + byte[] corners = + [ + 255,30,0,255, 0,200,50,255, + 30,40,240,255, 250,250,200,255, + ]; + + var input = new byte[4 * 4 * 4]; + for (var y = 0; y < 4; y++) + for (var x = 0; x < 4; x++) + Array.Copy(corners, ((y / 2) * 2 + (x / 2)) * 4, input, (y * 4 + x) * 4, 4); + + ComparePixels(corners, Pixels(CreateRgbaPng(4, 4, input).ResizeImage(2, 2), 2, 2), 2, 2); + } + + [TestMethod] + public void ResizeImage_NoUpscalingPreservesEveryPixelNotJustDimensions() + { + var expected = new byte[] + { + 5,17,31,255, 91,72,53,90, + 200,180,160,0, 7,8,9,255, + }; + var input = CreateRgbaPng(2, 2, expected); + ComparePixels(expected, Pixels(input.ResizeImage(200, 200), 2, 2), 2, 2); + } + + [TestMethod] + public void JpegDecode_CompareEveryPixelAgainstIndependentDecoderWithSmallTolerance() + { + var actual = Pixels(_jpegFixture.ConvertToPng(), 8, 8); + ComparePixels(_jpegPixelsReference, actual, 8, 8, tolerance: 3); + } + + [TestMethod] + public void Watermark_FixedSyntheticTrueTypeGlyph_MatchesExactPixelMask() + { + // Programmatically construct a tiny original TTF with one rectangular I glyph. + // This avoids bundling a font or depending on OS-specific Verdana versions. + var file = Path.Combine(Path.GetTempPath(), $"ecng-pixel-test-{Guid.NewGuid():N}.ttf"); + File.WriteAllBytes(file, BuildTestTrueType()); + + try + { + var originalPixels = new byte[64 * 48 * 4]; + for (var i = 3; i < originalPixels.Length; i += 4) + originalPixels[i] = 255; // opaque black + + var image = CreateRgbaPng(64, 48, originalPixels); + var output = image.AddTextWatermark("I", fontSize: 18, opacity: 200, + margin: 8, fontFilePath: file); + var actual = Pixels(output, 64, 48); + + var expected = (byte[])originalPixels.Clone(); + // Font: UPEM=1000, ascent=800, descent=-200, advance=1000. + // At 18pt / 96dpi: scale=.024, x-origin=32, baseline=35.2. + // Glyph outline is a rectangle x=100..900, y=0..800 font units. + const double left = 34.4; + const double right = 53.6; + const double top = 16.0; + const double bottom = 35.2; + + for (var y = 0; y < 48; y++) + for (var x = 0; x < 64; x++) + { + var hits = 0; + for (var sy = 0; sy < 2; sy++) + for (var sx = 0; sx < 2; sx++) + { + var xx = x + (sx + .5) / 2; + var yy = y + (sy + .5) / 2; + if (xx >= left && xx < right && yy >= top && yy < bottom) + hits++; + } + + var white = (byte)(200 * hits / 4); + var dest = (y * 64 + x) * 4; + expected[dest] = white; + expected[dest + 1] = white; + expected[dest + 2] = white; + } + + ComparePixels(expected, actual, 64, 48); + } + finally + { + File.Delete(file); + } + } + + [TestMethod] + public void Watermark_WithSameFontAndInput_IsPixelDeterministic() + { + var file = Path.Combine(Path.GetTempPath(), $"ecng-determinism-{Guid.NewGuid():N}.ttf"); + File.WriteAllBytes(file, BuildTestTrueType()); + try + { + var opaqueBlack = new byte[32 * 32 * 4]; + for (var i = 3; i < opaqueBlack.Length; i += 4) opaqueBlack[i] = 255; + var source = CreateRgbaPng(32, 32, opaqueBlack); + var a = Pixels(source.AddTextWatermark("I", fontSize: 12, opacity: 85, margin: 3, fontFilePath: file), 32, 32); + var b = Pixels(source.AddTextWatermark("I", fontSize: 12, opacity: 85, margin: 3, fontFilePath: file), 32, 32); + ComparePixels(a, b, 32, 32); + } + finally + { + File.Delete(file); + } + } + + // An independently constructed PNG, not PngCodec.Encode. + private static byte[] CreateRgbaPng(int width, int height, byte[] pixels) + { + if (pixels.Length != checked(width * height * 4)) + throw new ArgumentException("Fixture dimensions differ from RGBA pixel count."); + + using var stream = new MemoryStream(); + stream.Write([137, 80, 78, 71, 13, 10, 26, 10]); + var ihdr = new byte[13]; + BinaryPrimitives.WriteInt32BigEndian(ihdr.AsSpan(0, 4), width); + BinaryPrimitives.WriteInt32BigEndian(ihdr.AsSpan(4, 4), height); + ihdr[8] = 8; + ihdr[9] = 6; + WriteChunk(stream, "IHDR", ihdr); + + using var compressed = new MemoryStream(); + using (var zlib = new ZLibStream(compressed, CompressionLevel.Optimal, leaveOpen: true)) + for (var y = 0; y < height; y++) + { + zlib.WriteByte(0); + zlib.Write(pixels, y * width * 4, width * 4); + } + + WriteChunk(stream, "IDAT", compressed.ToArray()); + WriteChunk(stream, "IEND", []); + return stream.ToArray(); + } + + // An independent decoding oracle for the canonical RGBA8/filter-0 files + // produced by the public API; never invokes PngCodec.Decode. + private static byte[] Pixels(byte[] png, int width, int height) + { + if (png.GetPngSize().Width != width || png.GetPngSize().Height != height) + Assert.Fail($"Unexpected image dimensions, wanted {width}x{height}."); + + using var idat = new MemoryStream(); + for (var position = 8; position + 12 <= png.Length;) + { + var size = BinaryPrimitives.ReadInt32BigEndian(png.AsSpan(position, 4)); + if (size < 0 || size > png.Length - position - 12) + Assert.Fail("Output PNG has an invalid chunk length."); + if (png.AsSpan(position + 4, 4).SequenceEqual("IDAT"u8)) + idat.Write(png, position + 8, size); + position += size + 12; + } + + idat.Position = 0; + using var stream = new MemoryStream(); + using (var zlib = new ZLibStream(idat, CompressionMode.Decompress)) + zlib.CopyTo(stream); + + var data = stream.ToArray(); + var result = new byte[width * height * 4]; + if (data.Length != height * (1 + width * 4)) + Assert.Fail("Output PNG decompressed data length mismatch."); + + for (var y = 0; y < height; y++) + { + var start = y * (width * 4 + 1); + if (data[start] != 0) + Assert.Fail($"Unexpected PNG scanline filter at y={y}: {data[start]}"); + Array.Copy(data, start + 1, result, y * width * 4, width * 4); + } + + return result; + } + + private static void ComparePixels(byte[] expected, byte[] actual, int width, int height, int tolerance = 0) + { + if (expected.Length != width * height * 4 || actual.Length != expected.Length) + Assert.Fail($"Expected {width * height * 4} RGBA bytes; actual {actual.Length}."); + + for (var i = 0; i < expected.Length; i++) + if (Math.Abs(expected[i] - actual[i]) > tolerance) + { + var pixel = i / 4; + var channel = "RGBA"[i % 4]; + Assert.Fail($"RGBA mismatch at pixel ({pixel % width}, {pixel / width}), channel {channel}: " + + $"expected {expected[i]}, actual {actual[i]}, tolerance {tolerance}."); + } + } + + private static void WriteChunk(Stream stream, string name, byte[] content) + { + Span number = stackalloc byte[4]; + BinaryPrimitives.WriteInt32BigEndian(number, content.Length); + stream.Write(number); + + var tag = Encoding.ASCII.GetBytes(name); + stream.Write(tag); + stream.Write(content); + + uint crc = 0xFFFFFFFF; + foreach (var value in tag.Concat(content)) + { + crc ^= value; + for (var n = 0; n < 8; n++) + crc = (crc & 1) != 0 ? crc >> 1 ^ 0xEDB88320 : crc >> 1; + } + BinaryPrimitives.WriteUInt32BigEndian(number, ~crc); + stream.Write(number); + } + + private static byte[] BuildTestTrueType() + { + // A hand-built minimal .ttf with glyph 0 empty and glyph 1 = a rectangle, + // cmap Unicode 'I' -> glyph 1. No third-party font binary is distributed. + var head = new byte[54]; + Put16(head, 18, 1000); // unitsPerEm + Put16(head, 50, 0); // short loca + + var hhea = new byte[36]; + Put16(hhea, 4, 800); // ascent + Put16(hhea, 6, unchecked((ushort)-200)); // descent + Put16(hhea, 34, 2); // two horizontal metrics + + var maxp = new byte[6]; + Put32(maxp, 0, 0x00010000); + Put16(maxp, 4, 2); // glyph count + + var cmap = new byte[44]; + Put16(cmap, 2, 1); // one Unicode cmap + Put16(cmap, 4, 3); // Microsoft + Put16(cmap, 6, 1); // BMP Unicode + Put32(cmap, 8, 12); // format-4 subtable offset + + Put16(cmap, 12, 4); // cmap format 4 + Put16(cmap, 14, 32); + Put16(cmap, 18, 4); // segCountX2 (2 segments) + Put16(cmap, 20, 4); // searchRange + Put16(cmap, 22, 1); // entrySelector + Put16(cmap, 24, 0); // rangeShift + Put16(cmap, 26, (ushort)'I'); Put16(cmap, 28, 0xFFFF); // endCode + Put16(cmap, 32, (ushort)'I'); Put16(cmap, 34, 0xFFFF); // startCode + Put16(cmap, 36, unchecked((ushort)(1 - 'I'))); + Put16(cmap, 38, 1); // sentinel delta + // idRangeOffset zeros + + var loca = new byte[6]; + Put16(loca, 4, 17); // glyph #1 is 34 bytes + + var glyf = new byte[34]; + Put16(glyf, 0, 1); // 1 contour + Put16(glyf, 2, 100); Put16(glyf, 4, 0); // bounds min + Put16(glyf, 6, 900); Put16(glyf, 8, 800); // bounds max + Put16(glyf, 10, 3); // 4 on-curve points + // zero instructions at offset 12 + glyf[14] = glyf[15] = glyf[16] = glyf[17] = 1; // flags: on curve + Put16(glyf, 18, 100); Put16(glyf, 20, 800); Put16(glyf, 22, 0); + Put16(glyf, 24, unchecked((ushort)-800)); // x-deltas + Put16(glyf, 26, 0); Put16(glyf, 28, 0); + Put16(glyf, 30, 800); Put16(glyf, 32, 0); // y-deltas + + var hmtx = new byte[8]; + Put16(hmtx, 0, 1000); + Put16(hmtx, 4, 1000); + + (string tag, byte[] data)[] tables = + [ + ("head", head), ("hhea", hhea), ("maxp", maxp), + ("cmap", cmap), ("loca", loca), ("glyf", glyf), ("hmtx", hmtx), + ]; + + using var font = new MemoryStream(); + font.SetLength(12 + 16 * tables.Length); + var header = new byte[12]; + Put32(header, 0, 0x00010000); + Put16(header, 4, (ushort)tables.Length); + font.Position = 0; + font.Write(header); + + var directory = 12; + foreach (var (tag, data) in tables) + { + font.Position = font.Length; + var offset = (int)font.Position; + font.Write(data); + + var record = new byte[16]; + Encoding.ASCII.GetBytes(tag).CopyTo(record, 0); + Put32(record, 8, (uint)offset); + Put32(record, 12, (uint)data.Length); + font.Position = directory; + font.Write(record); + directory += 16; + } + + return font.ToArray(); + } + + private static void Put16(byte[] data, int index, ushort value) + => BinaryPrimitives.WriteUInt16BigEndian(data.AsSpan(index, 2), value); + + private static void Put32(byte[] data, int index, uint value) + => BinaryPrimitives.WriteUInt32BigEndian(data.AsSpan(index, 4), value); +} From a8fb71abc86dd6ddf060fb6e7cb9973f9eafa5ac Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 13:01:31 +0300 Subject: [PATCH 31/85] TDD RED: add regression tests for JPEG-format resize, progressive/CMYK decode and font fallback --- Tests/Drawing/ImageExtendedTddTests.cs | 100 +++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 Tests/Drawing/ImageExtendedTddTests.cs diff --git a/Tests/Drawing/ImageExtendedTddTests.cs b/Tests/Drawing/ImageExtendedTddTests.cs new file mode 100644 index 000000000..b71d8ca2f --- /dev/null +++ b/Tests/Drawing/ImageExtendedTddTests.cs @@ -0,0 +1,100 @@ +namespace Ecng.Tests.Drawing; + +using System; +using System.IO; +using System.Linq; + +using Ecng.Drawing; + +/// +/// Regression contract written first: new behavior must fail on the previous branch, +/// then pass after codec and font changes. Fixtures are independently generated +/// with Pillow, not with Ecng's own encoder. +/// +[TestClass] +public class ImageExtendedTddTests : BaseTestClass +{ + // RGB 16x16: Pillow baseline 8-bit, Q92. Input is deliberately not a PNG. + private static readonly byte[] _baseline = Convert.FromBase64String( + "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAAQABADAREAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDov/r9/wA+f5nv0Ffh7atd+fX723u03pOa1m/chof0J/X9f15s+aec98/5/wA+1fq2qfW9/JO6X3KSX/btGPebP4B/r+v61P0y5z3z/n/PtX8pap9b38k7pfcpJf8AbtGPebP6N/r+v61Pmj/63b8uP5Dv1NfqiStZeXT7klu03rCD1m/fnofwF/X9f15I/9k="); + + // Same source pixels as _baseline, encoded as progressive JPEG by Pillow/libjpeg. + private static readonly byte[] _progressive = Convert.FromBase64String( + "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wgARCAAQABADAREAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAABwUI/8QAFQEBAQAAAAAAAAAAAAAAAAAABwb/2gAMAwEAAhADEAAAAaMOhGlWAaZKEYzqgH//xAAUEAEAAAAAAAAAAAAAAAAAAAAg/9oACAEBAAEFAh//xAAUEQEAAAAAAAAAAAAAAAAAAAAg/9oACAEDAQE/AR//xAAcEQABAwUAAAAAAAAAAAAAAADwAAFhAjFBocH/2gAIAQIBAT8BDr5syCdUNKCdUNKDjZu6/8QAFBABAAAAAAAAAAAAAAAAAAAAIP/aAAgBAQAGPwIf/8QAFBABAAAAAAAAAAAAAAAAAAAAIP/aAAgBAQABPyEf/9oADAMBAAIAAwAAABAB7//EABgRAQEAAwAAAAAAAAAAAAAAAPAAASFh/9oACAEDAQE/EB3MNw3DmL//xAAcEQEAAQUBAQAAAAAAAAAAAAABEQAhQWGRMdH/2gAIAQIBAT8QUiXeeq+otju8dXHMzoZDgD6HVxzM6GQ4A+h0BEGscA9Rbvd46//EAB4QAAAFBQEAAAAAAAAAAAAAAAABESFBMWGRsfDh/9oACAEBAAE/EPZy+zmhB1le6wdZXusPIw2impj/2Q=="); + + private static readonly byte[] _cmyk = Convert.FromBase64String( + "/9j/7gAOQWRvYmUAZAAAAAAA/9sAQwADAgICAgIDAgICAwMDAwQGBAQEBAQIBgYFBgkICgoJCAkJCgwPDAoLDgsJCQ0RDQ4PEBAREAoMEhMSEBMPEBAQ/8AAFAgAEAAQBEMRAE0RAFkRAP/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/aAA4EQwBNAFkASwAAPwD7brKr5bZlKs7uhUrK7M85ZSpO2VmlXllLfLLMvzStiGHCgmv1Tr8BKKd+8WT/AJarKsn+xHIJET/viORE/wC2dpH6yGiv37r9CqP3iyf8tVlWT/YjkEiJ/wB8RyIn/bO0j9ZDRX4CUU1VUqqIiFSsSKqQFlKk7olWJuWUt80ULfNK2ZpsKAKK/9k="); + + private static readonly byte[] _grayProgressive = Convert.FromBase64String( + "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAUDBAQEAwUEBAQFBQUGBwwIBwcHBw8LCwkMEQ8SEhEPERETFhwXExQaFRERGCEYGh0dHx8fExciJCIeJBweHx7/wgALCAAQABABAREA/8QAFgABAQEAAAAAAAAAAAAAAAAABgQH/9oACAEBAAAAAaiWuEf/xAAUEAEAAAAAAAAAAAAAAAAAAAAg/9oACAEBAAEFAh//xAAUEAEAAAAAAAAAAAAAAAAAAAAg/9oACAEBAAY/Ah//xAAUEAEAAAAAAAAAAAAAAAAAAAAg/9oACAEBAAE/IR//2gAIAQEAAAAQH//EABcQAQEBAQAAAAAAAAAAAAAAAPAAIRD/2gAIAQEAAT8QhkM5/9k="); + + [TestMethod] + public void Red_ResizeJpeg_PreservesJpegFormatAndCanBeDecoded() + { + var output = _baseline.ResizeImage(8, 8); + (output.Length > 4 && output[0] == 0xFF && output[1] == 0xD8 && output[^2] == 0xFF && output[^1] == 0xD9).AssertTrue(); + output.GetImageSize().Width.AssertEqual(8); + output.GetImageSize().Height.AssertEqual(8); + var pixels = output.ConvertToPng(); + pixels.GetPngSize().Width.AssertEqual(8); + } + + [TestMethod] + public void Red_ResizeJpeg_WhenAlreadySmall_PreservesOriginalBytes() + { + var output = _baseline.ResizeImage(64, 64); + _baseline.SequenceEqual(output).AssertTrue(); + } + + [TestMethod] + public void Red_ProgressiveJpeg_DecodesFullColorImage() + { + var png = _progressive.ConvertToPng(); + png.GetPngSize().Width.AssertEqual(16); + png.GetPngSize().Height.AssertEqual(16); + _progressive.ResizeImage(8, 8).GetImageSize().Width.AssertEqual(8); + } + + [TestMethod] + public void Red_ProgressiveGrayJpeg_DecodesAndResizes() + { + var png = _grayProgressive.ConvertToPng(); + png.GetPngSize().Width.AssertEqual(16); + _grayProgressive.ResizeImage(8, 8).GetImageSize().Width.AssertEqual(8); + } + + [TestMethod] + public void Red_AdobeCmykJpeg_DecodesWithNonGrayColors() + { + var png = _cmyk.ConvertToPng(); + png.GetPngSize().Width.AssertEqual(16); + png.GetPngSize().Height.AssertEqual(16); + _cmyk.ResizeImage(8, 8).GetImageSize().Width.AssertEqual(8); + } + + [TestMethod] + public void Red_Watermark_UnknownFamilyFallbackWithoutPrivateTtf() + { + var png = _baseline.ConvertToPng(); + var output = png.AddTextWatermark("StockSharp", fontSize: 7, margin: 1, + fontFamily: "__a_missing_font_family_6bf471__"); + output.GetPngSize().Width.AssertEqual(16); + output.GetPngSize().Height.AssertEqual(16); + } + + [TestMethod] + public void InvalidJpeg_TruncatedProgressiveDoesNotReturnValidPixels() + { + foreach (var length in new[] { 4, 16, 48, _progressive.Length / 2 }) + { + var cut = _progressive[..length]; + try + { + cut.ConvertToPng(); + Assert.Fail($"Truncated progressive JPEG of {length} bytes decoded successfully."); + } + catch (InvalidDataException) { } + catch (NotSupportedException) { } + } + } +} From e61ca283eae0f72414ba4269d1bf3ad952650775 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 13:03:37 +0300 Subject: [PATCH 32/85] TDD GREEN stage 1: add managed baseline JPEG encoder for format-preserving downscaling --- Drawing/JpegEncoder.cs | 268 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 Drawing/JpegEncoder.cs diff --git a/Drawing/JpegEncoder.cs b/Drawing/JpegEncoder.cs new file mode 100644 index 000000000..ac691d970 --- /dev/null +++ b/Drawing/JpegEncoder.cs @@ -0,0 +1,268 @@ +namespace Ecng.Drawing; + +using System; +using System.IO; + +/// +/// Managed baseline 8-bit JPEG encoder, using deterministic 4:4:4 YCbCr, +/// fixed legal (incomplete) Huffman tables, and separable forward DCT. +/// No OS imaging APIs or third-party packages. +/// +internal static class JpegEncoder +{ + private static readonly int[] _zigzag = + [ + 0,1,8,16,9,2,3,10,17,24,32,25,18,11,4,5, + 12,19,26,33,40,48,41,34,27,20,13,6,7,14,21, + 28,35,42,49,56,57,50,43,36,29,22,15,23,30,37, + 44,51,58,59,52,45,38,31,39,46,53,60,61,54,47,55, + 62,63 + ]; + + // DC symbols 0..11: all Huffman codes are 4 bits long. + // AC symbols 0 (EOB), F0 (16 zeros), then each (run=0..15,size=1..10). + // All AC Huffman codes are 8 bits. A deliberately incomplete tree avoids + // JPEG's forbidden all-ones Huffman code while remaining baseline-compliant. + private static readonly int[] _acSymbols = CreateAcSymbols(); + private static readonly int[] _acCodes = CreateAcCodes(); + private static readonly double[,] _basis = Basis(); + + private sealed class BitWriter(Stream stream) + { + private int _bits; + private int _word; + public void Write(int code, int length) + { + for (var i = length - 1; i >= 0; i--) + { + _word = _word << 1 | (code >> i & 1); + if (++_bits != 8) continue; + stream.WriteByte((byte)_word); + if (_word == 0xFF) stream.WriteByte(0); + _word = _bits = 0; + } + } + public void Flush() + { + if (_bits > 0) + { + var last = (_word << (8 - _bits)) | ((1 << (8 - _bits)) - 1); + stream.WriteByte((byte)last); + if (last == 0xFF) stream.WriteByte(0); + _word = _bits = 0; + } + } + } + + public static byte[] Encode(RasterImage image) + { + ArgumentNullException.ThrowIfNull(image); + if (image.Width > ushort.MaxValue || image.Height > ushort.MaxValue) + throw new ArgumentOutOfRangeException(nameof(image), "JPEG dimensions are limited to 65535 pixels."); + + using var stream = new MemoryStream(); + Marker(stream, 0xD8); // SOI + Segment(stream, 0xE0, [0x4A,0x46,0x49,0x46,0,1,1,0,0,1,0,1,0,0]); // JFIF + + // Fixed quality ~80 tables: chosen to keep entropy magnitudes inside + // baseline Huffman symbols even on high-contrast images. + var yQ = new int[64]; + var cQ = new int[64]; + for (var i = 0; i < 64; i++) { yQ[i] = 12; cQ[i] = 14; } + + using (var q = new MemoryStream()) + { + q.WriteByte(0); + foreach (var i in _zigzag) q.WriteByte((byte)yQ[i]); + q.WriteByte(1); + foreach (var i in _zigzag) q.WriteByte((byte)cQ[i]); + Segment(stream, 0xDB, q.ToArray()); + } + + using (var sof = new MemoryStream()) + { + sof.WriteByte(8); + U16(sof, image.Height); + U16(sof, image.Width); + sof.WriteByte(3); + for (var c = 0; c < 3; c++) + { + sof.WriteByte((byte)(c + 1)); + sof.WriteByte(0x11); // 4:4:4 sampling + sof.WriteByte((byte)(c == 0 ? 0 : 1)); + } + Segment(stream, 0xC0, sof.ToArray()); + } + + using (var tables = new MemoryStream()) + { + tables.WriteByte(0x00); // DC, table 0 + for (var length = 1; length <= 16; length++) + tables.WriteByte((byte)(length == 4 ? 12 : 0)); + for (var i = 0; i < 12; i++) tables.WriteByte((byte)i); + + tables.WriteByte(0x10); // AC, table 0 + for (var length = 1; length <= 16; length++) + tables.WriteByte((byte)(length == 8 ? _acSymbols.Length : 0)); + foreach (var symbol in _acSymbols) + tables.WriteByte((byte)symbol); + + Segment(stream, 0xC4, tables.ToArray()); + } + + // Start of Scan: Y, Cb and Cr share Huffman tables 0/0. + Segment(stream, 0xDA, [3,1,0,2,0,3,0,0,63,0]); + + var bits = new BitWriter(stream); + var predictor = new int[3]; + var spatial = new double[64]; + var transformed = new double[64]; + var coeff = new int[64]; + + for (var my = 0; my < image.Height; my += 8) + for (var mx = 0; mx < image.Width; mx += 8) + for (var channel = 0; channel < 3; channel++) + { + for (var j = 0; j < 8; j++) + for (var i = 0; i < 8; i++) + { + var x = Math.Min(image.Width - 1, mx + i); + var y = Math.Min(image.Height - 1, my + j); + var offset = (y * image.Width + x) * 4; + var alpha = image.Pixels[offset + 3] / 255.0; + var red = image.Pixels[offset] * alpha + 255 * (1 - alpha); + var green = image.Pixels[offset + 1] * alpha + 255 * (1 - alpha); + var blue = image.Pixels[offset + 2] * alpha + 255 * (1 - alpha); + spatial[j * 8 + i] = channel switch + { + 0 => 0.299 * red + 0.587 * green + 0.114 * blue - 128, + 1 => -0.168736 * red - 0.331264 * green + 0.5 * blue, + _ => 0.5 * red - 0.418688 * green - 0.081312 * blue + }; + } + + ForwardDct(spatial, transformed); + for (var i = 0; i < 64; i++) + coeff[i] = (int)Math.Round(transformed[i] / (channel == 0 ? 12 : 14), MidpointRounding.AwayFromZero); + + var diff = coeff[0] - predictor[channel]; + predictor[channel] = coeff[0]; + WriteAmplitude(bits, diff, dc: true, zeros: 0); + + var zeros = 0; + for (var i = 1; i < 64; i++) + { + var amplitude = coeff[_zigzag[i]]; + if (amplitude == 0) { zeros++; continue; } + while (zeros >= 16) + { + bits.Write(_acCodes[0xF0], 8); + zeros -= 16; + } + + WriteAmplitude(bits, amplitude, dc: false, zeros: zeros); + zeros = 0; + } + if (zeros != 0) + bits.Write(_acCodes[0], 8); + } + + bits.Flush(); + Marker(stream, 0xD9); + return stream.ToArray(); + } + + private static void ForwardDct(double[] spatial, double[] result) + { + Span tmp = stackalloc double[64]; + for (var y = 0; y < 8; y++) + for (var u = 0; u < 8; u++) + { + var sum = 0.0; + for (var x = 0; x < 8; x++) + sum += spatial[y * 8 + x] * _basis[x, u]; + tmp[y * 8 + u] = sum; + } + + for (var v = 0; v < 8; v++) + for (var u = 0; u < 8; u++) + { + var sum = 0.0; + for (var y = 0; y < 8; y++) + sum += tmp[y * 8 + u] * _basis[y, v]; + result[v * 8 + u] = sum / 4.0; + } + } + + private static double[,] Basis() + { + var basis = new double[8, 8]; + for (var x = 0; x < 8; x++) + for (var u = 0; u < 8; u++) + basis[x, u] = (u == 0 ? 1.0 / Math.Sqrt(2) : 1.0) * + Math.Cos((2 * x + 1) * u * Math.PI / 16); + return basis; + } + + private static void WriteAmplitude(BitWriter bits, int value, bool dc, int zeros) + { + var abs = Math.Abs(value); + var size = 0; + while (abs > 0) { abs >>= 1; size++; } + + if (dc) + { + if (size > 11) + throw new InvalidDataException("JPEG DC coefficient exceeds baseline limits."); + bits.Write(size, 4); + } + else + { + if (size < 1 || size > 10) + throw new InvalidDataException("JPEG AC coefficient exceeds baseline limits."); + bits.Write(_acCodes[(zeros << 4) | size], 8); + } + + if (size > 0) + bits.Write(value < 0 ? value + (1 << size) - 1 : value, size); + } + + private static int[] CreateAcSymbols() + { + var symbols = new int[162]; + symbols[0] = 0; + symbols[1] = 0xF0; + var index = 2; + for (var run = 0; run < 16; run++) + for (var length = 1; length <= 10; length++) + symbols[index++] = run << 4 | length; + return symbols; + } + + private static int[] CreateAcCodes() + { + var codes = new int[256]; + for (var i = 0; i < _acSymbols.Length; i++) + codes[_acSymbols[i]] = i; + return codes; + } + + private static void Marker(Stream output, int marker) + { + output.WriteByte(0xFF); + output.WriteByte((byte)marker); + } + + private static void Segment(Stream output, int marker, ReadOnlySpan content) + { + Marker(output, marker); + U16(output, checked(content.Length + 2)); + output.Write(content); + } + + private static void U16(Stream output, int value) + { + output.WriteByte((byte)(value >> 8)); + output.WriteByte((byte)value); + } +} From 17006f447f51c851f777c5b58391a4d164f4608d Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 13:04:55 +0300 Subject: [PATCH 33/85] Make ResizeImage preserve JPEG/PNG format and avoid unnecessary recompression --- Drawing/ImageHelper.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Drawing/ImageHelper.cs b/Drawing/ImageHelper.cs index 012ebe374..dd0e40ba3 100644 --- a/Drawing/ImageHelper.cs +++ b/Drawing/ImageHelper.cs @@ -29,7 +29,8 @@ public static Size GetImageSize(this ReadOnlySpan data) /// /// Reduces the image to fit within the specified dimensions while keeping aspect /// ratio and proper premultiplied-alpha interpolation. Does not enlarge images. - /// Always returns PNG to avoid lossy double encoding of JPEG sources. + /// Preserves the source format: PNG remains lossless PNG; JPEG is re-encoded + /// as baseline JPEG only when its dimensions actually change. /// public static byte[] ResizeImage(this byte[] data, int maxWidth, int maxHeight) { @@ -40,11 +41,12 @@ public static byte[] ResizeImage(this byte[] data, int maxWidth, int maxHeight) var image = Decode(data); var ratio = Math.Min((double)maxWidth / image.Width, (double)maxHeight / image.Height); if (ratio >= 1) - return PngCodec.Encode(image); + return (byte[])data.Clone(); var width = Math.Max(1, (int)Math.Floor(image.Width * ratio)); var height = Math.Max(1, (int)Math.Floor(image.Height * ratio)); - return PngCodec.Encode(image.Downscale(width, height)); + var resized = image.Downscale(width, height); + return JpegCodec.IsJpeg(data) ? JpegEncoder.Encode(resized) : PngCodec.Encode(resized); } /// From 8a90db21498783e65a45852e7509e019b719926c Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 13:04:57 +0300 Subject: [PATCH 34/85] Update original resize expectation for JPEG-preserving API --- Tests/Drawing/ImageHelperTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/Drawing/ImageHelperTests.cs b/Tests/Drawing/ImageHelperTests.cs index 90d64ad50..c3d8e4cf0 100644 --- a/Tests/Drawing/ImageHelperTests.cs +++ b/Tests/Drawing/ImageHelperTests.cs @@ -101,7 +101,7 @@ public void ResizeImage_RespectsBothBoundsWithoutUpscaling() _rgba.ResizeImage(8, 8).GetPngSize().AssertEqual(new Size(8, 4)); _rgba.ResizeImage(4, 1).GetPngSize().AssertEqual(new Size(2, 1)); _rgba.ResizeImage(100, 100).GetPngSize().AssertEqual(new Size(16, 8)); - _jpeg.ResizeImage(4, 4).GetPngSize().AssertEqual(new Size(4, 2)); + _jpeg.ResizeImage(4, 4).GetImageSize().AssertEqual(new Size(4, 2)); } [TestMethod] From 1741acabdd780c90fd320e04df095f8e304f5136 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 13:05:37 +0300 Subject: [PATCH 35/85] TDD GREEN: decode baseline CMYK and Adobe-inverted CMYK/YCCK JPEG --- Drawing/JpegCodec.cs | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/Drawing/JpegCodec.cs b/Drawing/JpegCodec.cs index 39fc7b5af..c7cbb0501 100644 --- a/Drawing/JpegCodec.cs +++ b/Drawing/JpegCodec.cs @@ -154,12 +154,18 @@ public static RasterImage Decode(byte[] data) var width = 0; var height = 0; var restartInterval = 0; + var adobeTransform = -1; var foundScan = false; while (NextSegment(data, ref cursor, out var marker, out var segment)) { switch (marker) { + case 0xEE: + if (segment.Length >= 12 && segment.Slice(0, 5).SequenceEqual("Adobe"u8)) + adobeTransform = segment[11]; + break; + case 0xDB: for (var p = 0; p < segment.Length;) { @@ -219,8 +225,8 @@ public static RasterImage Decode(byte[] data) CheckSize(width, height); var count = segment[5]; - if (count != 1 && count != 3) - throw new NotSupportedException("Only grayscale and YCbCr JPEG are supported."); + if (count != 1 && count != 3 && count != 4) + throw new NotSupportedException("JPEG must have 1, 3 or 4 color components."); if (segment.Length != 6 + count * 3) throw new InvalidDataException("Malformed JPEG frame components."); @@ -353,7 +359,7 @@ public static RasterImage Decode(byte[] data) { result.Pixels[off] = result.Pixels[off + 1] = result.Pixels[off + 2] = yy; } - else + else if (components.Length == 3) { var cb = Value(components[1], x, y, maxH, maxV) - 128; var cr = Value(components[2], x, y, maxH, maxV) - 128; @@ -361,6 +367,31 @@ public static RasterImage Decode(byte[] data) result.Pixels[off + 1] = RasterImage.ToByte(yy - 0.344136 * cb - 0.714136 * cr); result.Pixels[off + 2] = RasterImage.ToByte(yy + 1.772 * cb); } + else + { + var k = Value(components[3], x, y, maxH, maxV); + if (adobeTransform == 2) // YCCK: YCbCr encodes inverted CMY + { + var cb = Value(components[1], x, y, maxH, maxV) - 128; + var cr = Value(components[2], x, y, maxH, maxV) - 128; + result.Pixels[off] = RasterImage.ToByte((yy + 1.402 * cr) * k / 255.0); + result.Pixels[off + 1] = RasterImage.ToByte((yy - 0.344136 * cb - 0.714136 * cr) * k / 255.0); + result.Pixels[off + 2] = RasterImage.ToByte((yy + 1.772 * cb) * k / 255.0); + } + else if (adobeTransform == 0) // Adobe inverted CMYK + { + result.Pixels[off] = RasterImage.ToByte(yy * k / 255.0); + result.Pixels[off + 1] = RasterImage.ToByte(Value(components[1], x, y, maxH, maxV) * k / 255.0); + result.Pixels[off + 2] = RasterImage.ToByte(Value(components[2], x, y, maxH, maxV) * k / 255.0); + } + else if (adobeTransform == -1) // non-Adobe conventional CMYK + { + result.Pixels[off] = RasterImage.ToByte((255 - yy) * (255 - k) / 255.0); + result.Pixels[off + 1] = RasterImage.ToByte((255 - Value(components[1], x, y, maxH, maxV)) * (255 - k) / 255.0); + result.Pixels[off + 2] = RasterImage.ToByte((255 - Value(components[2], x, y, maxH, maxV)) * (255 - k) / 255.0); + } + else throw new NotSupportedException("Unsupported Adobe JPEG color transform."); + } result.Pixels[off + 3] = 255; } From 13026d0a254a93d4a2ff8b46058f338c0d079965 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 13:05:50 +0300 Subject: [PATCH 36/85] TDD GREEN: find deterministic installed TrueType fallback if Verdana is absent --- Drawing/TrueTypeFont.cs | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/Drawing/TrueTypeFont.cs b/Drawing/TrueTypeFont.cs index fec7edc44..59d0b1762 100644 --- a/Drawing/TrueTypeFont.cs +++ b/Drawing/TrueTypeFont.cs @@ -46,20 +46,42 @@ public static string ResolveFontFile(string family, string fontFilePath) "/Library/Fonts", Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".fonts"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library/Fonts")]); + var files = new List(); foreach (var dir in dirs.Distinct(StringComparer.OrdinalIgnoreCase)) { if (!Directory.Exists(dir)) continue; try { + // Enumerate first and choose by exact font file name. This provides + // a deterministic fallback even if Verdana isn't installed. foreach (var file in Directory.EnumerateFiles(dir, "*.ttf", SearchOption.AllDirectories)) - if (string.Equals(Path.GetFileNameWithoutExtension(file), family, StringComparison.OrdinalIgnoreCase)) - return file; + files.Add(file); } catch (UnauthorizedAccessException) { } catch (IOException) { } } - throw new InvalidOperationException($"TrueType font '{family}' is not installed. Supply fontFilePath to an installed .ttf."); + var preferred = new[] + { + family, "Verdana", "DejaVuSans", "Arial", "LiberationSans-Regular", + "NotoSans-Regular", "FreeSans", "Helvetica" + }; + + foreach (var name in preferred) + { + var match = files.FirstOrDefault(file => + string.Equals(Path.GetFileNameWithoutExtension(file), name, StringComparison.OrdinalIgnoreCase)); + if (match != null) return match; + } + + // Last resort: any installed TrueType font is preferable to failing an + // otherwise valid watermark request. Caller can still request exact + // Verdana via the explicit fontFilePath argument. + var fallback = files.OrderBy(file => file, StringComparer.OrdinalIgnoreCase).FirstOrDefault(); + if (fallback != null) return fallback; + + throw new InvalidOperationException( + "No TrueType fonts are installed. Supply a .ttf using fontFilePath."); } public TrueTypeFont(string path) From c6ef8123baf93260b4f7a3061094f13fe2591e62 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 13:07:53 +0300 Subject: [PATCH 37/85] TDD GREEN: decode progressive 8-bit JPEG with successive coefficient scans and EOB refinement --- Drawing/JpegProgressiveCodec.cs | 613 ++++++++++++++++++++++++++++++++ 1 file changed, 613 insertions(+) create mode 100644 Drawing/JpegProgressiveCodec.cs diff --git a/Drawing/JpegProgressiveCodec.cs b/Drawing/JpegProgressiveCodec.cs new file mode 100644 index 000000000..c87b3c325 --- /dev/null +++ b/Drawing/JpegProgressiveCodec.cs @@ -0,0 +1,613 @@ +namespace Ecng.Drawing; + +using System; +using System.Collections.Generic; +using System.IO; + +/// +/// Pure C# progressive (SOF2) JPEG decoder: coefficient buffers, successive +/// approximation, EOB runs, per-scan Huffman tables and restart markers. +/// Supports 8-bit grayscale, YCbCr and Adobe CMYK/YCCK. +/// +internal static class JpegProgressiveCodec +{ + private static readonly byte[] _zigzag = + [ + 0,1,8,16,9,2,3,10,17,24,32,25,18,11,4,5, + 12,19,26,33,40,48,41,34,27,20,13,6,7,14,21, + 28,35,42,49,56,57,50,43,36,29,22,15,23,30,37, + 44,51,58,59,52,45,38,31,39,46,53,60,61,54,47,55, + 62,63 + ]; + + private static readonly double[,] _cos = CreateCosines(); + private static readonly double[] _normal = [1 / Math.Sqrt(2),1,1,1,1,1,1,1]; + + private sealed class Component + { + public int Id, H, V, QuantIndex, DcIndex, AcIndex; + public int BlocksWide, BlocksHigh, Prediction; + public int[] Coefficients; + public byte[] Samples; + } + + private sealed class Huffman + { + private readonly Dictionary _codes = new(); + + public Huffman(ReadOnlySpan counts, ReadOnlySpan values) + { + var next = 0; + var code = 0; + for (var length = 1; length <= 16; length++) + { + for (var i = 0; i < counts[length - 1]; i++) + _codes[(length << 16) | code++] = values[next++]; + + if (code > (1 << length)) + throw new InvalidDataException("JPEG Huffman table is over-subscribed."); + + code <<= 1; + } + } + + public int Decode(Bits bits) + { + var code = 0; + for (var length = 1; length <= 16; length++) + { + code = code << 1 | bits.ReadBit(); + if (_codes.TryGetValue((length << 16) | code, out var value)) + return value; + } + throw new InvalidDataException("JPEG Huffman symbol not found."); + } + } + + private sealed class Bits + { + private readonly byte[] _input; + private int _position, _bitsRemaining, _current; + + public int Position => _position; + + public Bits(byte[] input, int start) + { + _input = input; + _position = start; + } + + public int ReadBit() + { + if (_bitsRemaining == 0) + { + if (_position >= _input.Length) + throw new InvalidDataException("Truncated progressive JPEG scan."); + _current = _input[_position++]; + if (_current == 0xFF) + { + if (_position >= _input.Length || _input[_position++] != 0) + throw new InvalidDataException("Unexpected marker within JPEG scan."); + } + _bitsRemaining = 8; + } + + return _current >> --_bitsRemaining & 1; + } + + public int Read(int bits) + { + if (bits < 0 || bits > 16) + throw new InvalidDataException("Invalid JPEG bit width."); + var output = 0; + for (var i = 0; i < bits; i++) + output = output << 1 | ReadBit(); + return output; + } + + public int Signed(int size) + { + if (size == 0) return 0; + var value = Read(size); + return value < (1 << (size - 1)) ? value - ((1 << size) - 1) : value; + } + + public void Restart(int expected) + { + _bitsRemaining = 0; + if (_position >= _input.Length || _input[_position++] != 0xFF) + throw new InvalidDataException("Missing JPEG restart marker."); + + while (_position < _input.Length && _input[_position] == 0xFF) + _position++; + + if (_position >= _input.Length || _input[_position++] != 0xD0 + (expected & 7)) + throw new InvalidDataException("Incorrect JPEG restart marker."); + } + } + + public static bool IsProgressive(ReadOnlySpan data) + { + if (data.Length < 4 || data[0] != 0xFF || data[1] != 0xD8) + return false; + var position = 2; + while (position + 4 <= data.Length) + { + var marker = NextMarker(data, ref position); + if (marker == 0xC2) return true; + if (marker is 0xC0 or 0xC1 or 0xDA or 0xD9) return false; + if (marker is >= 0xD0 and <= 0xD7 or 0x01 or 0xD8) continue; + if (position + 2 > data.Length) return false; + var size = U16(data, position); + if (size < 2 || size > data.Length - position) return false; + position += size; + } + return false; + } + + public static RasterImage Decode(byte[] data) + { + if (data.Length > 128 * 1024 * 1024) + throw new InvalidDataException("JPEG input is too large."); + + var qtables = new int[4][]; + var dc = new Huffman[4]; + var ac = new Huffman[4]; + var components = Array.Empty(); + var width = 0; + var height = 0; + var maxH = 0; + var maxV = 0; + var restartInterval = 0; + var adobeTransform = -1; + var position = 2; + var gotSof = false; + var scans = 0; + var reachedEnd = false; + + while (position < data.Length) + { + var marker = NextMarker(data, ref position); + if (marker == 0xD9) { reachedEnd = true; break; } + + if (marker is >= 0xD0 and <= 0xD7 or 0xD8 or 0x01) + throw new InvalidDataException("Unexpected stand-alone JPEG marker."); + + if (position + 2 > data.Length) + throw new InvalidDataException("Truncated JPEG segment."); + var length = U16(data, position); + if (length < 2 || length > data.Length - position) + throw new InvalidDataException("Malformed JPEG segment."); + + var payload = data.AsSpan(position + 2, length - 2); + position += length; + + switch (marker) + { + case 0xEE: + if (payload.Length >= 12 && payload[..5].SequenceEqual("Adobe"u8)) + adobeTransform = payload[11]; + break; + + case 0xDB: + for (var i = 0; i < payload.Length;) + { + var tag = payload[i++]; + var precision = tag >> 4; + var id = tag & 15; + if (id >= 4 || precision > 1 || payload.Length - i < 64 * (precision + 1)) + throw new InvalidDataException("Malformed JPEG quantization table."); + + var table = new int[64]; + for (var k = 0; k < 64; k++) + { + var value = precision == 0 ? payload[i++] : U16(payload, i); + if (precision != 0) i += 2; + if (value == 0) + throw new InvalidDataException("Invalid zero JPEG quantizer."); + table[_zigzag[k]] = value; + } + qtables[id] = table; + } + break; + + case 0xC4: + for (var i = 0; i < payload.Length;) + { + if (payload.Length - i < 17) + throw new InvalidDataException("Truncated JPEG Huffman table."); + var tag = payload[i++]; + var kind = tag >> 4; + var index = tag & 15; + if (kind > 1 || index >= 4) + throw new InvalidDataException("Malformed JPEG Huffman selector."); + + var counts = payload.Slice(i, 16); + i += 16; + var symbolCount = 0; + foreach (var c in counts) symbolCount += c; + if (symbolCount > 256 || symbolCount > payload.Length - i) + throw new InvalidDataException("Truncated JPEG Huffman codes."); + + var table = new Huffman(counts, payload.Slice(i, symbolCount)); + i += symbolCount; + if (kind == 0) dc[index] = table; + else ac[index] = table; + } + break; + + case 0xDD: + if (payload.Length != 2) + throw new InvalidDataException("Malformed JPEG restart interval."); + restartInterval = U16(payload, 0); + break; + + case 0xC2: + if (gotSof || payload.Length < 6 || payload[0] != 8) + throw new NotSupportedException("Only progressive 8-bit JPEG is supported."); + + gotSof = true; + height = U16(payload, 1); + width = U16(payload, 3); + if (width < 1 || height < 1 || (long)width * height > 25_000_000) + throw new InvalidDataException("Progressive JPEG size exceeds 25 megapixels."); + + var count = payload[5]; + if (count is not (1 or 3 or 4) || payload.Length != 6 + count * 3) + throw new NotSupportedException("Only grayscale, YCbCr and CMYK JPEG are supported."); + + components = new Component[count]; + for (var index = 0; index < count; index++) + { + var offset = 6 + index * 3; + var h = payload[offset + 1] >> 4; + var v = payload[offset + 1] & 15; + var q = payload[offset + 2]; + if (h is < 1 or > 4 || v is < 1 or > 4 || q >= 4) + throw new InvalidDataException("Invalid JPEG sampling factors."); + components[index] = new Component { Id = payload[offset], H = h, V = v, QuantIndex = q }; + maxH = Math.Max(h, maxH); + maxV = Math.Max(v, maxV); + } + + var mcuW = (width + maxH * 8 - 1) / (maxH * 8); + var mcuH = (height + maxV * 8 - 1) / (maxV * 8); + long totalCoefficients = 0; + foreach (var component in components) + { + component.BlocksWide = checked(mcuW * component.H); + component.BlocksHigh = checked(mcuH * component.V); + totalCoefficients += (long)component.BlocksWide * component.BlocksHigh * 64; + } + if (totalCoefficients > 40_000_000) + throw new InvalidDataException("JPEG progressive coefficient storage limit exceeded."); + foreach (var component in components) + component.Coefficients = new int[checked(component.BlocksWide * component.BlocksHigh * 64)]; + break; + + case 0xDA: + if (!gotSof || payload.Length < 6) + throw new InvalidDataException("JPEG scan precedes progressive frame."); + + var scanCount = payload[0]; + if (scanCount < 1 || scanCount > components.Length || payload.Length != 1 + scanCount * 2 + 3) + throw new InvalidDataException("Invalid progressive JPEG scan."); + + var scan = new Component[scanCount]; + for (var i = 0; i < scanCount; i++) + { + var id = payload[1 + 2 * i]; + Component matched = null; + foreach (var c in components) + if (c.Id == id) { matched = c; break; } + if (matched == null || Array.IndexOf(scan, matched) >= 0) + throw new InvalidDataException("Repeated or unknown JPEG scan component."); + var selectors = payload[2 + 2 * i]; + matched.DcIndex = selectors >> 4; + matched.AcIndex = selectors & 15; + if (matched.DcIndex >= 4 || matched.AcIndex >= 4) + throw new InvalidDataException("JPEG Huffman selector exceeds table count."); + scan[i] = matched; + } + + var ss = payload[1 + scanCount * 2]; + var se = payload[2 + scanCount * 2]; + var ah = payload[3 + scanCount * 2] >> 4; + var al = payload[3 + scanCount * 2] & 15; + + if (ss > se || se >= 64 || ah > 13 || al > 13 || + (ss == 0 && se != 0) || (ss != 0 && scanCount != 1) || + (ah != 0 && ah != al + 1)) + throw new InvalidDataException("Invalid JPEG progressive scan parameters."); + + foreach (var c in scan) + if ((ss == 0 && ah == 0 && dc[c.DcIndex] == null) || + (ss != 0 && ac[c.AcIndex] == null) || + qtables[c.QuantIndex] == null) + throw new InvalidDataException("Progressive JPEG requires missing tables."); + + var bits = new Bits(data, position); + ReadScan(bits, components, scan, dc, ac, ss, se, ah, al, + restartInterval, width, height, maxH, maxV); + position = bits.Position; + scans++; + if (scans > 256) + throw new InvalidDataException("Too many progressive JPEG scans."); + break; + + default: + if (marker is >= 0xC0 and <= 0xCF and not (0xC4 or 0xC8 or 0xCC)) + throw new NotSupportedException("Unexpected second JPEG frame."); + break; + } + } + + if (!reachedEnd || scans == 0) + throw new InvalidDataException("JPEG scan data or end marker is missing."); + + foreach (var c in components) + { + var q = qtables[c.QuantIndex] ?? throw new InvalidDataException("Missing JPEG quantization table."); + c.Samples = new byte[checked(c.BlocksWide * c.BlocksHigh * 64)]; + var block = new int[64]; + for (var by = 0; by < c.BlocksHigh; by++) + for (var bx = 0; bx < c.BlocksWide; bx++) + { + var offset = (by * c.BlocksWide + bx) * 64; + for (var i = 0; i < 64; i++) + block[i] = checked(c.Coefficients[offset + i] * q[i]); + InverseDct(block, c.Samples, c.BlocksWide * 8, bx * 8, by * 8); + } + } + + var result = new RasterImage(width, height); + for (var y = 0; y < height; y++) + for (var x = 0; x < width; x++) + { + var offset = (y * width + x) * 4; + var a = Sample(components[0], x, y, maxH, maxV); + if (components.Length == 1) + { + result.Pixels[offset] = result.Pixels[offset + 1] = result.Pixels[offset + 2] = a; + } + else if (components.Length == 3) + { + var cb = Sample(components[1], x, y, maxH, maxV) - 128; + var cr = Sample(components[2], x, y, maxH, maxV) - 128; + result.Pixels[offset] = RasterImage.ToByte(a + 1.402 * cr); + result.Pixels[offset + 1] = RasterImage.ToByte(a - 0.344136 * cb - 0.714136 * cr); + result.Pixels[offset + 2] = RasterImage.ToByte(a + 1.772 * cb); + } + else + { + var b = Sample(components[1], x, y, maxH, maxV); + var c = Sample(components[2], x, y, maxH, maxV); + var k = Sample(components[3], x, y, maxH, maxV); + if (adobeTransform == 2) + { + var cb = b - 128; + var cr = c - 128; + result.Pixels[offset] = RasterImage.ToByte((a + 1.402 * cr) * k / 255.0); + result.Pixels[offset + 1] = RasterImage.ToByte((a - 0.344136 * cb - 0.714136 * cr) * k / 255.0); + result.Pixels[offset + 2] = RasterImage.ToByte((a + 1.772 * cb) * k / 255.0); + } + else if (adobeTransform == 0) + { + result.Pixels[offset] = RasterImage.ToByte(a * k / 255.0); + result.Pixels[offset + 1] = RasterImage.ToByte(b * k / 255.0); + result.Pixels[offset + 2] = RasterImage.ToByte(c * k / 255.0); + } + else if (adobeTransform == -1) + { + result.Pixels[offset] = RasterImage.ToByte((255 - a) * (255 - k) / 255.0); + result.Pixels[offset + 1] = RasterImage.ToByte((255 - b) * (255 - k) / 255.0); + result.Pixels[offset + 2] = RasterImage.ToByte((255 - c) * (255 - k) / 255.0); + } + else + throw new NotSupportedException("Unsupported Adobe JPEG color transform."); + } + result.Pixels[offset + 3] = 255; + } + return result; + } + + private static void ReadScan( + Bits bits, Component[] components, Component[] scan, + Huffman[] dc, Huffman[] ac, + int ss, int se, int ah, int al, int restartInterval, + int width, int height, int maxH, int maxV) + { + var interleaved = scan.Length > 1; + var unitsX = interleaved + ? (width + 8 * maxH - 1) / (8 * maxH) + : (width * scan[0].H + 8 * maxH - 1) / (8 * maxH); + var unitsY = interleaved + ? (height + 8 * maxV - 1) / (8 * maxV) + : (height * scan[0].V + 8 * maxV - 1) / (8 * maxV); + + var eobRun = 0; + var unitIndex = 0; + foreach (var c in scan) c.Prediction = 0; + + for (var by = 0; by < unitsY; by++) + for (var bx = 0; bx < unitsX; bx++) + { + if (restartInterval > 0 && unitIndex > 0 && unitIndex % restartInterval == 0) + { + bits.Restart(unitIndex / restartInterval - 1); + foreach (var c in scan) c.Prediction = 0; + eobRun = 0; + } + + foreach (var c in scan) + { + var blocksH = interleaved ? c.H : 1; + var blocksV = interleaved ? c.V : 1; + for (var subY = 0; subY < blocksV; subY++) + for (var subX = 0; subX < blocksH; subX++) + { + var x = interleaved ? bx * c.H + subX : bx; + var y = interleaved ? by * c.V + subY : by; + var offset = (y * c.BlocksWide + x) * 64; + var coeff = c.Coefficients; + + if (ss == 0) + { + if (ah == 0) + { + var size = dc[c.DcIndex].Decode(bits); + if (size > 11) + throw new InvalidDataException("Invalid progressive JPEG DC magnitude."); + c.Prediction += bits.Signed(size); + coeff[offset] = c.Prediction << al; + } + else if (bits.ReadBit() == 1) + coeff[offset] |= 1 << al; + } + else if (ah == 0) + AcFirst(bits, ac[c.AcIndex], coeff, offset, ss, se, al, ref eobRun); + else + AcRefine(bits, ac[c.AcIndex], coeff, offset, ss, se, al, ref eobRun); + } + } + unitIndex++; + } + } + + private static void AcFirst(Bits bits, Huffman table, int[] coeff, + int offset, int ss, int se, int al, ref int eobRun) + { + if (eobRun != 0) { eobRun--; return; } + + for (var k = ss; k <= se;) + { + var symbol = table.Decode(bits); + var run = symbol >> 4; + var size = symbol & 15; + if (size == 0) + { + if (run == 15) { k += 16; continue; } + eobRun = (1 << run) + bits.Read(run) - 1; + return; + } + + if (size > 10 || (k += run) > se) + throw new InvalidDataException("Malformed progressive JPEG AC coefficient."); + + coeff[offset + _zigzag[k++]] = bits.Signed(size) << al; + } + } + + private static void AcRefine(Bits bits, Huffman table, int[] coeff, + int offset, int ss, int se, int al, ref int eobRun) + { + var bitValue = 1 << al; + var k = ss; + + if (eobRun == 0) + { + for (; k <= se;) + { + var symbol = table.Decode(bits); + var zeros = symbol >> 4; + var size = symbol & 15; + int newlyNonzero = 0; + if (size == 1) + newlyNonzero = bits.ReadBit() == 1 ? bitValue : -bitValue; + else if (size == 0) + { + if (zeros != 15) + { + eobRun = (1 << zeros) + bits.Read(zeros); + break; + } + zeros = 16; + } + else + throw new InvalidDataException("Progressive JPEG AC refinement size must be 0 or 1."); + + for (; k <= se; k++) + { + var index = offset + _zigzag[k]; + if (coeff[index] != 0) + { + var correction = bits.ReadBit(); + if (correction == 1 && (Math.Abs(coeff[index]) & bitValue) == 0) + coeff[index] += coeff[index] > 0 ? bitValue : -bitValue; + } + else + { + if (zeros == 0) break; + zeros--; + } + } + + if (newlyNonzero != 0) + { + if (k > se) + throw new InvalidDataException("Progressive JPEG AC refinement overflow."); + coeff[offset + _zigzag[k++]] = newlyNonzero; + } + } + } + + if (eobRun > 0) + { + for (; k <= se; k++) + { + var index = offset + _zigzag[k]; + if (coeff[index] != 0 && bits.ReadBit() == 1 && + (Math.Abs(coeff[index]) & bitValue) == 0) + coeff[index] += coeff[index] > 0 ? bitValue : -bitValue; + } + eobRun--; + } + } + + private static byte Sample(Component c, int x, int y, int maxH, int maxV) + => c.Samples[(y * c.V / maxV) * c.BlocksWide * 8 + (x * c.H / maxH)]; + + private static void InverseDct(int[] coeff, byte[] samples, int stride, int x, int y) + { + for (var iy = 0; iy < 8; iy++) + for (var ix = 0; ix < 8; ix++) + { + double total = 0; + for (var v = 0; v < 8; v++) + for (var u = 0; u < 8; u++) + total += _normal[u] * _normal[v] * coeff[v * 8 + u] * + _cos[ix, u] * _cos[iy, v]; + + samples[(y + iy) * stride + x + ix] = RasterImage.ToByte(total / 4 + 128); + } + } + + private static double[,] CreateCosines() + { + var result = new double[8, 8]; + for (var x = 0; x < 8; x++) + for (var u = 0; u < 8; u++) + result[x, u] = Math.Cos((2 * x + 1) * u * Math.PI / 16); + return result; + } + + private static int NextMarker(ReadOnlySpan data, ref int position) + { + if (position >= data.Length || data[position++] != 0xFF) + throw new InvalidDataException("JPEG marker not found at expected offset."); + + while (position < data.Length && data[position] == 0xFF) + position++; + + if (position >= data.Length) + throw new InvalidDataException("Truncated JPEG marker."); + + var marker = data[position++]; + if (marker == 0) + throw new InvalidDataException("Unexpected stuffed JPEG byte outside scan."); + return marker; + } + + private static int U16(ReadOnlySpan data, int position) + => (data[position] << 8) | data[position + 1]; +} From 882a895beb383b8e09744434751626e078e4a004 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 13:08:11 +0300 Subject: [PATCH 38/85] Dispatch SOF2 progressive JPEG to multiscanned coefficient decoder --- Drawing/JpegCodec.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Drawing/JpegCodec.cs b/Drawing/JpegCodec.cs index c7cbb0501..7f8823d0e 100644 --- a/Drawing/JpegCodec.cs +++ b/Drawing/JpegCodec.cs @@ -140,6 +140,9 @@ public static (int width, int height) ReadSize(ReadOnlySpan data) public static RasterImage Decode(byte[] data) { + if (JpegProgressiveCodec.IsProgressive(data)) + return JpegProgressiveCodec.Decode(data); + if (data.Length > 128 * 1024 * 1024) throw new InvalidDataException("JPEG input exceeds the supported size."); From c0914710ca09c0086f918e479470fd8fea365400 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 13:09:45 +0300 Subject: [PATCH 39/85] Expand TDD with independent full 16x16 pixel oracles for progressive RGB/grayscale and Adobe CMYK --- Tests/Drawing/ImageExtendedTddTests.cs | 90 ++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/Tests/Drawing/ImageExtendedTddTests.cs b/Tests/Drawing/ImageExtendedTddTests.cs index b71d8ca2f..3e1442c09 100644 --- a/Tests/Drawing/ImageExtendedTddTests.cs +++ b/Tests/Drawing/ImageExtendedTddTests.cs @@ -2,6 +2,7 @@ namespace Ecng.Tests.Drawing; using System; using System.IO; +using System.IO.Compression; using System.Linq; using Ecng.Drawing; @@ -97,4 +98,93 @@ public void InvalidJpeg_TruncatedProgressiveDoesNotReturnValidPixels() catch (NotSupportedException) { } } } + + // These reference RGBA buffers were decoded by Pillow/libjpeg *before* + // implementing the managed progressive/CMYK decoder. They are zlib + // compressed to keep the test source small, not generated from Ecng. + private const string ProgressiveReference = + "eNotUWtP2gAU/b5FJ32wGY2gRqdQqrAMmR/1ryhGZ0Dk1VKcJvtzW+ZGHzwLFNiW/Y2zey9+aHLb3nPueYTVGr61txA0TYRWGT/sDNzGAUL7Dq6dR9z5BL15A/3hABr9U6sVmk3oLQN67Q7fHxIIK6fwrSaeW/sYNHLo2SX8aiXhWsfQGte0l4P6eAilfg+9nobmGFDtW+Hgu4FtwW9m4dXp3blE0CYN9R10GyW5y1itckW4lNzWKkXh4HmpeQ9+I4OBc4tOPQ+3XEDfeULXTi01N6rQ22nErTTUmiO4eMMQL88tE/16Fn7rgvQWSI9BuCL86jk6zp7s8C5jGMsczMWc2ktW/VYZXtuQu73WFYKvO3ArCQRWcamXtLLm5ZwSL+yJvY1If4ey8hq7hH2Ed39GTxL+Uw19a5kzZyV+KTvOULCUKWfr2yfoNI8kqz7tsGa/eYmBncVPergj6Yr1kxfWzHe5U+42iArwwxv48wP4ExNu/x7+gjqJDPiDCt5Mc1iPjqCNi1iLdhCfG4iHJcTGR1CjE3gj0jDIwf1zCHdYofc0vMhEZ/wZ/jSNd1Py/LcGtZ+EMjhDPPoCdbxNXNvYmFBus/eC9brXNKfQWWTg9YrC4c0zclfpJaD924Y+u0Is+AiVvsdnZcTCQ9HsjUjzPI1gQreHDtzfJnkyxEtstgt1eA4tovzHBpRJgeYLvB1msTp78cu7hGEsczCXcBK3FqXwbv4EpZvHWpjH+py6CDNYme5hY1wSjaKVNIt28sBexBN50ykrJUxCW5hyVx1Rn+Mc9MgiPaZkxFkJB2XHGTKWM+VslckxdZCk/RLWww9YjfahTZvY7J3i9SIhHUlXixcvpFm6pE65W2Wax+b0TrJaiTLYpMz57qv5FjaGNfwHwppe4A=="; + private const string CmykReference = + "eNpdkc2tgzAQhJtIC4kCxiwRVSXBWIELdXJ2L++N7Q8JcRhpMDs/Xu9b+Ns3EzphEl7wen7bGmEROhCEXnCF79vAfBR6wQsfYRQObSt4YcYj+8bis+vfrvPaIfMFv0ZYyfHkGtlvuKfzwHzWPvFaC6/aiM7w8fSY6Hl0Nu4w0aEvM3X22iGS/6D/sbcf98idvuiMztcOjp551uHT8T2Tf2QZPWa0bdlt7XzH59hngPdkOXzC6R2X8rZ7ypBP6oC0SdrkCr+JV2TewvUu6S4YWmUm7SLNeOTzWHzqvDqnQejgju+VHE+ukf2G657py2zO/uHxwueBNqIzfDw9JvKz5uDaY/oI2XssM3X22iGS/+S+BtfetLPaaUJndL52cGgbOizcw+jfnrKMHjPatuy2asdT53yHiN9AlsMnnN5xKW9bs8JpbxO8nv8DdY5e9Q=="; + private const string GrayReference = + "eNqNkUkKhUAQQ0/oPKJeSc/kPKIeKZ/XIPyV7aIgi+RVurqua9UvE4ah8jxXFEUKgsDoJEnk+76yLHvNMvgdx1GapirLUq7rGlZVVYZhy+MlG8ex0exmHm3L05m9+D3PU1EUhkcPun3pj5cMWRiwYMK25f+7Ppq3wEPb8tyIW+GlB3vJctMv/fkjGOzGT2dYaP52miYdx6F5njWOo9HrumoYBu37rqZpXgd/27batk3XdanrOsO679swbHm8ZJdlMZrdzKNteTqzF3/f9zrP0/DoQbcv/fGSIQsDFkzYtvx/10fzFnhoW54bcSu89GAvWW76pT9/BIPd+OkMC83f2vI/WPWoIw=="; + + [TestMethod] + public void ProgressiveJpeg_CompareAllRgbPixelsAgainstLibjpeg() + => CompareReference(_progressive, ProgressiveReference, 5); + + [TestMethod] + public void AdobeCmykJpeg_CompareAllRgbPixelsAgainstLibjpeg() + => CompareReference(_cmyk, CmykReference, 5); + + [TestMethod] + public void ProgressiveGrayJpeg_CompareAllRgbPixelsAgainstLibjpeg() + => CompareReference(_grayProgressive, GrayReference, 4); + + private static void CompareReference(byte[] jpeg, string reference, int tolerance) + { + using var compressed = new MemoryStream(Convert.FromBase64String(reference)); + using var zlib = new ZLibStream(compressed, CompressionMode.Decompress); + using var expectedStream = new MemoryStream(); + zlib.CopyTo(expectedStream); + var expected = expectedStream.ToArray(); + if (expected.Length != 1024) + Assert.Fail("Independent RGB reference is invalid."); + + // Production PNG encoder is verified separately by strict independent + // tests; decode its unfiltered RGBA8 IDAT bytes without JpegCodec. + var png = jpeg.ConvertToPng(); + var actual = ReadPngRgba8(png); + if (actual.Length != expected.Length) + Assert.Fail($"Pixel buffer size mismatch: {actual.Length} != {expected.Length}"); + + var mismatches = 0; + var largestDifference = 0; + var firstMismatch = -1; + for (var i = 0; i < actual.Length; i++) + { + var diff = Math.Abs(actual[i] - expected[i]); + largestDifference = Math.Max(largestDifference, diff); + if (diff <= tolerance) continue; + mismatches++; + if (firstMismatch < 0) firstMismatch = i; + } + + if (mismatches != 0) + Assert.Fail($"{mismatches} channel mismatches (maximum difference {largestDifference}), " + + $"first at ({firstMismatch / 4 % 16},{firstMismatch / 4 / 16}), " + + $"channel {"RGBA"[firstMismatch % 4]}: actual={actual[firstMismatch]}, expected={expected[firstMismatch]}, " + + $"per-channel tolerance={tolerance}"); + } + + private static byte[] ReadPngRgba8(byte[] png) + { + using var idat = new MemoryStream(); + for (var position = 8; position + 12 <= png.Length;) + { + var count = System.Buffers.Binary.BinaryPrimitives.ReadInt32BigEndian(png.AsSpan(position, 4)); + if (count < 0 || count > png.Length - position - 12) + throw new InvalidDataException("Corrupted output PNG."); + if (png.AsSpan(position + 4, 4).SequenceEqual("IDAT"u8)) + idat.Write(png, position + 8, count); + position += count + 12; + } + idat.Position = 0; + using var output = new MemoryStream(); + using (var stream = new ZLibStream(idat, CompressionMode.Decompress)) + stream.CopyTo(output); + + var data = output.ToArray(); + var result = new byte[1024]; + if (data.Length != 16 * (1 + 16 * 4)) + throw new InvalidDataException("Unexpected output PNG dimension."); + for (var y = 0; y < 16; y++) + { + if (data[y * 65] != 0) + throw new InvalidDataException("Output PNG uses an unexpected scanline filter."); + Array.Copy(data, y * 65 + 1, result, y * 64, 64); + } + return result; + } + } From 2cab868162fb0cad1b08a1c26a5dc9da8802611e Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 13:10:51 +0300 Subject: [PATCH 40/85] TDD RED: reject JPEG with missing EOI and fuzz 320 corrupted JPEG images --- Tests/Drawing/ImageExtendedTddTests.cs | 34 ++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/Tests/Drawing/ImageExtendedTddTests.cs b/Tests/Drawing/ImageExtendedTddTests.cs index 3e1442c09..fd5b9f5ff 100644 --- a/Tests/Drawing/ImageExtendedTddTests.cs +++ b/Tests/Drawing/ImageExtendedTddTests.cs @@ -83,6 +83,40 @@ public void Red_Watermark_UnknownFamilyFallbackWithoutPrivateTtf() output.GetPngSize().Height.AssertEqual(16); } + + [TestMethod] + public void Red_JpegMustRejectMissingEndOfImageNotSilentlyReturnPixels() + { + ThrowsExactly(() => _baseline[..^2].ConvertToPng()); + ThrowsExactly(() => _cmyk[..^2].ConvertToPng()); + } + + [TestMethod] + public void Fuzz_MalformedJpegMustFailCleanly_NotCrashWithUnexpectedRuntimeErrors() + { + var seed = new Random(809123); + var originals = new[] { _baseline, _progressive, _cmyk, _grayProgressive }; + foreach (var source in originals) + { + for (var iteration = 0; iteration < 80; iteration++) + { + var corrupted = (byte[])source.Clone(); + var index = seed.Next(2, corrupted.Length - 2); + corrupted[index] ^= (byte)(1 << seed.Next(8)); + try + { + var png = corrupted.ConvertToPng(); + var size = png.GetPngSize(); + size.Width.AssertEqual(16); + size.Height.AssertEqual(16); + } + catch (InvalidDataException) { } + catch (NotSupportedException) { } + catch (IOException) { } + } + } + } + [TestMethod] public void InvalidJpeg_TruncatedProgressiveDoesNotReturnValidPixels() { From 176e434b424050468ee35b63c33dd7903081552c Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 13:11:43 +0300 Subject: [PATCH 41/85] Repair CMYK test fixture missing fourth SOF component bytes --- Tests/Drawing/ImageExtendedTddTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/Drawing/ImageExtendedTddTests.cs b/Tests/Drawing/ImageExtendedTddTests.cs index fd5b9f5ff..5a8459f70 100644 --- a/Tests/Drawing/ImageExtendedTddTests.cs +++ b/Tests/Drawing/ImageExtendedTddTests.cs @@ -24,7 +24,7 @@ public class ImageExtendedTddTests : BaseTestClass "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wgARCAAQABADAREAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAABwUI/8QAFQEBAQAAAAAAAAAAAAAAAAAABwb/2gAMAwEAAhADEAAAAaMOhGlWAaZKEYzqgH//xAAUEAEAAAAAAAAAAAAAAAAAAAAg/9oACAEBAAEFAh//xAAUEQEAAAAAAAAAAAAAAAAAAAAg/9oACAEDAQE/AR//xAAcEQABAwUAAAAAAAAAAAAAAADwAAFhAjFBocH/2gAIAQIBAT8BDr5syCdUNKCdUNKDjZu6/8QAFBABAAAAAAAAAAAAAAAAAAAAIP/aAAgBAQAGPwIf/8QAFBABAAAAAAAAAAAAAAAAAAAAIP/aAAgBAQABPyEf/9oADAMBAAIAAwAAABAB7//EABgRAQEAAwAAAAAAAAAAAAAAAPAAASFh/9oACAEDAQE/EB3MNw3DmL//xAAcEQEAAQUBAQAAAAAAAAAAAAABEQAhQWGRMdH/2gAIAQIBAT8QUiXeeq+otju8dXHMzoZDgD6HVxzM6GQ4A+h0BEGscA9Rbvd46//EAB4QAAAFBQEAAAAAAAAAAAAAAAABESFBMWGRsfDh/9oACAEBAAE/EPZy+zmhB1le6wdZXusPIw2impj/2Q=="); private static readonly byte[] _cmyk = Convert.FromBase64String( - "/9j/7gAOQWRvYmUAZAAAAAAA/9sAQwADAgICAgIDAgICAwMDAwQGBAQEBAQIBgYFBgkICgoJCAkJCgwPDAoLDgsJCQ0RDQ4PEBAREAoMEhMSEBMPEBAQ/8AAFAgAEAAQBEMRAE0RAFkRAP/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/aAA4EQwBNAFkASwAAPwD7brKr5bZlKs7uhUrK7M85ZSpO2VmlXllLfLLMvzStiGHCgmv1Tr8BKKd+8WT/AJarKsn+xHIJET/viORE/wC2dpH6yGiv37r9CqP3iyf8tVlWT/YjkEiJ/wB8RyIn/bO0j9ZDRX4CUU1VUqqIiFSsSKqQFlKk7olWJuWUt80ULfNK2ZpsKAKK/9k="); + "/9j/7gAOQWRvYmUAZAAAAAAA/9sAQwADAgICAgIDAgICAwMDAwQGBAQEBAQIBgYFBgkICgoJCAkJCgwPDAoLDgsJCQ0RDQ4PEBAREAoMEhMSEBMPEBAQ/8AAFAgAEAAQBEMRAE0RAFkRAEsRAP/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/aAA4EQwBNAFkASwAAPwD7brKr5bZlKs7uhUrK7M85ZSpO2VmlXllLfLLMvzStiGHCgmv1Tr8BKKd+8WT/AJarKsn+xHIJET/viORE/wC2dpH6yGiv37r9CqP3iyf8tVlWT/YjkEiJ/wB8RyIn/bO0j9ZDRX4CUU1VUqqIiFSsSKqQFlKk7olWJuWUt80ULfNK2ZpsKAKK/9k="); private static readonly byte[] _grayProgressive = Convert.FromBase64String( "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAUDBAQEAwUEBAQFBQUGBwwIBwcHBw8LCwkMEQ8SEhEPERETFhwXExQaFRERGCEYGh0dHx8fExciJCIeJBweHx7/wgALCAAQABABAREA/8QAFgABAQEAAAAAAAAAAAAAAAAABgQH/9oACAEBAAAAAaiWuEf/xAAUEAEAAAAAAAAAAAAAAAAAAAAg/9oACAEBAAEFAh//xAAUEAEAAAAAAAAAAAAAAAAAAAAg/9oACAEBAAY/Ah//xAAUEAEAAAAAAAAAAAAAAAAAAAAg/9oACAEBAAE/IR//2gAIAQEAAAAQH//EABcQAQEBAQAAAAAAAAAAAAAAAPAAIRD/2gAIAQEAAT8QhkM5/9k="); From 71dacb3fe67e0eb0fe1f66c40a1278538523c0e4 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 13:12:18 +0300 Subject: [PATCH 42/85] TDD GREEN: reject truncated baseline JPEG lacking EOI and avoid silently decoded partial images --- Drawing/JpegCodec.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Drawing/JpegCodec.cs b/Drawing/JpegCodec.cs index 7f8823d0e..5941a95b0 100644 --- a/Drawing/JpegCodec.cs +++ b/Drawing/JpegCodec.cs @@ -66,6 +66,7 @@ private sealed class Bits { private readonly byte[] _data; private int _offset; + public int Position => _offset; private int _value, _remaining; public Bits(byte[] data, int offset) { _data = data; _offset = offset; } @@ -351,6 +352,17 @@ public static RasterImage Decode(byte[] data) mcuNumber++; } + // We consumed the declared MCU count. The next byte-aligned marker must + // close the image; accepting a missing EOI would silently treat truncated + // JPEG files as successfully decoded pictures. + var trailing = reader.Position; + if (trailing >= data.Length || data[trailing++] != 0xFF) + throw new InvalidDataException("JPEG end-of-image marker is missing."); + while (trailing < data.Length && data[trailing] == 0xFF) + trailing++; + if (trailing >= data.Length || data[trailing] != 0xD9) + throw new InvalidDataException("JPEG scan is incomplete or the EOI marker is missing."); + var result = new RasterImage(width, height); for (var y = 0; y < height; y++) for (var x = 0; x < width; x++) From f7f515b7e019a6f8ce6f30c2b05cbb26dbe4ec2c Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 13:14:06 +0300 Subject: [PATCH 43/85] TDD: stress PNG with all five filters, Adam7, palette transparency, 16-bit samples, 160 random images and CRC mutations --- Tests/Drawing/PngCodecStressTests.cs | 265 +++++++++++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 Tests/Drawing/PngCodecStressTests.cs diff --git a/Tests/Drawing/PngCodecStressTests.cs b/Tests/Drawing/PngCodecStressTests.cs new file mode 100644 index 000000000..2af1ef1aa --- /dev/null +++ b/Tests/Drawing/PngCodecStressTests.cs @@ -0,0 +1,265 @@ +namespace Ecng.Tests.Drawing; + +using System; +using System.Buffers.Binary; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; + +using Ecng.Drawing; + +/// +/// Independent byte-level PNG encoder generates adversarial and legal fixtures. +/// Production decoder is never used to create expected pixels. +/// +[TestClass] +public class PngCodecStressTests : BaseTestClass +{ + private static readonly (int x, int y, int dx, int dy)[] _adam7 = + [ + (0,0,8,8),(4,0,8,8),(0,4,4,8),(2,0,4,4), + (0,2,2,4),(1,0,2,2),(0,1,1,2) + ]; + + [TestMethod] + public void Png_AllFiveFilterTypes_RestoreEveryRgbaChannelExactly() + { + var expected = Pattern(19, 13); + for (var filter = 0; filter <= 4; filter++) + { + var png = Pack(19, 13, expected, filter, interlace: false); + Compare(expected, DecodeOutput(png.ConvertToPng(), 19, 13), 19); + } + } + + [TestMethod] + public void Png_Adam7InterlacedOddSizes_EqualExpectedAtEveryPixel() + { + foreach (var (w,h) in new[] { (1,1),(2,3),(5,4),(13,11),(17,15) }) + { + var pixels = Pattern(w,h); + var png = Pack(w,h,pixels,4,interlace:true); + Compare(pixels,DecodeOutput(png.ConvertToPng(),w,h),w); + } + } + + [TestMethod] + public void Png_MultipleScanlineFiltersInSingleImage_EveryPixelMatches() + { + var source = Pattern(17,19); + var png = Pack(17,19,source, -1,interlace:false); + Compare(source,DecodeOutput(png.ConvertToPng(),17,19),17); + } + + [TestMethod] + public void Png_SmallRandomizedRgbaPictures_LosslessExactPixelRoundTrips() + { + var rand = new Random(87234); + for (var iteration = 0; iteration < 160; iteration++) + { + var w = rand.Next(1,48); + var h = rand.Next(1,45); + var pixels = new byte[w*h*4]; + rand.NextBytes(pixels); + var source = Pack(w,h,pixels,iteration%5, interlace:iteration%4==0); + var copy = (byte[])source.Clone(); + Compare(pixels,DecodeOutput(source.ConvertToPng(),w,h),w); + source.SequenceEqual(copy).AssertTrue(); + } + } + + [TestMethod] + public void Png_TransparencyAndPaletteFourBitAllIndicesAreExact() + { + // Indexed 4-bit 4x2 with four RGBA palette entries including 0/128/255 alpha. + byte[] indices = [0,1,2,3,3,2,1,0]; + byte[] palette = [250,0,0,0,250,0,0,0,250,220,120,40]; + byte[] alpha = [0,127,255,220]; + byte[] raw = [0,0x01,0x23,0,0x32,0x10]; + var png = RawPng(4,2,4,3,0,raw,palette,alpha); + var expected = new byte[4*2*4]; + for(var i=0;i0&&ph>0) + WritePass(raw,rgba,w,pw,ph,x,y,dx,dy,chosenFilter); + } + + return RawPng(w,h,8,6,(byte)(interlace?1:0),raw.ToArray(),[],[]); + } + + private static void WritePass(Stream raw,byte[] rgba,int stride,int width,int height, + int startX,int startY,int stepX,int stepY,int chosenFilter) + { + var prev=new byte[width*4]; + var row=new byte[width*4]; + for(var y=0;y=4?row[i-4]:0; + var b=prev[i]; + var c=i>=4?prev[i-4]:0; + var prediction=filter switch + { + 0=>0, 1=>a, 2=>b, 3=>(a+b)/2, 4=>Paeth(a,b,c), + _=>throw new InvalidDataException() + }; + raw.WriteByte(unchecked((byte)(row[i]-prediction))); + } + (row,prev)=(prev,row); + } + } + + private static int Paeth(int a,int b,int c) + { + var p=a+b-c; + var da=Math.Abs(p-a);var db=Math.Abs(p-b);var dc=Math.Abs(p-c); + return da<=db&&da<=dc?a:db<=dc?b:c; + } + + private static byte[] RawPng(int w,int h,byte depth,byte color,byte interlace, + byte[] uncompressed,byte[] palette,byte[] transparency) + { + using var file=new MemoryStream(); + file.Write([137,80,78,71,13,10,26,10]); + var header=new byte[13]; + BinaryPrimitives.WriteInt32BigEndian(header.AsSpan(0,4),w); + BinaryPrimitives.WriteInt32BigEndian(header.AsSpan(4,4),h); + header[8]=depth;header[9]=color;header[12]=interlace; + Chunk(file,"IHDR",header); + if(palette.Length!=0)Chunk(file,"PLTE",palette); + if(transparency.Length!=0)Chunk(file,"tRNS",transparency); + using var data=new MemoryStream(); + using(var zipper=new ZLibStream(data,CompressionLevel.Optimal,leaveOpen:true)) + zipper.Write(uncompressed); + Chunk(file,"IDAT",data.ToArray()); + Chunk(file,"IEND",[]); + return file.ToArray(); + } + + private static void Chunk(Stream stream,string tag,byte[] data) + { + Span size=stackalloc byte[4]; + BinaryPrimitives.WriteInt32BigEndian(size,data.Length); + stream.Write(size); + var tagBytes=Encoding.ASCII.GetBytes(tag); + stream.Write(tagBytes); + stream.Write(data); + uint crc=0xffffffff; + foreach(var v in tagBytes.Concat(data)) + { + crc^=v; + for(var b=0;b<8;b++) crc=(crc&1)!=0?(crc>>1)^0xedb88320:crc>>1; + } + BinaryPrimitives.WriteUInt32BigEndian(size,~crc); + stream.Write(size); + } + + private static byte[] DecodeOutput(byte[] png,int w,int h) + { + if(png.GetPngSize().Width!=w||png.GetPngSize().Height!=h) + Assert.Fail("Incorrect PNG output dimensions."); + using var compressed=new MemoryStream(); + for(var p=8;p+12<=png.Length;) + { + var count=BinaryPrimitives.ReadInt32BigEndian(png.AsSpan(p,4)); + if(count<0||count>png.Length-p-12)Assert.Fail("Invalid PNG result chunk length."); + if(png.AsSpan(p+4,4).SequenceEqual("IDAT"u8)) + compressed.Write(png,p+8,count); + p+=count+12; + } + compressed.Position=0; + using var inflated=new MemoryStream(); + using(var zlib=new ZLibStream(compressed,CompressionMode.Decompress)) + zlib.CopyTo(inflated); + var rows=inflated.ToArray(); + if(rows.Length!=h*(w*4+1))Assert.Fail("Invalid PNG output row count."); + var output=new byte[w*h*4]; + for(var y=0;y Date: Sat, 10 Oct 2026 13:14:25 +0300 Subject: [PATCH 44/85] Correct separate scanline filters in independent 16-bit RGBA PNG fixture --- Tests/Drawing/PngCodecStressTests.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Tests/Drawing/PngCodecStressTests.cs b/Tests/Drawing/PngCodecStressTests.cs index 2af1ef1aa..93ed08c18 100644 --- a/Tests/Drawing/PngCodecStressTests.cs +++ b/Tests/Drawing/PngCodecStressTests.cs @@ -92,11 +92,12 @@ public void Png_TransparencyAndPaletteFourBitAllIndicesAreExact() public void Png_Rgba16Bit_ConvertsEveryChannelToHighOrderByte() { byte[] samples = [12,34,56,78,100,120,140,160,201,217,233,249,77,33,22,11]; - var raw = new byte[1+16*2]; + var raw = new byte[2*(1+2*4*2)]; for(var i=0;i<16;i++) { - raw[1+i*2]=samples[i]; - raw[2+i*2]= (byte)(i*11); + var at=1+(i/8)*17+(i%8)*2; + raw[at]=samples[i]; + raw[at+1]=(byte)(i*11); } var png=RawPng(2,2,16,6,0,raw,[],[]); // Four pixels, each RGBA channel retained via high byte. From 59defe4bd9fea53209d8251c16b4a9cf6d6f5a99 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 13:15:44 +0300 Subject: [PATCH 45/85] Pixel-oracle test JPEG resize against independent libjpeg plus bilinear reference --- Tests/Drawing/ImageExtendedTddTests.cs | 44 ++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/Tests/Drawing/ImageExtendedTddTests.cs b/Tests/Drawing/ImageExtendedTddTests.cs index 5a8459f70..fbf180452 100644 --- a/Tests/Drawing/ImageExtendedTddTests.cs +++ b/Tests/Drawing/ImageExtendedTddTests.cs @@ -143,6 +143,37 @@ public void InvalidJpeg_TruncatedProgressiveDoesNotReturnValidPixels() private const string GrayReference = "eNqNkUkKhUAQQ0/oPKJeSc/kPKIeKZ/XIPyV7aIgi+RVurqua9UvE4ah8jxXFEUKgsDoJEnk+76yLHvNMvgdx1GapirLUq7rGlZVVYZhy+MlG8ex0exmHm3L05m9+D3PU1EUhkcPun3pj5cMWRiwYMK25f+7Ppq3wEPb8tyIW+GlB3vJctMv/fkjGOzGT2dYaP52miYdx6F5njWOo9HrumoYBu37rqZpXgd/27batk3XdanrOsO679swbHm8ZJdlMZrdzKNteTqzF3/f9zrP0/DoQbcv/fGSIQsDFkzYtvx/10fzFnhoW54bcSu89GAvWW76pT9/BIPd+OkMC83f2vI/WPWoIw=="; + + [TestMethod] + public void JpegResizer_CompareEveryPixelAgainstIndependentPillowBilinearReference() + { + // Reference generated by decoding the original 16x16 JPEG with libjpeg, + // then center-aligned bilinear averaging to 8x8. JPEG compression on + // output is lossy, so bounded differences are expected. + const string reference = + "eNotjUESgjAQBP8AyWcAU3DRE+BrTIRPmsAJgvqTcXblkKrNdu9M9DWWMCCFC9LTwfo7zNTABMfXK1u8Q/QtVs5mbmBDRTaqk8LJ5hvioz7vxr9DV3JX/oUtU6eZ6rBDutJG9m4QM7P2HvbTodgq2INdh1OWcoVXHtUpNnZ8ryj2FiX/shMmjrg2D8os94azZIqjOeySTLkTr8w1fqf9l7U="; + + using var referenceCompressed = new MemoryStream(Convert.FromBase64String(reference)); + using var referenceZlib = new ZLibStream(referenceCompressed, CompressionMode.Decompress); + using var expectedBuffer = new MemoryStream(); + referenceZlib.CopyTo(expectedBuffer); + var expected = expectedBuffer.ToArray(); + + var result = _baseline.ResizeImage(8, 8); + (result.Length > 2 && result[0] == 0xFF && result[1] == 0xD8).AssertTrue(); + var actual = ReadPngRgba8(result.ConvertToPng(), 8, 8); + expected.Length.AssertEqual(8 * 8 * 4); + actual.Length.AssertEqual(expected.Length); + + for (var i = 0; i < actual.Length; i++) + { + var difference = Math.Abs(actual[i] - expected[i]); + if (difference > 25) + Assert.Fail($"JPEG resize at ({i/4%8},{i/4/8}), channel {"RGBA"[i%4]}: " + + $"wanted {expected[i]}, got {actual[i]}, tolerance 25."); + } + } + [TestMethod] public void ProgressiveJpeg_CompareAllRgbPixelsAgainstLibjpeg() => CompareReference(_progressive, ProgressiveReference, 5); @@ -191,7 +222,7 @@ private static void CompareReference(byte[] jpeg, string reference, int toleranc $"per-channel tolerance={tolerance}"); } - private static byte[] ReadPngRgba8(byte[] png) + private static byte[] ReadPngRgba8(byte[] png, int width = 16, int height = 16) { using var idat = new MemoryStream(); for (var position = 8; position + 12 <= png.Length;) @@ -209,14 +240,15 @@ private static byte[] ReadPngRgba8(byte[] png) stream.CopyTo(output); var data = output.ToArray(); - var result = new byte[1024]; - if (data.Length != 16 * (1 + 16 * 4)) + var result = new byte[width * height * 4]; + if (data.Length != height * (1 + width * 4)) throw new InvalidDataException("Unexpected output PNG dimension."); - for (var y = 0; y < 16; y++) + for (var y = 0; y < height; y++) { - if (data[y * 65] != 0) + var stride = width * 4 + 1; + if (data[y * stride] != 0) throw new InvalidDataException("Output PNG uses an unexpected scanline filter."); - Array.Copy(data, y * 65 + 1, result, y * 64, 64); + Array.Copy(data, y * stride + 1, result, y * width * 4, width * 4); } return result; } From 46fb3ce51e6fd589f5f54a5995025c2cdc3957cd Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 13:17:08 +0300 Subject: [PATCH 46/85] Document managed JPEG encoder, progressive/CMYK decoding, font fallback and TDD coverage --- Drawing/README.md | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/Drawing/README.md b/Drawing/README.md index 48a110158..3a5ba2044 100644 --- a/Drawing/README.md +++ b/Drawing/README.md @@ -282,30 +282,37 @@ Each of the three takes a `byte[]` or a `ReadOnlySpan`. ## Image Processing (fully managed C#) -No third-party NuGet image packages, GDI+, P/Invoke or native graphics engines are required. All four features run on Windows, Linux and macOS on both net6.0 and net10.0. +No third-party NuGet imaging packages, GDI+, P/Invoke, SkiaSharp, or native graphics engines are used. The image pipeline runs on Windows, Linux, and macOS with .NET 6 / .NET 10. -- `GetImageSize`: gets PNG or JPEG dimensions from the header without decompressing pixels. -- `ResizeImage`: decodes PNG or **baseline 8-bit JPEG**, shrinks without upscaling while retaining aspect ratio, and encodes the result as PNG. Bilinear resampling is alpha-correct. -- `ConvertToPng`: decodes supported images into RGBA8 and writes a lossless PNG, preserving transparency. -- `AddTextWatermark`: reads TrueType outlines from an installed Verdana .ttf (or another supplied .ttf) and rasterizes anti-aliased white text with configurable opacity and margins; output is PNG. +- `GetImageSize`: inspect PNG / JPEG dimensions without allocating the full pixel buffer. +- `ResizeImage`: downscale while preserving the aspect ratio and **input format**. PNG remains PNG; JPEG is re-encoded using a managed baseline JPEG encoder with 4:4:4 YCbCr sampling. Images which already fit are returned unchanged (as a new byte array). Alpha is resampled in premultiplied form for PNG. +- `ConvertToPng`: decode JPEG (baseline, progressive, grayscale, YCbCr, CMYK/YCCK) or PNG and emit lossless RGBA8 PNG with transparency. +- `AddTextWatermark`: anti-aliased white TrueType text with configurable opacity and margins. By default chooses installed Verdana; if Verdana is unavailable, it tries alternative system TrueType fonts (such as DejaVu Sans, Arial, Liberation Sans and Noto Sans). You can pin the exact font with `fontFilePath`. Watermarked output is PNG. ```csharp using Ecng.Drawing; byte[] jpeg = await File.ReadAllBytesAsync("photo.jpg"); + var size = jpeg.GetImageSize(); -byte[] thumbnailPng = jpeg.ResizeImage(640, 480); +byte[] thumbnailJpeg = jpeg.ResizeImage(640, 480); // JPEG remains JPEG byte[] png = jpeg.ConvertToPng(); -byte[] watermarked = png.AddTextWatermark("StockSharp"); // Installed Verdana +byte[] watermarked = png.AddTextWatermark("StockSharp"); await File.WriteAllBytesAsync("marked.png", watermarked); -// On systems without installed Verdana, supply an appropriate font file: +// For byte-for-byte consistent Verdana typography across environments: // png.AddTextWatermark("StockSharp", fontFilePath: "/path/to/Verdana.ttf"); ``` -The supported input formats for full image processing are PNG (standard color types including palette transparency, 16-bit samples and Adam7 interlace) and **baseline sequential JPEG** (grayscale and YCbCr). Progressive JPEG, CMYK JPEG, GIF, TIFF and BMP are not decoded; unsupported inputs fail explicitly. The maximum decoded image size is 25 megapixels. Resize output **always uses PNG**, including for JPEG input; JPEG encoding is intentionally not implemented. +Input PNG supports the standard color modes (including indexed transparency), scanline filters 0–4, 1/2/4/8/16-bit samples and Adam7 interlace. Input JPEG supports 8-bit sequential and progressive Huffman-coded scans, restart markers, grayscale, YCbCr and Adobe CMYK/YCCK. Arithmetic-coded, lossless and 12-bit JPEG are not supported. GIF, BMP and TIFF are not decoded. Invalid data throws rather than producing pixel buffers from incomplete files. Decoded images are limited to 25 megapixels; additional bounded checks protect intermediate buffers. + +JPEG output from `ResizeImage` is intentionally lossy. It uses built-in fixed quantization/Huffman tables rather than an external JPEG quality parameter. PNG output is lossless. The JPEG format and original bytes are retained when no resize is needed. + +Verdana is a separately licensed font and is **not** included in the library. The managed TrueType renderer supports quadratic `glyf` outlines, basic composite glyphs and Unicode cmap format 4/12, but not CFF font collections or advanced text shaping. If the host has no TrueType font, you must supply an appropriate `.ttf`; exact Verdana output requires a Verdana file. + +### Testing -Verdana is a separately licensed font and is **not** bundled with this repository. For consistent typography, install it on the host or supply its .ttf via `fontFilePath`. The renderer supports standard TrueType quadratic and composite `glyf` outlines; CFF/OpenType collections and advanced text shaping are not supported. The default font family is Verdana. +`Tests/Drawing` contains pixel-by-pixel golden fixtures generated independently of the implementation, progressive/CMYK JPEG fixtures with full libjpeg-decoded RGBA reference buffers, exact transparency/resizing calculations, an independently generated deterministic TrueType glyph, PNG Adam7/filter stress tests, randomized PNG roundtrips and malformed-input tests. Test-only reference data uses Pillow/libjpeg; runtime code and the CI test project do not require Python or imaging NuGet packages. ## Usage Examples From 6a5bb51d08dc4e797cd191b519c6910fc29d2353 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 13:19:14 +0300 Subject: [PATCH 47/85] Finalize JPEG codec API docs and retain TDD history without red test names --- Drawing/ImageHelper.cs | 10 +++++----- Drawing/JpegCodec.cs | 6 +++--- Tests/Drawing/ImageExtendedTddTests.cs | 14 +++++++------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/Drawing/ImageHelper.cs b/Drawing/ImageHelper.cs index dd0e40ba3..cb751594c 100644 --- a/Drawing/ImageHelper.cs +++ b/Drawing/ImageHelper.cs @@ -50,7 +50,7 @@ public static byte[] ResizeImage(this byte[] data, int maxWidth, int maxHeight) } /// - /// Converts a PNG or 8-bit baseline JPEG picture to a standard RGBA8 PNG. + /// Converts a PNG or 8-bit baseline/progressive JPEG (including grayscale and CMYK) to RGBA8 PNG. /// public static byte[] ConvertToPng(this byte[] data) { @@ -60,10 +60,10 @@ public static byte[] ConvertToPng(this byte[] data) /// /// Renders anti-aliased semi-transparent TrueType text into the bottom-right corner, - /// then returns a PNG. The default font is Verdana; it must be installed as a .ttf - /// on the host, or its path can be specified explicitly. No font is bundled. + /// then returns a PNG. Prefers Verdana, falls back to installed TrueType fonts, + /// or accepts an explicit .ttf path. No licensed font is bundled. /// - /// Encoded PNG or baseline JPEG image. + /// Encoded PNG or supported 8-bit JPEG image. /// Single-line watermark text. /// Maximum font size in points. /// White text opacity, 0..255. @@ -102,6 +102,6 @@ private static RasterImage Decode(byte[] data) if (data.Length == 0) throw new InvalidDataException("Image data is empty."); if (PngCodec.IsPng(data)) return PngCodec.Decode(data); if (JpegCodec.IsJpeg(data)) return JpegCodec.Decode(data); - throw new NotSupportedException("Only PNG and baseline 8-bit JPEG images are supported."); + throw new NotSupportedException("Only PNG and supported 8-bit JPEG images are supported."); } } diff --git a/Drawing/JpegCodec.cs b/Drawing/JpegCodec.cs index 5941a95b0..042ddfc48 100644 --- a/Drawing/JpegCodec.cs +++ b/Drawing/JpegCodec.cs @@ -5,9 +5,9 @@ namespace Ecng.Drawing; using System.IO; /// -/// Pure managed decoder for baseline sequential 8-bit JPEG (grayscale and YCbCr, including -/// common 4:2:0 / 4:2:2 subsampling and DRI restart markers). Progressive, arithmetic-coded, -/// CMYK and multi-scan JPEGs are deliberately rejected rather than misdecoded. +/// Pure managed decoder for 8-bit JPEG. Baseline grayscale/YCbCr/CMYK and restart markers +/// are decoded here; SOF2 progressive scans delegate to JpegProgressiveCodec. +/// Unsupported arithmetic-coded, lossless and 12-bit JPEG fail explicitly. /// internal static class JpegCodec { diff --git a/Tests/Drawing/ImageExtendedTddTests.cs b/Tests/Drawing/ImageExtendedTddTests.cs index fbf180452..421d8bccb 100644 --- a/Tests/Drawing/ImageExtendedTddTests.cs +++ b/Tests/Drawing/ImageExtendedTddTests.cs @@ -30,7 +30,7 @@ public class ImageExtendedTddTests : BaseTestClass "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAUDBAQEAwUEBAQFBQUGBwwIBwcHBw8LCwkMEQ8SEhEPERETFhwXExQaFRERGCEYGh0dHx8fExciJCIeJBweHx7/wgALCAAQABABAREA/8QAFgABAQEAAAAAAAAAAAAAAAAABgQH/9oACAEBAAAAAaiWuEf/xAAUEAEAAAAAAAAAAAAAAAAAAAAg/9oACAEBAAEFAh//xAAUEAEAAAAAAAAAAAAAAAAAAAAg/9oACAEBAAY/Ah//xAAUEAEAAAAAAAAAAAAAAAAAAAAg/9oACAEBAAE/IR//2gAIAQEAAAAQH//EABcQAQEBAQAAAAAAAAAAAAAAAPAAIRD/2gAIAQEAAT8QhkM5/9k="); [TestMethod] - public void Red_ResizeJpeg_PreservesJpegFormatAndCanBeDecoded() + public void ResizeJpeg_PreservesJpegFormatAndCanBeDecoded() { var output = _baseline.ResizeImage(8, 8); (output.Length > 4 && output[0] == 0xFF && output[1] == 0xD8 && output[^2] == 0xFF && output[^1] == 0xD9).AssertTrue(); @@ -41,14 +41,14 @@ public void Red_ResizeJpeg_PreservesJpegFormatAndCanBeDecoded() } [TestMethod] - public void Red_ResizeJpeg_WhenAlreadySmall_PreservesOriginalBytes() + public void ResizeJpeg_WhenAlreadySmall_PreservesOriginalBytes() { var output = _baseline.ResizeImage(64, 64); _baseline.SequenceEqual(output).AssertTrue(); } [TestMethod] - public void Red_ProgressiveJpeg_DecodesFullColorImage() + public void ProgressiveJpeg_DecodesFullColorImage() { var png = _progressive.ConvertToPng(); png.GetPngSize().Width.AssertEqual(16); @@ -57,7 +57,7 @@ public void Red_ProgressiveJpeg_DecodesFullColorImage() } [TestMethod] - public void Red_ProgressiveGrayJpeg_DecodesAndResizes() + public void ProgressiveGrayJpeg_DecodesAndResizes() { var png = _grayProgressive.ConvertToPng(); png.GetPngSize().Width.AssertEqual(16); @@ -65,7 +65,7 @@ public void Red_ProgressiveGrayJpeg_DecodesAndResizes() } [TestMethod] - public void Red_AdobeCmykJpeg_DecodesWithNonGrayColors() + public void AdobeCmykJpeg_DecodesWithNonGrayColors() { var png = _cmyk.ConvertToPng(); png.GetPngSize().Width.AssertEqual(16); @@ -74,7 +74,7 @@ public void Red_AdobeCmykJpeg_DecodesWithNonGrayColors() } [TestMethod] - public void Red_Watermark_UnknownFamilyFallbackWithoutPrivateTtf() + public void Watermark_UnknownFamilyFallbackWithoutPrivateTtf() { var png = _baseline.ConvertToPng(); var output = png.AddTextWatermark("StockSharp", fontSize: 7, margin: 1, @@ -85,7 +85,7 @@ public void Red_Watermark_UnknownFamilyFallbackWithoutPrivateTtf() [TestMethod] - public void Red_JpegMustRejectMissingEndOfImageNotSilentlyReturnPixels() + public void JpegMustRejectMissingEndOfImageNotSilentlyReturnPixels() { ThrowsExactly(() => _baseline[..^2].ConvertToPng()); ThrowsExactly(() => _cmyk[..^2].ConvertToPng()); From 2445f1e9786477e80d9cfca51c7ca83f8be8f32a Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 14:08:18 +0300 Subject: [PATCH 48/85] TDD RED: exact coverage for all PNG color/depth combinations, transparency, invalid chunks and trailing data --- Tests/Drawing/PngCodecStressTests.cs | 199 +++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) diff --git a/Tests/Drawing/PngCodecStressTests.cs b/Tests/Drawing/PngCodecStressTests.cs index 93ed08c18..3cf2128da 100644 --- a/Tests/Drawing/PngCodecStressTests.cs +++ b/Tests/Drawing/PngCodecStressTests.cs @@ -263,4 +263,203 @@ private static void Compare(byte[] expected,byte[] actual,int width) Assert.Fail($"Pixel ({i/4%width},{i/4/width}), channel {"RGBA"[i%4]}: "+ $"expected {expected[i]} actual {actual[i]}"); } + + [TestMethod] + public void Png_AllLegalColorTypesAndBitDepths_AgreeWithIndependentPixelOracle() + { + // All 15 legal (color type, bit depth) pairs, each with odd-width rows. + foreach (var (color, depth) in new (byte color, byte depth)[] + { + (0,1),(0,2),(0,4),(0,8),(0,16), + (2,8),(2,16), + (3,1),(3,2),(3,4),(3,8), + (4,8),(4,16), + (6,8),(6,16) + }) + { + const int width = 5, height = 3; + var channels = color switch { 2=>3, 4=>2, 6=>4, _=>1 }; + var maxSample = depth == 16 ? 65535 : (1 << depth) - 1; + byte[] palette = [], transparency = []; + if (color == 3) + { + var count = 1 << depth; + palette = new byte[count*3]; + for (var index = 0; index < count; index++) + { + palette[3*index] = (byte)(17*index); + palette[3*index+1] = (byte)(255-index); + palette[3*index+2] = (byte)(index ^ 0x5a); + } + transparency = Enumerable.Range(0,Math.Min(count,5)).Select(i=>(byte)(i*43)).ToArray(); + } + + var samples = new int[width*height*channels]; + for (var p = 0; p < width*height; p++) + for (var c = 0; c < channels; c++) + samples[p*channels+c] = color == 3 + ? p % (1 << depth) + : (p*37001 + c*13111 + 17773) & maxSample; + + using var raw = new MemoryStream(); + for (var y=0; y=bpp?row[j-bpp]:0; + var up=previous[j]; + var upperLeft=j>=bpp?previous[j-bpp]:0; + var predictor = y%5 switch + { + 0=>0, 1=>left, 2=>up, 3=>(left+up)/2, + 4=>Paeth(left,up,upperLeft), _=>0 + }; + raw.WriteByte(unchecked((byte)(row[j]-predictor))); + } + } + + var expected = new byte[width*height*4]; + for(var p=0;p samples[p*channels+c]; + byte Normal(int v) => depth==16?(byte)(v>>8):depth==8?(byte)v: + (byte)(v*255/maxSample); + var off=p*4; + switch(color) + { + case 0: + expected[off]=expected[off+1]=expected[off+2]=Normal(Read(0)); + expected[off+3]=255; + break; + case 2: + for(var c=0;c<3;c++)expected[off+c]=Normal(Read(c)); + expected[off+3]=255; + break; + case 3: + var idx=Read(0); + Array.Copy(palette,idx*3,expected,off,3); + expected[off+3]=idx>8),(byte)value]; + var pixels=DecodeOutput(RawPng(4,1,depth,0,0,raw,[],key).ConvertToPng(),4,1); + for(var i=0;i<4;i++) + pixels[4*i+3].AssertEqual((byte)(i is 1 or 2?0:255)); + } + + foreach(var depth in new byte[]{8,16}) + { + var values=depth==8?new[]{17,91,250}:new[]{0x1234,0x5678,0x9abc}; + byte[] raw=[0,..PackSamples([ + 0,0,0,values[0],values[1],values[2], + values[0],values[1],values[2],values[0],values[1],values[0]],depth)]; + var key=new byte[6]; + for(var j=0;j<3;j++) + { + key[2*j]=(byte)(values[j]>>8); + key[2*j+1]=(byte)values[j]; + } + var pixels=DecodeOutput(RawPng(4,1,depth,2,0,raw,[],key).ConvertToPng(),4,1); + for(var i=0;i<4;i++) + pixels[4*i+3].AssertEqual((byte)(i is 1 or 2?0:255)); + } + } + + [TestMethod] + public void Png_UnknownCriticalChunkRejected_AncillaryChunkIgnored() + { + var source=Pack(2,2,Pattern(2,2),0,false); + byte[] WithChunk(string tag) + { + using var output=new MemoryStream(); + output.Write(source,0,33); // signature + IHDR + Chunk(output,tag,[11,22,33]); + output.Write(source,33,source.Length-33); + return output.ToArray(); + } + ThrowsExactly(()=>WithChunk("UNKN").ConvertToPng()); + Compare(Pattern(2,2),DecodeOutput(WithChunk("tEXt").ConvertToPng(),2,2),2); + } + + [TestMethod] + public void Png_TruncatedAndMalformedContents_FailWithoutRuntimeIndexErrors() + { + var source=Pack(13,7,Pattern(13,7),4,false); + for(var length=8;length(()=> + RawPng(1,1,4,6,0,[0,0,0,0,0],[],[]).ConvertToPng()); + ThrowsExactly(()=> + RawPng(1,1,8,6,2,[0,0,0,0,0],[],[]).ConvertToPng()); + ThrowsExactly(()=> + RawPng(2,1,8,3,0,[0,1,2],[255,0,0],[]).ConvertToPng()); + ThrowsExactly(()=> + RawPng(1,1,8,6,0,[0,0,0,0,0,0,255],[],[]).ConvertToPng()); + } + + [TestMethod] + public void Png_IendMustBeLastChunk_NotSilentlyIgnoreAppendedData() + { + var source=Pack(2,2,Pattern(2,2),0,false); + ThrowsExactly(() => source.Concat([1,2,3,4]).ToArray().ConvertToPng()); + } + + private static byte[] PackSamples(ReadOnlySpan samples,int depth) + { + var result=new byte[(samples.Length*depth+7)/8]; + for(var i=0;i>8); + result[i*2+1]=(byte)samples[i]; + } + else if(depth==8)result[i]=(byte)samples[i]; + else + { + var bit=i*depth; + result[bit/8]|=(byte)(samples[i] << (8-depth-bit%8)); + } + } + return result; + } + } From e4bf6e8ff49a4c442055eb0e6530db84ba418597 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 14:09:21 +0300 Subject: [PATCH 49/85] Correct grayscale tRNS 1-bit fixture oracle: max sample equals transparency key --- Tests/Drawing/PngCodecStressTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/Drawing/PngCodecStressTests.cs b/Tests/Drawing/PngCodecStressTests.cs index 3cf2128da..ad1b8d1e1 100644 --- a/Tests/Drawing/PngCodecStressTests.cs +++ b/Tests/Drawing/PngCodecStressTests.cs @@ -372,7 +372,7 @@ public void Png_GrayscaleAndRgbTransparencyKeys_PreserveTransparentPixels() byte[] key=[(byte)(value>>8),(byte)value]; var pixels=DecodeOutput(RawPng(4,1,depth,0,0,raw,[],key).ConvertToPng(),4,1); for(var i=0;i<4;i++) - pixels[4*i+3].AssertEqual((byte)(i is 1 or 2?0:255)); + pixels[4*i+3].AssertEqual((byte)(i is 1 or 2 || (i == 3 && depth == 1)?0:255)); } foreach(var depth in new byte[]{8,16}) From c7fbe9ce5f04f87b1f1dfdf9533e6641865faa73 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 14:10:04 +0300 Subject: [PATCH 50/85] TDD: exact watermark alpha-compositing matrix and no-font zero-opacity test --- Tests/Drawing/ImagePixelGoldenTests.cs | 66 +++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/Tests/Drawing/ImagePixelGoldenTests.cs b/Tests/Drawing/ImagePixelGoldenTests.cs index 8861c16d6..f3958b6a1 100644 --- a/Tests/Drawing/ImagePixelGoldenTests.cs +++ b/Tests/Drawing/ImagePixelGoldenTests.cs @@ -284,7 +284,7 @@ private static void WriteChunk(Stream stream, string name, byte[] content) stream.Write(number); } - private static byte[] BuildTestTrueType() + internal static byte[] BuildTestTrueType() { // A hand-built minimal .ttf with glyph 0 empty and glyph 1 = a rectangle, // cmap Unicode 'I' -> glyph 1. No third-party font binary is distributed. @@ -376,4 +376,68 @@ private static void Put16(byte[] data, int index, ushort value) private static void Put32(byte[] data, int index, uint value) => BinaryPrimitives.WriteUInt32BigEndian(data.AsSpan(index, 4), value); + + [TestMethod] + public void Watermark_PixelExactSourceOverBlending_ForOpaqueAndTransparentBackgrounds() + { + // Analytically computed Porter-Duff source-over goldens, not an + // ImageHelper-vs-ImageHelper round trip. Glyph interior at (40,25). + var fontPath = Path.Combine(Path.GetTempPath(), $"ecng-alpha-{Guid.NewGuid():N}.ttf"); + File.WriteAllBytes(fontPath, BuildTestTrueType()); + try + { + foreach (var backgroundAlpha in new byte[] { 0, 1, 32, 127, 254, 255 }) + foreach (var watermarkOpacity in new byte[] { 1, 64, 160, 254, 255 }) + { + var sourcePixels = new byte[64 * 48 * 4]; + for (var index = 0; index < sourcePixels.Length; index += 4) + { + sourcePixels[index] = 30; + sourcePixels[index + 1] = 75; + sourcePixels[index + 2] = 120; + sourcePixels[index + 3] = backgroundAlpha; + } + + var input = CreateRgbaPng(64, 48, sourcePixels); + var copy = (byte[])input.Clone(); + var actual = Pixels(input.AddTextWatermark("I", fontSize: 18, + opacity: watermarkOpacity, margin: 8, fontFilePath: fontPath), 64, 48); + + input.SequenceEqual(copy).AssertTrue(); + for (var channel = 0; channel < 4; channel++) + actual[channel].AssertEqual(sourcePixels[channel]); // outside the glyph + + var destination = (25 * 64 + 40) * 4; + var foregroundAlpha = watermarkOpacity / 255.0; + var oldAlpha = backgroundAlpha / 255.0; + var combinedAlpha = foregroundAlpha + oldAlpha * (1 - foregroundAlpha); + for (var channel = 0; channel < 3; channel++) + { + var expected = (byte)Math.Clamp((int)Math.Round( + (255 * foregroundAlpha + sourcePixels[destination + channel] * + oldAlpha * (1 - foregroundAlpha)) / combinedAlpha), 0, 255); + actual[destination + channel].AssertEqual(expected); + } + actual[destination + 3].AssertEqual( + (byte)Math.Clamp((int)Math.Round(combinedAlpha * 255), 0, 255)); + } + } + finally + { + File.Delete(fontPath); + } + } + + [TestMethod] + public void Watermark_ZeroOpacityDoesNotNeedInstalledOrProvidedFont() + { + var pixelBytes = new byte[32 * 16 * 4]; + for (var i = 0; i < pixelBytes.Length; i++) + pixelBytes[i] = (byte)(i * 11); + var png = CreateRgbaPng(32, 16, pixelBytes); + var output = png.AddTextWatermark("Invisible", opacity: 0, margin: 1, + fontFilePath: Path.Combine(Path.GetTempPath(), "missing-ecng-invisible.ttf")); + ComparePixels(pixelBytes, Pixels(output, 32, 16), 32, 16); + } + } From ab2a8d9e03060f95d4eb6e9cb8ee20ba524d7225 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 14:10:06 +0300 Subject: [PATCH 51/85] TDD: watermark renders and preserves unmodified pixels across all JPEG decoder modes --- Tests/Drawing/ImageExtendedTddTests.cs | 31 ++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/Tests/Drawing/ImageExtendedTddTests.cs b/Tests/Drawing/ImageExtendedTddTests.cs index 421d8bccb..4029295b7 100644 --- a/Tests/Drawing/ImageExtendedTddTests.cs +++ b/Tests/Drawing/ImageExtendedTddTests.cs @@ -73,6 +73,37 @@ public void AdobeCmykJpeg_DecodesWithNonGrayColors() _cmyk.ResizeImage(8, 8).GetImageSize().Width.AssertEqual(8); } + + [TestMethod] + public void Watermark_OnEverySupportedJpegMode_ChangesPixelsAndPreservesOutsideRegion() + { + var path = Path.Combine(Path.GetTempPath(), $"ecng-jpeg-watermark-{Guid.NewGuid():N}.ttf"); + File.WriteAllBytes(path, ImagePixelGoldenTests.BuildTestTrueType()); + try + { + foreach (var source in new[] { _baseline, _progressive, _grayProgressive, _cmyk }) + { + var input = (byte[])source.Clone(); + var reference = ReadPngRgba8(source.ConvertToPng(), 16, 16); + var marked = source.AddTextWatermark("I", fontSize: 12, + opacity: 255, margin: 1, fontFilePath: path); + (marked.Length > 8 && marked[0] == 137 && marked[1] == 80).AssertTrue(); + var actual = ReadPngRgba8(marked, 16, 16); + actual.Length.AssertEqual(reference.Length); + var differences = 0; + for (var i = 0; i < actual.Length; i++) + if (actual[i] != reference[i]) + differences++; + + (differences > 0).AssertTrue(); + for (var i = 0; i < 4; i++) + actual[i].AssertEqual(reference[i]); // top-left unaffected + source.SequenceEqual(input).AssertTrue(); // input never mutated + } + } + finally { File.Delete(path); } + } + [TestMethod] public void Watermark_UnknownFamilyFallbackWithoutPrivateTtf() { From 1f3d47d2c7bf24719923868b7f9361d50a29fd1f Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 14:10:28 +0300 Subject: [PATCH 52/85] Fix C# overload inference in appended-data PNG regression fixture --- Tests/Drawing/PngCodecStressTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/Drawing/PngCodecStressTests.cs b/Tests/Drawing/PngCodecStressTests.cs index ad1b8d1e1..71877b868 100644 --- a/Tests/Drawing/PngCodecStressTests.cs +++ b/Tests/Drawing/PngCodecStressTests.cs @@ -439,7 +439,7 @@ public void Png_TruncatedAndMalformedContents_FailWithoutRuntimeIndexErrors() public void Png_IendMustBeLastChunk_NotSilentlyIgnoreAppendedData() { var source=Pack(2,2,Pattern(2,2),0,false); - ThrowsExactly(() => source.Concat([1,2,3,4]).ToArray().ConvertToPng()); + ThrowsExactly(() => source.Concat(new byte[] { 1, 2, 3, 4 }).ToArray().ConvertToPng()); } private static byte[] PackSamples(ReadOnlySpan samples,int depth) From d35face258aad9bdd0ea63f3b30b077d1d5849fe Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 14:11:12 +0300 Subject: [PATCH 53/85] Add test-only Coverlet collector to measure real Ecng.Drawing line and branch coverage --- Tests/Tests.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index 0e419a3d9..9b1ef9d84 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -10,6 +10,7 @@ + From e806b17ae78a4da5e7d05ede586d3f38f74f553e Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 14:11:32 +0300 Subject: [PATCH 54/85] Configure isolated Cobertura coverage for Ecng.Drawing --- Tests/drawing.coverage.runsettings | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 Tests/drawing.coverage.runsettings diff --git a/Tests/drawing.coverage.runsettings b/Tests/drawing.coverage.runsettings new file mode 100644 index 000000000..2754808d2 --- /dev/null +++ b/Tests/drawing.coverage.runsettings @@ -0,0 +1,15 @@ + + + + + + + cobertura + [Ecng.Drawing]* + GeneratedCodeAttribute + false + + + + + From b50164a61fcc7ea9a83144161cada4b793b68dee Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 14:11:35 +0300 Subject: [PATCH 55/85] Add dedicated CI workflow reporting Drawing line and branch coverage with downloadable Cobertura XML --- .github/workflows/drawing-coverage.yml | 60 ++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 .github/workflows/drawing-coverage.yml diff --git a/.github/workflows/drawing-coverage.yml b/.github/workflows/drawing-coverage.yml new file mode 100644 index 000000000..8054460d1 --- /dev/null +++ b/.github/workflows/drawing-coverage.yml @@ -0,0 +1,60 @@ +name: Drawing coverage + +on: + pull_request: + paths: + - 'Drawing/**' + - 'Tests/Drawing/**' + - 'Tests/Tests.csproj' + - '.github/workflows/drawing-coverage.yml' + workflow_dispatch: + +jobs: + drawing-coverage: + runs-on: ubuntu-latest + timeout-minutes: 12 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: | + 6.0.x + 10.0.x + - name: Restore + run: dotnet restore Tests/Tests.csproj + - name: Build + run: dotnet build Tests/Tests.csproj -f net10.0 -c Release --no-restore + - name: Run every Drawing test and collect line and branch coverage + run: >- + dotnet test Tests/Tests.csproj -f net10.0 -c Release + --no-build --no-restore + --filter "FullyQualifiedName~Ecng.Tests.Drawing" + --collect:"XPlat Code Coverage" + --settings Tests/drawing.coverage.runsettings + --results-directory coverage-out + - name: Coverage summary + shell: bash + run: | + python3 - <<'PY' + import glob + import xml.etree.ElementTree as ET + + files = glob.glob('coverage-out/**/coverage.cobertura.xml', recursive=True) + if len(files) != 1: + raise SystemExit(f'Expected exactly one coverage XML report; got {files}') + root = ET.parse(files[0]).getroot() + print('Ecng.Drawing:') + print(f' lines: {100 * float(root.attrib.get("line-rate", 0)):.1f}%') + print(f' branches: {100 * float(root.attrib.get("branch-rate", 0)):.1f}%') + for entry in root.findall('.//class'): + path = entry.get('filename', '') + rate = 100 * float(entry.get('line-rate', 0)) + branch = 100 * float(entry.get('branch-rate', 0)) + print(f' {path}: lines {rate:.1f}%, branches {branch:.1f}%') + PY + - uses: actions/upload-artifact@v4 + if: always() + with: + name: drawing-cobertura-report + path: coverage-out/**/coverage.cobertura.xml + if-no-files-found: warn From 3a96a4b04c0dbdd040edd626bed238c100ac957c Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 14:14:01 +0300 Subject: [PATCH 56/85] TDD GREEN: reject trailing bytes after PNG IEND, demonstrated by red regression --- Drawing/PngCodec.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Drawing/PngCodec.cs b/Drawing/PngCodec.cs index a7649b791..23bed77dc 100644 --- a/Drawing/PngCodec.cs +++ b/Drawing/PngCodec.cs @@ -96,6 +96,8 @@ public static RasterImage Decode(byte[] data) else if (tag.SequenceEqual("IEND"u8)) { if (length != 0) throw new InvalidDataException("Invalid IEND."); + if (offset + 12 != data.Length) + throw new InvalidDataException("PNG contains bytes after the IEND chunk."); ended = true; break; } From 2762ca4ab6a81c2a904102ce91b4fc0207e1db8c Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 14:14:23 +0300 Subject: [PATCH 57/85] Expose exact PNG color-type/bit-depth pair in pixel-oracle test failures --- Tests/Drawing/PngCodecStressTests.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Tests/Drawing/PngCodecStressTests.cs b/Tests/Drawing/PngCodecStressTests.cs index 71877b868..fde256023 100644 --- a/Tests/Drawing/PngCodecStressTests.cs +++ b/Tests/Drawing/PngCodecStressTests.cs @@ -277,6 +277,8 @@ public void Png_AllLegalColorTypesAndBitDepths_AgreeWithIndependentPixelOracle() (6,8),(6,16) }) { + try + { const int width = 5, height = 3; var channels = color switch { 2=>3, 4=>2, 6=>4, _=>1 }; var maxSample = depth == 16 ? 65535 : (1 << depth) - 1; @@ -359,6 +361,11 @@ byte Normal(int v) => depth==16?(byte)(v>>8):depth==8?(byte)v: var source=RawPng(width,height,depth,color,0,raw.ToArray(),palette,transparency); Compare(expected,DecodeOutput(source.ConvertToPng(),width,height),width); + } + catch (Exception ex) + { + Assert.Fail($"PNG color={color}, depth={depth}: {ex}"); + } } } From 921205a1f91affcfbd90f7b109502f09c9a2826f Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 14:15:15 +0300 Subject: [PATCH 58/85] TDD RED: independent libjpeg pixel oracle for previously untested Adobe YCCK (APP14 transform 2) --- Tests/Drawing/ImageExtendedTddTests.cs | 47 ++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/Tests/Drawing/ImageExtendedTddTests.cs b/Tests/Drawing/ImageExtendedTddTests.cs index 4029295b7..3b9374cca 100644 --- a/Tests/Drawing/ImageExtendedTddTests.cs +++ b/Tests/Drawing/ImageExtendedTddTests.cs @@ -205,6 +205,53 @@ public void JpegResizer_CompareEveryPixelAgainstIndependentPillowBilinearReferen } } + + // Independently created Pillow/libjpeg fixture: four-component JPEG with + // Adobe APP14 transform=2 (YCCK). This is NOT an Adobe CMYK transform=0 file. + private static readonly byte[] _ycckFixture = Convert.FromBase64String( + "/9j/7gAOQWRvYmUAZAAAAAAC/9sAQwADAgIDAgIDAwMDBAMDBAUIBQUEBAUKBwcGCAwKDAwLCgsLDQ4SEA0OEQ4LCxAWEBETFBUVFQwPFxgWFBgSFBUU/8AAFAgACwAPBEMRAE0RAFkRAEsRAP/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/aAA4EQwBNAFkASwAAPwD2D9q34gf8fn7z1716b4u/bS+//p//AI/TPF/7Y3ir4lareaZ4Q0zWPFWpRQtcyWeiWct5MkQZVMhSNWIUM6DdjGWA7in6x+0N8Ufj3cyR+AvDWseIrd5pbY6hDH5djHKkfmNHJdSFYY22FTtdwTuQDJZQfEP+Cf3gaHxV8XPFXxV1OO4+z+FEXT9IdoZFhkvblXWZ1lDBWaKDKtGQ3F4jHaQhPi3ir9tL5z/p/f8Av1BYfs5/FfxzPez/ABC8U6f8MbSNmSGHfHq15M4MZVtkMwiWIhpBuM28NHjy8MGqBv2ZP9Jvrj4u/E7+No7fT/BMvvGVle5uYf8ArqpiEP8AcbzOq0z9q2+n/wBM/et3r5x/Y28NWHxp/ax8E+DPGaTaz4b1L7f9rsvtMkHmeXYXEqfPEyuMPGh4YZxg8EivpjxfbWnw10q80zwhpuneFdNlma5ks9EsYbOF5SqqZCkaqCxVEG7GcKB2FexfGDxPqegaVHpmmXP9nabZQrbWtnaRrFDBEihUjRFACqqgAKAAAABXrf7H2iWfh/8AYw8KX+nxNb3euajqOoahJ5jN5863klsHwSQv7m2hXC4HyZxkkn9Vz+yr8HPB+i2+kWHww8K3Fpbltkmq6TDqFwdzljvnuFklfljjcxwMAYAAHxx8XfHOv+e//E0n+96j1r8+Pjb498Q/aX/4mtx9/wBR6/Sv/9k="); + + private const string YcckLibjpegRgba = + "eJwV0V1I01EYx/HHub9ubnNzbrb958uWsjnzbYHzBdEEa6XMNjHFauBqqMnCpIgioggqqZCwG6GL8iIIichACoqoMIPywpsIFELqQqibvI76drz48JwHnh+Hcx65Icgt5aYyo+z000KpqlWXhbZLwv6LDhKTOsfGKhlMhRkejmDPUXOjyogyoaSFvHHBlRFqTggt6QJiRz309ZcR76miL1bDoc5aopEQdlHz3cpBZb+g9QpBdd7TrRHtcnKgXaerzU9npJKuxhCRqgB+3YdV5bSdbJ1gUNwRIVAvhOqttIbdNNeW0RQspzngp7G0goBbx2l1YNJM3BedxxLEpKucV6goK6DBV0yD7qHO6yPq8RN26ui2YuwmG6Lu2Z7dZkH2Iol2ZCiBXpBHqc1Gtc1N5Q5LCUGrF7tmwWI08y/zi+3sNpsTm+xbX0eiMWUAaR3DZ3BQYXCy21iC1+jElVuIlmPkT8cPtjq28GxsULO2Rmx5meOLi4hvGCk/hVSfxitFuMSOWfL5a3nHb9ca8uYbthdf0V+v0rz0lvjCUwbn55H8NGIbQxwTWIuy5IvGd3nOT/mEzK0qXyic/Uj43iuaZp7Qe/sBQ1fukrl6Xb05Ta7hJDZjCreWRKbeI5MryJnPyIUVPNlnhKYeEc3OER+9w0DmGqmRs6TG7WguweFWf6vqriK1o74PSPIluUeW8PQ/JJKcoyUxTc954fA5oX/ESjxpwWwWChWHSbDnCxZN+A/l6hnb"; + + [TestMethod] + public void YcckJpeg_AdobeTransform2_DecodesPixelForPixelAgainstLibjpeg() + { + var png = _ycckFixture.ConvertToPng(); + var actual = ReadPngRgba8(png, 15, 11); + using var compressed = new MemoryStream(Convert.FromBase64String(YcckLibjpegRgba)); + using var zlib = new ZLibStream(compressed, CompressionMode.Decompress); + using var reference = new MemoryStream(); + zlib.CopyTo(reference); + var expected = reference.ToArray(); + actual.Length.AssertEqual(expected.Length); + var count = 0; + var peak = 0; + for (var i = 0; i < actual.Length; i++) + { + var difference = Math.Abs(actual[i] - expected[i]); + if (difference > peak) peak = difference; + if (difference > 4) count++; + } + if (count != 0) + Assert.Fail($"YCCK: {count} RGBA channels deviate > 4, peak={peak}; " + + $"at (0,0) actual=({actual[0]},{actual[1]},{actual[2]}) vs " + + $"reference=({expected[0]},{expected[1]},{expected[2]})."); + } + + [TestMethod] + public void YcckJpeg_ResizePreservesJpeg_AndWatermarkPreservesImageSize() + { + _ycckFixture.GetImageSize().Width.AssertEqual(15); + _ycckFixture.GetImageSize().Height.AssertEqual(11); + var resized = _ycckFixture.ResizeImage(8, 8); + (resized[0] == 0xFF && resized[1] == 0xD8).AssertTrue(); + resized.GetImageSize().Width.AssertEqual(8); + var marked = _ycckFixture.AddTextWatermark("Hello",opacity:0,margin:0); + marked.GetImageSize().Width.AssertEqual(15); + marked.GetImageSize().Height.AssertEqual(11); + } + [TestMethod] public void ProgressiveJpeg_CompareAllRgbPixelsAgainstLibjpeg() => CompareReference(_progressive, ProgressiveReference, 5); From 1a257d82824a626e18d625708ae1ba09ebd1981b Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 14:15:57 +0300 Subject: [PATCH 59/85] Fix switch-expression precedence in independent PNG filter oracle (y modulo 5) --- Tests/Drawing/PngCodecStressTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/Drawing/PngCodecStressTests.cs b/Tests/Drawing/PngCodecStressTests.cs index fde256023..b7a650a02 100644 --- a/Tests/Drawing/PngCodecStressTests.cs +++ b/Tests/Drawing/PngCodecStressTests.cs @@ -318,7 +318,7 @@ public void Png_AllLegalColorTypesAndBitDepths_AgreeWithIndependentPixelOracle() var left=j>=bpp?row[j-bpp]:0; var up=previous[j]; var upperLeft=j>=bpp?previous[j-bpp]:0; - var predictor = y%5 switch + var predictor = (y % 5) switch { 0=>0, 1=>left, 2=>up, 3=>(left+up)/2, 4=>Paeth(left,up,upperLeft), _=>0 From 173dea91eb4a4215fb6f4ae458360779ae6a6bed Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 14:16:50 +0300 Subject: [PATCH 60/85] TDD RED: enforce PNG IDAT chunk contiguity; independently test legal split IDAT and malformed chunk ordering --- Tests/Drawing/PngCodecStressTests.cs | 51 ++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/Tests/Drawing/PngCodecStressTests.cs b/Tests/Drawing/PngCodecStressTests.cs index b7a650a02..acbbaca5d 100644 --- a/Tests/Drawing/PngCodecStressTests.cs +++ b/Tests/Drawing/PngCodecStressTests.cs @@ -469,4 +469,55 @@ private static byte[] PackSamples(ReadOnlySpan samples,int depth) return result; } + + [TestMethod] + public void Png_IdatChunksSeparatedByAncillaryChunk_MustBeRejected() + { + // Valid PNG compressors may split one zlib stream into multiple IDAT + // chunks, but the chunks must be contiguous (PNG chunk ordering rule). + var original=Pack(7,5,Pattern(7,5),0,false); + var originalIdatLength=BinaryPrimitives.ReadInt32BigEndian(original.AsSpan(33,4)); + var compressed=original.AsSpan(41,originalIdatLength).ToArray(); + using var file=new MemoryStream(); + file.Write(original,0,33); + var split=compressed.Length/2; + Chunk(file,"IDAT",compressed.AsSpan(0,split).ToArray()); + Chunk(file,"tEXt",[77,97,114,107]); + Chunk(file,"IDAT",compressed.AsSpan(split).ToArray()); + Chunk(file,"IEND",[]); + ThrowsExactly(()=>file.ToArray().ConvertToPng()); + } + + [TestMethod] + public void Png_ConsecutiveIdatChunks_AreDecodedAsOneCompressedStream() + { + var original=Pack(7,5,Pattern(7,5),0,false); + var len=BinaryPrimitives.ReadInt32BigEndian(original.AsSpan(33,4)); + var compressed=original.AsSpan(41,len).ToArray(); + using var file=new MemoryStream(); + file.Write(original,0,33); + var split=compressed.Length/3; + Chunk(file,"IDAT",compressed.AsSpan(0,split).ToArray()); + Chunk(file,"IDAT",compressed.AsSpan(split,compressed.Length-split).ToArray()); + Chunk(file,"IEND",[]); + Compare(Pattern(7,5),DecodeOutput(file.ToArray().ConvertToPng(),7,5),7); + } + + [TestMethod] + public void Png_DuplicateIhdrOrMisorderedPalette_IsInvalid() + { + var original=Pack(3,3,Pattern(3,3),0,false); + using var duplicated=new MemoryStream(); + duplicated.Write(original,0,33); + duplicated.Write(original,8,25); // second complete IHDR + duplicated.Write(original,33,original.Length-33); + ThrowsExactly(()=>duplicated.ToArray().ConvertToPng()); + + using var misplaced=new MemoryStream(); + misplaced.Write(original,0,original.Length-12); // IHDR + IDAT + Chunk(misplaced,"PLTE",[0,0,0,255,255,255]); + Chunk(misplaced,"IEND",[]); + ThrowsExactly(()=>misplaced.ToArray().ConvertToPng()); + } + } From 7ee566db7ab4a8f0f5ee2721ae9a18ba9f4cc9ed Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 14:17:59 +0300 Subject: [PATCH 61/85] TDD GREEN: correct Adobe YCCK color inversion against libjpeg RGBA oracle in both JPEG decoders --- Drawing/JpegCodec.cs | 6 +++--- Drawing/JpegProgressiveCodec.cs | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Drawing/JpegCodec.cs b/Drawing/JpegCodec.cs index 042ddfc48..582f909de 100644 --- a/Drawing/JpegCodec.cs +++ b/Drawing/JpegCodec.cs @@ -389,9 +389,9 @@ public static RasterImage Decode(byte[] data) { var cb = Value(components[1], x, y, maxH, maxV) - 128; var cr = Value(components[2], x, y, maxH, maxV) - 128; - result.Pixels[off] = RasterImage.ToByte((yy + 1.402 * cr) * k / 255.0); - result.Pixels[off + 1] = RasterImage.ToByte((yy - 0.344136 * cb - 0.714136 * cr) * k / 255.0); - result.Pixels[off + 2] = RasterImage.ToByte((yy + 1.772 * cb) * k / 255.0); + result.Pixels[off] = RasterImage.ToByte((255 - RasterImage.ToByte(yy + 1.402 * cr)) * k / 255.0); + result.Pixels[off + 1] = RasterImage.ToByte((255 - RasterImage.ToByte(yy - 0.344136 * cb - 0.714136 * cr)) * k / 255.0); + result.Pixels[off + 2] = RasterImage.ToByte((255 - RasterImage.ToByte(yy + 1.772 * cb)) * k / 255.0); } else if (adobeTransform == 0) // Adobe inverted CMYK { diff --git a/Drawing/JpegProgressiveCodec.cs b/Drawing/JpegProgressiveCodec.cs index c87b3c325..073e304c4 100644 --- a/Drawing/JpegProgressiveCodec.cs +++ b/Drawing/JpegProgressiveCodec.cs @@ -387,9 +387,9 @@ public static RasterImage Decode(byte[] data) { var cb = b - 128; var cr = c - 128; - result.Pixels[offset] = RasterImage.ToByte((a + 1.402 * cr) * k / 255.0); - result.Pixels[offset + 1] = RasterImage.ToByte((a - 0.344136 * cb - 0.714136 * cr) * k / 255.0); - result.Pixels[offset + 2] = RasterImage.ToByte((a + 1.772 * cb) * k / 255.0); + result.Pixels[offset] = RasterImage.ToByte((255 - RasterImage.ToByte(a + 1.402 * cr)) * k / 255.0); + result.Pixels[offset + 1] = RasterImage.ToByte((255 - RasterImage.ToByte(a - 0.344136 * cb - 0.714136 * cr)) * k / 255.0); + result.Pixels[offset + 2] = RasterImage.ToByte((255 - RasterImage.ToByte(a + 1.772 * cb)) * k / 255.0); } else if (adobeTransform == 0) { From 288635c65971e57639148a52dbb0ffc50da387f8 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 14:18:30 +0300 Subject: [PATCH 62/85] TDD GREEN: reject interleaved IDAT chunks while accepting legal consecutive split IDAT streams --- Drawing/PngCodec.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Drawing/PngCodec.cs b/Drawing/PngCodec.cs index 23bed77dc..3163d21dd 100644 --- a/Drawing/PngCodec.cs +++ b/Drawing/PngCodec.cs @@ -44,6 +44,7 @@ public static RasterImage Decode(byte[] data) var transparency = Array.Empty(); var gotHeader = false; var gotData = false; + var dataClosed = false; var ended = false; using var compressed = new MemoryStream(); var offset = 8; @@ -60,6 +61,11 @@ public static RasterImage Decode(byte[] data) if (actualCrc != Crc(data.AsSpan(offset + 4, (int)length + 4))) throw new InvalidDataException("PNG CRC mismatch."); + // PNG requires all IDAT chunks to appear consecutively. + // Ancillary chunks may follow IDAT, but cannot split its sequence. + if (gotData && !tag.SequenceEqual("IDAT"u8) && !tag.SequenceEqual("IEND"u8)) + dataClosed = true; + if (tag.SequenceEqual("IHDR"u8)) { if (gotHeader || offset != 8 || length != 13) @@ -88,6 +94,8 @@ public static RasterImage Decode(byte[] data) } else if (tag.SequenceEqual("IDAT"u8)) { + if (dataClosed) + throw new InvalidDataException("PNG IDAT chunks must be contiguous."); if (!gotHeader) throw new InvalidDataException("PNG data precedes header."); gotData = true; From 25b9a356c55e7f714206f6747e7a83206c00e39d Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 14:19:32 +0300 Subject: [PATCH 63/85] TDD: independent golden pixel fixtures for baseline and progressive JPEG restart markers --- Tests/Drawing/ImageExtendedTddTests.cs | 37 ++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/Tests/Drawing/ImageExtendedTddTests.cs b/Tests/Drawing/ImageExtendedTddTests.cs index 3b9374cca..8d52c341e 100644 --- a/Tests/Drawing/ImageExtendedTddTests.cs +++ b/Tests/Drawing/ImageExtendedTddTests.cs @@ -331,4 +331,41 @@ private static byte[] ReadPngRgba8(byte[] png, int width = 16, int height = 16) return result; } + + // Created by libjpeg with restart_marker_blocks=1. Both have real DRI and + // RST0 markers, exercising predictor and bit alignment reset logic. + private static readonly byte[] _restartBaseline = Convert.FromBase64String( + "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQECAgMCAgICAgQDAwIDBQQFBQUEBAQFBgcGBQUHBgQEBgkGBwgICAgIBQYJCgkICgcICAj/wAALCAAIABABAREA/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/90ABAAB/9oACAEBAAA/AM//AIJk/wDBKX/kXv8Aim/7n/LL6V//0OP/AG5f+CjPir4h+Krb9i3/AIJc+PvEHgv4baLqEK+M/jJ4R1CSzvvFV9BIGFh4d1C3ZZIdMjkQebfwsGvmXZCwswz6h//Z"); + private static readonly byte[] _restartProgressive = Convert.FromBase64String( + "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAICAgICAQICAgIDAgIDAwYEAwMDAwcFBQQGCAcJCAgHCAgJCg0LCQoMCggICw8LDA0ODg8OCQsQERAOEQ0ODg7/wgALCAAIABABAREA/8QAFQABAQAAAAAAAAAAAAAAAAAAAwT/3QAEAAH/2gAIAQEAAAABP//Qj//EABgQAAIDAAAAAAAAAAAAAAAAAAQGAAMF/9oACAEBAAEFAllUn//Q3GO0gr//xAAjEAABAgILAAAAAAAAAAAAAAADAQIFQgAEBhEjJDJBYnGh/9oACAEBAAY/Ah4flP/QSC2XO4NWG7MRADrnFVJRuSXlv1q//8QAGxABAAEFAQAAAAAAAAAAAAAAAQARIUFRYaH/2gAIAQEAAT8h8mP/0CJ5r7LYBS45M4//2gAIAQEAAAAQf//Qf//EABYQAQEBAAAAAAAAAAAAAAAAAAERAP/aAAgBAQABPxDYf//QPfeOsbgIWiyoXP8A/9k="); + private const string RestartBaselineLibjpegRgba = + "eJw9zjGogWEAheFfJmUhBklKkqQsipIYJMnAYlCEQZIki5RBiSRJklKiDAZSkqSUEoOBspAkpVgYpDCgzr33G+7Zn95DURRYLBZEIhGUSiUMBgPsdjt8Ph+i0SgymQwqlQparRZGoxEWiwX2+z1utxv+xmazIRaLiTUajcT6/X7EYjFks1lUq1V0Oh2Msl0scj0fc73dQv90/K5FIoFarYTKZ4HA4EAgEEI/HkcvlUKvV0G63MZlMsFqtcDgc8Hg8QKfTweFw/q3ZbCY2GAwSm8/nUa/X0e12MZ1OiT2dTni9XqDRaOByuZBKpVCpVLBYLHA6nQiFQkgkEigUCmg0Guj1epjNZliv1zifz3g+n2AwGODz+cRqtVry2eVyIRwOE1ssFont9/vEbjYbYt/vN7E8Hg8ymQwajYZ03W43IpEIkskkSqUSms0mBoMB5vM5ttstLpcLPp8PsQKBAHK5HHq9HlarFV6vl3RTqRTK5TKxw+GQ2N1uh+v1iu/3CyaTCaFQCIVCAZ1OB5vNBo/HQ7rpdBo/g1M5nw=="; + private const string RestartProgressiveLibjpegRgba = + "eJwd0L2rMXAYxnEDsiApkjLIaymlhLNRBkkyKBKySBmIJEk6nRjEoGRCiSgGg8Gk5CVlIAPJIMoimeic4ajrPM/vD/j0va+bQqGAw+FALBZDo9HAbDbD6XQiGAwimUwin8+jVquh0+lgMplguVzidDrh+Xzi9/cXXC4XEokEWq2WWJfLhVAohFQqhUKhgEajgV6vh/F4jM1mg8vlgsfjAcq/Lo/Hg0wmg06ng8VigdvtRjgcRjqdRrFYRL1eR7/fx3Q6xXq9JvZ/l0ajgcViQSqVQq/XE+vxeIjNZDIolUqkOxgMMJvNsNvtcD6f8Xq9QKVSwefzoVQqYTAYYLPZ4PP5EIlEkM1mUS6X0Ww2MRwOMZ/Psd1ucb1eyVY6nQ6hUAi5XI6Pjw9YrVb4/X5Eo1F8fn4S22q1iF0sFtjv97jf7/j+/gaDwYBAIIBCoSDWbreTm2OxGL6+vlCpVNButzEajciPD4cDbrcbfn5+wGazSVelUsFoNBLr9XqRSCSQy+VQrVbR7XaJXa1WOB6PpPt+v8FkMiESiaBWq2EymeBwOBAIBBCPx0n3D3JnOZM="; + + [TestMethod] + public void BaselineJpeg_RestartMarkers_EveryPixelMatchesIndependentDecoder() + => AssertRestartPixels(_restartBaseline, RestartBaselineLibjpegRgba); + + [TestMethod] + public void ProgressiveJpeg_RestartMarkers_EveryPixelMatchesIndependentDecoder() + => AssertRestartPixels(_restartProgressive, RestartProgressiveLibjpegRgba); + + private static void AssertRestartPixels(byte[] jpeg, string compressedReference) + { + var actual = ReadPngRgba8(jpeg.ConvertToPng(), 16, 8); + using var input = new MemoryStream(Convert.FromBase64String(compressedReference)); + using var zlib = new ZLibStream(input, CompressionMode.Decompress); + using var raw = new MemoryStream(); + zlib.CopyTo(raw); + var expected = raw.ToArray(); + expected.Length.AssertEqual(actual.Length); + for (var i = 0; i < expected.Length; i++) + { + if (Math.Abs(expected[i] - actual[i]) > 4) + Assert.Fail($"Restart JPEG pixel ({i/4%16},{i/4/16}), channel {"RGBA"[i%4]}: " + + $"actual {actual[i]}, expected {expected[i]}."); + } + } + } From 99c631bf2f9cc9e26c16e7207d3ed3e0616e0412 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 14:21:42 +0300 Subject: [PATCH 64/85] Harden TrueType tests with malformed font headers, glyph offsets and 300 fuzzed TTF inputs --- Tests/Drawing/ImagePixelGoldenTests.cs | 85 ++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/Tests/Drawing/ImagePixelGoldenTests.cs b/Tests/Drawing/ImagePixelGoldenTests.cs index f3958b6a1..b1cdd1439 100644 --- a/Tests/Drawing/ImagePixelGoldenTests.cs +++ b/Tests/Drawing/ImagePixelGoldenTests.cs @@ -440,4 +440,89 @@ public void Watermark_ZeroOpacityDoesNotNeedInstalledOrProvidedFont() ComparePixels(pixelBytes, Pixels(output, 32, 16), 32, 16); } + + [TestMethod] + public void Watermark_MalformedTrueTypeTableAndGlyphHeaders_FailWithControlledExceptions() + { + var font = BuildTestTrueType(); + var path = Path.Combine(Path.GetTempPath(), $"ecng-font-invalid-{Guid.NewGuid():N}.ttf"); + var source = CreateRgbaPng(64, 48, new byte[64*48*4]); + var head = TestFontTableOffset(font,"head"); + var hhea = TestFontTableOffset(font,"hhea"); + var maxp = TestFontTableOffset(font,"maxp"); + var cmap = TestFontTableOffset(font,"cmap"); + var loca = TestFontTableOffset(font,"loca"); + + void MustReject(Action mutate) + { + var copy = (byte[])font.Clone(); + mutate(copy); + File.WriteAllBytes(path,copy); + try + { + source.AddTextWatermark("I",fontSize:18,margin:8,fontFilePath:path); + Assert.Fail("A malformed TrueType font was accepted."); + } + catch(InvalidDataException) {} + catch(NotSupportedException) {} + } + + try + { + MustReject(bytes=>bytes[0]=0); + MustReject(bytes=>BinaryPrimitives.WriteUInt16BigEndian(bytes.AsSpan(4,2),0)); + MustReject(bytes=>BinaryPrimitives.WriteUInt16BigEndian(bytes.AsSpan(head+18,2),0)); + MustReject(bytes=>BinaryPrimitives.WriteUInt16BigEndian(bytes.AsSpan(head+50,2),3)); + MustReject(bytes=>BinaryPrimitives.WriteUInt16BigEndian(bytes.AsSpan(hhea+34,2),0)); + MustReject(bytes=>BinaryPrimitives.WriteUInt16BigEndian(bytes.AsSpan(maxp+4,2),0)); + MustReject(bytes=>BinaryPrimitives.WriteUInt16BigEndian(bytes.AsSpan(loca+4,2),0xFFFF)); + MustReject(bytes=>BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(cmap+8,4),0x7FFFFFFF)); + MustReject(bytes=>BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(12+8,4),0x7FFFFFFF)); + } + finally { File.Delete(path); } + } + + [TestMethod] + public void Watermark_FuzzHundredsOfMalformedTrueTypeFiles_NoRuntimeCrashes() + { + var font=BuildTestTrueType(); + var rng=new Random(420071); + var path=Path.Combine(Path.GetTempPath(),$"ecng-ttf-fuzz-{Guid.NewGuid():N}.ttf"); + var input=CreateRgbaPng(64,48,new byte[64*48*4]); + + try + { + for(var iteration=0;iteration<300;iteration++) + { + var data=(byte[])font.Clone(); + var corruptionCount=1+rng.Next(4); + for(var j=0;j Date: Sat, 10 Oct 2026 14:22:40 +0300 Subject: [PATCH 65/85] Fix independently generated baseline restart PNG oracle Base64 transcription --- Tests/Drawing/ImageExtendedTddTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/Drawing/ImageExtendedTddTests.cs b/Tests/Drawing/ImageExtendedTddTests.cs index 8d52c341e..2cb59ceb9 100644 --- a/Tests/Drawing/ImageExtendedTddTests.cs +++ b/Tests/Drawing/ImageExtendedTddTests.cs @@ -339,7 +339,7 @@ private static byte[] ReadPngRgba8(byte[] png, int width = 16, int height = 16) private static readonly byte[] _restartProgressive = Convert.FromBase64String( "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAICAgICAQICAgIDAgIDAwYEAwMDAwcFBQQGCAcJCAgHCAgJCg0LCQoMCggICw8LDA0ODg8OCQsQERAOEQ0ODg7/wgALCAAIABABAREA/8QAFQABAQAAAAAAAAAAAAAAAAAAAwT/3QAEAAH/2gAIAQEAAAABP//Qj//EABgQAAIDAAAAAAAAAAAAAAAAAAQGAAMF/9oACAEBAAEFAllUn//Q3GO0gr//xAAjEAABAgILAAAAAAAAAAAAAAADAQIFQgAEBhEjJDJBYnGh/9oACAEBAAY/Ah4flP/QSC2XO4NWG7MRADrnFVJRuSXlv1q//8QAGxABAAEFAQAAAAAAAAAAAAAAAQARIUFRYaH/2gAIAQEAAT8h8mP/0CJ5r7LYBS45M4//2gAIAQEAAAAQf//Qf//EABYQAQEBAAAAAAAAAAAAAAAAAAERAP/aAAgBAQABPxDYf//QPfeOsbgIWiyoXP8A/9k="); private const string RestartBaselineLibjpegRgba = - "eJw9zjGogWEAheFfJmUhBklKkqQsipIYJMnAYlCEQZIki5RBiSRJklKiDAZSkqSUEoOBspAkpVgYpDCgzr33G+7Zn95DURRYLBZEIhGUSiUMBgPsdjt8Ph+i0SgymQwqlQparRZGoxEWiwX2+z1utxv+xmazIRaLiTUajcT6/X7EYjFks1lUq1V0Oh2Msl0scj0fc73dQv90/K5FIoFarYTKZ4HA4EAgEEI/HkcvlUKvV0G63MZlMsFqtcDgc8Hg8QKfTweFw/q3ZbCY2GAwSm8/nUa/X0e12MZ1OiT2dTni9XqDRaOByuZBKpVCpVLBYLHA6nQiFQkgkEigUCmg0Guj1epjNZliv1zifz3g+n2AwGODz+cRqtVry2eVyIRwOE1ssFont9/vEbjYbYt/vN7E8Hg8ymQwajYZ03W43IpEIkskkSqUSms0mBoMB5vM5ttstLpcLPp8PsQKBAHK5HHq9HlarFV6vl3RTqRTK5TKxw+GQ2N1uh+v1iu/3CyaTCaFQCIVCAZ1OB5vNBo/HQ7rpdBo/g1M5nw=="; + "eJw9zjGogWEAheFfJmUhBklKkqQsipIYJMnAYlCEQZIki5RBiSRJklKiDAZSkqSUEoOBspAkpVgYpDCgzr33G+7Zn95DURRYLBZEIhGUSiUMBgPsdjt8Ph+i0SgymQwqlQparRZGoxEWiwX2+z1utxv+xmazIRaLiTUajcT6/X7EYjFks1lUq1V0Oh2Mx2Msl0scj0fc73dQv90/K5FIoFarYTKZ4HA4EAgEEI/HkcvlUKvV0G63MZlMsFqtcDgc8Hg8QKfTweFw/q3ZbCY2GAwSm8/nUa/X0e12MZ1OiT2dTni9XqDRaOByuZBKpVCpVLBYLHA6nQiFQkgkEigUCmg0Guj1epjNZliv1zifz3g+n2AwGODz+cRqtVry2eVyIRwOE1ssFont9/vEbjYbYt/vN7E8Hg8ymQwajYZ03W43IpEIkskkSqUSms0mBoMB5vM5ttstLpcLPp8PsQKBAHK5HHq9HlarFV6vl3RTqRTK5TKxw+GQ2N1uh+v1iu/3CyaTCaFQCIVCAZ1OB5vNBo/HQ7rpdBo/g1M5nw=="; private const string RestartProgressiveLibjpegRgba = "eJwd0L2rMXAYxnEDsiApkjLIaymlhLNRBkkyKBKySBmIJEk6nRjEoGRCiSgGg8Gk5CVlIAPJIMoimeic4ajrPM/vD/j0va+bQqGAw+FALBZDo9HAbDbD6XQiGAwimUwin8+jVquh0+lgMplguVzidDrh+Xzi9/cXXC4XEokEWq2WWJfLhVAohFQqhUKhgEajgV6vh/F4jM1mg8vlgsfjAcq/Lo/Hg0wmg06ng8VigdvtRjgcRjqdRrFYRL1eR7/fx3Q6xXq9JvZ/l0ajgcViQSqVQq/XE+vxeIjNZDIolUqkOxgMMJvNsNvtcD6f8Xq9QKVSwefzoVQqYTAYYLPZ4PP5EIlEkM1mUS6X0Ww2MRwOMZ/Psd1ucb1eyVY6nQ6hUAi5XI6Pjw9YrVb4/X5Eo1F8fn4S22q1iF0sFtjv97jf7/j+/gaDwYBAIIBCoSDWbreTm2OxGL6+vlCpVNButzEajciPD4cDbrcbfn5+wGazSVelUsFoNBLr9XqRSCSQy+VQrVbR7XaJXa1WOB6PpPt+v8FkMiESiaBWq2EymeBwOBAIBBCPx0n3D3JnOZM="; From b0e1a26f619b996eccf6f7c389135517d4ca9e71 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 14:24:08 +0300 Subject: [PATCH 66/85] TDD GREEN: convert invalid TrueType offsets into controlled data exceptions and correct mutated signature oracle --- Drawing/TrueTypeFont.cs | 30 +++++++++++++++++++------- Tests/Drawing/ImagePixelGoldenTests.cs | 2 +- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/Drawing/TrueTypeFont.cs b/Drawing/TrueTypeFont.cs index 59d0b1762..28b6c4226 100644 --- a/Drawing/TrueTypeFont.cs +++ b/Drawing/TrueTypeFont.cs @@ -100,8 +100,8 @@ public TrueTypeFont(string path) { var pos = 12 + i * 16; var tag = Encoding.ASCII.GetString(_file, pos, 4); - var offset = checked((int)U32(pos + 8)); - var length = checked((int)U32(pos + 12)); + var offset = SafeU32(U32(pos + 8)); + var length = SafeU32(U32(pos + 12)); Require(offset, length); _tables[tag] = (offset, length); } @@ -269,8 +269,8 @@ private Glyph LoadGlyph(int glyphId, int depth) var glyph = new Glyph { Advance = U16(_hmtx + Math.Min(glyphId, _metricsCount - 1) * 4) }; var offset = _loca + glyphId * (_locaFormat == 0 ? 2 : 4); - var start = _locaFormat == 0 ? U16(offset) * 2 : checked((int)U32(offset)); - var end = _locaFormat == 0 ? U16(offset + 2) * 2 : checked((int)U32(offset + 4)); + var start = _locaFormat == 0 ? U16(offset) * 2 : SafeU32(U32(offset)); + var end = _locaFormat == 0 ? U16(offset + 2) * 2 : SafeU32(U32(offset + 4)); if (start == end) { _cache[glyphId] = glyph; return glyph; } if (end < start || end > _tables["glyf"].length) @@ -415,7 +415,7 @@ private int SelectUnicodeCmap(int cmap) var pos = cmap + 4 + i * 8; var platform = U16(pos); var encoding = U16(pos + 2); - var location = checked((int)U32(pos + 4)); + var location = SafeU32(U32(pos + 4)); var candidate = cmap + location; if (candidate < 0 || candidate + 2 > _file.Length || !(platform == 0 || (platform == 3 && encoding is 1 or 10))) continue; @@ -439,8 +439,9 @@ private int Map(int codepoint) var format = U16(_cmap); if (format == 12) { - var count = checked((int)U32(_cmap + 12)); - Require(_cmap + 16, checked(count * 12)); + var count = SafeU32(U32(_cmap + 12)); + if (count > int.MaxValue / 12) throw new InvalidDataException("TrueType cmap group count exceeds supported size."); + Require(_cmap + 16, count * 12); var lo = 0; var hi = count - 1; while (lo <= hi) @@ -451,7 +452,13 @@ private int Map(int codepoint) var last = U32(at + 4); if (codepoint < first) hi = i - 1; else if (codepoint > last) lo = i + 1; - else return checked((int)(U32(at + 8) + codepoint - first)); + else + { + var mapped = (long)U32(at + 8) + codepoint - first; + if (mapped < 0 || mapped > int.MaxValue) + throw new InvalidDataException("TrueType cmap glyph index overflows."); + return (int)mapped; + } } return 0; } @@ -496,6 +503,13 @@ private void Require(int pos, int len) throw new InvalidDataException("Truncated TrueType font data."); } + private static int SafeU32(uint value) + { + if (value > int.MaxValue) + throw new InvalidDataException("TrueType table offsets and counts must fit a signed 32-bit value."); + return (int)value; + } + private ushort U16(int at) { Require(at, 2); return BinaryPrimitives.ReadUInt16BigEndian(_file.AsSpan(at, 2)); } private short I16(int at) { Require(at, 2); return BinaryPrimitives.ReadInt16BigEndian(_file.AsSpan(at, 2)); } private uint U32(int at) { Require(at, 4); return BinaryPrimitives.ReadUInt32BigEndian(_file.AsSpan(at, 4)); } diff --git a/Tests/Drawing/ImagePixelGoldenTests.cs b/Tests/Drawing/ImagePixelGoldenTests.cs index b1cdd1439..ade7cf695 100644 --- a/Tests/Drawing/ImagePixelGoldenTests.cs +++ b/Tests/Drawing/ImagePixelGoldenTests.cs @@ -469,7 +469,7 @@ void MustReject(Action mutate) try { - MustReject(bytes=>bytes[0]=0); + MustReject(bytes=>bytes[0]=0xff); MustReject(bytes=>BinaryPrimitives.WriteUInt16BigEndian(bytes.AsSpan(4,2),0)); MustReject(bytes=>BinaryPrimitives.WriteUInt16BigEndian(bytes.AsSpan(head+18,2),0)); MustReject(bytes=>BinaryPrimitives.WriteUInt16BigEndian(bytes.AsSpan(head+50,2),3)); From 8449c326abb16172087db90f0c49958f0d488ad6 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 14:26:53 +0300 Subject: [PATCH 67/85] Gate image-codec coverage at 90% lines / 85% branches and document Cobertura reports --- .github/workflows/drawing-coverage.yml | 8 ++++++++ Drawing/README.md | 3 +++ 2 files changed, 11 insertions(+) diff --git a/.github/workflows/drawing-coverage.yml b/.github/workflows/drawing-coverage.yml index 8054460d1..5fb874633 100644 --- a/.github/workflows/drawing-coverage.yml +++ b/.github/workflows/drawing-coverage.yml @@ -46,6 +46,14 @@ jobs: print('Ecng.Drawing:') print(f' lines: {100 * float(root.attrib.get("line-rate", 0)):.1f}%') print(f' branches: {100 * float(root.attrib.get("branch-rate", 0)):.1f}%') + line_rate = float(root.attrib.get('line-rate', 0)) + branch_rate = float(root.attrib.get('branch-rate', 0)) + # CI regression gate for the Drawing assembly when running the + # Drawing test suite. Do not weaken these to get a green build. + if line_rate < 0.90 or branch_rate < 0.85: + raise SystemExit( + f'Coverage regression: lines {line_rate:.1%} (min 90%), ' + f'branches {branch_rate:.1%} (min 85%)') for entry in root.findall('.//class'): path = entry.get('filename', '') rate = 100 * float(entry.get('line-rate', 0)) diff --git a/Drawing/README.md b/Drawing/README.md index 3a5ba2044..c49499fa4 100644 --- a/Drawing/README.md +++ b/Drawing/README.md @@ -312,6 +312,9 @@ Verdana is a separately licensed font and is **not** included in the library. Th ### Testing +CI includes a dedicated [Drawing coverage](https://github.com/StockSharp/Ecng/actions/workflows/drawing-coverage.yml) workflow. It instruments `Ecng.Drawing` with Coverlet, uploads the Cobertura XML report, prints per-class line/branch percentages, and **fails if line coverage drops below 90% or branch coverage drops below 85%**. The report covers the Drawing assembly as exercised by the Drawing test suite; it is not proof of complete path coverage or a security audit. + + `Tests/Drawing` contains pixel-by-pixel golden fixtures generated independently of the implementation, progressive/CMYK JPEG fixtures with full libjpeg-decoded RGBA reference buffers, exact transparency/resizing calculations, an independently generated deterministic TrueType glyph, PNG Adam7/filter stress tests, randomized PNG roundtrips and malformed-input tests. Test-only reference data uses Pillow/libjpeg; runtime code and the CI test project do not require Python or imaging NuGet packages. ## Usage Examples From 0f9e1644c174881241305f6ac781e5cb720ebd6a Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 14:48:10 +0300 Subject: [PATCH 68/85] TDD RED: independent BMP pixel fixtures and animated GIF all-frame/loop/disposal/APNG/watermark regressions --- Tests/Drawing/BmpGifTddTests.cs | 239 ++++++++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 Tests/Drawing/BmpGifTddTests.cs diff --git a/Tests/Drawing/BmpGifTddTests.cs b/Tests/Drawing/BmpGifTddTests.cs new file mode 100644 index 000000000..de24a5549 --- /dev/null +++ b/Tests/Drawing/BmpGifTddTests.cs @@ -0,0 +1,239 @@ +namespace Ecng.Tests.Drawing; + +using System; +using System.Buffers.Binary; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; + +using Ecng.Drawing; + +/// +/// Independent Pillow-created BMP/GIF fixtures and byte-level APNG/GIF assertions. +/// TDD: tests committed before any GIF/BMP production decoder. +/// +[TestClass] +public class BmpGifTddTests : BaseTestClass +{ + private static readonly byte[] _rgbBmp = Convert.FromBase64String("Qk1OAAAAAAAAADYAAAAoAAAAAwAAAAIAAAABABgAAAAAABgAAADEDgAAxA4AAAAAAAAAAAAAAP////8A/wD/AAAAAAD/AP8A/wAAAAAA"); + private static readonly byte[] _rgbaBmp = Convert.FromBase64String("Qk1OAAAAAAAAADYAAAAoAAAAAwAAAAIAAAABACAAAAAAABgAAADEDgAAxA4AAAAAAAAAAAAAAP//ZP//AAD/AP/cAAD//wD/AP//AAD/"); + // GIF89a, four independently encoded indexed frames, durations 50/120/200/70ms, + // loop=2, disposal modes 2/3/2/1. Generated using Pillow, not Ecng codec. + private static readonly byte[] _animated = Convert.FromBase64String( + "R0lGODlhBAAEAIEAAAAAAP8AAAAAAAAAACH/C05FVFNDQVBFMi4wAwECAAAh+QQJBQAAACwAAAAABAAEAAAIDQADABgokOBAAAUBBAQAIfkEDQwAAAAsAQAAAAEABACBAAAAAP8AAAAAAAAACAYAAwgMEBAAIfkECRQAAAAsAQAAAAIABACBAAAAAAD/AAAAAAAACAgAAQQQSDBAQAAh+QQFBwAAACwDAAAAAQAEAIEAAAD//wAAAAAAAAAIBgADCAwQEAA7"); + private static readonly byte[] _watermarkGif = Convert.FromBase64String( + "R0lGODlhQAAwAIEAAAAAAP8AAAAAAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQJBAAAACwAAAAAQAAwAAAI/wADCBwoEIDBgwgTAiBIUKHDgwwHPoxY8KFDigEsXqQ4kaPGhBg/gvSoMKRIiCRPmiyZUuRKlS1RRjwpkyFNgy8R5vy4U2PPhTF9BrX48ybQmTeLGt2IlKbSpSObwpQKVShVnkOrRrWZNKvWmg27Xv26NaxTr2SflpVI9ijXs2OJDlWrE+3aiizjdtTL9O3djGL9upxrty7fvILBsoWb2Gpjt2anPt77mK5ivJIxRh58+C/lzVg7G65c+DJgxqBxllYt2vTnxZlhcybdmvXkvqkhyw59G3Fuy7Zzv8asFbhu4m1HC8e9O3nw5nJrLzVu3Cj11dOxVw+83Df05NelDyU/HRu549/a08cN39szc/PR2yuHzr67e+/wx1snLL0+ffXymRYQACH5BA0HAAAALAAAAABAADAAgQAAAAD/AAAAAAAAAAj/AAEIHEiwIIAACBMiNMhwoEKFDSNKLPgw4USKFQNc3Mgwo0aOBzOCHBmy4kiPJEGiVCky5caVHGG6lCjzYs2ZHVvG1InzJk2eNoE6FPrTJEujBn1GVNpQKdOcSF8SLfnw5NSmU58mvQq1aleIR73ujEpQK0ayEz2aLct1K1qBa4e+XZq17VmxUufGhWuXrd6+cvEG/TsXa+GvFhEvDAt2rODAjfM+LjqZ5F6qkXEq/ug4s+a7nimH/sz3sNvKpC9ftmoadGLSp0cbRv1ZNWDRryXL3jx7d+zcgyvbbu1X+G3MwNPWJQ45Oe7Fv6Hrdk6XMO3onKdLL+7b9Xblaq97Ks8efPRw8dypn++e/ntp9M3dVzfO/D37+OTxs4ZvX/3x9f7Vh5x8vVEXEAAh+QQFCQAAACwAAAAAQAAwAIEAAAAAAP8AAAAAAAAI/wABCBwoMIDBgwYJKlwIACFChhAbOkzIcCLFiAotBsC4UCNEjxwJggxZ0OJHkyRLTkyp0uHJlSxHkpQpEmVKmhxxtnwY0+ZMnzVh3gSaUyNRjDojJk360mVPoRmPKpXalGdHqhWxXoUa1OlQrkXB7jzIUqLYqWeZZj1blexarz/ZvrVaNqrcrXDrhs27l65epFrt8v071+3XwYQF++1rOHHbi3EROx4LOaRax5cVNyacuetiwGkDe97MuPLAzqdFpw59V7Np0IhRU974NLbq2WWX3jYrufBr3p/R9sZrlDTs4I9pEzcuHLlv5a6hW96tu/Xo38mz13a+/Lds4My1dyqXXpr8cebf0+8WH5l7YvXW/8If/p76+u3hn09unn/8fvbTxafXfO4RFBAAOw=="); + + [TestMethod] + public void Bmp_IndependentPillowRgb24_DimensionsAndEveryPixel() + { + _rgbBmp.GetImageSize().Width.AssertEqual(3); + _rgbBmp.GetImageSize().Height.AssertEqual(2); + var png = _rgbBmp.ConvertToPng(); + var expected = new byte[] + { + 255,0,0,255,0,255,0,255,0,0,255,255, + 255,255,0,255,0,255,255,255,255,0,255,255 + }; + Compare(expected, Pixels(png,3,2)); + } + + [TestMethod] + public void Bmp_PillowRgb24_ResizePreservesFormat_AndNoUpscaleIsByteIdentical() + { + var source=(byte[])_rgbBmp.Clone(); + var same=source.ResizeImage(100,100); + Compare(source,same); + var resized=source.ResizeImage(2,2); + (resized[0]=='B' && resized[1]=='M').AssertTrue(); + resized.GetImageSize().Width.AssertEqual(2); + resized.GetImageSize().Height.AssertEqual(1); + Compare(source,_rgbBmp); + } + + [TestMethod] + public void Bmp_Watermark_ProducesBmpAndDoesNotAlterSource() + { + var source=(byte[])_rgbaBmp.Clone(); + var marked=source.AddTextWatermark("hello",opacity:0,margin:0); + (marked[0]=='B' && marked[1]=='M').AssertTrue(); + Compare(Pixels(source.ConvertToPng(),3,2),Pixels(marked.ConvertToPng(),3,2)); + Compare(source,_rgbaBmp); + } + + [TestMethod] + public void Gif_PillowFourFrameFixture_InspectEveryCompositedFrameAndTiming() + { + _animated.GetImageSize().Width.AssertEqual(4); + _animated.GetImageSize().Height.AssertEqual(4); + var frames = _animated.GetGifFrames(); + frames.Count.AssertEqual(4); + var times = new[] { 50,120,200,70 }; + byte[][] colors = + [ + [255,0,0,255], [0,255,0,255], + [0,0,255,255], [255,255,0,255] + ]; + + for (var frame=0;frame<4;frame++) + { + frames[frame].delayMilliseconds.AssertEqual(times[frame]); + var rgba=Pixels(frames[frame].png,4,4); + for(var y=0;y<4;y++) + for(var x=0;x<4;x++) + { + var offset=(y*4+x)*4; + var wanted = x==frame ? colors[frame] : new byte[]{0,0,0,0}; + for(var c=0;c<4;c++) + rgba[offset+c].AssertEqual(wanted[c]); + } + } + _animated.GetGifLoopCount().AssertEqual(2); + } + + [TestMethod] + public void Gif_Resize_AllFourFramesStayAnimated_PreservesDelayAndLoops() + { + var output=_animated.ResizeImage(2,2); + (output[0]=='G' && output[1]=='I' && output[2]=='F').AssertTrue(); + output.GetImageSize().Width.AssertEqual(2); + output.GetImageSize().Height.AssertEqual(2); + output.GetGifLoopCount().AssertEqual(2); + var frames=output.GetGifFrames(); + frames.Count.AssertEqual(4); + var delays=new[]{50,120,200,70}; + for(var i=0;i<4;i++) + { + frames[i].delayMilliseconds.AssertEqual(delays[i]); + frames[i].png.GetImageSize().Width.AssertEqual(2); + frames[i].png.GetImageSize().Height.AssertEqual(2); + } + } + + [TestMethod] + public void Gif_ConvertToPng_PreservesEveryFrameAsApng() + { + var apng=_animated.ConvertToPng(); + apng.GetImageSize().Width.AssertEqual(4); + apng.GetImageSize().Height.AssertEqual(4); + var chunks=ReadPngChunks(apng); + var animation=chunks.Single(x=>x.tag=="acTL").data; + BinaryPrimitives.ReadUInt32BigEndian(animation.AsSpan(0,4)).AssertEqual((uint)4); + BinaryPrimitives.ReadUInt32BigEndian(animation.AsSpan(4,4)).AssertEqual((uint)2); + chunks.Count(x=>x.tag=="fcTL").AssertEqual(4); + chunks.Count(x=>x.tag=="fdAT").AssertEqual(3); + Compare(Pixels(_animated.GetGifFrames()[0].png,4,4),Pixels(apng,4,4)); + } + + [TestMethod] + public void Gif_Watermark_EachOfThreeFramesChanged_AnimationPreserved() + { + var file=Path.Combine(Path.GetTempPath(),$"ecng-gif-{Guid.NewGuid():N}.ttf"); + File.WriteAllBytes(file,ImagePixelGoldenTests.BuildTestTrueType()); + try + { + var source=(byte[])_watermarkGif.Clone(); + var expected=source.GetGifFrames(); + var result=source.AddTextWatermark("I",fontSize:18,opacity:255, + margin:8,fontFilePath:file); + (result[0]=='G' && result[1]=='I' && result[2]=='F').AssertTrue(); + result.GetGifLoopCount().AssertEqual(0); + var frames=result.GetGifFrames(); + frames.Count.AssertEqual(3); + for(var i=0;i(()=>_animated[..length].ConvertToPng()); + + var random=new Random(18057); + for(var iter=0;iter<100;iter++) + { + var bytes=(byte[])_animated.Clone(); + var index=random.Next(13,bytes.Length-1); + bytes[index]^=(byte)(1< 0 && frames.Count <= 512).AssertTrue(); + } + catch(InvalidDataException) {} + catch(NotSupportedException) {} + } + } + + private static void Compare(byte[] expected,byte[] actual) + { + expected.Length.AssertEqual(actual.Length); + for(var i=0;i(); + for(var pos=8;pos+12<=png.Length;) + { + var length=BinaryPrimitives.ReadInt32BigEndian(png.AsSpan(pos,4)); + if(length<0||length>png.Length-pos-12) + throw new InvalidDataException("PNG fixture chunk bounds."); + var tag=Encoding.ASCII.GetString(png,pos+4,4); + list.Add((tag,png.AsSpan(pos+8,length).ToArray())); + pos+=length+12; + } + return list.ToArray(); + } +} From 75111549aeafe0bf1458e51505592dc11fcff811 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 14:49:19 +0300 Subject: [PATCH 69/85] TDD GREEN stage 1: managed BMP palette/true-color/bitfields/RLE decoder and BGRA V4 encoder --- Drawing/BmpCodec.cs | 312 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 Drawing/BmpCodec.cs diff --git a/Drawing/BmpCodec.cs b/Drawing/BmpCodec.cs new file mode 100644 index 000000000..c2ca651bc --- /dev/null +++ b/Drawing/BmpCodec.cs @@ -0,0 +1,312 @@ +namespace Ecng.Drawing; + +using System; +using System.Buffers.Binary; +using System.IO; +using System.Numerics; + +/// +/// BMP/DIB in pure managed code. Palette 1/4/8bpp, RGB555/RGB565 and BGRA32, +/// RGB24, BI_BITFIELDS and RLE4/RLE8 are supported. The encoder emits V4 BGRA32. +/// +internal static class BmpCodec +{ + public static bool IsBmp(ReadOnlySpan data) => + data.Length >= 2 && data[0] == (byte)'B' && data[1] == (byte)'M'; + + public static (int width, int height) ReadSize(ReadOnlySpan data) + { + if (!IsBmp(data) || data.Length < 26) + throw new InvalidDataException("BMP file header is incomplete."); + var dib = U32(data, 14); + int width, height; + if (dib == 12) + { + width = U16(data, 18); + height = U16(data, 20); + } + else if (dib >= 40 && dib <= 124 && data.Length >= 14 + dib) + { + width = I32(data, 18); + var h = I32(data, 22); + if (h == int.MinValue) throw new InvalidDataException("Invalid BMP height."); + height = Math.Abs(h); + } + else throw new NotSupportedException("Unsupported BMP DIB header."); + + if (width <= 0 || height <= 0 || (long)width * height > 25_000_000) + throw new InvalidDataException("BMP dimensions exceed the 25-megapixel limit."); + return (width, height); + } + + public static RasterImage Decode(byte[] bytes) + { + var data = bytes.AsSpan(); + var (width, height) = ReadSize(data); + if (data.Length > 128 * 1024 * 1024) + throw new InvalidDataException("BMP is too large."); + + var dib = U32(data, 14); + var core = dib == 12; + var bits = U16(data, core ? 24 : 28); + var compression = core ? 0u : U32(data, 30); + var topDown = !core && I32(data, 22) < 0; + if (bits is not (1 or 4 or 8 or 16 or 24 or 32)) + throw new NotSupportedException("BMP bit depth is not supported."); + if (!(compression == 0 || compression == 3 || compression == 6 || + (compression == 1 && bits == 8) || (compression == 2 && bits == 4))) + throw new NotSupportedException("BMP compression is not supported."); + if (topDown && compression is 1 or 2) + throw new InvalidDataException("Compressed BMP cannot be top-down."); + + var pixelOffset = checked((int)U32(data, 10)); + if (pixelOffset > data.Length || pixelOffset < 14 + (int)dib) + throw new InvalidDataException("Invalid BMP pixel offset."); + + var rMask = bits == 16 ? 0x7C00u : 0x00FF0000u; + var gMask = bits == 16 ? 0x03E0u : 0x0000FF00u; + var bMask = bits == 16 ? 0x001Fu : 0x000000FFu; + var aMask = 0u; + + var cursor = checked(14 + (int)dib); + if (compression is 3 or 6) + { + if (bits is not (16 or 32)) + throw new InvalidDataException("BMP bitfields require 16 or 32 bits."); + if (dib >= 52) + { + rMask = U32(data, 54); + gMask = U32(data, 58); + bMask = U32(data, 62); + if (dib >= 56) aMask = U32(data, 66); + } + else + { + if (cursor + (compression == 6 ? 16 : 12) > pixelOffset) + throw new InvalidDataException("BMP masks exceed pixel offset."); + rMask = U32(data, cursor); + gMask = U32(data, cursor + 4); + bMask = U32(data, cursor + 8); + cursor += 12; + if (compression == 6) { aMask = U32(data, cursor); cursor += 4; } + else if (cursor + 4 <= pixelOffset) // Optional alpha mask + { + var candidate = U32(data, cursor); + if ((candidate & (rMask | gMask | bMask)) == 0) + { + aMask = candidate; + cursor += 4; + } + } + } + + if (rMask == 0 || gMask == 0 || bMask == 0 || + ((rMask & gMask) | (rMask & bMask) | (gMask & bMask)) != 0 || + (aMask & (rMask | gMask | bMask)) != 0) + throw new InvalidDataException("BMP channel masks are overlapping or empty."); + } + else if (bits == 32) + aMask = 0xFF000000; + + var count = bits <= 8 ? 1 << bits : 0; + if (count != 0 && !core) + { + var used = U32(data, 46); + if (used > count) throw new InvalidDataException("BMP palette is too large."); + if (used != 0) count = (int)used; + } + + var palette = new byte[count * 4]; + for (var index = 0; index < count; index++) + { + var stride = core ? 3 : 4; + if (cursor + stride > pixelOffset) + throw new InvalidDataException("Truncated BMP palette."); + palette[index * 4] = data[cursor + 2]; + palette[index * 4 + 1] = data[cursor + 1]; + palette[index * 4 + 2] = data[cursor]; + palette[index * 4 + 3] = 255; + cursor += stride; + } + + var result = new RasterImage(width, height); + if (compression is 1 or 2) + { + DecodeRle(data[pixelOffset..], result, bits, palette); + return result; + } + + var strideBytes = checked((int)(((long)width * bits + 31) / 32 * 4)); + if ((long)pixelOffset + (long)strideBytes * height > data.Length) + throw new InvalidDataException("Truncated BMP pixel array."); + + var nonZeroAlpha = false; + for (var y = 0; y < height; y++) + { + var sourceRow = pixelOffset + (topDown ? y : height - 1 - y) * strideBytes; + for (var x = 0; x < width; x++) + { + var dest = (y * width + x) * 4; + if (bits <= 8) + { + var packed = data[sourceRow + x * bits / 8]; + var index = (packed >> (8 - bits - (x * bits & 7))) & ((1 << bits) - 1); + CopyPalette(index, result.Pixels, dest, palette); + } + else if (bits == 24) + { + var src = sourceRow + x * 3; + result.Pixels[dest] = data[src + 2]; + result.Pixels[dest + 1] = data[src + 1]; + result.Pixels[dest + 2] = data[src]; + result.Pixels[dest + 3] = 255; + } + else + { + var src = sourceRow + x * (bits / 8); + uint value = bits == 16 ? U16(data, src) : U32(data, src); + result.Pixels[dest] = Channel(value, rMask); + result.Pixels[dest + 1] = Channel(value, gMask); + result.Pixels[dest + 2] = Channel(value, bMask); + var alpha = aMask == 0 ? (byte)255 : Channel(value, aMask); + result.Pixels[dest + 3] = alpha; + nonZeroAlpha |= alpha != 0; + } + } + } + + // Windows BI_RGB 32bpp traditionally stores a reserved (not alpha) byte. + if (bits == 32 && compression == 0 && !nonZeroAlpha) + for (var i = 3; i < result.Pixels.Length; i += 4) + result.Pixels[i] = 255; + + return result; + } + + private static void DecodeRle(ReadOnlySpan input, RasterImage image, int bits, byte[] palette) + { + var x = 0; + var y = image.Height - 1; + var position = 0; + var ended = false; + // Skipped pixels and unwritten rows use palette index zero. + for (var i = 0; i < image.Width * image.Height; i++) + CopyPalette(0, image.Pixels, i * 4, palette); + + void Write(int idx) + { + if (y < 0 || x >= image.Width) + throw new InvalidDataException("BMP RLE writes beyond image bounds."); + CopyPalette(idx, image.Pixels, (y * image.Width + x) * 4, palette); + x++; + } + + while (position < input.Length) + { + if (input.Length - position < 2) + throw new InvalidDataException("Truncated BMP RLE command."); + var run = input[position++]; + var code = input[position++]; + if (run != 0) + { + for (var k = 0; k < run; k++) + Write(bits == 8 ? code : (k & 1) == 0 ? code >> 4 : code & 15); + continue; + } + + if (code == 0) { x = 0; y--; continue; } + if (code == 1) { ended = true; break; } + if (code == 2) + { + if (input.Length - position < 2) + throw new InvalidDataException("Truncated BMP RLE delta."); + x += input[position++]; + y -= input[position++]; + if (x > image.Width || y < 0) + throw new InvalidDataException("BMP RLE delta outside image."); + continue; + } + + var byteCount = bits == 8 ? code : (code + 1) / 2; + if (input.Length - position < byteCount + (byteCount & 1)) + throw new InvalidDataException("Truncated BMP RLE absolute run."); + for (var k = 0; k < code; k++) + { + var value = input[position + (bits == 8 ? k : k / 2)]; + Write(bits == 8 ? value : (k & 1) == 0 ? value >> 4 : value & 15); + } + position += byteCount + (byteCount & 1); + } + if (!ended) throw new InvalidDataException("BMP RLE end marker is missing."); + } + + public static byte[] Encode(RasterImage image) + { + ArgumentNullException.ThrowIfNull(image); + var size = checked(14 + 108 + image.Pixels.Length); + var output = new byte[size]; + output[0] = (byte)'B'; + output[1] = (byte)'M'; + Put(output, 2, (uint)size); + Put(output, 10, 122); + Put(output, 14, 108); // BITMAPV4HEADER + Put(output, 18, (uint)image.Width); + Put(output, 22, (uint)-image.Height); // top-down + BinaryPrimitives.WriteUInt16LittleEndian(output.AsSpan(26), 1); + BinaryPrimitives.WriteUInt16LittleEndian(output.AsSpan(28), 32); + Put(output, 30, 3); // BI_BITFIELDS + Put(output, 34, (uint)image.Pixels.Length); + Put(output, 54, 0x00FF0000); + Put(output, 58, 0x0000FF00); + Put(output, 62, 0x000000FF); + Put(output, 66, 0xFF000000); + Put(output, 70, 0x73524742); // LCS_sRGB + for (var i = 0; i < image.Width * image.Height; i++) + { + var src = i * 4; + var dest = 122 + src; + output[dest] = image.Pixels[src + 2]; + output[dest + 1] = image.Pixels[src + 1]; + output[dest + 2] = image.Pixels[src]; + output[dest + 3] = image.Pixels[src + 3]; + } + return output; + } + + private static byte Channel(uint value, uint mask) + { + if (mask == 0) return 255; + var shifted = value & mask; + var firstBit = BitOperations.TrailingZeroCount(mask); + var scale = mask >> firstBit; + var component = shifted >> firstBit; + return (byte)((component * 255UL + scale / 2UL) / scale); + } + + private static void CopyPalette(int index, byte[] output, int at, byte[] palette) + { + if ((uint)index >= (uint)(palette.Length / 4)) + throw new InvalidDataException("BMP palette index outside table."); + Array.Copy(palette, index * 4, output, at, 4); + } + + private static ushort U16(ReadOnlySpan data, int offset) + { + if (offset < 0 || data.Length - offset < 2) + throw new InvalidDataException("Truncated BMP."); + return BinaryPrimitives.ReadUInt16LittleEndian(data[offset..]); + } + + private static uint U32(ReadOnlySpan data, int offset) + { + if (offset < 0 || data.Length - offset < 4) + throw new InvalidDataException("Truncated BMP."); + return BinaryPrimitives.ReadUInt32LittleEndian(data[offset..]); + } + + private static int I32(ReadOnlySpan data, int offset) => + unchecked((int)U32(data, offset)); + + private static void Put(byte[] data, int offset, uint value) => + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(offset), value); +} From 56cce4bc20b3806d36e433d0cdc60a298afa0d7f Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 14:51:17 +0300 Subject: [PATCH 70/85] TDD GREEN: managed full-frame animated GIF89a LZW decode/encode, disposal, delays, interlace and looping --- Drawing/GifCodec.cs | 504 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 504 insertions(+) create mode 100644 Drawing/GifCodec.cs diff --git a/Drawing/GifCodec.cs b/Drawing/GifCodec.cs new file mode 100644 index 000000000..adc56321e --- /dev/null +++ b/Drawing/GifCodec.cs @@ -0,0 +1,504 @@ +namespace Ecng.Drawing; + +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +/// Decoded GIF89a frame, composited onto the logical screen, including its original delay. +internal sealed class GifFrame(RasterImage image, int delayCentiseconds) +{ + public RasterImage Image { get; } = image; + public int DelayCentiseconds { get; } = delayCentiseconds; +} + +/// All GIF frames and the Netscape loop count (0=infinite, -1=not specified). +internal sealed class GifAnimation(int width, int height) +{ + public int Width { get; } = width; + public int Height { get; } = height; + public int LoopCount { get; set; } = -1; + public List Frames { get; } = []; +} + +/// +/// Fully managed animated GIF decoder/encoder. Supports global/local palettes, +/// LZW, interlace, transparent indices, disposal 0/1/2/3, Netscape loop count, +/// arbitrary image subrectangles, delay and full frame compositing. +/// GIF output has 1-bit alpha and <=256 colors per frame by format definition. +/// +internal static class GifCodec +{ + public static bool IsGif(ReadOnlySpan bytes) => + bytes.Length >= 6 && (bytes[..6].SequenceEqual("GIF87a"u8) || bytes[..6].SequenceEqual("GIF89a"u8)); + + public static (int width, int height) ReadSize(ReadOnlySpan bytes) + { + if (!IsGif(bytes) || bytes.Length < 13) + throw new InvalidDataException("Invalid GIF header."); + var width = U16(bytes, 6); + var height = U16(bytes, 8); + if (width == 0 || height == 0 || (long)width * height > 25_000_000) + throw new InvalidDataException("GIF logical dimensions exceed 25 megapixels."); + return (width, height); + } + + public static GifAnimation Decode(byte[] data) + { + var (width, height) = ReadSize(data); + if (data.Length > 128 * 1024 * 1024) + throw new InvalidDataException("GIF input exceeds supported size."); + + var bytes = data.AsSpan(); + var packed = data[10]; + var backgroundIndex = data[11]; + var position = 13; + var globalPalette = (packed & 0x80) != 0 ? + Palette(bytes, ref position, 1 << ((packed & 7) + 1)) : Array.Empty(); + + var animation = new GifAnimation(width, height); + var canvas = new RasterImage(width, height); + var initialized = false; + var previousDisposal = 0; + var previousX = 0; + var previousY = 0; + var previousWidth = 0; + var previousHeight = 0; + var previousClearAlpha = (byte)0; + byte[] savedBeforePrevious = null; + + var transparency = -1; + var delay = 0; + var disposal = 0; + var ended = false; + long totalFramePixels = 0; + + while (position < data.Length) + { + var tag = data[position++]; + if (tag == 0x3B) { ended = true; break; } + if (tag == 0x21) + { + if (position >= data.Length) throw new InvalidDataException("Truncated GIF extension."); + var extension = data[position++]; + if (extension == 0xF9) + { + if (position + 6 > data.Length || data[position++] != 4) + throw new InvalidDataException("Malformed GIF graphic-control extension."); + var gce = data[position++]; + delay = U16(data, position); position += 2; + transparency = (gce & 1) != 0 ? data[position] : -1; + position++; + if (data[position++] != 0) + throw new InvalidDataException("GIF graphic-control terminator is missing."); + disposal = (gce >> 2) & 7; + if (disposal > 3) + throw new NotSupportedException("Unsupported GIF disposal method."); + } + else if (extension == 0xFF) + { + if (position >= data.Length) throw new InvalidDataException("GIF application extension is truncated."); + var size = data[position++]; + if (size > data.Length - position) throw new InvalidDataException("Truncated GIF application name."); + var name = System.Text.Encoding.ASCII.GetString(data, position, size); + position += size; + var payload = SubBlocks(data, ref position); + if ((name == "NETSCAPE2.0" || name == "ANIMEXTS1.0") && + payload.Length >= 3 && payload[0] == 1) + animation.LoopCount = payload[1] | payload[2] << 8; + } + else + _ = SubBlocks(data, ref position); + continue; + } + if (tag != 0x2C) + throw new InvalidDataException($"Unexpected GIF block 0x{tag:X2}."); + if (position + 9 > data.Length) + throw new InvalidDataException("Truncated GIF image descriptor."); + + var left = U16(data, position); + var top = U16(data, position + 2); + var w = U16(data, position + 4); + var h = U16(data, position + 6); + var flags = data[position + 8]; + position += 9; + + if (w == 0 || h == 0 || left + w > width || top + h > height) + throw new InvalidDataException("GIF image rectangle exceeds logical screen."); + if (++totalFramePixels > 40_000_000 || animation.Frames.Count >= 512) + throw new InvalidDataException("GIF total animation pixel budget exceeded."); + + var palette = (flags & 0x80) != 0 + ? Palette(bytes, ref position, 1 << ((flags & 7) + 1)) + : globalPalette; + if (palette.Length == 0) + throw new InvalidDataException("GIF image does not have a color table."); + + if (position >= data.Length) + throw new InvalidDataException("Missing GIF LZW minimum code size."); + var minSize = data[position++]; + var compressed = SubBlocks(data, ref position); + var indices = Decompress(compressed, minSize, w * h); + if ((flags & 0x40) != 0) + indices = Deinterlace(indices, w, h); + + // GIF logical background is transparent when its index is declared + // transparent by the initial frame, otherwise it is the global color. + if (!initialized) + { + if (globalPalette.Length > backgroundIndex * 3 && transparency != backgroundIndex) + Fill(canvas.Pixels, globalPalette, backgroundIndex, 255); + initialized = true; + } + + // Disposal of the preceding frame happens before painting this one. + if (previousDisposal == 2) + { + for (var y = previousY; y < previousY + previousHeight; y++) + for (var x = previousX; x < previousX + previousWidth; x++) + { + var offset = (y * width + x) * 4; + if (previousClearAlpha == 0) + Array.Clear(canvas.Pixels, offset, 4); + else + AssignBackground(canvas.Pixels, offset, globalPalette, backgroundIndex); + } + } + else if (previousDisposal == 3 && savedBeforePrevious != null) + Array.Copy(savedBeforePrevious, canvas.Pixels, canvas.Pixels.Length); + + var backup = disposal == 3 ? (byte[])canvas.Pixels.Clone() : null; + for (var y = 0; y < h; y++) + for (var x = 0; x < w; x++) + { + var index = indices[y * w + x]; + if (index == transparency) continue; + if (index * 3 + 2 >= palette.Length) + throw new InvalidDataException("GIF palette index outside color table."); + var at = ((top + y) * width + left + x) * 4; + canvas.Pixels[at] = palette[index * 3]; + canvas.Pixels[at + 1] = palette[index * 3 + 1]; + canvas.Pixels[at + 2] = palette[index * 3 + 2]; + canvas.Pixels[at + 3] = 255; + } + var composited = new RasterImage(width, height); + Array.Copy(canvas.Pixels, composited.Pixels, canvas.Pixels.Length); + animation.Frames.Add(new GifFrame(composited, delay)); + + previousDisposal = disposal; + previousX = left; previousY = top; + previousWidth = w; previousHeight = h; + previousClearAlpha = (byte)(transparency == backgroundIndex ? 0 : 255); + savedBeforePrevious = backup; + transparency = -1; delay = 0; disposal = 0; + } + + if (!ended || animation.Frames.Count == 0 || position != data.Length) + throw new InvalidDataException("GIF animation is truncated or contains trailing bytes."); + return animation; + } + + public static byte[] Encode(GifAnimation animation) + { + if (animation.Frames.Count == 0) + throw new InvalidDataException("Cannot encode an empty GIF."); + + using var output = new MemoryStream(); + output.Write("GIF89a"u8); + Word(output, animation.Width); + Word(output, animation.Height); + output.WriteByte(0x70); // no global table, 8-bit source precision + output.WriteByte(0); // background index: transparent + output.WriteByte(0); + + if (animation.LoopCount >= 0) + { + output.Write([0x21,0xFF,0x0B]); + output.Write("NETSCAPE2.0"u8); + output.WriteByte(3); + output.WriteByte(1); + Word(output, animation.LoopCount); + output.WriteByte(0); + } + + foreach (var frame in animation.Frames) + { + if (frame.Image.Width != animation.Width || frame.Image.Height != animation.Height) + throw new InvalidDataException("GIF frame size must equal logical canvas."); + + var (indexed, palette) = Quantize(frame.Image); + var delay = Math.Clamp(frame.DelayCentiseconds, 0, ushort.MaxValue); + // Full-canvas source frames. Restore-to-transparent background + // before each next frame so frames are independent of each other. + output.Write([0x21,0xF9,4,0x09]); + Word(output, delay); + output.WriteByte(0); + output.WriteByte(0); + output.WriteByte(0x2C); + Word(output, 0); Word(output, 0); + Word(output, animation.Width); Word(output, animation.Height); + output.WriteByte(0x87); // local 256-color palette + output.Write(palette); + output.WriteByte(8); // LZW minimum size + var compressed = Compress(indexed); + for (var pos = 0; pos < compressed.Length;) + { + var n = Math.Min(255, compressed.Length - pos); + output.WriteByte((byte)n); + output.Write(compressed, pos, n); + pos += n; + } + output.WriteByte(0); + } + output.WriteByte(0x3B); + return output.ToArray(); + } + + private static (byte[] indexed, byte[] palette) Quantize(RasterImage image) + { + var indexed = new byte[image.Width * image.Height]; + var palette = new byte[256 * 3]; + var colors = new Dictionary(); + var tooManyColors = false; + for (var i = 0; i < indexed.Length; i++) + { + var pos = i * 4; + if (image.Pixels[pos + 3] < 128) continue; + var key = image.Pixels[pos] << 16 | image.Pixels[pos + 1] << 8 | image.Pixels[pos + 2]; + if (!colors.ContainsKey(key)) + { + if (colors.Count == 255) { tooManyColors = true; break; } + var index = checked((byte)(colors.Count + 1)); + colors.Add(key, index); + palette[index * 3] = image.Pixels[pos]; + palette[index * 3 + 1] = image.Pixels[pos + 1]; + palette[index * 3 + 2] = image.Pixels[pos + 2]; + } + } + + if (tooManyColors) + { + colors.Clear(); + for (var index = 1; index <= 255; index++) + { + palette[index * 3] = (byte)((index >> 5) * 255 / 7); + palette[index * 3 + 1] = (byte)(((index >> 2) & 7) * 255 / 7); + palette[index * 3 + 2] = (byte)((index & 3) * 255 / 3); + } + } + + for (var i = 0; i < indexed.Length; i++) + { + var pos = i * 4; + if (image.Pixels[pos + 3] < 128) { indexed[i] = 0; continue; } + if (tooManyColors) + { + var colorCode = (image.Pixels[pos] >> 5) << 5 | + (image.Pixels[pos + 1] >> 5) << 2 | image.Pixels[pos + 2] >> 6; + indexed[i] = (byte)Math.Max(1, colorCode); + } + else + { + var key = image.Pixels[pos] << 16 | image.Pixels[pos + 1] << 8 | image.Pixels[pos + 2]; + indexed[i] = colors[key]; + } + } + + return (indexed, palette); + } + + // Valid and deliberately bounded LZW encoding: emit a CLEAR every 200 + // literal symbols, keeping code widths at 9 bits. This prioritizes simple, + // deterministic and interoperable output over the smallest GIF filesize. + private static byte[] Compress(byte[] values) + { + using var output = new MemoryStream(); + var buffer = 0u; + var available = 0; + void Emit(int code) + { + buffer |= (uint)code << available; + available += 9; + while (available >= 8) + { + output.WriteByte((byte)buffer); + buffer >>= 8; + available -= 8; + } + } + + Emit(256); // clear + var sinceClear = 0; + foreach (var value in values) + { + if (sinceClear == 200) { Emit(256); sinceClear = 0; } + Emit(value); + sinceClear++; + } + Emit(257); // EOI + if (available > 0) output.WriteByte((byte)buffer); + return output.ToArray(); + } + + private static byte[] Decompress(byte[] input, int minimum, int expected) + { + if (minimum < 2 || minimum > 8) + throw new InvalidDataException("GIF LZW minimum code size is invalid."); + + var clear = 1 << minimum; + var end = clear + 1; + var next = end + 1; + var codeSize = minimum + 1; + var prefixes = new int[4096]; + var suffixes = new byte[4096]; + var stack = new byte[4096]; + var pixels = new byte[expected]; + var cursor = 0; var bit = 0; + var old = -1; byte first = 0; + var ended = false; + + int ReadCode() + { + if ((long)bit + codeSize > input.Length * 8L) + throw new InvalidDataException("Truncated GIF LZW stream."); + var value = 0; + for (var i = 0; i < codeSize; i++) + value |= (input[(bit + i) / 8] >> ((bit + i) & 7) & 1) << i; + bit += codeSize; + return value; + } + + while (true) + { + var code = ReadCode(); + if (code == clear) + { + next = end + 1; + codeSize = minimum + 1; + old = -1; + continue; + } + if (code == end) { ended = true; break; } + if (code > next || code >= 4096) + throw new InvalidDataException("Invalid GIF LZW dictionary code."); + + var decoded = code; + var top = 0; + if (code == next) + { + if (old < 0) throw new InvalidDataException("Invalid initial GIF LZW code."); + stack[top++] = first; + decoded = old; + } + + var steps = 0; + while (decoded >= clear) + { + if (decoded >= next || top == stack.Length || ++steps > 4096) + throw new InvalidDataException("Cyclic or oversized GIF LZW dictionary."); + stack[top++] = suffixes[decoded]; + decoded = prefixes[decoded]; + } + if (decoded >= clear) + throw new InvalidDataException("GIF LZW literal out of range."); + + first = (byte)decoded; + stack[top++] = first; + while (top > 0) + { + if (cursor >= expected) + throw new InvalidDataException("GIF LZW produced extra pixels."); + pixels[cursor++] = stack[--top]; + } + + if (old >= 0 && next < 4096) + { + prefixes[next] = old; + suffixes[next] = first; + next++; + if (next == (1 << codeSize) && codeSize < 12) + codeSize++; + } + old = code; + } + + if (!ended || cursor != expected) + throw new InvalidDataException("GIF LZW did not decode expected pixel count."); + return pixels; + } + + private static byte[] Deinterlace(byte[] indices, int width, int height) + { + var output = new byte[indices.Length]; + var source = 0; + foreach (var (start, step) in new (int start, int step)[] { (0,8),(4,8),(2,4),(1,2) }) + for (var y = start; y < height; y += step) + { + Array.Copy(indices, source, output, y * width, width); + source += width; + } + return output; + } + + private static byte[] SubBlocks(byte[] data, ref int cursor) + { + using var combined = new MemoryStream(); + while (true) + { + if (cursor >= data.Length) + throw new InvalidDataException("Truncated GIF data sub-block."); + var length = data[cursor++]; + if (length == 0) break; + if (length > data.Length - cursor) + throw new InvalidDataException("GIF sub-block exceeds input."); + combined.Write(data, cursor, length); + cursor += length; + } + return combined.ToArray(); + } + + private static byte[] Palette(ReadOnlySpan data, ref int cursor, int count) + { + var length = checked(count * 3); + if (cursor + length > data.Length) + throw new InvalidDataException("Truncated GIF color table."); + var colors = data.Slice(cursor, length).ToArray(); + cursor += length; + return colors; + } + + private static void Fill(byte[] target, byte[] palette, int index, byte alpha) + { + for (var at = 0; at < target.Length; at += 4) + { + target[at] = palette[index * 3]; + target[at + 1] = palette[index * 3 + 1]; + target[at + 2] = palette[index * 3 + 2]; + target[at + 3] = alpha; + } + } + + private static void AssignBackground(byte[] target, int at, byte[] palette, int index) + { + if (palette.Length <= index * 3 + 2) { Array.Clear(target, at, 4); return; } + target[at] = palette[index * 3]; + target[at + 1] = palette[index * 3 + 1]; + target[at + 2] = palette[index * 3 + 2]; + target[at + 3] = 255; + } + + private static ushort U16(ReadOnlySpan data, int at) + { + if (at < 0 || data.Length - at < 2) + throw new InvalidDataException("Truncated GIF data."); + return BinaryPrimitives.ReadUInt16LittleEndian(data[at..]); + } + private static void Word(Stream output, int value) + { + if (value < 0 || value > ushort.MaxValue) + throw new InvalidDataException("GIF field exceeds 16-bit range."); + output.WriteByte((byte)value); + output.WriteByte((byte)(value >> 8)); + } +} From 4469be287cd1bd1151b2d9da429c59631ed942e1 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 14:51:56 +0300 Subject: [PATCH 71/85] Preserve complete GIF animation during ConvertToPng by writing RGBA APNG with all frames and delays --- Drawing/ApngEncoder.cs | 105 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 Drawing/ApngEncoder.cs diff --git a/Drawing/ApngEncoder.cs b/Drawing/ApngEncoder.cs new file mode 100644 index 000000000..d249b3a44 --- /dev/null +++ b/Drawing/ApngEncoder.cs @@ -0,0 +1,105 @@ +namespace Ecng.Drawing; + +using System; +using System.Buffers.Binary; +using System.IO; +using System.IO.Compression; + +/// +/// Minimal standards-compliant APNG writer. A GIF frame is emitted as an entire +/// RGBA8 source-composited frame (blend SOURCE, disposal NONE), retaining every +/// frame and its timing instead of silently dropping animation to static PNG. +/// +internal static class ApngEncoder +{ + private static ReadOnlySpan PngSignature => [137,80,78,71,13,10,26,10]; + + public static byte[] Encode(GifAnimation source) + { + if (source.Frames.Count == 0) + throw new InvalidDataException("Cannot export zero GIF frames as APNG."); + + using var png = new MemoryStream(); + png.Write(PngSignature); + var ihdr = new byte[13]; + Put(ihdr, 0, (uint)source.Width); + Put(ihdr, 4, (uint)source.Height); + ihdr[8] = 8; // bit depth + ihdr[9] = 6; // RGBA + Chunk(png, "IHDR"u8, ihdr); + + var animation = new byte[8]; + Put(animation, 0, (uint)source.Frames.Count); + // GIF NETSCAPE=0 means infinite; no extension means play once. + Put(animation, 4, (uint)(source.LoopCount < 0 ? 1 : source.LoopCount)); + Chunk(png, "acTL"u8, animation); + + uint sequence = 0; + for (var index = 0; index < source.Frames.Count; index++) + { + var frame = source.Frames[index]; + if (frame.Image.Width != source.Width || frame.Image.Height != source.Height) + throw new InvalidDataException("APNG frame dimensions must equal canvas."); + + var control = new byte[26]; + Put(control, 0, sequence++); + Put(control, 4, (uint)source.Width); + Put(control, 8, (uint)source.Height); + // Full-canvas frame offset (0,0). + BinaryPrimitives.WriteUInt16BigEndian(control.AsSpan(20), (ushort)frame.DelayCentiseconds); + BinaryPrimitives.WriteUInt16BigEndian(control.AsSpan(22), 100); + control[24] = 0; // APNG_DISPOSE_OP_NONE + control[25] = 0; // APNG_BLEND_OP_SOURCE + Chunk(png, "fcTL"u8, control); + + using var compressed = new MemoryStream(); + using (var zlib = new ZLibStream(compressed, CompressionLevel.Optimal, leaveOpen: true)) + { + for (var y = 0; y < source.Height; y++) + { + zlib.WriteByte(0); + zlib.Write(frame.Image.Pixels, y * source.Width * 4, source.Width * 4); + } + } + + if (index == 0) + Chunk(png, "IDAT"u8, compressed.ToArray()); + else + { + var payload = new byte[checked(compressed.Length + 4)]; + Put(payload, 0, sequence++); + compressed.Position = 0; + compressed.ReadExactly(payload.AsSpan(4)); + Chunk(png, "fdAT"u8, payload); + } + } + + Chunk(png, "IEND"u8, []); + return png.ToArray(); + } + + private static void Chunk(Stream output, ReadOnlySpan tag, ReadOnlySpan payload) + { + Span buffer = stackalloc byte[4]; + Put(buffer, 0, (uint)payload.Length); + output.Write(buffer); + output.Write(tag); + output.Write(payload); + uint crc = 0xFFFFFFFF; + foreach (var b in tag) crc = CrcStep(crc, b); + foreach (var b in payload) crc = CrcStep(crc, b); + Put(buffer, 0, ~crc); + output.Write(buffer); + } + + private static uint CrcStep(uint crc, byte value) + { + crc ^= value; + for (var i = 0; i < 8; i++) + crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xEDB88320 : crc >> 1; + return crc; + } + + private static void Put(Span data, int at, uint value) => + BinaryPrimitives.WriteUInt32BigEndian(data[at..], value); +} From f62965e5282f43da0a4ff860a473e3a0beac424d Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 14:52:08 +0300 Subject: [PATCH 72/85] Keep APNG frame byte copy compatible with .NET 6 --- Drawing/ApngEncoder.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Drawing/ApngEncoder.cs b/Drawing/ApngEncoder.cs index d249b3a44..4b1684c57 100644 --- a/Drawing/ApngEncoder.cs +++ b/Drawing/ApngEncoder.cs @@ -68,8 +68,7 @@ public static byte[] Encode(GifAnimation source) { var payload = new byte[checked(compressed.Length + 4)]; Put(payload, 0, sequence++); - compressed.Position = 0; - compressed.ReadExactly(payload.AsSpan(4)); + compressed.ToArray().CopyTo(payload, 4); Chunk(png, "fdAT"u8, payload); } } From 29da1e1415a3affe08a458620c2cab74b6f2cdf8 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 14:52:36 +0300 Subject: [PATCH 73/85] Wire BMP and all-frame GIF into image API: format-preserving resize/watermark, GIF frame access and APNG conversion --- Drawing/ImageHelper.cs | 62 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 6 deletions(-) diff --git a/Drawing/ImageHelper.cs b/Drawing/ImageHelper.cs index cb751594c..e1302a022 100644 --- a/Drawing/ImageHelper.cs +++ b/Drawing/ImageHelper.cs @@ -11,7 +11,7 @@ namespace Ecng.Drawing; public static class ImageHelper { /// - /// Reads dimensions from the PNG or JPEG header without allocating a pixel buffer. + /// Reads PNG, JPEG, BMP and GIF dimensions without allocating a pixel buffer. /// public static Size GetImageSize(this byte[] data) => GetImageSize((ReadOnlySpan)(data ?? throw new ArgumentNullException(nameof(data)))); @@ -21,7 +21,9 @@ public static Size GetImageSize(this ReadOnlySpan data) { var (width, height) = PngCodec.IsPng(data) ? PngCodec.ReadSize(data) : JpegCodec.IsJpeg(data) ? JpegCodec.ReadSize(data) : - throw new NotSupportedException("Only PNG and JPEG images are supported."); + BmpCodec.IsBmp(data) ? BmpCodec.ReadSize(data) : + GifCodec.IsGif(data) ? GifCodec.ReadSize(data) : + throw new NotSupportedException("Only PNG, JPEG, BMP and GIF images are supported."); return new Size(width, height); } @@ -38,6 +40,20 @@ public static byte[] ResizeImage(this byte[] data, int maxWidth, int maxHeight) if (maxWidth <= 0) throw new ArgumentOutOfRangeException(nameof(maxWidth)); if (maxHeight <= 0) throw new ArgumentOutOfRangeException(nameof(maxHeight)); + if (GifCodec.IsGif(data)) + { + var animation = GifCodec.Decode(data); + var gifRatio = Math.Min((double)maxWidth / animation.Width, (double)maxHeight / animation.Height); + if (gifRatio >= 1) return (byte[])data.Clone(); + var gifWidth = Math.Max(1, (int)Math.Floor(animation.Width * gifRatio)); + var gifHeight = Math.Max(1, (int)Math.Floor(animation.Height * gifRatio)); + var resizedAnimation = new GifAnimation(gifWidth, gifHeight) { LoopCount = animation.LoopCount }; + foreach (var frame in animation.Frames) + resizedAnimation.Frames.Add(new GifFrame(frame.Image.Downscale(gifWidth, gifHeight), + frame.DelayCentiseconds)); + return GifCodec.Encode(resizedAnimation); + } + var image = Decode(data); var ratio = Math.Min((double)maxWidth / image.Width, (double)maxHeight / image.Height); if (ratio >= 1) @@ -46,7 +62,8 @@ public static byte[] ResizeImage(this byte[] data, int maxWidth, int maxHeight) var width = Math.Max(1, (int)Math.Floor(image.Width * ratio)); var height = Math.Max(1, (int)Math.Floor(image.Height * ratio)); var resized = image.Downscale(width, height); - return JpegCodec.IsJpeg(data) ? JpegEncoder.Encode(resized) : PngCodec.Encode(resized); + return JpegCodec.IsJpeg(data) ? JpegEncoder.Encode(resized) : + BmpCodec.IsBmp(data) ? BmpCodec.Encode(resized) : PngCodec.Encode(resized); } /// @@ -55,7 +72,7 @@ public static byte[] ResizeImage(this byte[] data, int maxWidth, int maxHeight) public static byte[] ConvertToPng(this byte[] data) { ArgumentNullException.ThrowIfNull(data); - return PngCodec.Encode(Decode(data)); + return GifCodec.IsGif(data) ? ApngEncoder.Encode(GifCodec.Decode(data)) : PngCodec.Encode(Decode(data)); } /// @@ -84,6 +101,18 @@ public static byte[] AddTextWatermark( if (string.IsNullOrWhiteSpace(fontFamily)) throw new ArgumentException("A font family is required.", nameof(fontFamily)); + if (GifCodec.IsGif(data)) + { + var animation = GifCodec.Decode(data); + if (2L * margin >= animation.Width || 2L * margin >= animation.Height) + throw new ArgumentOutOfRangeException(nameof(margin), "Margins leave no room for text."); + if (opacity == 0) return GifCodec.Encode(animation); + var font = new TrueTypeFont(TrueTypeFont.ResolveFontFile(fontFamily, fontFilePath)); + foreach (var frame in animation.Frames) + font.Draw(frame.Image, text, fontSize, opacity, margin); + return GifCodec.Encode(animation); + } + var image = Decode(data); if (2L * margin >= image.Width || 2L * margin >= image.Height) throw new ArgumentOutOfRangeException(nameof(margin), "Margins leave no room for text."); @@ -94,7 +123,27 @@ public static byte[] AddTextWatermark( font.Draw(image, text, fontSize, opacity, margin); } - return PngCodec.Encode(image); + return BmpCodec.IsBmp(data) ? BmpCodec.Encode(image) : PngCodec.Encode(image); + } + + /// All displayed GIF frames as full-canvas RGBA8 PNG images with their delays. + public static System.Collections.Generic.IReadOnlyList<(byte[] png, int delayMilliseconds)> GetGifFrames(this byte[] data) + { + ArgumentNullException.ThrowIfNull(data); + if (!GifCodec.IsGif(data)) throw new NotSupportedException("Expected GIF87a/GIF89a data."); + var animation = GifCodec.Decode(data); + var frames = new (byte[] png, int delayMilliseconds)[animation.Frames.Count]; + for (var i = 0; i < frames.Length; i++) + frames[i] = (PngCodec.Encode(animation.Frames[i].Image), animation.Frames[i].DelayCentiseconds * 10); + return frames; + } + + /// NETSCAPE loop count: 0 for infinite; -1 if no loop extension exists. + public static int GetGifLoopCount(this byte[] data) + { + ArgumentNullException.ThrowIfNull(data); + if (!GifCodec.IsGif(data)) throw new NotSupportedException("Expected GIF87a/GIF89a data."); + return GifCodec.Decode(data).LoopCount; } private static RasterImage Decode(byte[] data) @@ -102,6 +151,7 @@ private static RasterImage Decode(byte[] data) if (data.Length == 0) throw new InvalidDataException("Image data is empty."); if (PngCodec.IsPng(data)) return PngCodec.Decode(data); if (JpegCodec.IsJpeg(data)) return JpegCodec.Decode(data); - throw new NotSupportedException("Only PNG and supported 8-bit JPEG images are supported."); + if (BmpCodec.IsBmp(data)) return BmpCodec.Decode(data); + throw new NotSupportedException("Only PNG, supported 8-bit JPEG, BMP and GIF are supported."); } } From 8a72f3d6002942ead9e22e296ca3a97b3176ab50 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 14:54:40 +0300 Subject: [PATCH 74/85] TDD: expect unsupported-format result for truncated GIF signature shorter than six bytes --- Tests/Drawing/BmpGifTddTests.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Tests/Drawing/BmpGifTddTests.cs b/Tests/Drawing/BmpGifTddTests.cs index de24a5549..e982ae7c4 100644 --- a/Tests/Drawing/BmpGifTddTests.cs +++ b/Tests/Drawing/BmpGifTddTests.cs @@ -169,7 +169,15 @@ public void Gif_ZeroOpacityWatermark_LeavesAllFramesVisuallyUnchanged() public void Gif_TruncatedAndCorruptFiles_FailInControlledManner() { foreach(var length in new[]{1,6,13,25,_animated.Length/2,_animated.Length-1}) - ThrowsExactly(()=>_animated[..length].ConvertToPng()); + { + try + { + _animated[..length].ConvertToPng(); + Assert.Fail($"Truncated GIF of {length} bytes was accepted."); + } + catch(InvalidDataException) {} + catch(NotSupportedException) {} // <6-byte data is unrecognizable as GIF. + } var random=new Random(18057); for(var iter=0;iter<100;iter++) From 1c016a183812376bb09c2745599f6ba75f4d8738 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 14:56:05 +0300 Subject: [PATCH 75/85] Cover indexed BMP 1/4/8bpp, RGB565 masks, top-down, OS/2 core, RLE4/8, alpha, watermark and corruptions --- Tests/Drawing/BmpExtendedTddTests.cs | 256 +++++++++++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 Tests/Drawing/BmpExtendedTddTests.cs diff --git a/Tests/Drawing/BmpExtendedTddTests.cs b/Tests/Drawing/BmpExtendedTddTests.cs new file mode 100644 index 000000000..6857cc7b0 --- /dev/null +++ b/Tests/Drawing/BmpExtendedTddTests.cs @@ -0,0 +1,256 @@ +namespace Ecng.Tests.Drawing; + +using System; +using System.Buffers.Binary; +using System.IO; +using System.IO.Compression; +using System.Linq; + +using Ecng.Drawing; + +/// +/// Independent BMP fixture generation for all indexed depths, BI_BITFIELDS, +/// 16bpp RGB565, OS/2 BITMAPCORE and RLE4/RLE8, plus malformed data tests. +/// +[TestClass] +public class BmpExtendedTddTests : BaseTestClass +{ + private static readonly byte[][] _colors = + [ + [255,0,0,255], [0,255,0,255], [0,0,255,255], [255,255,0,255] + ]; + + [TestMethod] + public void Bmp_AllIndexedDepths_TopDownAndBottomUp_ArePixelExact() + { + foreach (var depth in new[] { 1, 4, 8 }) + foreach (var topDown in new[] { false, true }) + { + var colors = depth == 1 ? 2 : 4; + var bmp = Indexed(depth, 5, 3, topDown); + var pixels = ReadPng(bmp.ConvertToPng(), 5, 3); + for (var y = 0; y < 3; y++) + for (var x = 0; x < 5; x++) + { + var expected = _colors[(x + y) % colors]; + for (var c = 0; c < 4; c++) + pixels[(y*5 + x)*4 + c].AssertEqual(expected[c]); + } + } + } + + [TestMethod] + public void Bmp_BitfieldsRgb565_PreservesPrimaryColorsWithoutSwappingRedBlue() + { + var bmp = CreateBmp(2,2,16,3,[0x00,0xF8,0xE0,0x07,0x1F,0x00,0xFF,0xFF], + masks:[0xF800,0x07E0,0x001F],topDown:true); + var actual = ReadPng(bmp.ConvertToPng(),2,2); + Equal([255,0,0,255,0,255,0,255,0,0,255,255,255,255,255,255],actual); + } + + [TestMethod] + public void Bmp_Os2Core24bpp_UsesThreeBytePaletteOrNoPaletteAndCorrectRows() + { + var bmp = new byte[26 + 16]; + bmp[0]=(byte)'B'; bmp[1]=(byte)'M'; + Put(bmp,2,(uint)bmp.Length); + Put(bmp,10,26); + Put(bmp,14,12); + Short(bmp,18,2); Short(bmp,20,2); + Short(bmp,22,1); Short(bmp,24,24); + // Bottom row: red, green; top row: blue, white. + Array.Copy(new byte[]{0,0,255,0,255,0,0,0,255,0,0,255,255,255,0,0},0,bmp,26,16); + var actual=ReadPng(bmp.ConvertToPng(),2,2); + Equal([0,0,255,255,255,255,255,255,255,0,0,255,0,255,0,255],actual); + } + + [TestMethod] + public void Bmp_Rle8_AbsoluteAndEncodedRuns_ReconstructPixelGrid() + { + byte[] commands = [4,1,0,0,0,4,3,2,1,0,0,0,0,4,0,1,2,3,0,0,0,1]; + var bmp = CreateBmp(4,3,8,1,commands,numberOfColors:4); + ExpectedGrid(ReadPng(bmp.ConvertToPng(),4,3)); + } + + [TestMethod] + public void Bmp_Rle4_NibbleRunsAndAbsolutePixels_ReconstructPixelGrid() + { + byte[] commands = [4,0x11,0,0,0,4,0x32,0x10,0,0,0,4,0x01,0x23,0,0,0,1]; + var bmp=CreateBmp(4,3,4,2,commands,numberOfColors:4); + ExpectedGrid(ReadPng(bmp.ConvertToPng(),4,3)); + } + + [TestMethod] + public void Bmp_Rle8_DeltaAndEarlyEnd_AreHandledWithoutReadingOutsideImage() + { + byte[] commands = [0,2,1,0,2,2,0,0,4,3,0,0,0,1]; + var bmp=CreateBmp(4,2,8,1,commands,numberOfColors:4); + var output=ReadPng(bmp.ConvertToPng(),4,2); + // Top line is index 3, bottom line is index 0 apart from indices x=1..2 = blue. + var top=(byte[])_colors[3].Clone(); + for(var x=0;x<4;x++) + for(var c=0;c<4;c++) + output[x*4+c].AssertEqual(top[c]); + for(var x=0;x<4;x++) + for(var c=0;c<4;c++) + output[(4+x)*4+c].AssertEqual(_colors[x is 1 or 2 ? 2 : 0][c]); + } + + [TestMethod] + public void Bmp_32bitRgbReservedZeroAlphaIsTreatedAsOpaque() + { + byte[] bgra=[0,0,255,0,0,255,0,0,255,0,0,0,255,255,255,0]; + var bmp=CreateBmp(2,2,32,0,bgra,topDown:true); + var actual=ReadPng(bmp.ConvertToPng(),2,2); + for(var i=3;i60).AssertTrue(); + actual[0].AssertEqual((byte)60); + actual[1].AssertEqual((byte)40); + actual[2].AssertEqual((byte)20); + } + finally { File.Delete(path); } + } + + [TestMethod] + public void Bmp_120BitFlippedAndTruncatedInputs_RejectWithoutRuntimeCrashes() + { + var original=Indexed(8,5,4,false); + var random=new Random(105912); + for(var length=1;length 0 and <= 100).AssertTrue(); + } + catch(InvalidDataException){} + catch(NotSupportedException){} + } + } + + private static void ExpectedGrid(byte[] rgba) + { + int[] indices = [0,1,2,3,3,2,1,0,1,1,1,1]; + for(var i=0;i<12;i++) + for(var c=0;c<4;c++) + rgba[4*i+c].AssertEqual(_colors[indices[i]][c]); + } + + private static byte[] Indexed(int depth,int width,int height,bool topDown) + { + var count=depth==1?2:4; + var stride=(width*depth+31)/32*4; + var rows=new byte[stride*height]; + for(var y=0;ypng.Length-p-12)throw new InvalidDataException("Corrupt result PNG."); + if(png.AsSpan(p+4,4).SequenceEqual("IDAT"u8)) + compressed.Write(png,p+8,count); + p+=count+12; + } + compressed.Position=0; + using var rows=new MemoryStream(); + using(var zlib=new ZLibStream(compressed,CompressionMode.Decompress))zlib.CopyTo(rows); + var raw=rows.ToArray(); + if(raw.Length!=height*(1+width*4))throw new InvalidDataException("Wrong output PNG size."); + var rgba=new byte[width*height*4]; + for(var y=0;y + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(offset),value); + private static void Short(byte[] data,int offset,int value)=> + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(offset),(ushort)value); +} From 1679084a805c40f52ce95f7bab6181a1f0bcb923 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 14:58:11 +0300 Subject: [PATCH 76/85] TDD: independently generate interlaced GIF89a with local palettes, extensions and LZW corruption fixtures --- Tests/Drawing/GifExtendedTddTests.cs | 208 +++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 Tests/Drawing/GifExtendedTddTests.cs diff --git a/Tests/Drawing/GifExtendedTddTests.cs b/Tests/Drawing/GifExtendedTddTests.cs new file mode 100644 index 000000000..065545b1d --- /dev/null +++ b/Tests/Drawing/GifExtendedTddTests.cs @@ -0,0 +1,208 @@ +namespace Ecng.Tests.Drawing; + +using System; +using System.Buffers.Binary; +using System.IO; +using System.Linq; + +using Ecng.Drawing; + +/// +/// Independent GIF89a fixture builder with literal LZW codes. Exercises +/// interlaced rows, local palettes, control and comment extensions, and rejects +/// corrupt GIF headers and streams without using production GIF encoders. +/// +[TestClass] +public class GifExtendedTddTests : BaseTestClass +{ + [TestMethod] + public void Gif_InterlacedLocalPalette_AllRowsAndChannelsExactlyMatchOracle() + { + var gif=BuildInterlacedGif(comment:false); + var frames=gif.GetGifFrames(); + frames.Count.AssertEqual(1); + frames[0].delayMilliseconds.AssertEqual(50); + gif.GetGifLoopCount().AssertEqual(-1); + gif.GetImageSize().Width.AssertEqual(4); + gif.GetImageSize().Height.AssertEqual(8); + + var output=ReadRgbaPng(frames[0].png,4,8); + byte[][] palette = [[0,0,0,0],[255,0,0,255],[0,255,0,255],[0,0,255,255]]; + for(var y=0;y<8;y++) + for(var x=0;x<4;x++) + for(var c=0;c<4;c++) + output[(y*4+x)*4+c].AssertEqual(palette[y%3+1][c]); + } + + [TestMethod] + public void Gif_UnknownCommentExtensionIsSkippedWithoutChangingFrames() + { + var plain=BuildInterlacedGif(false); + var annotated=BuildInterlacedGif(true); + var a=plain.GetGifFrames(); + var b=annotated.GetGifFrames(); + a.Count.AssertEqual(b.Count); + a[0].delayMilliseconds.AssertEqual(b[0].delayMilliseconds); + var pa=ReadRgbaPng(a[0].png,4,8); + var pb=ReadRgbaPng(b[0].png,4,8); + pa.SequenceEqual(pb).AssertTrue(); + } + + [TestMethod] + public void Gif_NoUpscalingClonesBytesNotOnlyCanvasAndFrames() + { + var source=BuildInterlacedGif(false); + var resized=source.ResizeImage(40,80); + source.SequenceEqual(resized).AssertTrue(); + ReferenceEquals(source,resized).AssertFalse(); + } + + [TestMethod] + public void Gif_InvalidDisposalAndTruncatedLocalColorTable_AreRejected() + { + var valid=BuildInterlacedGif(false); + var gce=FindSequence(valid,[0x21,0xF9,4]); + (gce >= 0).AssertTrue(); + var invalid=(byte[])valid.Clone(); + invalid[gce+3]=(byte)(4<<2|1); + ThrowsExactly(()=>invalid.GetGifFrames()); + + var local=FindSequence(valid,[0x2C]); + (local >= 0).AssertTrue(); + var truncated=valid[..(local+10+4)]; // fewer than four palette entries + ThrowsExactly(()=>truncated.GetGifFrames()); + } + + [TestMethod] + public void Gif_LzwIncorrectMinimumCodeSizeAndBrokenTerminator_AreRejected() + { + var gif=BuildInterlacedGif(false); + var local=FindSequence(gif,[0x2C]); + var lzwSize=local+10+12; // descriptor + 4-entry RGB palette + gif[lzwSize].AssertEqual((byte)8); + var wrong=(byte[])gif.Clone(); + wrong[lzwSize]=9; + ThrowsExactly(()=>wrong.GetGifFrames()); + + var broken=(byte[])gif.Clone(); + broken[^1]=0; + ThrowsExactly(()=>broken.GetGifFrames()); + } + + [TestMethod] + public void Gif_ConvertInterlacedToApng_HasOneFrameAndItsPixels() + { + var original=BuildInterlacedGif(true); + var apng=original.ConvertToPng(); + var rgba=ReadRgbaPng(apng,4,8); + var expected=ReadRgbaPng(original.GetGifFrames()[0].png,4,8); + rgba.SequenceEqual(expected).AssertTrue(); + EncodingAsciiContains(apng,"acTL").AssertTrue(); + EncodingAsciiContains(apng,"fcTL").AssertTrue(); + } + + private static byte[] BuildInterlacedGif(bool comment) + { + using var file=new MemoryStream(); + file.Write("GIF89a"u8); + U16(file,4); U16(file,8); + file.WriteByte(0x80); // global two-color palette + file.WriteByte(0);file.WriteByte(0); + file.Write([0,0,0,255,255,255]); + + if(comment) + file.Write([0x21,0xFE,3,(byte)'H',(byte)'i',(byte)'!',0]); + + file.Write([0x21,0xF9,4,1,5,0,0,0]); // transparent index 0, 50ms + file.WriteByte(0x2C); + U16(file,0); U16(file,0); + U16(file,4); U16(file,8); + file.WriteByte(0xC1); // interlace + local four-color palette + file.Write([0,0,0,255,0,0,0,255,0,0,0,255]); + + var indices=new byte[4*8]; + var next=0; + foreach(var(start,step) in new(int start,int step)[]{(0,8),(4,8),(2,4),(1,2)}) + for(var y=start;y<8;y+=step) + for(var x=0;x<4;x++) + indices[next++]=(byte)(y%3+1); + + file.WriteByte(8); // LZW literal code width 8 + var data=EncodeLzwLiteralFixture(indices); + for(var at=0;at=8) + { + compressed.WriteByte((byte)accumulator); + accumulator>>=8; + bits-=8; + } + } + Append(256); + foreach(var pixel in pixels)Append(pixel); + Append(257); + if(bits>0)compressed.WriteByte((byte)accumulator); + return compressed.ToArray(); + } + + private static byte[] ReadRgbaPng(byte[] png,int width,int height) + { + using var packed=new MemoryStream(); + for(var at=8;at+12<=png.Length;) + { + var length=BinaryPrimitives.ReadInt32BigEndian(png.AsSpan(at,4)); + if(length<0||length>png.Length-at-12) + throw new InvalidDataException("Bad oracle PNG chunk."); + if(png.AsSpan(at+4,4).SequenceEqual("IDAT"u8)) + packed.Write(png,at+8,length); + at+=length+12; + } + packed.Position=0; + using var raw=new MemoryStream(); + using(var z=new System.IO.Compression.ZLibStream(packed,System.IO.Compression.CompressionMode.Decompress)) + z.CopyTo(raw); + var bytes=raw.ToArray(); + if(bytes.Length!=height*(1+width*4)) + throw new InvalidDataException("Unexpected oracle PNG dimensions."); + var rgba=new byte[width*height*4]; + for(var y=0;y + System.Text.Encoding.ASCII.GetString(bytes).Contains(word,StringComparison.Ordinal); + + private static void U16(Stream stream,int value) + { + stream.WriteByte((byte)value); + stream.WriteByte((byte)(value>>8)); + } +} From 9e4486b3fb249e461d700758c27202bc55b71adf Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 15:00:59 +0300 Subject: [PATCH 77/85] Independently decompress and verify every APNG frame from animated GIF, plus timings and sequence numbers --- Tests/Drawing/BmpGifTddTests.cs | 68 +++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/Tests/Drawing/BmpGifTddTests.cs b/Tests/Drawing/BmpGifTddTests.cs index e982ae7c4..f114b2edf 100644 --- a/Tests/Drawing/BmpGifTddTests.cs +++ b/Tests/Drawing/BmpGifTddTests.cs @@ -126,6 +126,74 @@ public void Gif_ConvertToPng_PreservesEveryFrameAsApng() Compare(Pixels(_animated.GetGifFrames()[0].png,4,4),Pixels(apng,4,4)); } + + [TestMethod] + public void Gif_ApngExport_CheckEveryFramePayloadAndControlAgainstIndependentColors() + { + var apng = _animated.ConvertToPng(); + var chunks = ReadPngChunks(apng); + var expectedDurations = new[] { 5,12,20,7 }; // centiseconds + byte[][] colors = + [ + [255,0,0,255], [0,255,0,255], + [0,0,255,255], [255,255,0,255] + ]; + + var seq = 0u; + var frameIndex = -1; + using var currentCompressed = new MemoryStream(); + + void VerifyCompleted() + { + if (frameIndex < 0) return; + currentCompressed.Position = 0; + using var decoded = new MemoryStream(); + using(var zlib = new ZLibStream(currentCompressed, CompressionMode.Decompress, leaveOpen:true)) + zlib.CopyTo(decoded); + var pixels = decoded.ToArray(); + pixels.Length.AssertEqual(4*(1+4*4)); + for(var y=0;y<4;y++) + { + pixels[y*17].AssertEqual((byte)0); + for(var x=0;x<4;x++) + for(var c=0;c<4;c++) + { + var expected = x==frameIndex ? colors[frameIndex][c] : (byte)0; + pixels[y*17+1+x*4+c].AssertEqual(expected); + } + } + currentCompressed.SetLength(0); + } + + foreach (var chunk in chunks) + { + if (chunk.tag == "fcTL") + { + VerifyCompleted(); + frameIndex++; + var control = chunk.data; + control.Length.AssertEqual(26); + BinaryPrimitives.ReadUInt32BigEndian(control.AsSpan(0,4)).AssertEqual(seq++); + BinaryPrimitives.ReadUInt32BigEndian(control.AsSpan(4,4)).AssertEqual((uint)4); + BinaryPrimitives.ReadUInt32BigEndian(control.AsSpan(8,4)).AssertEqual((uint)4); + BinaryPrimitives.ReadUInt16BigEndian(control.AsSpan(20,2)).AssertEqual( + (ushort)expectedDurations[frameIndex]); + BinaryPrimitives.ReadUInt16BigEndian(control.AsSpan(22,2)).AssertEqual((ushort)100); + control[24].AssertEqual((byte)0); + control[25].AssertEqual((byte)0); + } + else if (chunk.tag == "IDAT") + currentCompressed.Write(chunk.data); + else if (chunk.tag == "fdAT") + { + BinaryPrimitives.ReadUInt32BigEndian(chunk.data.AsSpan(0,4)).AssertEqual(seq++); + currentCompressed.Write(chunk.data,4,chunk.data.Length-4); + } + } + VerifyCompleted(); + (frameIndex+1).AssertEqual(4); + } + [TestMethod] public void Gif_Watermark_EachOfThreeFramesChanged_AnimationPreserved() { From 889db9af30a349ccf02720f7473fa0733cd76512 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 15:01:43 +0300 Subject: [PATCH 78/85] TDD RED: prohibit losing animation when APNG from GIF is resized or watermarked --- Tests/Drawing/BmpGifTddTests.cs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/Tests/Drawing/BmpGifTddTests.cs b/Tests/Drawing/BmpGifTddTests.cs index f114b2edf..a2da0b392 100644 --- a/Tests/Drawing/BmpGifTddTests.cs +++ b/Tests/Drawing/BmpGifTddTests.cs @@ -127,6 +127,39 @@ public void Gif_ConvertToPng_PreservesEveryFrameAsApng() } + + [TestMethod] + public void Apng_OutputFromGif_ResizeMustPreserveAllFourFramesAndDelays() + { + var apng = _animated.ConvertToPng(); + var resized = apng.ResizeImage(2, 2); + var chunks = ReadPngChunks(resized); + var acTl = chunks.Single(x=>x.tag=="acTL").data; + BinaryPrimitives.ReadUInt32BigEndian(acTl.AsSpan(0,4)).AssertEqual((uint)4); + BinaryPrimitives.ReadUInt32BigEndian(acTl.AsSpan(4,4)).AssertEqual((uint)2); + chunks.Count(x=>x.tag=="fcTL").AssertEqual(4); + chunks.Count(x=>x.tag=="fdAT").AssertEqual(3); + resized.GetImageSize().Width.AssertEqual(2); + resized.GetImageSize().Height.AssertEqual(2); + var delays = new[]{5,12,20,7}; + var controls = chunks.Where(x=>x.tag=="fcTL").ToArray(); + for (var i=0;i<4;i++) + BinaryPrimitives.ReadUInt16BigEndian(controls[i].data.AsSpan(20,2)) + .AssertEqual((ushort)delays[i]); + } + + [TestMethod] + public void Apng_OutputFromGif_InvisibleWatermarkMustNotDiscardAnyFrame() + { + var apng = _animated.ConvertToPng(); + var output = apng.AddTextWatermark("Invisible",opacity:0,margin:0); + var before = ReadPngChunks(apng); + var after = ReadPngChunks(output); + before.Count(x=>x.tag=="fcTL").AssertEqual(after.Count(x=>x.tag=="fcTL")); + after.Count(x=>x.tag=="fcTL").AssertEqual(4); + after.Count(x=>x.tag=="fdAT").AssertEqual(3); + } + [TestMethod] public void Gif_ApngExport_CheckEveryFramePayloadAndControlAgainstIndependentColors() { From ef1c1ee1181d63fd9f81312e61834f6748351901 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 15:02:50 +0300 Subject: [PATCH 79/85] TDD GREEN: decode APNG full animation, frame offsets/blends/disposals/delays/looping with CRC verification --- Drawing/ApngDecoder.cs | 247 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 Drawing/ApngDecoder.cs diff --git a/Drawing/ApngDecoder.cs b/Drawing/ApngDecoder.cs new file mode 100644 index 000000000..3e359a389 --- /dev/null +++ b/Drawing/ApngDecoder.cs @@ -0,0 +1,247 @@ +namespace Ecng.Drawing; + +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.IO; + +/// +/// Decodes APNG animation without discarding frames. PNG scanline decompression +/// and CRC validation are performed by the existing managed PNG decoder. +/// Supports full PNG color modes, frame subrectangles, SOURCE/OVER blending, +/// NONE/BACKGROUND/PREVIOUS disposal, arbitrary delays and loop counts. +/// +internal static class ApngDecoder +{ + private sealed class RawFrame + { + public int X, Y, Width, Height, Delay, Disposal, Blend; + public MemoryStream Compressed { get; } = new(); + } + + private static ReadOnlySpan Signature => [137,80,78,71,13,10,26,10]; + + public static bool IsApng(ReadOnlySpan data) + { + if (!data.StartsWith(Signature)) return false; + for (var pos=8;pos+12<=data.Length;) + { + var length = BinaryPrimitives.ReadUInt32BigEndian(data.Slice(pos,4)); + if (length > (uint)(data.Length - pos - 12)) return false; + var tag = data.Slice(pos+4,4); + if (tag.SequenceEqual("acTL"u8)) return true; + if (tag.SequenceEqual("IDAT"u8) || tag.SequenceEqual("IEND"u8)) return false; + pos += (int)length + 12; + } + return false; + } + + public static GifAnimation Decode(byte[] data) + { + if (!data.AsSpan().StartsWith(Signature) || data.Length > 128*1024*1024) + throw new InvalidDataException("Invalid APNG input."); + + var bytes=data.AsSpan(); + var position=8; + var header=Array.Empty(); + var palette=Array.Empty(); + var transparency=Array.Empty(); + var width=0;var height=0; + var expectedCount=0; + var loopCount=1; + var expectedSequence=0u; + var seenIdat=false; + var seenActl=false; + var ended=false; + var frameData=new List(); + RawFrame active=null; + long totalPixels=0; + + while(position+12<=data.Length) + { + var size=BinaryPrimitives.ReadUInt32BigEndian(bytes.Slice(position,4)); + if(size > (uint)(data.Length-position-12)) + throw new InvalidDataException("APNG chunk length exceeds file."); + var chunk=bytes.Slice(position+4,4); + var content=bytes.Slice(position+8,(int)size); + var crc=BinaryPrimitives.ReadUInt32BigEndian(bytes.Slice(position+8+(int)size,4)); + if(Crc(bytes.Slice(position+4,(int)size+4))!=crc) + throw new InvalidDataException("APNG chunk CRC mismatch."); + + if(chunk.SequenceEqual("IHDR"u8)) + { + if(header.Length>0||position!=8||content.Length!=13) + throw new InvalidDataException("Invalid APNG IHDR."); + header=content.ToArray(); + width=BinaryPrimitives.ReadInt32BigEndian(header.AsSpan(0,4)); + height=BinaryPrimitives.ReadInt32BigEndian(header.AsSpan(4,4)); + if(width<1||height<1||(long)width*height>25_000_000) + throw new InvalidDataException("APNG dimensions exceed 25 megapixels."); + } + else if(chunk.SequenceEqual("PLTE"u8)) + palette=content.ToArray(); + else if(chunk.SequenceEqual("tRNS"u8)) + transparency=content.ToArray(); + else if(chunk.SequenceEqual("acTL"u8)) + { + if(seenActl||seenIdat||content.Length!=8) + throw new InvalidDataException("Invalid APNG animation control."); + seenActl=true; + var frames=BinaryPrimitives.ReadUInt32BigEndian(content[..4]); + var loops=BinaryPrimitives.ReadUInt32BigEndian(content[4..]); + if(frames<1||frames>512||loops>int.MaxValue) + throw new InvalidDataException("APNG count or loop limit exceeded."); + expectedCount=(int)frames; + loopCount=(int)loops; + } + else if(chunk.SequenceEqual("fcTL"u8)) + { + if(!seenActl||content.Length!=26) + throw new InvalidDataException("Invalid APNG frame control."); + var sequence=BinaryPrimitives.ReadUInt32BigEndian(content[..4]); + if(sequence!=expectedSequence++) + throw new InvalidDataException("APNG frame sequence out of order."); + var w=BinaryPrimitives.ReadUInt32BigEndian(content.Slice(4,4)); + var h=BinaryPrimitives.ReadUInt32BigEndian(content.Slice(8,4)); + var x=BinaryPrimitives.ReadUInt32BigEndian(content.Slice(12,4)); + var y=BinaryPrimitives.ReadUInt32BigEndian(content.Slice(16,4)); + if(w==0||h==0||w>width||h>height||x>width-w||y>height-h) + throw new InvalidDataException("APNG frame rectangle exceeds canvas."); + totalPixels+=(long)w*h; + if(frameData.Count>=512||totalPixels>40_000_000) + throw new InvalidDataException("APNG frame pixel budget exceeded."); + + var numerator=BinaryPrimitives.ReadUInt16BigEndian(content.Slice(20,2)); + var denominator=BinaryPrimitives.ReadUInt16BigEndian(content.Slice(22,2)); + if(denominator==0)denominator=100; + var disposal=content[24];var blend=content[25]; + if(disposal>2||blend>1) + throw new InvalidDataException("Unsupported APNG disposal/blend operation."); + active=new RawFrame + { + Width=(int)w, Height=(int)h,X=(int)x,Y=(int)y, + Delay=(int)Math.Round((double)numerator*100/denominator), + Disposal=disposal,Blend=blend + }; + frameData.Add(active); + } + else if(chunk.SequenceEqual("IDAT"u8)) + { + if(!seenActl) + throw new InvalidDataException("APNG control must precede IDAT."); + seenIdat=true; + // With fcTL preceding IDAT the default image is frame zero. + // Otherwise IDAT is a static poster, not part of the animation. + if(active!=null && frameData.Count==1) + active.Compressed.Write(content); + } + else if(chunk.SequenceEqual("fdAT"u8)) + { + if(!seenActl||active==null||content.Length<4) + throw new InvalidDataException("Orphaned APNG frame data."); + var sequence=BinaryPrimitives.ReadUInt32BigEndian(content[..4]); + if(sequence!=expectedSequence++) + throw new InvalidDataException("APNG frame data sequence out of order."); + active.Compressed.Write(content[4..]); + } + else if(chunk.SequenceEqual("IEND"u8)) + { + if(content.Length!=0||position+12!=data.Length) + throw new InvalidDataException("Invalid APNG end."); + ended=true; + break; + } + else if((chunk[0]&0x20)==0) + throw new NotSupportedException("Unexpected critical APNG chunk."); + + position+=12+(int)size; + } + + if(!ended||!seenActl||frameData.Count!=expectedCount||header.Length!=13) + throw new InvalidDataException("Incomplete APNG animation."); + + var animation=new GifAnimation(width,height){LoopCount=loopCount}; + var canvas=new RasterImage(width,height); + RawFrame previous=null; + byte[] saved=null; + + foreach(var frame in frameData) + { + if(previous!=null) + { + if(previous.Disposal==1) + { + for(var y=previous.Y;y tag,ReadOnlySpan content) + { + Span number=stackalloc byte[4]; + BinaryPrimitives.WriteUInt32BigEndian(number,(uint)content.Length); + output.Write(number);output.Write(tag);output.Write(content); + uint crc=0xFFFFFFFF; + foreach(var b in tag)crc=NextCrc(crc,b); + foreach(var b in content)crc=NextCrc(crc,b); + BinaryPrimitives.WriteUInt32BigEndian(number,~crc); + output.Write(number); + } + private static uint Crc(ReadOnlySpan payload) + { + uint crc=0xFFFFFFFF; + foreach(var b in payload)crc=NextCrc(crc,b); + return ~crc; + } + private static uint NextCrc(uint crc,byte b) + { + crc^=b; + for(var i=0;i<8;i++) + crc=(crc&1)!=0?(crc>>1)^0xEDB88320:crc>>1; + return crc; + } +} From a6b9bb2532fb9e57dcef689da3897cbf0feebd68 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 15:03:20 +0300 Subject: [PATCH 80/85] Preserve animation across APNG reprocessing and expose all GIF/APNG composited frames to callers --- Drawing/ImageHelper.cs | 41 +++++++++++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/Drawing/ImageHelper.cs b/Drawing/ImageHelper.cs index e1302a022..b376533da 100644 --- a/Drawing/ImageHelper.cs +++ b/Drawing/ImageHelper.cs @@ -40,9 +40,10 @@ public static byte[] ResizeImage(this byte[] data, int maxWidth, int maxHeight) if (maxWidth <= 0) throw new ArgumentOutOfRangeException(nameof(maxWidth)); if (maxHeight <= 0) throw new ArgumentOutOfRangeException(nameof(maxHeight)); - if (GifCodec.IsGif(data)) + if (GifCodec.IsGif(data) || ApngDecoder.IsApng(data)) { - var animation = GifCodec.Decode(data); + var isGif = GifCodec.IsGif(data); + var animation = isGif ? GifCodec.Decode(data) : ApngDecoder.Decode(data); var gifRatio = Math.Min((double)maxWidth / animation.Width, (double)maxHeight / animation.Height); if (gifRatio >= 1) return (byte[])data.Clone(); var gifWidth = Math.Max(1, (int)Math.Floor(animation.Width * gifRatio)); @@ -51,7 +52,7 @@ public static byte[] ResizeImage(this byte[] data, int maxWidth, int maxHeight) foreach (var frame in animation.Frames) resizedAnimation.Frames.Add(new GifFrame(frame.Image.Downscale(gifWidth, gifHeight), frame.DelayCentiseconds)); - return GifCodec.Encode(resizedAnimation); + return isGif ? GifCodec.Encode(resizedAnimation) : ApngEncoder.Encode(resizedAnimation); } var image = Decode(data); @@ -72,7 +73,13 @@ public static byte[] ResizeImage(this byte[] data, int maxWidth, int maxHeight) public static byte[] ConvertToPng(this byte[] data) { ArgumentNullException.ThrowIfNull(data); - return GifCodec.IsGif(data) ? ApngEncoder.Encode(GifCodec.Decode(data)) : PngCodec.Encode(Decode(data)); + if (GifCodec.IsGif(data)) return ApngEncoder.Encode(GifCodec.Decode(data)); + if (ApngDecoder.IsApng(data)) + { + _ = ApngDecoder.Decode(data); // validate every frame instead of silently discarding animation + return (byte[])data.Clone(); + } + return PngCodec.Encode(Decode(data)); } /// @@ -106,11 +113,11 @@ public static byte[] AddTextWatermark( var animation = GifCodec.Decode(data); if (2L * margin >= animation.Width || 2L * margin >= animation.Height) throw new ArgumentOutOfRangeException(nameof(margin), "Margins leave no room for text."); - if (opacity == 0) return GifCodec.Encode(animation); + if (opacity == 0) return isGif ? GifCodec.Encode(animation) : ApngEncoder.Encode(animation); var font = new TrueTypeFont(TrueTypeFont.ResolveFontFile(fontFamily, fontFilePath)); foreach (var frame in animation.Frames) font.Draw(frame.Image, text, fontSize, opacity, margin); - return GifCodec.Encode(animation); + return isGif ? GifCodec.Encode(animation) : ApngEncoder.Encode(animation); } var image = Decode(data); @@ -138,6 +145,28 @@ public static byte[] AddTextWatermark( return frames; } + /// All displayed frames of GIF or APNG as full-canvas RGBA8 PNG images. + public static System.Collections.Generic.IReadOnlyList<(byte[] png, int delayMilliseconds)> GetAnimationFrames(this byte[] data) + { + ArgumentNullException.ThrowIfNull(data); + var animation = GifCodec.IsGif(data) ? GifCodec.Decode(data) : + ApngDecoder.IsApng(data) ? ApngDecoder.Decode(data) : + throw new NotSupportedException("Only GIF and APNG animations are supported."); + var frames = new (byte[] png, int delayMilliseconds)[animation.Frames.Count]; + for (var i = 0; i < frames.Length; i++) + frames[i] = (PngCodec.Encode(animation.Frames[i].Image), animation.Frames[i].DelayCentiseconds * 10); + return frames; + } + + /// Return animation loop count (0=infinite) for GIF or APNG. + public static int GetAnimationLoopCount(this byte[] data) + { + ArgumentNullException.ThrowIfNull(data); + if (GifCodec.IsGif(data)) return GifCodec.Decode(data).LoopCount; + if (ApngDecoder.IsApng(data)) return ApngDecoder.Decode(data).LoopCount; + throw new NotSupportedException("Only GIF and APNG animations are supported."); + } + /// NETSCAPE loop count: 0 for infinite; -1 if no loop extension exists. public static int GetGifLoopCount(this byte[] data) { From cfc7686afda9380399ec1c9e3d600ce7773bbaa5 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 15:03:41 +0300 Subject: [PATCH 81/85] Correct animated watermark dispatch scope for GIF and APNG --- Drawing/ImageHelper.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Drawing/ImageHelper.cs b/Drawing/ImageHelper.cs index b376533da..7abd86f89 100644 --- a/Drawing/ImageHelper.cs +++ b/Drawing/ImageHelper.cs @@ -108,9 +108,10 @@ public static byte[] AddTextWatermark( if (string.IsNullOrWhiteSpace(fontFamily)) throw new ArgumentException("A font family is required.", nameof(fontFamily)); - if (GifCodec.IsGif(data)) + if (GifCodec.IsGif(data) || ApngDecoder.IsApng(data)) { - var animation = GifCodec.Decode(data); + var isGif = GifCodec.IsGif(data); + var animation = isGif ? GifCodec.Decode(data) : ApngDecoder.Decode(data); if (2L * margin >= animation.Width || 2L * margin >= animation.Height) throw new ArgumentOutOfRangeException(nameof(margin), "Margins leave no room for text."); if (opacity == 0) return isGif ? GifCodec.Encode(animation) : ApngEncoder.Encode(animation); From d8865b0d32e1f9fcf447f5fe8a8345b5e7456498 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 15:04:54 +0300 Subject: [PATCH 82/85] TDD: APNG roundtrip all frames and golden blend/disposal/CRC/sequence error regressions --- Tests/Drawing/BmpGifTddTests.cs | 104 ++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/Tests/Drawing/BmpGifTddTests.cs b/Tests/Drawing/BmpGifTddTests.cs index a2da0b392..2e21af38b 100644 --- a/Tests/Drawing/BmpGifTddTests.cs +++ b/Tests/Drawing/BmpGifTddTests.cs @@ -296,6 +296,110 @@ public void Gif_TruncatedAndCorruptFiles_FailInControlledManner() } } + + [TestMethod] + public void Apng_ReimportEveryGifFrame_AgreesWithIndependentPillowPixelOracle() + { + var animation=_animated.ConvertToPng(); + var frames=animation.GetAnimationFrames(); + frames.Count.AssertEqual(4); + animation.GetAnimationLoopCount().AssertEqual(2); + var original=_animated.GetGifFrames(); + for(var i=0;i<4;i++) + { + frames[i].delayMilliseconds.AssertEqual(original[i].delayMilliseconds); + Compare(Pixels(original[i].png,4,4),Pixels(frames[i].png,4,4)); + } + } + + [TestMethod] + public void Apng_FullFrameBlendSourceAndBlendOver_RespectTransparentPixels() + { + var source=ReadPngChunks(_animated.ConvertToPng()); + var controls=0; + foreach(var i in Enumerable.Range(0,source.Length)) + if(source[i].tag=="fcTL") + { + // First frame retains red column; second frame should composite + // green over red instead of wiping transparent pixels. + if(controls==1)source[i].data[25]=1; + controls++; + } + var apng=BuildApngChunks(source); + var frames=apng.GetAnimationFrames(); + var second=Pixels(frames[1].png,4,4); + for(var y=0;y<4;y++) + { + Compare([255,0,0,255],second.AsSpan((y*4+0)*4,4).ToArray()); + Compare([0,255,0,255],second.AsSpan((y*4+1)*4,4).ToArray()); + Compare([0,0,0,0],second.AsSpan((y*4+2)*4,4).ToArray()); + } + } + + [TestMethod] + public void Apng_AllThreeDisposalModes_RenderExpectedFramePixels() + { + for(var disposal=0;disposal<3;disposal++) + { + var chunks=ReadPngChunks(_animated.ConvertToPng()); + var first=Array.FindIndex(chunks,c=>c.tag=="fcTL"); + chunks[first].data[24]=(byte)disposal; + var second=Array.FindIndex(chunks,first+1,c=>c.tag=="fcTL"); + chunks[second].data[25]=1; // overlay on previous canvas + var frames=BuildApngChunks(chunks).GetAnimationFrames(); + var next=Pixels(frames[1].png,4,4); + // NONE preserves red; BACKGROUND and PREVIOUS clear/restore it. + for(var y=0;y<4;y++) + next[(y*4)*4+3].AssertEqual((byte)(disposal==0?255:0)); + } + } + + [TestMethod] + public void Apng_MalformedFrameSequenceDimensionsCrcOrMetadata_FailsClosed() + { + var bytes=_animated.ConvertToPng(); + var changed=(byte[])bytes.Clone(); + changed[changed.Length-24]^=0x80; + ThrowsExactly(()=>changed.GetAnimationFrames()); + + void Reject(Action<(string tag,byte[] data)[]> change) + { + var chunks=ReadPngChunks(bytes); + change(chunks); + ThrowsExactly(()=>BuildApngChunks(chunks).GetAnimationFrames()); + } + Reject(chunks=>BinaryPrimitives.WriteUInt32BigEndian(chunks.Single(c=>c.tag=="acTL").data.AsSpan(0,4),8)); + Reject(chunks=>BinaryPrimitives.WriteUInt32BigEndian(chunks.First(c=>c.tag=="fcTL").data.AsSpan(0,4),100)); + Reject(chunks=>BinaryPrimitives.WriteUInt32BigEndian(chunks.First(c=>c.tag=="fdAT").data.AsSpan(0,4),100)); + Reject(chunks=>BinaryPrimitives.WriteUInt32BigEndian(chunks.First(c=>c.tag=="fcTL").data.AsSpan(4,4),5)); + Reject(chunks=>chunks.First(c=>c.tag=="fcTL").data[24]=3); + Reject(chunks=>chunks.First(c=>c.tag=="fcTL").data[25]=2); + Reject(chunks=>chunks.First(c=>c.tag=="fdAT").data=Array.Empty()); + } + + private static byte[] BuildApngChunks((string tag,byte[] data)[] chunks) + { + using var output=new MemoryStream(); + output.Write([137,80,78,71,13,10,26,10]); + foreach(var(tag,payload) in chunks) + { + Span number=stackalloc byte[4]; + BinaryPrimitives.WriteUInt32BigEndian(number,(uint)payload.Length); + output.Write(number); + var name=Encoding.ASCII.GetBytes(tag); + output.Write(name);output.Write(payload); + uint crc=0xFFFFFFFF; + foreach(var b in name.Concat(payload)) + { + crc^=b; + for(var i=0;i<8;i++)crc=(crc&1)!=0?(crc>>1)^0xEDB88320:crc>>1; + } + BinaryPrimitives.WriteUInt32BigEndian(number,~crc); + output.Write(number); + } + return output.ToArray(); + } + private static void Compare(byte[] expected,byte[] actual) { expected.Length.AssertEqual(actual.Length); From c8acd2f709ac8695b637141dc0eb482fd46128aa Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 15:05:12 +0300 Subject: [PATCH 83/85] Correct immutable LINQ result assignment in APNG malformed-frame fixture --- Tests/Drawing/BmpGifTddTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/Drawing/BmpGifTddTests.cs b/Tests/Drawing/BmpGifTddTests.cs index 2e21af38b..fa75464ad 100644 --- a/Tests/Drawing/BmpGifTddTests.cs +++ b/Tests/Drawing/BmpGifTddTests.cs @@ -374,7 +374,7 @@ void Reject(Action<(string tag,byte[] data)[]> change) Reject(chunks=>BinaryPrimitives.WriteUInt32BigEndian(chunks.First(c=>c.tag=="fcTL").data.AsSpan(4,4),5)); Reject(chunks=>chunks.First(c=>c.tag=="fcTL").data[24]=3); Reject(chunks=>chunks.First(c=>c.tag=="fcTL").data[25]=2); - Reject(chunks=>chunks.First(c=>c.tag=="fdAT").data=Array.Empty()); + Reject(chunks=> { var at=Array.FindIndex(chunks,c=>c.tag=="fdAT"); chunks[at] = (chunks[at].tag, Array.Empty()); }); } private static byte[] BuildApngChunks((string tag,byte[] data)[] chunks) From 1beff54adf14a51845f32da9d130a19d829a5af1 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 15:06:25 +0300 Subject: [PATCH 84/85] Resolve tuple-return mutation compiler errors in malformed APNG test lambdas --- Tests/Drawing/BmpGifTddTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/Drawing/BmpGifTddTests.cs b/Tests/Drawing/BmpGifTddTests.cs index fa75464ad..ff403d04e 100644 --- a/Tests/Drawing/BmpGifTddTests.cs +++ b/Tests/Drawing/BmpGifTddTests.cs @@ -372,8 +372,8 @@ void Reject(Action<(string tag,byte[] data)[]> change) Reject(chunks=>BinaryPrimitives.WriteUInt32BigEndian(chunks.First(c=>c.tag=="fcTL").data.AsSpan(0,4),100)); Reject(chunks=>BinaryPrimitives.WriteUInt32BigEndian(chunks.First(c=>c.tag=="fdAT").data.AsSpan(0,4),100)); Reject(chunks=>BinaryPrimitives.WriteUInt32BigEndian(chunks.First(c=>c.tag=="fcTL").data.AsSpan(4,4),5)); - Reject(chunks=>chunks.First(c=>c.tag=="fcTL").data[24]=3); - Reject(chunks=>chunks.First(c=>c.tag=="fcTL").data[25]=2); + Reject(chunks=>{ var d=chunks.First(c=>c.tag=="fcTL").data; d[24]=3; }); + Reject(chunks=>{ var d=chunks.First(c=>c.tag=="fcTL").data; d[25]=2; }); Reject(chunks=> { var at=Array.FindIndex(chunks,c=>c.tag=="fdAT"); chunks[at] = (chunks[at].tag, Array.Empty()); }); } From 2879c7df221055cf8dae2ef3fb917fa37469bedf Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 10 Oct 2026 15:07:24 +0300 Subject: [PATCH 85/85] Document full BMP/GIF/APNG format matrix, all-frame animation semantics and test coverage --- Drawing/README.md | 70 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 48 insertions(+), 22 deletions(-) diff --git a/Drawing/README.md b/Drawing/README.md index c49499fa4..1448b987d 100644 --- a/Drawing/README.md +++ b/Drawing/README.md @@ -282,40 +282,66 @@ Each of the three takes a `byte[]` or a `ReadOnlySpan`. ## Image Processing (fully managed C#) -No third-party NuGet imaging packages, GDI+, P/Invoke, SkiaSharp, or native graphics engines are used. The image pipeline runs on Windows, Linux, and macOS with .NET 6 / .NET 10. +The image pipeline is pure managed .NET and requires no third-party imaging packages, GDI+, SkiaSharp, P/Invoke, or native graphics codecs. Tested on Windows, Linux and macOS. -- `GetImageSize`: inspect PNG / JPEG dimensions without allocating the full pixel buffer. -- `ResizeImage`: downscale while preserving the aspect ratio and **input format**. PNG remains PNG; JPEG is re-encoded using a managed baseline JPEG encoder with 4:4:4 YCbCr sampling. Images which already fit are returned unchanged (as a new byte array). Alpha is resampled in premultiplied form for PNG. -- `ConvertToPng`: decode JPEG (baseline, progressive, grayscale, YCbCr, CMYK/YCCK) or PNG and emit lossless RGBA8 PNG with transparency. -- `AddTextWatermark`: anti-aliased white TrueType text with configurable opacity and margins. By default chooses installed Verdana; if Verdana is unavailable, it tries alternative system TrueType fonts (such as DejaVu Sans, Arial, Liberation Sans and Noto Sans). You can pin the exact font with `fontFilePath`. Watermarked output is PNG. +| Input | GetImageSize | ResizeImage | ConvertToPng | AddTextWatermark | +|---|---|---|---|---| +| PNG | Yes | PNG | PNG | PNG | +| APNG (animated PNG) | Yes | **APNG, all frames** | APNG unchanged | **APNG, all frames** | +| JPEG (8-bit baseline / progressive, CMYK, YCCK) | Yes | JPEG | PNG | PNG | +| BMP (indexed, RGB, bitfields and RLE) | Yes | BMP | PNG | BMP | +| GIF87a / GIF89a | Yes | **animated GIF, all frames** | **APNG, all frames** | **animated GIF, all frames** | -```csharp -using Ecng.Drawing; +### Animated GIF and APNG -byte[] jpeg = await File.ReadAllBytesAsync("photo.jpg"); +GIF processing preserves **every displayed frame**, its delay and the NETSCAPE looping setting (0 means infinite), and composites GIF subrectangles using transparent indices and disposal modes 0–3. Interlaced GIF frames, global/local color tables, and LZW are supported. -var size = jpeg.GetImageSize(); -byte[] thumbnailJpeg = jpeg.ResizeImage(640, 480); // JPEG remains JPEG -byte[] png = jpeg.ConvertToPng(); -byte[] watermarked = png.AddTextWatermark("StockSharp"); -await File.WriteAllBytesAsync("marked.png", watermarked); +- `GetGifFrames()` returns a list of `(byte[] png, int delayMilliseconds)` for all displayed GIF frames. Each `png` is a *full-canvas* composited RGBA8 PNG. +- `GetGifLoopCount()` returns the GIF NETSCAPE repetition count (0 means infinite; -1 means no loop extension). +- `GetAnimationFrames()` and `GetAnimationLoopCount()` also work with **APNG** input. +- Resizing GIF/APNG resizes **every displayed frame**. Animation timings and looping remain unchanged. +- Adding a watermark to GIF/APNG draws the text **on every frame**, retaining the animation. +- `ConvertToPng` on GIF yields **APNG**, not a frozen first frame. APNG carries the complete animation, timings and loop metadata. Calling the other methods on that APNG is also animation-preserving. +- APNG decoding handles per-frame rectangles, SOURCE/OVER blending, and NONE/BACKGROUND/PREVIOUS disposal. -// For byte-for-byte consistent Verdana typography across environments: -// png.AddTextWatermark("StockSharp", fontFilePath: "/path/to/Verdana.ttf"); -``` +GIF's native palette stores at most **256 indexed colors per frame** and **binary** transparency. When re-encoding GIF, exact colors are preserved if a frame has no more than 255 different nontransparent RGB colors; more complex pictures are quantized to an RGB 3:3:2 palette, and alpha below 128 is made transparent. GIF's LZW encoder favors simple, deterministic interoperable output over maximum compression efficiency. APNG uses full lossless RGBA8; converting GIF to APNG does not introduce new quantization. -Input PNG supports the standard color modes (including indexed transparency), scanline filters 0–4, 1/2/4/8/16-bit samples and Adam7 interlace. Input JPEG supports 8-bit sequential and progressive Huffman-coded scans, restart markers, grayscale, YCbCr and Adobe CMYK/YCCK. Arithmetic-coded, lossless and 12-bit JPEG are not supported. GIF, BMP and TIFF are not decoded. Invalid data throws rather than producing pixel buffers from incomplete files. Decoded images are limited to 25 megapixels; additional bounded checks protect intermediate buffers. +### BMP -JPEG output from `ResizeImage` is intentionally lossy. It uses built-in fixed quantization/Huffman tables rather than an external JPEG quality parameter. PNG output is lossless. The JPEG format and original bytes are retained when no resize is needed. +The BMP decoder accepts OS/2 BITMAPCORE and Windows BITMAPINFO/V4/V5 DIB headers, top-down or bottom-up scanlines, indexed 1/4/8-bit color, RGB555/RGB565 and other nonoverlapping 16/32-bit bitfield masks, uncompressed 24/32-bit color, and BI_RLE4/BI_RLE8. BMP encoding for resizing and watermarking always emits a portable top-down **32-bit BGRA BITMAPV4HEADER** with explicit RGBA masks to retain alpha. Embedded JPEG/PNG compression inside BMP, RLE24, and exotic OS/2-only codecs are not supported. -Verdana is a separately licensed font and is **not** included in the library. The managed TrueType renderer supports quadratic `glyf` outlines, basic composite glyphs and Unicode cmap format 4/12, but not CFF font collections or advanced text shaping. If the host has no TrueType font, you must supply an appropriate `.ttf`; exact Verdana output requires a Verdana file. +### JPEG and PNG + +- PNG: all legal color types and bit depths including grayscale, indexed transparency, 8/16-bit samples, all five filter types, and Adam7 interlacing. Input APNG is recognized and handled as animated rather than accidentally discarding frames. +- JPEG: 8-bit baseline and progressive Huffman encoding; grayscale, RGB/YCrCb, Adobe CMYK/YCCK and restart markers. Arithmetic-coded, lossless and 12-bit JPEG are not supported. +- JPEG downscaling returns managed baseline JPEG with fixed quantization/Huffman tables. There is no output quality setting yet. +- PNG processing preserves alpha using premultiplied color interpolation, and watermarks use managed TrueType rendering. +- Verdana itself is not distributed. The watermark renderer prefers installed Verdana, falls back to a system TrueType font or accepts the exact `.ttf` through `fontFilePath`. + +```csharp +using Ecng.Drawing; + +byte[] animatedGif = await File.ReadAllBytesAsync("animation.gif"); +var size = animatedGif.GetImageSize(); +var frames = animatedGif.GetGifFrames(); +int loops = animatedGif.GetGifLoopCount(); // 0: infinite, -1: unspecified + +byte[] smallerGif = animatedGif.ResizeImage(320, 240); // Still animated GIF +byte[] markedGif = smallerGif.AddTextWatermark("StockSharp"); // Every frame +byte[] animatedPng = animatedGif.ConvertToPng(); // All frames in APNG +byte[] smallerApng = animatedPng.ResizeImage(320, 240); // Still animated APNG + +byte[] bmp = await File.ReadAllBytesAsync("picture.bmp"); +byte[] markedBmp = bmp.AddTextWatermark("StockSharp"); // BMP output +``` -### Testing +### Testing and coverage -CI includes a dedicated [Drawing coverage](https://github.com/StockSharp/Ecng/actions/workflows/drawing-coverage.yml) workflow. It instruments `Ecng.Drawing` with Coverlet, uploads the Cobertura XML report, prints per-class line/branch percentages, and **fails if line coverage drops below 90% or branch coverage drops below 85%**. The report covers the Drawing assembly as exercised by the Drawing test suite; it is not proof of complete path coverage or a security audit. +Dedicated [Drawing coverage CI](https://github.com/StockSharp/Ecng/actions/workflows/drawing-coverage.yml) instruments `Ecng.Drawing` with Coverlet, reports line/branch coverage for each codec, and uploads the Cobertura XML. It fails below **90% line / 85% branch coverage**. +The tests use independent Pillow-created BMP/GIF fixtures plus programmatically constructed BMP palette/RLE/mask and interlaced GIF/LZW fixtures. Pixel oracles cover all 15 legal PNG color/depth pairs, JPEG baseline/progressive/CMYK/YCCK, BMP indexed and true-color samples, every composited GIF animation frame, APNG fdAT streams, frame delays, disposal/blending, and TrueType watermark alpha composition. Invalid/truncated images, GIF, PNG, JPEG, BMP, APNG and malformed font data are checked with deterministic mutations. -`Tests/Drawing` contains pixel-by-pixel golden fixtures generated independently of the implementation, progressive/CMYK JPEG fixtures with full libjpeg-decoded RGBA reference buffers, exact transparency/resizing calculations, an independently generated deterministic TrueType glyph, PNG Adam7/filter stress tests, randomized PNG roundtrips and malformed-input tests. Test-only reference data uses Pillow/libjpeg; runtime code and the CI test project do not require Python or imaging NuGet packages. +As with any custom image parser, broad regression tests and passing coverage thresholds are not substitutes for a security audit or a comprehensive external fuzzing campaign. Memory and total frame budgets limit pathological inputs. ## Usage Examples