Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
85 commits
Select commit Hold shift + click to select a range
d0840f6
Add image dimension, resize, PNG conversion and text watermark operat…
mikasoukhov Oct 9, 2026
89677f2
Pin compatible ImageSharp 3 and Drawing 2 dependencies
mikasoukhov Oct 9, 2026
9b8aaaa
Reference managed cross-platform image processing packages
mikasoukhov Oct 9, 2026
bff472c
Avoid ambiguous drawing Size types in image extensions
mikasoukhov Oct 9, 2026
3b8045b
Test cross-platform image sizing, resize, encoding, watermark and edg…
mikasoukhov Oct 9, 2026
5b7360c
Document image manipulation API and Verdana font requirement
mikasoukhov Oct 9, 2026
14cef03
Fix markdown code formatting in image API documentation
mikasoukhov Oct 9, 2026
ba2a1b4
Fix ImageSharp 3 resize overload for net6 and net10
mikasoukhov Oct 9, 2026
18a77bb
Reject vulnerable TIFF decoding and skip unneeded metadata
mikasoukhov Oct 9, 2026
102fe82
Cover TIFF rejection and use predictable installed fonts in tests
mikasoukhov Oct 9, 2026
e0eeeac
Document safe TIFF policy and metadata handling
mikasoukhov Oct 9, 2026
7368408
Remove all image NuGet dependencies; use Windows system GDI+ and depe…
mikasoukhov Oct 9, 2026
4cacd6b
Release drawing context before encoding watermark PNG
mikasoukhov Oct 9, 2026
8f3a5b2
Introduce managed RGBA pixel buffer with alpha-correct downscaling
mikasoukhov Oct 10, 2026
ddde5b8
Implement managed PNG codec with colors, filters, Adam7, alpha and CR…
mikasoukhov Oct 10, 2026
1cc0862
Add pure C# baseline JPEG decoder with Huffman, DCT and chroma sampling
mikasoukhov Oct 10, 2026
2d0eb0f
Render Verdana TrueType outlines using managed font parsing and antia…
mikasoukhov Oct 10, 2026
e59946a
Switch public image API to managed PNG/JPEG codecs and TrueType water…
mikasoukhov Oct 10, 2026
ad8312c
Fix managed-code glyph enumeration and JPEG scan table
mikasoukhov Oct 10, 2026
bc336f5
Fix managed-code glyph enumeration and JPEG scan table
mikasoukhov Oct 10, 2026
8e62cf0
Test pure managed PNG/JPEG decode, resize, alpha, font rendering, and…
mikasoukhov Oct 10, 2026
577f390
Document fully managed cross-platform image codecs and font renderer …
mikasoukhov Oct 10, 2026
ab35540
Fix JPEG Huffman variable scope in both .NET target frameworks
mikasoukhov Oct 10, 2026
bcb7cde
Correct CRC polynomial bit-test precedence
mikasoukhov Oct 10, 2026
1d33f19
Correct bilinear interpolation fractions for all input pixel coordinates
mikasoukhov Oct 10, 2026
c551c40
Replace corrupted fixture literals and test true text rendering on a …
mikasoukhov Oct 10, 2026
bd47cdc
Generate watermark test canvas with independent PNG writer to avoid f…
mikasoukhov Oct 10, 2026
48c2045
Search all system font directories before deciding to skip watermark …
mikasoukhov Oct 10, 2026
1db9fda
Fix watermark font scaling with floating-point width ratio
mikasoukhov Oct 10, 2026
33c50b6
Add pixel-by-pixel golden tests for PNG, resize, JPEG reference and d…
mikasoukhov Oct 10, 2026
a8fb71a
TDD RED: add regression tests for JPEG-format resize, progressive/CMY…
mikasoukhov Oct 10, 2026
e61ca28
TDD GREEN stage 1: add managed baseline JPEG encoder for format-prese…
mikasoukhov Oct 10, 2026
17006f4
Make ResizeImage preserve JPEG/PNG format and avoid unnecessary recom…
mikasoukhov Oct 10, 2026
8a90db2
Update original resize expectation for JPEG-preserving API
mikasoukhov Oct 10, 2026
1741aca
TDD GREEN: decode baseline CMYK and Adobe-inverted CMYK/YCCK JPEG
mikasoukhov Oct 10, 2026
13026d0
TDD GREEN: find deterministic installed TrueType fallback if Verdana …
mikasoukhov Oct 10, 2026
c6ef812
TDD GREEN: decode progressive 8-bit JPEG with successive coefficient …
mikasoukhov Oct 10, 2026
882a895
Dispatch SOF2 progressive JPEG to multiscanned coefficient decoder
mikasoukhov Oct 10, 2026
c091471
Expand TDD with independent full 16x16 pixel oracles for progressive …
mikasoukhov Oct 10, 2026
2cab868
TDD RED: reject JPEG with missing EOI and fuzz 320 corrupted JPEG images
mikasoukhov Oct 10, 2026
176e434
Repair CMYK test fixture missing fourth SOF component bytes
mikasoukhov Oct 10, 2026
71dacb3
TDD GREEN: reject truncated baseline JPEG lacking EOI and avoid silen…
mikasoukhov Oct 10, 2026
f7f515b
TDD: stress PNG with all five filters, Adam7, palette transparency, 1…
mikasoukhov Oct 10, 2026
f72ea31
Correct separate scanline filters in independent 16-bit RGBA PNG fixture
mikasoukhov Oct 10, 2026
59defe4
Pixel-oracle test JPEG resize against independent libjpeg plus biline…
mikasoukhov Oct 10, 2026
46fb3ce
Document managed JPEG encoder, progressive/CMYK decoding, font fallba…
mikasoukhov Oct 10, 2026
6a5bb51
Finalize JPEG codec API docs and retain TDD history without red test …
mikasoukhov Oct 10, 2026
2445f1e
TDD RED: exact coverage for all PNG color/depth combinations, transpa…
mikasoukhov Oct 10, 2026
e4bf6e8
Correct grayscale tRNS 1-bit fixture oracle: max sample equals transp…
mikasoukhov Oct 10, 2026
c7fbe9c
TDD: exact watermark alpha-compositing matrix and no-font zero-opacit…
mikasoukhov Oct 10, 2026
ab2a8d9
TDD: watermark renders and preserves unmodified pixels across all JPE…
mikasoukhov Oct 10, 2026
1f3d47d
Fix C# overload inference in appended-data PNG regression fixture
mikasoukhov Oct 10, 2026
d35face
Add test-only Coverlet collector to measure real Ecng.Drawing line an…
mikasoukhov Oct 10, 2026
e806b17
Configure isolated Cobertura coverage for Ecng.Drawing
mikasoukhov Oct 10, 2026
b50164a
Add dedicated CI workflow reporting Drawing line and branch coverage …
mikasoukhov Oct 10, 2026
3a96a4b
TDD GREEN: reject trailing bytes after PNG IEND, demonstrated by red …
mikasoukhov Oct 10, 2026
2762ca4
Expose exact PNG color-type/bit-depth pair in pixel-oracle test failures
mikasoukhov Oct 10, 2026
921205a
TDD RED: independent libjpeg pixel oracle for previously untested Ado…
mikasoukhov Oct 10, 2026
1a257d8
Fix switch-expression precedence in independent PNG filter oracle (y …
mikasoukhov Oct 10, 2026
173dea9
TDD RED: enforce PNG IDAT chunk contiguity; independently test legal …
mikasoukhov Oct 10, 2026
7ee566d
TDD GREEN: correct Adobe YCCK color inversion against libjpeg RGBA or…
mikasoukhov Oct 10, 2026
288635c
TDD GREEN: reject interleaved IDAT chunks while accepting legal conse…
mikasoukhov Oct 10, 2026
25b9a35
TDD: independent golden pixel fixtures for baseline and progressive J…
mikasoukhov Oct 10, 2026
99c631b
Harden TrueType tests with malformed font headers, glyph offsets and …
mikasoukhov Oct 10, 2026
43e3294
Fix independently generated baseline restart PNG oracle Base64 transc…
mikasoukhov Oct 10, 2026
b0e1a26
TDD GREEN: convert invalid TrueType offsets into controlled data exce…
mikasoukhov Oct 10, 2026
8449c32
Gate image-codec coverage at 90% lines / 85% branches and document Co…
mikasoukhov Oct 10, 2026
0f9e164
TDD RED: independent BMP pixel fixtures and animated GIF all-frame/lo…
mikasoukhov Oct 10, 2026
7511154
TDD GREEN stage 1: managed BMP palette/true-color/bitfields/RLE decod…
mikasoukhov Oct 10, 2026
56cce4b
TDD GREEN: managed full-frame animated GIF89a LZW decode/encode, disp…
mikasoukhov Oct 10, 2026
4469be2
Preserve complete GIF animation during ConvertToPng by writing RGBA A…
mikasoukhov Oct 10, 2026
f62965e
Keep APNG frame byte copy compatible with .NET 6
mikasoukhov Oct 10, 2026
29da1e1
Wire BMP and all-frame GIF into image API: format-preserving resize/w…
mikasoukhov Oct 10, 2026
8a72f3d
TDD: expect unsupported-format result for truncated GIF signature sho…
mikasoukhov Oct 10, 2026
1c016a1
Cover indexed BMP 1/4/8bpp, RGB565 masks, top-down, OS/2 core, RLE4/8…
mikasoukhov Oct 10, 2026
1679084
TDD: independently generate interlaced GIF89a with local palettes, ex…
mikasoukhov Oct 10, 2026
9e4486b
Independently decompress and verify every APNG frame from animated GI…
mikasoukhov Oct 10, 2026
889db9a
TDD RED: prohibit losing animation when APNG from GIF is resized or w…
mikasoukhov Oct 10, 2026
ef1c1ee
TDD GREEN: decode APNG full animation, frame offsets/blends/disposals…
mikasoukhov Oct 10, 2026
a6b9bb2
Preserve animation across APNG reprocessing and expose all GIF/APNG c…
mikasoukhov Oct 10, 2026
cfc7686
Correct animated watermark dispatch scope for GIF and APNG
mikasoukhov Oct 10, 2026
d8865b0
TDD: APNG roundtrip all frames and golden blend/disposal/CRC/sequence…
mikasoukhov Oct 10, 2026
c8acd2f
Correct immutable LINQ result assignment in APNG malformed-frame fixture
mikasoukhov Oct 10, 2026
1beff54
Resolve tuple-return mutation compiler errors in malformed APNG test …
mikasoukhov Oct 10, 2026
2879c7d
Document full BMP/GIF/APNG format matrix, all-frame animation semanti…
mikasoukhov Oct 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions .github/workflows/drawing-coverage.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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}%')
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))
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
247 changes: 247 additions & 0 deletions Drawing/ApngDecoder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
namespace Ecng.Drawing;

