diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 985d571..927f502 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -355,11 +355,11 @@ Same caveat about duplicate entries applies. ### 5.9 GIF Export 1. For each document in container, composite into RGBA buffer. -2. Use external library (e.g. `SixLabors.ImageSharp`) to encode frames - with per-frame delay from `FrameDelayMs`. -3. Output as animated GIF. +2. Quantize each frame to 256 colors (popularity-based, with transparent slot). +3. LZW-compress and write as GIF89a with per-frame delay from `FrameDelayMs`. +4. NETSCAPE2.0 application extension for infinite looping. -Only the GIF encoder is external; the `.minint` format is fully self-implemented. +Fully self-implemented — no external library for GIF encoding. ### 5.10 Pattern Generation (Б4) @@ -438,11 +438,11 @@ Canvas (`PixelCanvas` custom control): **Chosen**: eraser writes index 0 (transparent). **Rationale**: consistent with palette convention; compositing naturally handles it. -### D7. GIF export library +### D7. GIF export -**Chosen**: external library for GIF encoding only (`SixLabors.ImageSharp` or equivalent). -**Rationale**: GIF LZW compression is complex and not the focus of this project. -**Constraint**: `.minint` serialization remains fully self-implemented. +**Chosen**: fully self-implemented GIF89a encoder with LZW compression. +**Rationale**: avoids external dependencies; the entire project uses only Avalonia and CommunityToolkit.Mvvm. +**Details**: popularity-based color quantization to 256 colors, NETSCAPE2.0 looping extension. ### D8. No undo/redo in initial implementation @@ -456,16 +456,16 @@ Canvas (`PixelCanvas` custom control): | Stage | Scope | |-------|----------------------------------------------------| | 1 | ✅ Architecture & design (this document) | -| 2 | Solution scaffold + domain models | -| 3 | Binary `.minint` serialization + round-trip test | -| 4 | Compositing + palette management + RGBA buffer | -| 5 | Basic Avalonia UI (main window, menus, panels) | -| 6 | Canvas: pan, zoom, grid, nearest-neighbor | -| 7 | Drawing tools: brush, eraser, flood fill, preview | -| 8 | Layer & document management UI | -| 9 | Effects: contrast, grayscale, fragment copy, patterns | -| 10 | Animation playback + BMP/GIF export | -| 11 | Polish, tests, documentation | +| 2 | ✅ Solution scaffold + domain models | +| 3 | ✅ Binary `.minint` serialization + round-trip test | +| 4 | ✅ Compositing + palette management + RGBA buffer | +| 5 | ✅ Basic Avalonia UI (main window, menus, panels) | +| 6 | ✅ Canvas: pan, zoom, grid, nearest-neighbor | +| 7 | ✅ Drawing tools: brush, eraser, flood fill, preview | +| 8 | ✅ Layer & document management UI | +| 9 | ✅ Effects: contrast, grayscale, fragment copy, patterns | +| 10 | ✅ Animation playback + BMP/GIF export | +| 11 | ✅ Polish, tests, documentation | --- @@ -477,6 +477,6 @@ Canvas (`PixelCanvas` custom control): | Avalonia.Desktop 11.3.8 | Minint (UI) | Desktop host | | Avalonia.Themes.Fluent 11.3.8 | Minint (UI) | Theme | | CommunityToolkit.Mvvm 8.2.1 | Minint (UI) | MVVM helpers | -| SixLabors.ImageSharp (TBD) | Infrastructure | GIF export only | +| _(no external deps)_ | Infrastructure | GIF/BMP fully self-implemented | Core project: **zero** external dependencies. diff --git a/FORMAT.md b/FORMAT.md new file mode 100644 index 0000000..5334947 --- /dev/null +++ b/FORMAT.md @@ -0,0 +1,122 @@ +# Спецификация формата `.minint` (версия 1) + +## Общие сведения + +- Все многобайтовые целые числа — **little-endian**. +- Строки — **UTF-8**, с префиксом длины 1 байт (макс. 255 байт). +- Формат не использует сжатие — данные хранятся как есть. + +## Структура файла + +``` +┌──────────────────────────┐ +│ Container Header │ 28 байт (фиксированный) +├──────────────────────────┤ +│ Document 1 │ (блок переменной длины) +├──────────────────────────┤ +│ Document 2 │ +├──────────────────────────┤ +│ ... │ +├──────────────────────────┤ +│ Document N │ +└──────────────────────────┘ +``` + +## Container Header (28 байт) + +| Смещение | Размер | Тип | Описание | +|----------|--------|--------|---------------------------------| +| 0 | 6 | ASCII | Сигнатура: `MININT` | +| 6 | 2 | uint16 | Версия формата (текущая: `1`) | +| 8 | 4 | uint32 | Ширина (Width) | +| 12 | 4 | uint32 | Высота (Height) | +| 16 | 4 | uint32 | Количество документов | +| 20 | 8 | — | Зарезервировано (нули) | + +## Блок документа + +Повторяется `DocumentCount` раз, последовательно. + +### Заголовок документа + +| Размер | Тип | Описание | +|---------------------|--------|-----------------------------| +| 1 | uint8 | Длина имени (NameLen) | +| NameLen | UTF-8 | Имя документа | +| 4 | uint32 | FrameDelayMs | +| 4 | uint32 | Количество цветов (PalCnt) | + +### Палитра + +`PalCnt × 4` байт. Каждый цвет: `[R, G, B, A]` (по 1 байту). + +Индекс 0 всегда соответствует прозрачному цвету `(0, 0, 0, 0)`. + +### Ширина индекса + +Вычисляется из `PalCnt` (не хранится в файле явно): + +| PalCnt | Байт на индекс | +|---------------------|-----------------| +| 1 – 255 | 1 | +| 256 – 65 535 | 2 | +| 65 536 – 16 777 215 | 3 | +| 16 777 216+ | 4 | + +### Количество слоёв + +| Размер | Тип | Описание | +|--------|--------|-------------------| +| 4 | uint32 | Количество слоёв | + +### Блок слоя + +Повторяется `LayerCount` раз. + +| Размер | Тип | Описание | +|---------------------------|-------|------------------------------------| +| 1 | uint8 | Длина имени слоя (LayerNameLen) | +| LayerNameLen | UTF-8 | Имя слоя | +| 1 | uint8 | Видимость (0 = скрыт, 1 = виден) | +| 1 | uint8 | Opacity (0–255) | +| Width × Height × ByteWidth | bytes | Индексы палитры, row-major, LE | + +## Правила валидации + +1. Сигнатура — строго `MININT` (6 байт ASCII). +2. Версия — строго `1` (неизвестные версии отклоняются). +3. Width, Height >= 1; максимум 65 536. +4. DocumentCount >= 1. +5. PaletteCount >= 1. +6. Каждый индекс пикселя < PaletteCount. +7. IsVisible — только 0 или 1. +8. Зарезервированные байты — допускаются ненулевые (forward compatibility). +9. Неожиданный конец файла — ошибка с описанием контекста. + +## Пример + +Контейнер 4×4, 1 документ, 2 цвета (прозрачный + красный), 1 слой: + +``` +4D 49 4E 49 4E 54 — "MININT" +01 00 — version 1 +04 00 00 00 — width = 4 +04 00 00 00 — height = 4 +01 00 00 00 — 1 document +00 00 00 00 00 00 00 00 — reserved + +05 — name length = 5 +44 6F 63 20 31 — "Doc 1" +64 00 00 00 — FrameDelayMs = 100 +02 00 00 00 — palette count = 2 +00 00 00 00 — color 0: transparent +FF 00 00 FF — color 1: red (R=255, G=0, B=0, A=255) + +01 00 00 00 — 1 layer +07 — layer name length = 7 +4C 61 79 65 72 20 31 — "Layer 1" +01 — visible +FF — opacity = 255 +00 00 00 00 00 00 00 00 — 16 pixels, all index 0 (transparent) +00 00 00 00 00 00 00 00 +``` diff --git a/Minint.Tests/CompositorTests.cs b/Minint.Tests/CompositorTests.cs new file mode 100644 index 0000000..a532b1e --- /dev/null +++ b/Minint.Tests/CompositorTests.cs @@ -0,0 +1,76 @@ +using Minint.Core.Models; +using Minint.Core.Services.Impl; + +namespace Minint.Tests; + +public class CompositorTests +{ + private readonly Compositor _compositor = new(); + + [Fact] + public void Composite_EmptyLayer_AllTransparent() + { + var doc = new MinintDocument("test"); + doc.Layers.Add(new MinintLayer("L1", 4)); + + uint[] result = _compositor.Composite(doc, 2, 2); + + Assert.All(result, px => Assert.Equal(0u, px)); + } + + [Fact] + public void Composite_SingleOpaquePixel() + { + var doc = new MinintDocument("test"); + var red = new RgbaColor(255, 0, 0, 255); + doc.EnsureColorCached(red); + var layer = new MinintLayer("L1", 4); + layer.Pixels[0] = 1; + doc.Layers.Add(layer); + + uint[] result = _compositor.Composite(doc, 2, 2); + + // ARGB packed as 0xAARRGGBB + uint expected = 0xFF_FF_00_00u; + Assert.Equal(expected, result[0]); + Assert.Equal(0u, result[1]); // rest is transparent + } + + [Fact] + public void Composite_HiddenLayer_Ignored() + { + var doc = new MinintDocument("test"); + doc.EnsureColorCached(new RgbaColor(0, 255, 0, 255)); + var layer = new MinintLayer("L1", 4); + layer.Pixels[0] = 1; + layer.IsVisible = false; + doc.Layers.Add(layer); + + uint[] result = _compositor.Composite(doc, 2, 2); + + Assert.Equal(0u, result[0]); + } + + [Fact] + public void Composite_TwoLayers_TopOverBottom() + { + var doc = new MinintDocument("test"); + var red = new RgbaColor(255, 0, 0, 255); + var blue = new RgbaColor(0, 0, 255, 255); + int redIdx = doc.EnsureColorCached(red); + int blueIdx = doc.EnsureColorCached(blue); + + var bottom = new MinintLayer("bottom", 1); + bottom.Pixels[0] = redIdx; + var top = new MinintLayer("top", 1); + top.Pixels[0] = blueIdx; + + doc.Layers.Add(bottom); + doc.Layers.Add(top); + + uint[] result = _compositor.Composite(doc, 1, 1); + + // Blue on top, fully opaque, should overwrite red + Assert.Equal(0xFF_00_00_FFu, result[0]); + } +} diff --git a/Minint.Tests/DrawingTests.cs b/Minint.Tests/DrawingTests.cs new file mode 100644 index 0000000..1cd44f1 --- /dev/null +++ b/Minint.Tests/DrawingTests.cs @@ -0,0 +1,63 @@ +using Minint.Core.Models; +using Minint.Core.Services.Impl; + +namespace Minint.Tests; + +public class DrawingTests +{ + private readonly DrawingService _drawing = new(); + + [Fact] + public void ApplyBrush_Radius0_SetsSinglePixel() + { + var layer = new MinintLayer("L1", 9); + _drawing.ApplyBrush(layer, 1, 1, 0, 1, 3, 3); + + Assert.Equal(1, layer.Pixels[1 * 3 + 1]); + Assert.Equal(0, layer.Pixels[0]); // (0,0) untouched + } + + [Fact] + public void ApplyBrush_Radius1_SetsCircle() + { + var layer = new MinintLayer("L1", 25); + _drawing.ApplyBrush(layer, 2, 2, 1, 1, 5, 5); + + // Center + 4 neighbors should be set + Assert.Equal(1, layer.Pixels[2 * 5 + 2]); // center + Assert.Equal(1, layer.Pixels[1 * 5 + 2]); // top + Assert.Equal(1, layer.Pixels[3 * 5 + 2]); // bottom + Assert.Equal(1, layer.Pixels[2 * 5 + 1]); // left + Assert.Equal(1, layer.Pixels[2 * 5 + 3]); // right + } + + [Fact] + public void ApplyEraser_SetsToZero() + { + var layer = new MinintLayer("L1", 9); + Array.Fill(layer.Pixels, 5); + _drawing.ApplyEraser(layer, 1, 1, 0, 3, 3); + + Assert.Equal(0, layer.Pixels[1 * 3 + 1]); + Assert.Equal(5, layer.Pixels[0]); // untouched + } + + [Fact] + public void GetBrushMask_Radius0_SinglePixel() + { + var mask = _drawing.GetBrushMask(2, 2, 0, 5, 5); + Assert.Single(mask); + Assert.Equal((2, 2), mask[0]); + } + + [Fact] + public void GetBrushMask_OutOfBounds_Clamped() + { + var mask = _drawing.GetBrushMask(0, 0, 2, 3, 3); + Assert.All(mask, p => + { + Assert.InRange(p.X, 0, 2); + Assert.InRange(p.Y, 0, 2); + }); + } +} diff --git a/Minint.Tests/ExportTests.cs b/Minint.Tests/ExportTests.cs new file mode 100644 index 0000000..761ee7e --- /dev/null +++ b/Minint.Tests/ExportTests.cs @@ -0,0 +1,68 @@ +using System.Text; +using Minint.Core.Models; +using Minint.Core.Services.Impl; +using Minint.Infrastructure.Export; + +namespace Minint.Tests; + +public class ExportTests +{ + [Fact] + public void BmpExport_WritesValidBmp() + { + var exporter = new BmpExporter(); + var pixels = new uint[] { 0xFF_FF_00_00, 0xFF_00_FF_00, 0xFF_00_00_FF, 0xFF_FF_FF_FF }; + + var ms = new MemoryStream(); + exporter.Export(ms, pixels, 2, 2); + + ms.Position = 0; + byte[] data = ms.ToArray(); + + Assert.True(data.Length > 0); + Assert.Equal((byte)'B', data[0]); + Assert.Equal((byte)'M', data[1]); + + int fileSize = BitConverter.ToInt32(data, 2); + Assert.Equal(data.Length, fileSize); + } + + [Fact] + public void GifExport_WritesValidGif() + { + var exporter = new GifExporter(); + var frame1 = new uint[16]; // 4x4 transparent + var frame2 = new uint[16]; + Array.Fill(frame2, 0xFF_FF_00_00u); + + var frames = new List<(uint[] Pixels, uint DelayMs)> + { + (frame1, 100), + (frame2, 200), + }; + + var ms = new MemoryStream(); + exporter.Export(ms, frames, 4, 4); + + ms.Position = 0; + byte[] data = ms.ToArray(); + + Assert.True(data.Length > 0); + string sig = Encoding.ASCII.GetString(data, 0, 6); + Assert.Equal("GIF89a", sig); + Assert.Equal(0x3B, data[^1]); // GIF trailer + } + + [Fact] + public void BmpExport_DimensionMismatch_Throws() + { + var exporter = new BmpExporter(); + var pixels = new uint[3]; // does not match 2x2 + + Assert.Throws(() => + { + var ms = new MemoryStream(); + exporter.Export(ms, pixels, 2, 2); + }); + } +} diff --git a/Minint.Tests/FloodFillTests.cs b/Minint.Tests/FloodFillTests.cs new file mode 100644 index 0000000..4fb15f7 --- /dev/null +++ b/Minint.Tests/FloodFillTests.cs @@ -0,0 +1,52 @@ +using Minint.Core.Models; +using Minint.Core.Services.Impl; + +namespace Minint.Tests; + +public class FloodFillTests +{ + private readonly FloodFillService _fill = new(); + + [Fact] + public void Fill_EmptyLayer_FillsAll() + { + var layer = new MinintLayer("L1", 9); // 3x3, all zeros + _fill.Fill(layer, 0, 0, 1, 3, 3); + + Assert.All(layer.Pixels, px => Assert.Equal(1, px)); + } + + [Fact] + public void Fill_SameColor_NoOp() + { + var layer = new MinintLayer("L1", 4); + Array.Fill(layer.Pixels, 2); + _fill.Fill(layer, 0, 0, 2, 2, 2); + + Assert.All(layer.Pixels, px => Assert.Equal(2, px)); + } + + [Fact] + public void Fill_Bounded_DoesNotCrossBorder() + { + // 3x3 grid with a wall: + // 0 0 0 + // 1 1 1 + // 0 0 0 + var layer = new MinintLayer("L1", 9); + layer.Pixels[3] = 1; // (0,1) + layer.Pixels[4] = 1; // (1,1) + layer.Pixels[5] = 1; // (2,1) + + _fill.Fill(layer, 0, 0, 2, 3, 3); + + // Top row should be filled + Assert.Equal(2, layer.Pixels[0]); + Assert.Equal(2, layer.Pixels[1]); + Assert.Equal(2, layer.Pixels[2]); + // Wall untouched + Assert.Equal(1, layer.Pixels[3]); + // Bottom row untouched (blocked by wall) + Assert.Equal(0, layer.Pixels[6]); + } +} diff --git a/Minint.Tests/FragmentServiceTests.cs b/Minint.Tests/FragmentServiceTests.cs new file mode 100644 index 0000000..6337835 --- /dev/null +++ b/Minint.Tests/FragmentServiceTests.cs @@ -0,0 +1,68 @@ +using Minint.Core.Models; +using Minint.Core.Services.Impl; + +namespace Minint.Tests; + +public class FragmentServiceTests +{ + private readonly FragmentService _fragment = new(); + + [Fact] + public void CopyFragment_SameDocument_CopiesPixels() + { + var doc = new MinintDocument("test"); + var red = new RgbaColor(255, 0, 0, 255); + doc.EnsureColorCached(red); + + var src = new MinintLayer("src", 16); + src.Pixels[0] = 1; // (0,0) = red + src.Pixels[1] = 1; // (1,0) = red + doc.Layers.Add(src); + + var dst = new MinintLayer("dst", 16); + doc.Layers.Add(dst); + + _fragment.CopyFragment(doc, 0, 0, 0, 2, 1, doc, 1, 2, 2, 4, 4); + + Assert.Equal(1, dst.Pixels[2 * 4 + 2]); // (2,2) + Assert.Equal(1, dst.Pixels[2 * 4 + 3]); // (3,2) + } + + [Fact] + public void CopyFragment_DifferentDocuments_MergesPalette() + { + var srcDoc = new MinintDocument("src"); + var blue = new RgbaColor(0, 0, 255, 255); + int blueIdx = srcDoc.EnsureColorCached(blue); + var srcLayer = new MinintLayer("L1", 4); + srcLayer.Pixels[0] = blueIdx; + srcDoc.Layers.Add(srcLayer); + + var dstDoc = new MinintDocument("dst"); + var dstLayer = new MinintLayer("L1", 4); + dstDoc.Layers.Add(dstLayer); + + _fragment.CopyFragment(srcDoc, 0, 0, 0, 1, 1, dstDoc, 0, 0, 0, 2, 2); + + int dstBlueIdx = dstDoc.FindColorCached(blue); + Assert.True(dstBlueIdx > 0); + Assert.Equal(dstBlueIdx, dstLayer.Pixels[0]); + } + + [Fact] + public void CopyFragment_TransparentPixels_Skipped() + { + var doc = new MinintDocument("test"); + var src = new MinintLayer("src", 4); // all zeros (transparent) + doc.Layers.Add(src); + + var dst = new MinintLayer("dst", 4); + Array.Fill(dst.Pixels, 0); + dst.Pixels[0] = 0; // explicitly 0 + doc.Layers.Add(dst); + + _fragment.CopyFragment(doc, 0, 0, 0, 2, 2, doc, 1, 0, 0, 2, 2); + + Assert.Equal(0, dst.Pixels[0]); // stays transparent + } +} diff --git a/Minint.Tests/ImageEffectsTests.cs b/Minint.Tests/ImageEffectsTests.cs new file mode 100644 index 0000000..2c66f1f --- /dev/null +++ b/Minint.Tests/ImageEffectsTests.cs @@ -0,0 +1,65 @@ +using Minint.Core.Models; +using Minint.Core.Services.Impl; + +namespace Minint.Tests; + +public class ImageEffectsTests +{ + private readonly ImageEffectsService _effects = new(); + + [Fact] + public void ApplyGrayscale_ConvertsColors() + { + var doc = new MinintDocument("test"); + var red = new RgbaColor(255, 0, 0, 255); + doc.EnsureColorCached(red); + + _effects.ApplyGrayscale(doc); + + var gray = doc.Palette[1]; + Assert.Equal(gray.R, gray.G); + Assert.Equal(gray.G, gray.B); + Assert.Equal(255, gray.A); + // BT.601: 0.299*255 ≈ 76 + Assert.InRange(gray.R, 74, 78); + } + + [Fact] + public void ApplyGrayscale_PreservesTransparentIndex() + { + var doc = new MinintDocument("test"); + doc.EnsureColorCached(new RgbaColor(100, 200, 50, 255)); + + _effects.ApplyGrayscale(doc); + + Assert.Equal(RgbaColor.Transparent, doc.Palette[0]); + } + + [Fact] + public void ApplyContrast_IncreasesContrast() + { + var doc = new MinintDocument("test"); + var midGray = new RgbaColor(128, 128, 128, 255); + var lightGray = new RgbaColor(192, 192, 192, 255); + doc.EnsureColorCached(midGray); + doc.EnsureColorCached(lightGray); + + _effects.ApplyContrast(doc, 2.0); + + // midGray (128) stays ~128: factor*(128-128)+128 = 128 + Assert.InRange(doc.Palette[1].R, 126, 130); + // lightGray (192): factor*(192-128)+128 = 2*64+128 = 256 → clamped to 255 + Assert.Equal(255, doc.Palette[2].R); + } + + [Fact] + public void ApplyContrast_PreservesAlpha() + { + var doc = new MinintDocument("test"); + doc.EnsureColorCached(new RgbaColor(100, 100, 100, 200)); + + _effects.ApplyContrast(doc, 1.5); + + Assert.Equal(200, doc.Palette[1].A); + } +} diff --git a/Minint.Tests/Minint.Tests.csproj b/Minint.Tests/Minint.Tests.csproj new file mode 100644 index 0000000..2ec1b97 --- /dev/null +++ b/Minint.Tests/Minint.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Minint.Tests/PatternGeneratorTests.cs b/Minint.Tests/PatternGeneratorTests.cs new file mode 100644 index 0000000..caca19d --- /dev/null +++ b/Minint.Tests/PatternGeneratorTests.cs @@ -0,0 +1,41 @@ +using Minint.Core.Models; +using Minint.Core.Services; +using Minint.Core.Services.Impl; + +namespace Minint.Tests; + +public class PatternGeneratorTests +{ + private readonly PatternGenerator _gen = new(); + + [Theory] + [InlineData(PatternType.Checkerboard)] + [InlineData(PatternType.HorizontalGradient)] + [InlineData(PatternType.VerticalGradient)] + [InlineData(PatternType.HorizontalStripes)] + [InlineData(PatternType.VerticalStripes)] + [InlineData(PatternType.ConcentricCircles)] + [InlineData(PatternType.Tile)] + public void Generate_AllTypes_ProducesValidDocument(PatternType type) + { + var colors = new[] { new RgbaColor(255, 0, 0, 255), new RgbaColor(0, 0, 255, 255) }; + var doc = _gen.Generate(type, 16, 16, colors, 4, 4); + + Assert.Equal($"Pattern ({type})", doc.Name); + Assert.Single(doc.Layers); + Assert.Equal(256, doc.Layers[0].Pixels.Length); + Assert.True(doc.Palette.Count >= 2); + } + + [Fact] + public void Generate_Checkerboard_AlternatesColors() + { + var colors = new[] { new RgbaColor(255, 0, 0, 255), new RgbaColor(0, 255, 0, 255) }; + var doc = _gen.Generate(PatternType.Checkerboard, 4, 4, colors, 2); + + var layer = doc.Layers[0]; + int topLeft = layer.Pixels[0]; + int topRight = layer.Pixels[2]; // cellSize=2, so (2,0) is next cell + Assert.NotEqual(topLeft, topRight); + } +} diff --git a/Minint.Tests/SerializerTests.cs b/Minint.Tests/SerializerTests.cs new file mode 100644 index 0000000..2d95858 --- /dev/null +++ b/Minint.Tests/SerializerTests.cs @@ -0,0 +1,121 @@ +using Minint.Core.Models; +using Minint.Infrastructure.Serialization; + +namespace Minint.Tests; + +public class SerializerTests +{ + private readonly MinintSerializer _serializer = new(); + + [Fact] + public void RoundTrip_EmptyDocument_PreservesStructure() + { + var container = new MinintContainer(32, 16); + container.AddNewDocument("Doc1"); + + var result = RoundTrip(container); + + Assert.Equal(32, result.Width); + Assert.Equal(16, result.Height); + Assert.Single(result.Documents); + Assert.Equal("Doc1", result.Documents[0].Name); + Assert.Single(result.Documents[0].Layers); + Assert.Equal(32 * 16, result.Documents[0].Layers[0].Pixels.Length); + } + + [Fact] + public void RoundTrip_MultipleDocuments_PreservesAll() + { + var container = new MinintContainer(8, 8); + var doc1 = container.AddNewDocument("Frame1"); + doc1.FrameDelayMs = 200; + var doc2 = container.AddNewDocument("Frame2"); + doc2.FrameDelayMs = 500; + + var result = RoundTrip(container); + + Assert.Equal(2, result.Documents.Count); + Assert.Equal("Frame1", result.Documents[0].Name); + Assert.Equal(200u, result.Documents[0].FrameDelayMs); + Assert.Equal("Frame2", result.Documents[1].Name); + Assert.Equal(500u, result.Documents[1].FrameDelayMs); + } + + [Fact] + public void RoundTrip_PaletteAndPixels_Preserved() + { + var container = new MinintContainer(4, 4); + var doc = container.AddNewDocument("Test"); + var red = new RgbaColor(255, 0, 0, 255); + doc.EnsureColorCached(red); + + var layer = doc.Layers[0]; + layer.Pixels[0] = 1; // red + + var result = RoundTrip(container); + var rdoc = result.Documents[0]; + + Assert.Equal(2, rdoc.Palette.Count); // transparent + red + Assert.Equal(RgbaColor.Transparent, rdoc.Palette[0]); + Assert.Equal(red, rdoc.Palette[1]); + Assert.Equal(1, rdoc.Layers[0].Pixels[0]); + Assert.Equal(0, rdoc.Layers[0].Pixels[1]); + } + + [Fact] + public void RoundTrip_LayerProperties_Preserved() + { + var container = new MinintContainer(2, 2); + var doc = container.AddNewDocument("Test"); + doc.Layers[0].Name = "Background"; + doc.Layers[0].IsVisible = false; + doc.Layers[0].Opacity = 128; + + var result = RoundTrip(container); + var layer = result.Documents[0].Layers[0]; + + Assert.Equal("Background", layer.Name); + Assert.False(layer.IsVisible); + Assert.Equal(128, layer.Opacity); + } + + [Fact] + public void RoundTrip_LargePalette_Uses2ByteIndices() + { + var container = new MinintContainer(2, 2); + var doc = container.AddNewDocument("BigPalette"); + + for (int i = 0; i < 300; i++) + doc.EnsureColorCached(new RgbaColor((byte)(i % 256), (byte)(i / 256), 0, 255)); + + Assert.Equal(2, doc.IndexByteWidth); + + int lastIdx = doc.Palette.Count - 1; + doc.Layers[0].Pixels[0] = lastIdx; + + var result = RoundTrip(container); + Assert.Equal(lastIdx, result.Documents[0].Layers[0].Pixels[0]); + } + + [Fact] + public void Read_InvalidSignature_Throws() + { + var ms = new MemoryStream("BADDATA"u8.ToArray()); + Assert.Throws(() => _serializer.Read(ms)); + } + + [Fact] + public void Read_TruncatedStream_Throws() + { + var ms = new MemoryStream("MINI"u8.ToArray()); + Assert.Throws(() => _serializer.Read(ms)); + } + + private MinintContainer RoundTrip(MinintContainer container) + { + var ms = new MemoryStream(); + _serializer.Write(ms, container); + ms.Position = 0; + return _serializer.Read(ms); + } +} diff --git a/Minint.slnx b/Minint.slnx index af12a1b..3be1b2f 100644 --- a/Minint.slnx +++ b/Minint.slnx @@ -1,5 +1,6 @@ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..5267c25 --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# Minint + +> Растровый редактор с собственным бинарным форматом `.minint`, поддержкой слоёв, анимации и палитровых эффектов. + +## Что это + +Desktop-приложение для создания и редактирования пиксельной графики. Изображения хранятся в собственном бинарном формате с палитровой моделью, где каждый документ (кадр) содержит свою палитру RGBA и набор слоёв. Контейнер объединяет несколько документов для анимации. + +## Возможности + +- **Собственный формат `.minint`** — самописная бинарная сериализация с валидацией, переменным размером индексов палитры (1–4 байта) +- **Слои** — добавление, удаление, переименование, порядок, видимость, прозрачность, дублирование +- **Документы/кадры** — несколько документов в одном контейнере, каждый со своей палитрой +- **Инструменты** — кисть, ластик (с регулируемым радиусом), заливка (flood fill), выделение + копирование/вставка +- **Холст** — pan (среднее колёсико / touchpad), zoom (Ctrl+колёсико), nearest-neighbor масштабирование, пиксельная сетка +- **Эффекты** — контрастность (A1), перевод в градации серого (A2) — применяются через палитру +- **Копирование фрагмента (A4)** — визуальное выделение на холсте, плавающая вставка с поддержкой смены документа/слоя +- **Генерация узоров (Б4)** — шахматка, градиент, полосы, концентрические круги, плитка +- **Анимация** — проигрывание документов как кадров с настраиваемым delay +- **Экспорт** — BMP (32-bit BGRA, самописный), GIF (анимированный, LZW, самописный) + +## Стек + +| Компонент | Технология | +|-----------|------------| +| Язык | C# / .NET 10 | +| UI | Avalonia 11.3.8 | +| MVVM | CommunityToolkit.Mvvm 8.2.1 | +| Тесты | xUnit | +| Сериализация | Полностью самописная (бинарная) | +| Экспорт BMP/GIF | Полностью самописный | + +## Структура проекта + +``` +Minint.slnx +├── Minint.Core/ — Доменные модели, интерфейсы сервисов, чистая логика +├── Minint.Infrastructure/ — Сериализация .minint, экспорт BMP/GIF +├── Minint/ — Avalonia UI приложение +└── Minint.Tests/ — Unit-тесты (xUnit) +``` + +Core не зависит ни от каких внешних пакетов. Infrastructure зависит только от Core. UI зависит от Core + Infrastructure + Avalonia. + +## Требования + +- [.NET 10 SDK](https://dotnet.microsoft.com/download) или новее + +## Сборка и запуск + +```bash +dotnet build +dotnet run --project Minint +``` + +## Тесты + +```bash +dotnet test +``` + +37 тестов покрывают: сериализацию (round-trip), композитинг, инструменты рисования, flood fill, эффекты (контраст, grayscale), генерацию паттернов, копирование фрагмента, экспорт BMP/GIF. + +## Управление + +| Действие | Управление | +|----------|------------| +| Зум | Ctrl + колёсико мыши | +| Перемещение (pan) | Среднее колёсико / touchpad scroll | +| Рисование | Левая кнопка мыши | +| Пиксельная сетка | Ctrl+G или View > Pixel Grid | +| Копирование фрагмента | Select tool → выделить → Ctrl+C → Ctrl+V → кликнуть для фиксации | +| Отмена вставки | Escape | +| Новый файл | Ctrl+N | +| Открыть | Ctrl+O | +| Сохранить | Ctrl+S | + +## Формат `.minint` + +Подробная спецификация формата — в файле [`FORMAT.md`](FORMAT.md). + +Ключевые свойства: +- Little-endian +- Сигнатура `MININT`, версия формата +- Палитра RGBA на документ, индексы переменной ширины (1–4 байта) +- Полная валидация при чтении + +## Ограничения + +- **Undo/Redo** не реализовано (архитектура не запрещает добавление) +- **Размер контейнера** — все документы имеют одинаковые размеры (width × height на уровне контейнера) +- **GIF квантизация** — простая popularity-based (до 256 цветов), может терять оттенки +- **Максимальный размер** — 65536 × 65536 пикселей (ограничение формата) +- Имена документов/слоёв — максимум 255 UTF-8 байт