using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.IO;

/// <summary>
/// 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.
/// </summary>
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<byte> Signature => [137,80,78,71,13,10,26,10];

public static bool IsApng(ReadOnlySpan<byte> 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<byte>();
var palette=Array.Empty<byte>();
var transparency=Array.Empty<byte>();
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>();
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<previous.Y+previous.Height;y++)
for(var x=previous.X;x<previous.X+previous.Width;x++)
Array.Clear(canvas.Pixels,(y*width+x)*4,4);
}
else if(previous.Disposal==2 && saved!=null)
Array.Copy(saved,canvas.Pixels,canvas.Pixels.Length);
}

var backup=frame.Disposal==2?(byte[])canvas.Pixels.Clone():null;
if(frame.Compressed.Length==0)
throw new InvalidDataException("APNG frame data is missing.");
var image=DecodeFrame(header,palette,transparency,frame);
for(var y=0;y<frame.Height;y++)
for(var x=0;x<frame.Width;x++)
{
var src=(y*frame.Width+x)*4;
var dest=((y+frame.Y)*width+x+frame.X)*4;
if(frame.Blend==0)
Array.Copy(image.Pixels,src,canvas.Pixels,dest,4);
else
canvas.BlendPixel(x+frame.X,y+frame.Y,
image.Pixels[src],image.Pixels[src+1],
image.Pixels[src+2],image.Pixels[src+3]);
}

var screenshot=new RasterImage(width,height);
Array.Copy(canvas.Pixels,screenshot.Pixels,canvas.Pixels.Length);
animation.Frames.Add(new GifFrame(screenshot,frame.Delay));
previous=frame;saved=backup;
}

return animation;
}

private static RasterImage DecodeFrame(byte[] originalHeader,byte[] palette,byte[] transparency,RawFrame frame)
{
using var output=new MemoryStream();
output.Write(Signature);
var header=(byte[])originalHeader.Clone();
BinaryPrimitives.WriteInt32BigEndian(header.AsSpan(0,4),frame.Width);
BinaryPrimitives.WriteInt32BigEndian(header.AsSpan(4,4),frame.Height);
WriteChunk(output,"IHDR"u8,header);
if(palette.Length!=0)WriteChunk(output,"PLTE"u8,palette);
if(transparency.Length!=0)WriteChunk(output,"tRNS"u8,transparency);
WriteChunk(output,"IDAT"u8,frame.Compressed.ToArray());
WriteChunk(output,"IEND"u8,[]);
return PngCodec.Decode(output.ToArray());
}

private static void WriteChunk(Stream output,ReadOnlySpan<byte> tag,ReadOnlySpan<byte> content)
{
Span<byte> 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<byte> 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;
}
}
Loading
Loading