diff --git a/Report/append_sources_to_report.py b/Report/append_sources_to_report.py
new file mode 100644
index 0000000..715c8ef
--- /dev/null
+++ b/Report/append_sources_to_report.py
@@ -0,0 +1,143 @@
+#!/usr/bin/env python3
+"""
+Append source code listings to a Markdown report.
+
+Usage example:
+ python3 Report/append_sources_to_report.py \
+ --input Report/lab2/zivro-lab2-report.md \
+ --output Report/lab2/zivro-lab2-report-with-code.md \
+ --base . \
+ --include "Minint/**/*.cs" \
+ --include "Minint.Core/**/*.cs" \
+ --include "Minint.Infrastructure/**/*.cs" \
+ --include "Minint.Tests/**/*.cs"
+"""
+
+from __future__ import annotations
+
+import argparse
+from pathlib import Path
+
+
+EXT_TO_LANG: dict[str, str] = {
+ ".cs": "csharp",
+ ".axaml": "xml",
+ ".xml": "xml",
+ ".json": "json",
+ ".md": "markdown",
+ ".sh": "bash",
+ ".py": "python",
+}
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Append code listings to report markdown.")
+ parser.add_argument("--input", required=True, help="Path to source markdown report.")
+ parser.add_argument("--output", required=True, help="Path to resulting markdown report.")
+ parser.add_argument(
+ "--base",
+ default=".",
+ help="Project root path used for resolving include patterns.",
+ )
+ parser.add_argument(
+ "--include",
+ action="append",
+ default=[],
+ help="Glob pattern relative to --base. Can be passed multiple times.",
+ )
+ parser.add_argument(
+ "--exclude-substring",
+ action="append",
+ default=["/bin/", "/obj/", ".git/"],
+ help="Skip files that contain this substring in path.",
+ )
+ parser.add_argument(
+ "--title",
+ default="Приложение A. Исходные тексты",
+ help="Heading title for generated appendix.",
+ )
+ return parser.parse_args()
+
+
+def detect_lang(path: Path) -> str:
+ return EXT_TO_LANG.get(path.suffix.lower(), "text")
+
+
+def should_skip(path: Path, excludes: list[str]) -> bool:
+ normalized = str(path).replace("\\", "/")
+ return any(sub in normalized for sub in excludes)
+
+
+def collect_files(base: Path, patterns: list[str], excludes: list[str]) -> list[Path]:
+ files: list[Path] = []
+ seen: set[Path] = set()
+ for pattern in patterns:
+ for p in base.glob(pattern):
+ if not p.is_file():
+ continue
+ if should_skip(p, excludes):
+ continue
+ rp = p.resolve()
+ if rp in seen:
+ continue
+ seen.add(rp)
+ files.append(rp)
+ files.sort(key=lambda p: str(p).replace("\\", "/"))
+ return files
+
+
+def build_appendix(base: Path, files: list[Path], title: str) -> str:
+ lines: list[str] = []
+ lines.append("")
+ lines.append("---")
+ lines.append("")
+ lines.append(f"## {title}")
+ lines.append("")
+ lines.append(
+ f"Сформировано автоматически скриптом `Report/append_sources_to_report.py` (файлов: {len(files)})."
+ )
+ lines.append("")
+
+ for i, file_path in enumerate(files, start=1):
+ rel = file_path.relative_to(base).as_posix()
+ lang = detect_lang(file_path)
+ content = file_path.read_text(encoding="utf-8", errors="replace").rstrip()
+ lines.append(f"### A.{i}. `{rel}`")
+ lines.append("")
+ lines.append(f"```{lang}")
+ if content:
+ lines.append(content)
+ lines.append("```")
+ lines.append("")
+
+ return "\n".join(lines)
+
+
+def main() -> int:
+ args = parse_args()
+
+ input_md = Path(args.input).resolve()
+ output_md = Path(args.output).resolve()
+ base = Path(args.base).resolve()
+ patterns = args.include or ["**/*.cs"]
+
+ if not input_md.is_file():
+ raise FileNotFoundError(f"Input markdown not found: {input_md}")
+ if not base.exists():
+ raise FileNotFoundError(f"Base path not found: {base}")
+
+ report_body = input_md.read_text(encoding="utf-8")
+ files = collect_files(base, patterns, args.exclude_substring)
+ appendix = build_appendix(base, files, args.title)
+
+ output_md.parent.mkdir(parents=True, exist_ok=True)
+ output_md.write_text(report_body.rstrip() + "\n" + appendix + "\n", encoding="utf-8")
+
+ print(f"Input: {input_md}")
+ print(f"Output: {output_md}")
+ print(f"Files appended: {len(files)}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/Report/header.tex b/Report/header.tex
new file mode 100644
index 0000000..7b55a5e
--- /dev/null
+++ b/Report/header.tex
@@ -0,0 +1,16 @@
+\usepackage{fvextra}
+\DefineVerbatimEnvironment{Highlighting}{Verbatim}{
+ breaklines,
+ breakanywhere,
+ commandchars=\\\{\},
+ fontsize=\small
+}
+
+\usepackage{xurl}
+\urlstyle{same}
+\setlength{\emergencystretch}{3em}
+
+\usepackage{graphicx}
+\setkeys{Gin}{width=\linewidth,height=0.80\textheight,keepaspectratio}
+\usepackage{float}
+\floatplacement{figure}{H}
diff --git a/Report/lab2/Screenshot.png b/Report/lab2/Screenshot.png
new file mode 100644
index 0000000..cc21959
Binary files /dev/null and b/Report/lab2/Screenshot.png differ
diff --git a/Report/lab2/generate-pdf.sh b/Report/lab2/generate-pdf.sh
new file mode 100755
index 0000000..795d802
--- /dev/null
+++ b/Report/lab2/generate-pdf.sh
@@ -0,0 +1,38 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPORT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
+ROOT_DIR="$(cd "${REPORT_DIR}/.." && pwd)"
+
+INPUT_MD="${SCRIPT_DIR}/zivro-lab2-report.md"
+WITH_CODE_MD="${SCRIPT_DIR}/zivro-lab2-report-with-code.md"
+OUTPUT_PDF="${SCRIPT_DIR}/zivro-lab2-report.pdf"
+
+echo "[1/3] Генерация PNG из UML..."
+python3 "${REPORT_DIR}/render_uml_png.py" \
+ --input-dir "${SCRIPT_DIR}/uml" \
+ --glob "*.puml"
+
+echo "[2/3] Обновление версии отчёта с листингом кода..."
+python3 "${REPORT_DIR}/append_sources_to_report.py" \
+ --input "${INPUT_MD}" \
+ --output "${WITH_CODE_MD}" \
+ --base "${ROOT_DIR}" \
+ --include "Minint/**/*.cs" \
+ --include "Minint.Core/**/*.cs" \
+ --include "Minint.Infrastructure/**/*.cs" \
+ --include "Minint.Tests/**/*.cs"
+
+echo "[3/3] Генерация PDF..."
+pandoc "${WITH_CODE_MD}" \
+ -f markdown+implicit_figures+link_attributes+tex_math_dollars \
+ -o "${OUTPUT_PDF}" \
+ --pdf-engine=xelatex \
+ --toc \
+ -V geometry:left=1.5cm,right=1.5cm,top=1.5cm,bottom=1.5cm \
+ -V mainfont="DejaVu Serif" \
+ -V monofont="DejaVu Sans Mono" \
+ -H "${REPORT_DIR}/header.tex"
+
+echo "Готово: ${OUTPUT_PDF}"
diff --git a/Report/lab2/uml/lr2-container-serialization.png b/Report/lab2/uml/lr2-container-serialization.png
new file mode 100644
index 0000000..37d6125
Binary files /dev/null and b/Report/lab2/uml/lr2-container-serialization.png differ
diff --git a/Report/lab2/uml/lr2-container-serialization.puml b/Report/lab2/uml/lr2-container-serialization.puml
new file mode 100644
index 0000000..66ece6c
--- /dev/null
+++ b/Report/lab2/uml/lr2-container-serialization.puml
@@ -0,0 +1,42 @@
+@startuml
+title ЛР2 ИВ1: структура контейнера и сериализация
+
+rectangle "Файл .minint" {
+ rectangle "Header" as H
+ rectangle "Documents[]" as D
+}
+
+rectangle "Header" {
+ card "Signature: MININT"
+ card "Version"
+ card "Width, Height"
+ card "DocumentCount"
+ card "Reserved[8]"
+}
+
+rectangle "Document" {
+ card "Name"
+ card "FrameDelayMs"
+ card "PaletteCount"
+ card "Palette RGBA[]"
+ card "LayerCount"
+ card "Layers[]"
+}
+
+rectangle "Layer" {
+ card "Name"
+ card "IsVisible"
+ card "Opacity"
+ card "PixelIndices[]"
+}
+
+H --> D
+D --> "Document"
+"Document" --> "Layer"
+
+note bottom
+PixelIndices хранят индексы палитры.
+Ширина индекса в файле: 1..4 байта
+в зависимости от размера палитры.
+end note
+@enduml
diff --git a/Report/lab2/uml/lr2-editor-workflow.png b/Report/lab2/uml/lr2-editor-workflow.png
new file mode 100644
index 0000000..25c6532
Binary files /dev/null and b/Report/lab2/uml/lr2-editor-workflow.png differ
diff --git a/Report/lab2/uml/lr2-editor-workflow.puml b/Report/lab2/uml/lr2-editor-workflow.puml
new file mode 100644
index 0000000..68380dd
--- /dev/null
+++ b/Report/lab2/uml/lr2-editor-workflow.puml
@@ -0,0 +1,28 @@
+@startuml
+title ЛР2 ИВ1: основной рабочий цикл растрового редактора
+
+start
+:Запуск приложения;
+:Инициализация EditorViewModel и PixelCanvas;
+
+repeat
+ :Ожидание действий пользователя;
+ if (Выбран файл контейнера?) then (да)
+ :Чтение .minint через MinintSerializer;
+ :Построение модели документа;
+ endif
+
+ if (Режим редактирования?) then (да)
+ :Инструмент Brush/Eraser/Fill/Select;
+ :Изменение пикселей активного слоя;
+ :Обновление холста (composite);
+ endif
+
+ if (Сохранить?) then (да)
+ :Запись контейнера в .minint;
+ endif
+repeat while (Продолжать работу?) is (да)
+
+:Штатное завершение;
+stop
+@enduml
diff --git a/Report/lab2/uml/lr2-selection-copy-paste.png b/Report/lab2/uml/lr2-selection-copy-paste.png
new file mode 100644
index 0000000..9452453
Binary files /dev/null and b/Report/lab2/uml/lr2-selection-copy-paste.png differ
diff --git a/Report/lab2/uml/lr2-selection-copy-paste.puml b/Report/lab2/uml/lr2-selection-copy-paste.puml
new file mode 100644
index 0000000..550e1cc
--- /dev/null
+++ b/Report/lab2/uml/lr2-selection-copy-paste.puml
@@ -0,0 +1,26 @@
+@startuml
+title ЛР2 ИВ1: выделение, копирование и вставка
+
+start
+:Пользователь выбирает Select;
+:Формирование прямоугольника выделения;
+
+if (Copy/Cut?) then (да)
+ :Пройти выделенную область;
+ :Преобразовать индексы в RGBA;
+ :Сохранить ClipboardFragment;
+ if (Cut?) then (да)
+ :Очистить выделенную область (индекс 0);
+ endif
+endif
+
+if (Paste?) then (да)
+ :Показать "плавающий" фрагмент;
+ :Пользователь задаёт позицию;
+ :CommitPaste -> запись в слой;
+ :Прозрачные пиксели пропустить;
+endif
+
+:Обновить холст;
+stop
+@enduml
diff --git a/Report/lab2/uml/lr2-tools-and-fill.png b/Report/lab2/uml/lr2-tools-and-fill.png
new file mode 100644
index 0000000..0c002e3
Binary files /dev/null and b/Report/lab2/uml/lr2-tools-and-fill.png differ
diff --git a/Report/lab2/uml/lr2-tools-and-fill.puml b/Report/lab2/uml/lr2-tools-and-fill.puml
new file mode 100644
index 0000000..7424b01
--- /dev/null
+++ b/Report/lab2/uml/lr2-tools-and-fill.puml
@@ -0,0 +1,24 @@
+@startuml
+title ЛР2 ИВ1: обработка инструментов рисования
+
+start
+:Событие мыши в PixelCanvas;
+:Преобразование координат экрана -> пиксель;
+
+if (Инструмент == Brush?) then (да)
+ :Вычислить маску кисти (круг);
+ :Записать выбранный индекс цвета;
+elseif (Инструмент == Eraser?) then (да)
+ :Вычислить маску кисти (круг);
+ :Записать индекс 0 (прозрачный);
+elseif (Инструмент == Fill?) then (да)
+ :Запустить FloodFillService.Fill;
+ :BFS по 4-связным соседям;
+ :Перекрасить только область target-индекса;
+else (Select)
+ :Передать управление блоку выделения;
+endif
+
+:Обновить итоговый буфер и холст;
+stop
+@enduml
diff --git a/Report/lab2/zivro-lab2-report-with-code.md b/Report/lab2/zivro-lab2-report-with-code.md
new file mode 100644
index 0000000..69a15ea
--- /dev/null
+++ b/Report/lab2/zivro-lab2-report-with-code.md
@@ -0,0 +1,4882 @@
+# Лабораторная работа 2
+## Способы и средства хранения и обработки графических данных
+### Вариант ИВ1: разработка растрового редактора
+
+## 1. Цель и практический результат
+
+Цель работы - разработать растровый редактор, выполняющий создание, загрузку, редактирование и сохранение графического контейнера с пиксельными данными, а также реализовать базовые инструменты рисования и редактирования фрагментов.
+
+Практический результат:
+
+- разработано настольное приложение `Minint` на `C#` + `Avalonia`;
+- реализован собственный бинарный формат контейнера `.minint` с чтением/записью;
+- реализованы инструменты `Brush`, `Eraser`, `Fill`, `Select`, `Copy/Cut/Paste`;
+- подготовлена документация с UML-диаграммами и приложением исходного кода.
+
+
+
+## 2. Соответствие варианту ИВ1 и замечание по структуре контейнера
+
+По методическим указаниям ИВ1 требуется использовать структуру пикселя и контейнера из одного из вариантов `КВ1–КВ4`.
+
+В текущем проекте фактически реализован палитровый контейнер с `RGBA`-палитрой и индексами пикселей (`MinintContainer`/`MinintDocument`/`MinintLayer`), что не совпадает буквально с описаниями `КВ1–КВ4`, но полностью закрывает функциональные требования ИВ1 (создание, редактирование, загрузка, сохранение, инструменты рисования, работа с фрагментами).
+
+Это ограничение фиксируется в отчёте явно, чтобы не было расхождения между кодом и документацией.
+
+## 3. Выполнение основных требований ИВ1
+
+### 3.1 Создание, загрузка и сохранение контейнера
+
+- создание нового контейнера выполняется через `EditorViewModel.NewContainer(...)`;
+- загрузка/сохранение выполняется через `MinintSerializer` (собственная реализация чтения/записи);
+- контейнер хранит общие размеры, набор документов (кадров), палитры и слои.
+
+Реализация: `Minint/ViewModels/EditorViewModel.cs`, `Minint.Infrastructure/Serialization/MinintSerializer.cs`, `Minint.Core/Models/*`.
+
+### 3.2 Редактирование единичных пикселей
+
+- инструмент `Brush` изменяет значения пикселей маской радиуса;
+- инструмент `Eraser` записывает индекс прозрачного цвета (`0`);
+- выбор цвета выполняется через текущий `SelectedColor` и палитру документа.
+
+Реализация: `Minint.Core/Services/Impl/DrawingService.cs`, `Minint/ViewModels/EditorViewModel.cs`.
+
+### 3.3 Непрерывная отрисовка "кистью"
+
+- при перемещении мыши по зажатой кнопке вызывается последовательная обработка точек;
+- маска кисти вычисляется как круг по радиусу;
+- поддерживается визуальный preview маски инструмента.
+
+Реализация: `Minint/Controls/PixelCanvas.cs`, `Minint/Core/Services/Impl/DrawingService.cs`, `Minint/ViewModels/EditorViewModel.cs`.
+
+### 3.4 Закраска области ("заливка")
+
+- реализован алгоритм flood fill (4-связность);
+- заливка ограничена областью одинакового исходного индекса;
+- алгоритм работает в границах изображения.
+
+Реализация: `Minint.Core/Services/Impl/FloodFillService.cs`.
+
+### 3.5 Выделение, копирование/вырезание и вставка фрагмента
+
+- реализована рамка выделения (`SelectionRect`);
+- данные буфера обмена хранятся в palette-independent виде (`ClipboardFragment`, `RGBA`);
+- вставка поддерживает предпросмотр и подтверждение позиции;
+- прозрачные пиксели фрагмента при вставке пропускаются.
+
+Реализация: `Minint/ViewModels/EditorViewModel.cs`, `Minint/Controls/PixelCanvas.cs`, `Minint.Core/Services/Impl/FragmentService.cs`.
+
+## 4. Структура контейнера и пикселя
+
+### 4.1 Структура контейнера `.minint`
+
+Контейнер состоит из:
+
+1. Заголовка файла:
+ - сигнатура `MININT`;
+ - версия формата;
+ - ширина и высота;
+ - количество документов;
+ - резерв.
+2. Набора документов:
+ - имя документа;
+ - задержка кадра;
+ - палитра `RGBA`;
+ - набор слоёв.
+3. Набора слоёв:
+ - имя слоя;
+ - признак видимости;
+ - непрозрачность;
+ - массив индексов пикселей.
+
+### 4.2 Структура пикселя
+
+Логически пиксель хранится как индекс в палитре документа (`int` в ОЗУ, переменная ширина 1..4 байта в файле), а итоговый цвет формируется по таблице `RgbaColor`.
+
+## 5. Основные алгоритмы
+
+1. Запись контейнера в бинарный поток (`WriteHeader`, `WriteDocument`, `WriteLayer`).
+2. Чтение и валидация контейнера (`ReadHeader`, `ReadDocument`, `ReadLayer`).
+3. Круглая кисть по маске радиуса.
+4. Очистка пикселей ластиком.
+5. Flood fill по очереди.
+6. Копирование/вставка фрагмента с отсечением по границам.
+7. Композиция слоёв при обновлении холста.
+
+## 6. UML-диаграммы (PlantUML)
+
+### 6.1 Основной рабочий цикл редактора
+
+`Report/lab2/uml/lr2-editor-workflow.puml`
+
+
+
+### 6.2 Формат контейнера и сериализация
+
+`Report/lab2/uml/lr2-container-serialization.puml`
+
+
+
+### 6.3 Инструменты рисования и заливки
+
+`Report/lab2/uml/lr2-tools-and-fill.puml`
+
+
+
+### 6.4 Выделение и буфер обмена фрагментов
+
+`Report/lab2/uml/lr2-selection-copy-paste.puml`
+
+
+
+## 7. Проверка работоспособности
+
+Для проверки корректности реализации используются модульные тесты проекта `Minint.Tests`:
+
+- `DrawingTests`;
+- `FloodFillTests`;
+- `FragmentServiceTests`;
+- `SerializerTests`;
+- `CompositorTests`;
+- `ExportTests`.
+
+## 8. Вывод
+
+В рамках ЛР2 (вариант ИВ1) реализовано рабочее приложение-растровый редактор с собственным контейнером данных и базовым набором инструментов редактирования изображения. Практические требования ИВ1 закрыты на уровне пользовательского сценария и программной реализации.
+
+Отдельно зафиксировано, что выбранная структура контейнера является палитровой и не повторяет буквально формулировки `КВ1–КВ4`; при этом это не противоречит задаче разработки редактора и демонстрирует полноценную обработку графических данных.
+
+---
+
+## Приложение A. Исходные тексты
+
+Сформировано автоматически скриптом `Report/append_sources_to_report.py` (файлов: 48).
+
+### A.1. `Minint.Core/Models/MinintContainer.cs`
+
+```csharp
+namespace Minint.Core.Models;
+
+///
+/// Top-level container: holds dimensions shared by all documents/layers,
+/// and a list of documents (frames).
+///
+public sealed class MinintContainer
+{
+ public int Width { get; set; }
+ public int Height { get; set; }
+ public List Documents { get; }
+
+ public int PixelCount => Width * Height;
+
+ public MinintContainer(int width, int height)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(width, 1);
+ ArgumentOutOfRangeException.ThrowIfLessThan(height, 1);
+
+ Width = width;
+ Height = height;
+ Documents = [];
+ }
+
+ ///
+ /// Creates a new document with a single transparent layer and adds it to the container.
+ ///
+ public MinintDocument AddNewDocument(string name)
+ {
+ var doc = new MinintDocument(name);
+ doc.Layers.Add(new MinintLayer("Layer 1", PixelCount));
+ Documents.Add(doc);
+ return doc;
+ }
+}
+```
+
+### A.2. `Minint.Core/Models/MinintDocument.cs`
+
+```csharp
+namespace Minint.Core.Models;
+
+///
+/// A single document (frame) within a container.
+/// Has its own palette shared by all layers, plus a list of layers.
+///
+public sealed class MinintDocument
+{
+ public string Name { get; set; }
+
+ ///
+ /// Delay before showing the next frame during animation playback (ms).
+ ///
+ public uint FrameDelayMs { get; set; }
+
+ ///
+ /// Document palette. Index 0 is always .
+ /// All layers reference colors by index into this list.
+ ///
+ public List Palette { get; }
+
+ public List Layers { get; }
+
+ ///
+ /// Reverse lookup cache: RgbaColor → palette index. Built lazily, invalidated
+ /// on structural palette changes (compact, clear). Call
+ /// after bulk palette modifications.
+ ///
+ private Dictionary? _paletteCache;
+
+ public MinintDocument(string name)
+ {
+ Name = name;
+ FrameDelayMs = 100;
+ Palette = [RgbaColor.Transparent];
+ Layers = [];
+ }
+
+ ///
+ /// Constructor for deserialization — accepts pre-built palette and layers.
+ ///
+ public MinintDocument(string name, uint frameDelayMs, List palette, List layers)
+ {
+ Name = name;
+ FrameDelayMs = frameDelayMs;
+ Palette = palette;
+ Layers = layers;
+ }
+
+ ///
+ /// Returns the number of bytes needed to store a single palette index on disk.
+ ///
+ public int IndexByteWidth => Palette.Count switch
+ {
+ <= 255 => 1,
+ <= 65_535 => 2,
+ <= 16_777_215 => 3,
+ _ => 4
+ };
+
+ ///
+ /// O(1) lookup of a color in the palette. Returns the index, or -1 if not found.
+ /// Lazily builds an internal dictionary on first call.
+ ///
+ public int FindColorCached(RgbaColor color)
+ {
+ var cache = EnsurePaletteCache();
+ return cache.GetValueOrDefault(color, -1);
+ }
+
+ ///
+ /// Returns the index of . If absent, appends it to the palette
+ /// and updates the cache. O(1) amortized.
+ ///
+ public int EnsureColorCached(RgbaColor color)
+ {
+ var cache = EnsurePaletteCache();
+ if (cache.TryGetValue(color, out int idx))
+ return idx;
+
+ idx = Palette.Count;
+ Palette.Add(color);
+ cache[color] = idx;
+ return idx;
+ }
+
+ ///
+ /// Drops the reverse lookup cache. Must be called after any operation that
+ /// reorders, removes, or bulk-replaces palette entries (e.g. compact, grayscale).
+ ///
+ public void InvalidatePaletteCache() => _paletteCache = null;
+
+ private Dictionary EnsurePaletteCache()
+ {
+ if (_paletteCache is not null)
+ return _paletteCache;
+
+ var cache = new Dictionary(Palette.Count);
+ for (int i = 0; i < Palette.Count; i++)
+ cache.TryAdd(Palette[i], i); // first occurrence wins (for dupes)
+ _paletteCache = cache;
+ return cache;
+ }
+}
+```
+
+### A.3. `Minint.Core/Models/MinintLayer.cs`
+
+```csharp
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+
+namespace Minint.Core.Models;
+
+///
+/// A single raster layer. Pixels are indices into the parent document's palette.
+/// Array layout is row-major: Pixels[y * width + x].
+///
+public sealed class MinintLayer : INotifyPropertyChanged
+{
+ private string _name;
+ private bool _isVisible;
+ private byte _opacity;
+
+ public string Name
+ {
+ get => _name;
+ set { if (_name != value) { _name = value; Notify(); } }
+ }
+
+ public bool IsVisible
+ {
+ get => _isVisible;
+ set { if (_isVisible != value) { _isVisible = value; Notify(); } }
+ }
+
+ ///
+ /// Per-layer opacity (0 = fully transparent, 255 = fully opaque).
+ /// Used during compositing: effective alpha = paletteColor.A * Opacity / 255.
+ ///
+ public byte Opacity
+ {
+ get => _opacity;
+ set { if (_opacity != value) { _opacity = value; Notify(); } }
+ }
+
+ ///
+ /// Palette indices, length must equal container Width * Height.
+ /// Index 0 = transparent by convention.
+ ///
+ public int[] Pixels { get; }
+
+ public MinintLayer(string name, int pixelCount)
+ {
+ _name = name;
+ _isVisible = true;
+ _opacity = 255;
+ Pixels = new int[pixelCount];
+ }
+
+ ///
+ /// Constructor for deserialization — accepts a pre-filled pixel buffer.
+ ///
+ public MinintLayer(string name, bool isVisible, byte opacity, int[] pixels)
+ {
+ _name = name;
+ _isVisible = isVisible;
+ _opacity = opacity;
+ Pixels = pixels;
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+ private void Notify([CallerMemberName] string? prop = null)
+ => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(prop));
+}
+```
+
+### A.4. `Minint.Core/Models/RgbaColor.cs`
+
+```csharp
+using System.Runtime.InteropServices;
+
+namespace Minint.Core.Models;
+
+///
+/// 4-byte RGBA color value. Equality is component-wise.
+///
+[StructLayout(LayoutKind.Sequential, Pack = 1)]
+public readonly record struct RgbaColor(byte R, byte G, byte B, byte A)
+{
+ public static readonly RgbaColor Transparent = new(0, 0, 0, 0);
+ public static readonly RgbaColor Black = new(0, 0, 0, 255);
+ public static readonly RgbaColor White = new(255, 255, 255, 255);
+
+ ///
+ /// Packs color into a single uint as 0xAABBGGRR (little-endian RGBA).
+ /// Suitable for writing directly into BGRA bitmap buffers after byte-swap,
+ /// or for use as a dictionary key.
+ ///
+ public uint ToPackedRgba() =>
+ (uint)(R | (G << 8) | (B << 16) | (A << 24));
+
+ public static RgbaColor FromPackedRgba(uint packed) =>
+ new(
+ (byte)(packed & 0xFF),
+ (byte)((packed >> 8) & 0xFF),
+ (byte)((packed >> 16) & 0xFF),
+ (byte)((packed >> 24) & 0xFF));
+
+ ///
+ /// Packs as 0xAARRGGBB — used for Avalonia/SkiaSharp pixel buffers.
+ ///
+ public uint ToPackedArgb() =>
+ (uint)(B | (G << 8) | (R << 16) | (A << 24));
+
+ public override string ToString() => $"#{R:X2}{G:X2}{B:X2}{A:X2}";
+}
+```
+
+### A.5. `Minint.Core/Services/IBmpExporter.cs`
+
+```csharp
+namespace Minint.Core.Services;
+
+public interface IBmpExporter
+{
+ ///
+ /// Exports a composited ARGB pixel buffer as a 32-bit BMP file.
+ ///
+ /// Output stream.
+ /// Pixel data packed as 0xAARRGGBB, row-major.
+ /// Image width.
+ /// Image height.
+ void Export(Stream stream, uint[] pixels, int width, int height);
+}
+```
+
+### A.6. `Minint.Core/Services/ICompositor.cs`
+
+```csharp
+using Minint.Core.Models;
+
+namespace Minint.Core.Services;
+
+public interface ICompositor
+{
+ ///
+ /// Composites all visible layers of into a flat RGBA buffer.
+ /// Result is packed as ARGB (0xAARRGGBB) per pixel, row-major, length = width * height.
+ /// Layers are blended bottom-to-top with alpha compositing.
+ ///
+ uint[] Composite(MinintDocument document, int width, int height);
+}
+```
+
+### A.7. `Minint.Core/Services/IDrawingService.cs`
+
+```csharp
+using Minint.Core.Models;
+
+namespace Minint.Core.Services;
+
+public interface IDrawingService
+{
+ ///
+ /// Applies a circular brush stroke at (, )
+ /// with the given . Sets affected pixels to .
+ ///
+ void ApplyBrush(MinintLayer layer, int cx, int cy, int radius, int colorIndex, int width, int height);
+
+ ///
+ /// Applies a circular eraser at (, )
+ /// with the given . Sets affected pixels to index 0 (transparent).
+ ///
+ void ApplyEraser(MinintLayer layer, int cx, int cy, int radius, int width, int height);
+
+ ///
+ /// Returns the set of pixel coordinates affected by a circular brush/eraser
+ /// centered at (, ) with given .
+ /// Used for tool preview overlay.
+ ///
+ List<(int X, int Y)> GetBrushMask(int cx, int cy, int radius, int width, int height);
+}
+```
+
+### A.8. `Minint.Core/Services/IFloodFillService.cs`
+
+```csharp
+using Minint.Core.Models;
+
+namespace Minint.Core.Services;
+
+public interface IFloodFillService
+{
+ ///
+ /// Flood-fills a contiguous region of identical color starting at (, )
+ /// with . Uses 4-connectivity (up/down/left/right).
+ ///
+ void Fill(MinintLayer layer, int x, int y, int newColorIndex, int width, int height);
+}
+```
+
+### A.9. `Minint.Core/Services/IFragmentService.cs`
+
+```csharp
+using Minint.Core.Models;
+
+namespace Minint.Core.Services;
+
+public interface IFragmentService
+{
+ ///
+ /// Copies a rectangular region from one document/layer to another.
+ /// Palette colors are merged: missing colors are added to the destination palette.
+ ///
+ /// Source document.
+ /// Index of the source layer.
+ /// Source rectangle X origin.
+ /// Source rectangle Y origin.
+ /// Width of the region to copy.
+ /// Height of the region to copy.
+ /// Destination document.
+ /// Index of the destination layer.
+ /// Destination X origin.
+ /// Destination Y origin.
+ /// Container width (shared by both docs).
+ /// Container height (shared by both docs).
+ void CopyFragment(
+ MinintDocument srcDoc, int srcLayerIndex,
+ int srcX, int srcY, int regionWidth, int regionHeight,
+ MinintDocument dstDoc, int dstLayerIndex,
+ int dstX, int dstY,
+ int containerWidth, int containerHeight);
+}
+```
+
+### A.10. `Minint.Core/Services/IGifExporter.cs`
+
+```csharp
+namespace Minint.Core.Services;
+
+public interface IGifExporter
+{
+ ///
+ /// Exports multiple frames as an animated GIF.
+ ///
+ /// Output stream.
+ /// Sequence of (ARGB pixels, delay in ms) per frame.
+ /// Frame width.
+ /// Frame height.
+ void Export(Stream stream, IReadOnlyList<(uint[] Pixels, uint DelayMs)> frames, int width, int height);
+}
+```
+
+### A.11. `Minint.Core/Services/IImageEffectService.cs`
+
+```csharp
+using Minint.Core.Models;
+
+namespace Minint.Core.Services;
+
+public interface IImageEffectService
+{
+ ///
+ /// Adjusts contrast of the document by modifying palette colors.
+ /// > 1 increases contrast, < 1 decreases.
+ /// Index 0 (transparent) is not modified.
+ ///
+ void AdjustContrast(MinintDocument document, float factor);
+
+ ///
+ /// Converts the document to grayscale by modifying palette colors.
+ /// Uses ITU-R BT.601 luminance: gray = 0.299R + 0.587G + 0.114B.
+ /// Index 0 (transparent) is not modified.
+ ///
+ void ToGrayscale(MinintDocument document);
+}
+```
+
+### A.12. `Minint.Core/Services/IImageEffectsService.cs`
+
+```csharp
+using Minint.Core.Models;
+
+namespace Minint.Core.Services;
+
+public interface IImageEffectsService
+{
+ ///
+ /// Adjusts contrast of the document by transforming its palette colors.
+ /// of 0 = all gray, 1 = no change, >1 = increased contrast.
+ ///
+ void ApplyContrast(MinintDocument doc, double factor);
+
+ ///
+ /// Converts the document to grayscale by transforming its palette colors
+ /// using the luminance formula: 0.299R + 0.587G + 0.114B.
+ ///
+ void ApplyGrayscale(MinintDocument doc);
+}
+```
+
+### A.13. `Minint.Core/Services/IMinintSerializer.cs`
+
+```csharp
+using Minint.Core.Models;
+
+namespace Minint.Core.Services;
+
+public interface IMinintSerializer
+{
+ ///
+ /// Serializes the container to a binary .minint stream.
+ ///
+ void Write(Stream stream, MinintContainer container);
+
+ ///
+ /// Deserializes a .minint stream into a container.
+ /// Throws on format/validation errors.
+ ///
+ MinintContainer Read(Stream stream);
+}
+```
+
+### A.14. `Minint.Core/Services/IPaletteService.cs`
+
+```csharp
+using Minint.Core.Models;
+
+namespace Minint.Core.Services;
+
+public interface IPaletteService
+{
+ ///
+ /// Returns the index of in the document palette.
+ /// If the color is not present, appends it and returns the new index.
+ ///
+ int EnsureColor(MinintDocument document, RgbaColor color);
+
+ ///
+ /// Finds index of an exact color match, or returns -1 if not found.
+ ///
+ int FindColor(MinintDocument document, RgbaColor color);
+
+ ///
+ /// Removes unused colors from the palette and remaps all layer pixel indices.
+ /// Index 0 (transparent) is always preserved.
+ ///
+ void CompactPalette(MinintDocument document);
+}
+```
+
+### A.15. `Minint.Core/Services/IPatternGenerator.cs`
+
+```csharp
+using Minint.Core.Models;
+
+namespace Minint.Core.Services;
+
+public enum PatternType
+{
+ Checkerboard,
+ HorizontalGradient,
+ VerticalGradient,
+ HorizontalStripes,
+ VerticalStripes,
+ ConcentricCircles,
+ Tile
+}
+
+public interface IPatternGenerator
+{
+ ///
+ /// Generates a new document with a single layer filled with the specified pattern.
+ ///
+ /// Pattern type.
+ /// Image width in pixels.
+ /// Image height in pixels.
+ /// Colors to use (interpretation depends on pattern type).
+ /// Primary parameter: cell/stripe size, ring width, etc.
+ /// Secondary parameter (optional, pattern-dependent).
+ MinintDocument Generate(PatternType type, int width, int height, RgbaColor[] colors, int param1, int param2 = 0);
+}
+```
+
+### A.16. `Minint.Core/Services/Impl/Compositor.cs`
+
+```csharp
+using Minint.Core.Models;
+
+namespace Minint.Core.Services.Impl;
+
+public sealed class Compositor : ICompositor
+{
+ ///
+ public uint[] Composite(MinintDocument document, int width, int height)
+ {
+ int pixelCount = width * height;
+ var result = new uint[pixelCount]; // starts as 0x00000000 (transparent black)
+
+ var palette = document.Palette;
+
+ foreach (var layer in document.Layers)
+ {
+ if (!layer.IsVisible)
+ continue;
+
+ byte layerOpacity = layer.Opacity;
+ if (layerOpacity == 0)
+ continue;
+
+ var pixels = layer.Pixels;
+ for (int i = 0; i < pixelCount; i++)
+ {
+ int idx = pixels[i];
+ if (idx == 0)
+ continue; // transparent — skip
+
+ var src = palette[idx];
+
+ // Effective source alpha = palette alpha * layer opacity / 255
+ int srcA = src.A * layerOpacity / 255;
+ if (srcA == 0)
+ continue;
+
+ if (srcA == 255)
+ {
+ // Fully opaque — fast path, no blending needed
+ result[i] = PackArgb(src.R, src.G, src.B, 255);
+ continue;
+ }
+
+ // Standard "over" alpha compositing
+ uint dst = result[i];
+ int dstA = (int)(dst >> 24);
+ int dstR = (int)((dst >> 16) & 0xFF);
+ int dstG = (int)((dst >> 8) & 0xFF);
+ int dstB = (int)(dst & 0xFF);
+
+ int outA = srcA + dstA * (255 - srcA) / 255;
+ if (outA == 0)
+ continue;
+
+ int outR = (src.R * srcA + dstR * dstA * (255 - srcA) / 255) / outA;
+ int outG = (src.G * srcA + dstG * dstA * (255 - srcA) / 255) / outA;
+ int outB = (src.B * srcA + dstB * dstA * (255 - srcA) / 255) / outA;
+
+ result[i] = PackArgb(
+ (byte)Math.Min(outR, 255),
+ (byte)Math.Min(outG, 255),
+ (byte)Math.Min(outB, 255),
+ (byte)Math.Min(outA, 255));
+ }
+ }
+
+ return result;
+ }
+
+ private static uint PackArgb(byte r, byte g, byte b, byte a) =>
+ (uint)(b | (g << 8) | (r << 16) | (a << 24));
+}
+```
+
+### A.17. `Minint.Core/Services/Impl/DrawingService.cs`
+
+```csharp
+using Minint.Core.Models;
+
+namespace Minint.Core.Services.Impl;
+
+public sealed class DrawingService : IDrawingService
+{
+ public void ApplyBrush(MinintLayer layer, int cx, int cy, int radius, int colorIndex, int width, int height)
+ {
+ foreach (var (x, y) in GetBrushMask(cx, cy, radius, width, height))
+ layer.Pixels[y * width + x] = colorIndex;
+ }
+
+ public void ApplyEraser(MinintLayer layer, int cx, int cy, int radius, int width, int height)
+ {
+ foreach (var (x, y) in GetBrushMask(cx, cy, radius, width, height))
+ layer.Pixels[y * width + x] = 0;
+ }
+
+ public List<(int X, int Y)> GetBrushMask(int cx, int cy, int radius, int width, int height)
+ {
+ var mask = new List<(int, int)>();
+ int r = Math.Max(radius, 0);
+ int r2 = r * r;
+
+ int xMin = Math.Max(0, cx - r);
+ int xMax = Math.Min(width - 1, cx + r);
+ int yMin = Math.Max(0, cy - r);
+ int yMax = Math.Min(height - 1, cy + r);
+
+ for (int py = yMin; py <= yMax; py++)
+ {
+ int dy = py - cy;
+ for (int px = xMin; px <= xMax; px++)
+ {
+ int dx = px - cx;
+ if (dx * dx + dy * dy <= r2)
+ mask.Add((px, py));
+ }
+ }
+
+ return mask;
+ }
+}
+```
+
+### A.18. `Minint.Core/Services/Impl/FloodFillService.cs`
+
+```csharp
+using Minint.Core.Models;
+
+namespace Minint.Core.Services.Impl;
+
+public sealed class FloodFillService : IFloodFillService
+{
+ public void Fill(MinintLayer layer, int x, int y, int newColorIndex, int width, int height)
+ {
+ if (x < 0 || x >= width || y < 0 || y >= height)
+ return;
+
+ var pixels = layer.Pixels;
+ int targetIndex = pixels[y * width + x];
+
+ if (targetIndex == newColorIndex)
+ return;
+
+ var queue = new Queue<(int X, int Y)>();
+ var visited = new bool[width * height];
+
+ queue.Enqueue((x, y));
+ visited[y * width + x] = true;
+
+ while (queue.Count > 0)
+ {
+ var (cx, cy) = queue.Dequeue();
+ pixels[cy * width + cx] = newColorIndex;
+
+ Span<(int, int)> neighbors =
+ [
+ (cx - 1, cy), (cx + 1, cy),
+ (cx, cy - 1), (cx, cy + 1)
+ ];
+
+ foreach (var (nx, ny) in neighbors)
+ {
+ if (nx < 0 || nx >= width || ny < 0 || ny >= height)
+ continue;
+ int ni = ny * width + nx;
+ if (visited[ni] || pixels[ni] != targetIndex)
+ continue;
+ visited[ni] = true;
+ queue.Enqueue((nx, ny));
+ }
+ }
+ }
+}
+```
+
+### A.19. `Minint.Core/Services/Impl/FragmentService.cs`
+
+```csharp
+using System;
+using Minint.Core.Models;
+
+namespace Minint.Core.Services.Impl;
+
+public sealed class FragmentService : IFragmentService
+{
+ public void CopyFragment(
+ MinintDocument srcDoc, int srcLayerIndex,
+ int srcX, int srcY, int regionWidth, int regionHeight,
+ MinintDocument dstDoc, int dstLayerIndex,
+ int dstX, int dstY,
+ int containerWidth, int containerHeight)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegative(srcLayerIndex);
+ ArgumentOutOfRangeException.ThrowIfNegative(dstLayerIndex);
+ if (srcLayerIndex >= srcDoc.Layers.Count)
+ throw new ArgumentOutOfRangeException(nameof(srcLayerIndex));
+ if (dstLayerIndex >= dstDoc.Layers.Count)
+ throw new ArgumentOutOfRangeException(nameof(dstLayerIndex));
+
+ var srcLayer = srcDoc.Layers[srcLayerIndex];
+ var dstLayer = dstDoc.Layers[dstLayerIndex];
+
+ int clippedSrcX = Math.Max(srcX, 0);
+ int clippedSrcY = Math.Max(srcY, 0);
+ int clippedEndX = Math.Min(srcX + regionWidth, containerWidth);
+ int clippedEndY = Math.Min(srcY + regionHeight, containerHeight);
+
+ for (int sy = clippedSrcY; sy < clippedEndY; sy++)
+ {
+ int dy = dstY + (sy - srcY);
+ if (dy < 0 || dy >= containerHeight) continue;
+
+ for (int sx = clippedSrcX; sx < clippedEndX; sx++)
+ {
+ int dx = dstX + (sx - srcX);
+ if (dx < 0 || dx >= containerWidth) continue;
+
+ int srcIdx = srcLayer.Pixels[sy * containerWidth + sx];
+ if (srcIdx == 0) continue; // skip transparent
+
+ RgbaColor color = srcDoc.Palette[srcIdx];
+ int dstIdx = dstDoc.EnsureColorCached(color);
+ dstLayer.Pixels[dy * containerWidth + dx] = dstIdx;
+ }
+ }
+ }
+}
+```
+
+### A.20. `Minint.Core/Services/Impl/ImageEffectsService.cs`
+
+```csharp
+using Minint.Core.Models;
+
+namespace Minint.Core.Services.Impl;
+
+public sealed class ImageEffectsService : IImageEffectsService
+{
+ public void ApplyContrast(MinintDocument doc, double factor)
+ {
+ for (int i = 1; i < doc.Palette.Count; i++)
+ {
+ var c = doc.Palette[i];
+ doc.Palette[i] = new RgbaColor(
+ ContrastByte(c.R, factor),
+ ContrastByte(c.G, factor),
+ ContrastByte(c.B, factor),
+ c.A);
+ }
+ doc.InvalidatePaletteCache();
+ }
+
+ public void ApplyGrayscale(MinintDocument doc)
+ {
+ for (int i = 1; i < doc.Palette.Count; i++)
+ {
+ var c = doc.Palette[i];
+ byte gray = (byte)Math.Clamp((int)(0.299 * c.R + 0.587 * c.G + 0.114 * c.B + 0.5), 0, 255);
+ doc.Palette[i] = new RgbaColor(gray, gray, gray, c.A);
+ }
+ doc.InvalidatePaletteCache();
+ }
+
+ private static byte ContrastByte(byte value, double factor)
+ {
+ double v = ((value / 255.0) - 0.5) * factor + 0.5;
+ return (byte)Math.Clamp((int)(v * 255 + 0.5), 0, 255);
+ }
+}
+```
+
+### A.21. `Minint.Core/Services/Impl/PaletteService.cs`
+
+```csharp
+using Minint.Core.Models;
+
+namespace Minint.Core.Services.Impl;
+
+public sealed class PaletteService : IPaletteService
+{
+ public int FindColor(MinintDocument document, RgbaColor color)
+ => document.FindColorCached(color);
+
+ public int EnsureColor(MinintDocument document, RgbaColor color)
+ => document.EnsureColorCached(color);
+
+ public void CompactPalette(MinintDocument document)
+ {
+ var palette = document.Palette;
+ if (palette.Count <= 1)
+ return;
+
+ var usedIndices = new HashSet { 0 };
+ foreach (var layer in document.Layers)
+ {
+ foreach (int idx in layer.Pixels)
+ usedIndices.Add(idx);
+ }
+
+ var oldToNew = new int[palette.Count];
+ var newPalette = new List(usedIndices.Count);
+
+ newPalette.Add(palette[0]);
+ oldToNew[0] = 0;
+
+ for (int i = 1; i < palette.Count; i++)
+ {
+ if (usedIndices.Contains(i))
+ {
+ oldToNew[i] = newPalette.Count;
+ newPalette.Add(palette[i]);
+ }
+ }
+
+ if (newPalette.Count == palette.Count)
+ return;
+
+ palette.Clear();
+ palette.AddRange(newPalette);
+
+ foreach (var layer in document.Layers)
+ {
+ var px = layer.Pixels;
+ for (int i = 0; i < px.Length; i++)
+ px[i] = oldToNew[px[i]];
+ }
+
+ document.InvalidatePaletteCache();
+ }
+}
+```
+
+### A.22. `Minint.Core/Services/Impl/PatternGenerator.cs`
+
+```csharp
+using System;
+using Minint.Core.Models;
+
+namespace Minint.Core.Services.Impl;
+
+public sealed class PatternGenerator : IPatternGenerator
+{
+ public MinintDocument Generate(PatternType type, int width, int height, RgbaColor[] colors, int param1, int param2 = 0)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(width, 1);
+ ArgumentOutOfRangeException.ThrowIfLessThan(height, 1);
+ if (colors.Length < 2)
+ throw new ArgumentException("At least two colors are required.", nameof(colors));
+
+ var doc = new MinintDocument($"Pattern ({type})");
+ var layer = new MinintLayer("Pattern", width * height);
+ doc.Layers.Add(layer);
+
+ int[] colorIndices = new int[colors.Length];
+ for (int i = 0; i < colors.Length; i++)
+ colorIndices[i] = doc.EnsureColorCached(colors[i]);
+
+ int cellSize = Math.Max(param1, 1);
+
+ switch (type)
+ {
+ case PatternType.Checkerboard:
+ FillCheckerboard(layer.Pixels, width, height, colorIndices, cellSize);
+ break;
+ case PatternType.HorizontalGradient:
+ FillGradient(layer.Pixels, width, height, colors[0], colors[1], doc, horizontal: true);
+ break;
+ case PatternType.VerticalGradient:
+ FillGradient(layer.Pixels, width, height, colors[0], colors[1], doc, horizontal: false);
+ break;
+ case PatternType.HorizontalStripes:
+ FillStripes(layer.Pixels, width, height, colorIndices, cellSize, horizontal: true);
+ break;
+ case PatternType.VerticalStripes:
+ FillStripes(layer.Pixels, width, height, colorIndices, cellSize, horizontal: false);
+ break;
+ case PatternType.ConcentricCircles:
+ FillCircles(layer.Pixels, width, height, colorIndices, cellSize);
+ break;
+ case PatternType.Tile:
+ FillTile(layer.Pixels, width, height, colorIndices, cellSize, Math.Max(param2, 1));
+ break;
+ }
+
+ return doc;
+ }
+
+ private static void FillCheckerboard(int[] pixels, int w, int h, int[] ci, int cell)
+ {
+ for (int y = 0; y < h; y++)
+ for (int x = 0; x < w; x++)
+ pixels[y * w + x] = ci[((x / cell) + (y / cell)) % 2 == 0 ? 0 : 1];
+ }
+
+ private static void FillGradient(int[] pixels, int w, int h, RgbaColor c0, RgbaColor c1,
+ MinintDocument doc, bool horizontal)
+ {
+ int steps = horizontal ? w : h;
+ for (int s = 0; s < steps; s++)
+ {
+ double t = steps > 1 ? (double)s / (steps - 1) : 0;
+ var c = new RgbaColor(
+ Lerp(c0.R, c1.R, t), Lerp(c0.G, c1.G, t),
+ Lerp(c0.B, c1.B, t), Lerp(c0.A, c1.A, t));
+ int idx = doc.EnsureColorCached(c);
+ if (horizontal)
+ for (int y = 0; y < h; y++) pixels[y * w + s] = idx;
+ else
+ for (int x = 0; x < w; x++) pixels[s * w + x] = idx;
+ }
+ }
+
+ private static void FillStripes(int[] pixels, int w, int h, int[] ci, int stripe, bool horizontal)
+ {
+ for (int y = 0; y < h; y++)
+ for (int x = 0; x < w; x++)
+ {
+ int coord = horizontal ? y : x;
+ pixels[y * w + x] = ci[(coord / stripe) % ci.Length];
+ }
+ }
+
+ private static void FillCircles(int[] pixels, int w, int h, int[] ci, int ringWidth)
+ {
+ double cx = (w - 1) / 2.0, cy = (h - 1) / 2.0;
+ for (int y = 0; y < h; y++)
+ for (int x = 0; x < w; x++)
+ {
+ double dist = Math.Sqrt((x - cx) * (x - cx) + (y - cy) * (y - cy));
+ int ring = (int)(dist / ringWidth);
+ pixels[y * w + x] = ci[ring % ci.Length];
+ }
+ }
+
+ private static void FillTile(int[] pixels, int w, int h, int[] ci, int tileW, int tileH)
+ {
+ for (int y = 0; y < h; y++)
+ for (int x = 0; x < w; x++)
+ {
+ int tx = (x / tileW) % ci.Length;
+ int ty = (y / tileH) % ci.Length;
+ pixels[y * w + x] = ci[(tx + ty) % ci.Length];
+ }
+ }
+
+ private static byte Lerp(byte a, byte b, double t)
+ => (byte)Math.Clamp((int)(a + (b - a) * t + 0.5), 0, 255);
+}
+```
+
+### A.23. `Minint.Infrastructure/Export/BmpExporter.cs`
+
+```csharp
+using Minint.Core.Services;
+
+namespace Minint.Infrastructure.Export;
+
+///
+/// Writes a 32-bit BGRA BMP (BITMAPV4HEADER) from an ARGB pixel buffer.
+/// BMP rows are bottom-up, so we flip vertically during write.
+///
+public sealed class BmpExporter : IBmpExporter
+{
+ private const int BmpFileHeaderSize = 14;
+ private const int BitmapV4HeaderSize = 108;
+ private const int HeadersTotal = BmpFileHeaderSize + BitmapV4HeaderSize;
+
+ public void Export(Stream stream, uint[] pixels, int width, int height)
+ {
+ ArgumentNullException.ThrowIfNull(stream);
+ ArgumentNullException.ThrowIfNull(pixels);
+ if (pixels.Length != width * height)
+ throw new ArgumentException("Pixel buffer size does not match dimensions.");
+
+ int rowBytes = width * 4;
+ int imageSize = rowBytes * height;
+ int fileSize = HeadersTotal + imageSize;
+
+ using var w = new BinaryWriter(stream, System.Text.Encoding.UTF8, leaveOpen: true);
+
+ // BITMAPFILEHEADER (14 bytes)
+ w.Write((byte)'B');
+ w.Write((byte)'M');
+ w.Write(fileSize);
+ w.Write((ushort)0); // reserved1
+ w.Write((ushort)0); // reserved2
+ w.Write(HeadersTotal); // pixel data offset
+
+ // BITMAPV4HEADER (108 bytes)
+ w.Write(BitmapV4HeaderSize);
+ w.Write(width);
+ w.Write(height); // positive = bottom-up
+ w.Write((ushort)1); // planes
+ w.Write((ushort)32); // bpp
+ w.Write(3); // biCompression = BI_BITFIELDS
+ w.Write(imageSize);
+ w.Write(2835); // X pixels per meter (~72 DPI)
+ w.Write(2835); // Y pixels per meter
+ w.Write(0); // colors used
+ w.Write(0); // important colors
+
+ // Channel masks (BGRA order in file)
+ w.Write(0x00FF0000u); // red mask
+ w.Write(0x0000FF00u); // green mask
+ w.Write(0x000000FFu); // blue mask
+ w.Write(0xFF000000u); // alpha mask
+
+ // Color space type: LCS_sRGB
+ w.Write(0x73524742); // 'sRGB'
+ // CIEXYZTRIPLE endpoints (36 bytes zeroed)
+ w.Write(new byte[36]);
+ // Gamma RGB (12 bytes zeroed)
+ w.Write(new byte[12]);
+
+ // Pixel data: BMP is bottom-up, our buffer is top-down
+ for (int y = height - 1; y >= 0; y--)
+ {
+ int rowStart = y * width;
+ for (int x = 0; x < width; x++)
+ {
+ uint argb = pixels[rowStart + x];
+ byte a = (byte)(argb >> 24);
+ byte r = (byte)((argb >> 16) & 0xFF);
+ byte g = (byte)((argb >> 8) & 0xFF);
+ byte b = (byte)(argb & 0xFF);
+ w.Write(b);
+ w.Write(g);
+ w.Write(r);
+ w.Write(a);
+ }
+ }
+ }
+}
+```
+
+### A.24. `Minint.Infrastructure/Export/GifExporter.cs`
+
+```csharp
+using System;
+using System.Collections.Generic;
+using System.IO;
+using Minint.Core.Services;
+
+namespace Minint.Infrastructure.Export;
+
+///
+/// Self-implemented GIF89a animated exporter.
+/// Quantizes each ARGB frame to 256 colors (including transparent) using popularity.
+/// Uses LZW compression as required by the GIF spec.
+///
+public sealed class GifExporter : IGifExporter
+{
+ public void Export(Stream stream, IReadOnlyList<(uint[] Pixels, uint DelayMs)> frames, int width, int height)
+ {
+ ArgumentNullException.ThrowIfNull(stream);
+ if (frames.Count == 0)
+ throw new ArgumentException("At least one frame is required.");
+
+ using var w = new BinaryWriter(stream, System.Text.Encoding.UTF8, leaveOpen: true);
+
+ WriteGifHeader(w, width, height);
+ WriteNetscapeExtension(w); // infinite loop
+
+ foreach (var (pixels, delayMs) in frames)
+ {
+ var (palette, indices, transparentIndex) = Quantize(pixels, width * height);
+ int colorBits = GetColorBits(palette.Length);
+ int tableSize = 1 << colorBits;
+
+ WriteGraphicControlExtension(w, delayMs, transparentIndex);
+ WriteImageDescriptor(w, width, height, colorBits);
+ WriteColorTable(w, palette, tableSize);
+ WriteLzwImageData(w, indices, colorBits);
+ }
+
+ w.Write((byte)0x3B); // GIF trailer
+ }
+
+ private static void WriteGifHeader(BinaryWriter w, int width, int height)
+ {
+ w.Write("GIF89a"u8.ToArray());
+ w.Write((ushort)width);
+ w.Write((ushort)height);
+ // Global color table flag=0, color resolution=7, sort=0, gct size=0
+ w.Write((byte)0x70);
+ w.Write((byte)0); // background color index
+ w.Write((byte)0); // pixel aspect ratio
+ }
+
+ private static void WriteNetscapeExtension(BinaryWriter w)
+ {
+ w.Write((byte)0x21); // extension introducer
+ w.Write((byte)0xFF); // application extension
+ w.Write((byte)11); // block size
+ w.Write("NETSCAPE2.0"u8.ToArray());
+ w.Write((byte)3); // sub-block size
+ w.Write((byte)1); // sub-block ID
+ w.Write((ushort)0); // loop count: 0 = infinite
+ w.Write((byte)0); // block terminator
+ }
+
+ private static void WriteGraphicControlExtension(BinaryWriter w, uint delayMs, int transparentIndex)
+ {
+ w.Write((byte)0x21); // extension introducer
+ w.Write((byte)0xF9); // graphic control label
+ w.Write((byte)4); // block size
+ byte packed = (byte)(transparentIndex >= 0 ? 0x09 : 0x08);
+ // disposal method=2 (restore to background), no user input, transparent flag
+ w.Write(packed);
+ ushort delayCs = (ushort)(delayMs / 10); // GIF delay is in centiseconds
+ if (delayCs == 0 && delayMs > 0) delayCs = 1;
+ w.Write(delayCs);
+ w.Write((byte)(transparentIndex >= 0 ? transparentIndex : 0));
+ w.Write((byte)0); // block terminator
+ }
+
+ private static void WriteImageDescriptor(BinaryWriter w, int width, int height, int colorBits)
+ {
+ w.Write((byte)0x2C); // image separator
+ w.Write((ushort)0); // left
+ w.Write((ushort)0); // top
+ w.Write((ushort)width);
+ w.Write((ushort)height);
+ byte packed = (byte)(0x80 | (colorBits - 1)); // local color table, not interlaced
+ w.Write(packed);
+ }
+
+ private static void WriteColorTable(BinaryWriter w, byte[][] palette, int tableSize)
+ {
+ for (int i = 0; i < tableSize; i++)
+ {
+ if (i < palette.Length)
+ {
+ w.Write(palette[i][0]); // R
+ w.Write(palette[i][1]); // G
+ w.Write(palette[i][2]); // B
+ }
+ else
+ {
+ w.Write((byte)0);
+ w.Write((byte)0);
+ w.Write((byte)0);
+ }
+ }
+ }
+
+ #region LZW compression
+
+ private static void WriteLzwImageData(BinaryWriter w, byte[] indices, int colorBits)
+ {
+ int minCodeSize = Math.Max(colorBits, 2);
+ w.Write((byte)minCodeSize);
+
+ var output = new List();
+ LzwCompress(indices, minCodeSize, output);
+
+ int offset = 0;
+ while (offset < output.Count)
+ {
+ int blockLen = Math.Min(255, output.Count - offset);
+ w.Write((byte)blockLen);
+ for (int i = 0; i < blockLen; i++)
+ w.Write(output[offset + i]);
+ offset += blockLen;
+ }
+ w.Write((byte)0); // block terminator
+ }
+
+ private static void LzwCompress(byte[] indices, int minCodeSize, List output)
+ {
+ int clearCode = 1 << minCodeSize;
+ int eoiCode = clearCode + 1;
+
+ int codeSize = minCodeSize + 1;
+ int nextCode = eoiCode + 1;
+ int maxCode = (1 << codeSize) - 1;
+
+ var table = new Dictionary<(int Prefix, byte Suffix), int>();
+
+ int bitBuffer = 0;
+ int bitCount = 0;
+
+ void EmitCode(int code)
+ {
+ bitBuffer |= code << bitCount;
+ bitCount += codeSize;
+ while (bitCount >= 8)
+ {
+ output.Add((byte)(bitBuffer & 0xFF));
+ bitBuffer >>= 8;
+ bitCount -= 8;
+ }
+ }
+
+ void ResetTable()
+ {
+ table.Clear();
+ codeSize = minCodeSize + 1;
+ nextCode = eoiCode + 1;
+ maxCode = (1 << codeSize) - 1;
+ }
+
+ EmitCode(clearCode);
+ ResetTable();
+
+ if (indices.Length == 0)
+ {
+ EmitCode(eoiCode);
+ if (bitCount > 0) output.Add((byte)(bitBuffer & 0xFF));
+ return;
+ }
+
+ int prefix = indices[0];
+
+ for (int i = 1; i < indices.Length; i++)
+ {
+ byte suffix = indices[i];
+ var key = (prefix, suffix);
+
+ if (table.TryGetValue(key, out int existing))
+ {
+ prefix = existing;
+ }
+ else
+ {
+ EmitCode(prefix);
+
+ if (nextCode <= 4095)
+ {
+ table[key] = nextCode++;
+ if (nextCode > maxCode + 1 && codeSize < 12)
+ {
+ codeSize++;
+ maxCode = (1 << codeSize) - 1;
+ }
+ }
+ else
+ {
+ EmitCode(clearCode);
+ ResetTable();
+ }
+
+ prefix = suffix;
+ }
+ }
+
+ EmitCode(prefix);
+ EmitCode(eoiCode);
+ if (bitCount > 0) output.Add((byte)(bitBuffer & 0xFF));
+ }
+
+ #endregion
+
+ #region Quantization
+
+ ///
+ /// Quantizes ARGB pixels to max 256 palette entries.
+ /// Reserves index 0 for transparent if any pixel has alpha < 128.
+ /// Uses popularity-based selection for opaque colors.
+ ///
+ private static (byte[][] Palette, byte[] Indices, int TransparentIndex) Quantize(uint[] argb, int count)
+ {
+ bool hasTransparency = false;
+ var colorCounts = new Dictionary();
+
+ for (int i = 0; i < count; i++)
+ {
+ uint px = argb[i];
+ byte a = (byte)(px >> 24);
+ if (a < 128)
+ {
+ hasTransparency = true;
+ continue;
+ }
+ uint opaque = px | 0xFF000000u;
+ colorCounts.TryGetValue(opaque, out int c);
+ colorCounts[opaque] = c + 1;
+ }
+
+ int transparentIndex = hasTransparency ? 0 : -1;
+ int maxColors = hasTransparency ? 255 : 256;
+
+ var sorted = new List>(colorCounts);
+ sorted.Sort((a, b) => b.Value.CompareTo(a.Value));
+ if (sorted.Count > maxColors)
+ sorted.RemoveRange(maxColors, sorted.Count - maxColors);
+
+ var palette = new List();
+ var colorToIndex = new Dictionary();
+
+ if (hasTransparency)
+ {
+ palette.Add([0, 0, 0]); // transparent slot
+ }
+
+ foreach (var kv in sorted)
+ {
+ uint px = kv.Key;
+ byte idx = (byte)palette.Count;
+ palette.Add([
+ (byte)((px >> 16) & 0xFF),
+ (byte)((px >> 8) & 0xFF),
+ (byte)(px & 0xFF)
+ ]);
+ colorToIndex[px] = idx;
+ }
+
+ if (palette.Count == 0)
+ palette.Add([0, 0, 0]);
+
+ var indices = new byte[count];
+ for (int i = 0; i < count; i++)
+ {
+ uint px = argb[i];
+ byte a = (byte)(px >> 24);
+ if (a < 128)
+ {
+ indices[i] = (byte)(transparentIndex >= 0 ? transparentIndex : 0);
+ continue;
+ }
+
+ uint opaque = px | 0xFF000000u;
+ if (colorToIndex.TryGetValue(opaque, out byte idx))
+ {
+ indices[i] = idx;
+ }
+ else
+ {
+ indices[i] = FindClosest(opaque, palette, hasTransparency ? 1 : 0);
+ }
+ }
+
+ return (palette.ToArray(), indices, transparentIndex);
+ }
+
+ private static byte FindClosest(uint argb, List palette, int startIdx)
+ {
+ byte r = (byte)((argb >> 16) & 0xFF);
+ byte g = (byte)((argb >> 8) & 0xFF);
+ byte b = (byte)(argb & 0xFF);
+
+ int bestIdx = startIdx;
+ int bestDist = int.MaxValue;
+ for (int i = startIdx; i < palette.Count; i++)
+ {
+ int dr = r - palette[i][0];
+ int dg = g - palette[i][1];
+ int db = b - palette[i][2];
+ int dist = dr * dr + dg * dg + db * db;
+ if (dist < bestDist)
+ {
+ bestDist = dist;
+ bestIdx = i;
+ }
+ }
+ return (byte)bestIdx;
+ }
+
+ private static int GetColorBits(int paletteCount)
+ {
+ int bits = 2;
+ while ((1 << bits) < paletteCount) bits++;
+ return Math.Min(bits, 8);
+ }
+
+ #endregion
+}
+```
+
+### A.25. `Minint.Infrastructure/Serialization/MinintSerializer.cs`
+
+```csharp
+using System.Text;
+using Minint.Core.Models;
+using Minint.Core.Services;
+
+namespace Minint.Infrastructure.Serialization;
+
+///
+/// Self-implemented binary reader/writer for the .minint container format.
+/// All multi-byte integers are little-endian. Strings are UTF-8 with a 1-byte length prefix.
+///
+public sealed class MinintSerializer : IMinintSerializer
+{
+ private static readonly byte[] Signature = "MININT"u8.ToArray();
+ private const ushort CurrentVersion = 1;
+ private const int ReservedBytes = 8;
+ private const int MaxNameLength = 255;
+
+ #region Write
+
+ public void Write(Stream stream, MinintContainer container)
+ {
+ ArgumentNullException.ThrowIfNull(stream);
+ ArgumentNullException.ThrowIfNull(container);
+
+ using var w = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true);
+
+ WriteHeader(w, container);
+
+ foreach (var doc in container.Documents)
+ WriteDocument(w, doc, container.Width, container.Height);
+ }
+
+ private static void WriteHeader(BinaryWriter w, MinintContainer c)
+ {
+ w.Write(Signature);
+ w.Write(CurrentVersion);
+ w.Write((uint)c.Width);
+ w.Write((uint)c.Height);
+ w.Write((uint)c.Documents.Count);
+ w.Write(new byte[ReservedBytes]);
+ }
+
+ private static void WriteDocument(BinaryWriter w, MinintDocument doc, int width, int height)
+ {
+ WritePrefixedString(w, doc.Name);
+ w.Write(doc.FrameDelayMs);
+ w.Write((uint)doc.Palette.Count);
+
+ foreach (var color in doc.Palette)
+ {
+ w.Write(color.R);
+ w.Write(color.G);
+ w.Write(color.B);
+ w.Write(color.A);
+ }
+
+ w.Write((uint)doc.Layers.Count);
+
+ int byteWidth = doc.IndexByteWidth;
+ foreach (var layer in doc.Layers)
+ WriteLayer(w, layer, byteWidth, width * height);
+ }
+
+ private static void WriteLayer(BinaryWriter w, MinintLayer layer, int byteWidth, int pixelCount)
+ {
+ WritePrefixedString(w, layer.Name);
+ w.Write(layer.IsVisible ? (byte)1 : (byte)0);
+ w.Write(layer.Opacity);
+
+ if (layer.Pixels.Length != pixelCount)
+ throw new InvalidOperationException(
+ $"Layer '{layer.Name}' has {layer.Pixels.Length} pixels, expected {pixelCount}.");
+
+ for (int i = 0; i < pixelCount; i++)
+ WriteIndex(w, layer.Pixels[i], byteWidth);
+ }
+
+ private static void WriteIndex(BinaryWriter w, int index, int byteWidth)
+ {
+ switch (byteWidth)
+ {
+ case 1:
+ w.Write((byte)index);
+ break;
+ case 2:
+ w.Write((ushort)index);
+ break;
+ case 3:
+ w.Write((byte)(index & 0xFF));
+ w.Write((byte)((index >> 8) & 0xFF));
+ w.Write((byte)((index >> 16) & 0xFF));
+ break;
+ case 4:
+ w.Write(index);
+ break;
+ }
+ }
+
+ private static void WritePrefixedString(BinaryWriter w, string value)
+ {
+ var bytes = Encoding.UTF8.GetBytes(value);
+ if (bytes.Length > MaxNameLength)
+ throw new InvalidOperationException(
+ $"String '{value}' exceeds max length of {MaxNameLength} UTF-8 bytes.");
+ w.Write((byte)bytes.Length);
+ w.Write(bytes);
+ }
+
+ #endregion
+
+ #region Read
+
+ public MinintContainer Read(Stream stream)
+ {
+ ArgumentNullException.ThrowIfNull(stream);
+
+ using var r = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true);
+
+ var (width, height, docCount) = ReadHeader(r);
+ var container = new MinintContainer(width, height);
+
+ for (int i = 0; i < docCount; i++)
+ container.Documents.Add(ReadDocument(r, width, height));
+
+ return container;
+ }
+
+ private static (int Width, int Height, int DocCount) ReadHeader(BinaryReader r)
+ {
+ byte[] sig = ReadExact(r, Signature.Length, "file signature");
+ if (!sig.AsSpan().SequenceEqual(Signature))
+ throw new InvalidDataException(
+ "Invalid file signature. Expected 'MININT'.");
+
+ ushort version = r.ReadUInt16();
+ if (version != CurrentVersion)
+ throw new InvalidDataException(
+ $"Unsupported format version {version}. Only version {CurrentVersion} is supported.");
+
+ uint width = r.ReadUInt32();
+ uint height = r.ReadUInt32();
+ uint docCount = r.ReadUInt32();
+
+ if (width == 0 || height == 0)
+ throw new InvalidDataException("Width and height must be at least 1.");
+ if (width > 65_536 || height > 65_536)
+ throw new InvalidDataException(
+ $"Dimensions {width}x{height} exceed maximum supported size (65536).");
+ if (docCount == 0)
+ throw new InvalidDataException("Container must have at least 1 document.");
+
+ byte[] reserved = ReadExact(r, ReservedBytes, "reserved bytes");
+ for (int i = 0; i < reserved.Length; i++)
+ {
+ if (reserved[i] != 0)
+ break; // non-zero reserved bytes: tolerated for forward compat
+ }
+
+ return ((int)width, (int)height, (int)docCount);
+ }
+
+ private static MinintDocument ReadDocument(BinaryReader r, int width, int height)
+ {
+ string name = ReadPrefixedString(r);
+ uint frameDelay = r.ReadUInt32();
+ uint paletteCount = r.ReadUInt32();
+
+ if (paletteCount == 0)
+ throw new InvalidDataException("Palette must have at least 1 color.");
+
+ var palette = new List((int)paletteCount);
+ for (uint i = 0; i < paletteCount; i++)
+ {
+ byte cr = r.ReadByte();
+ byte cg = r.ReadByte();
+ byte cb = r.ReadByte();
+ byte ca = r.ReadByte();
+ palette.Add(new RgbaColor(cr, cg, cb, ca));
+ }
+
+ int byteWidth = GetIndexByteWidth((int)paletteCount);
+
+ uint layerCount = r.ReadUInt32();
+ if (layerCount == 0)
+ throw new InvalidDataException($"Document '{name}' must have at least 1 layer.");
+
+ var layers = new List((int)layerCount);
+ int pixelCount = width * height;
+ for (uint i = 0; i < layerCount; i++)
+ layers.Add(ReadLayer(r, byteWidth, pixelCount, (int)paletteCount));
+
+ return new MinintDocument(name, frameDelay, palette, layers);
+ }
+
+ private static MinintLayer ReadLayer(BinaryReader r, int byteWidth, int pixelCount, int paletteCount)
+ {
+ string name = ReadPrefixedString(r);
+ byte visByte = r.ReadByte();
+ if (visByte > 1)
+ throw new InvalidDataException(
+ $"Layer '{name}': invalid visibility flag {visByte} (expected 0 or 1).");
+ bool isVisible = visByte == 1;
+ byte opacity = r.ReadByte();
+
+ var pixels = new int[pixelCount];
+ for (int i = 0; i < pixelCount; i++)
+ {
+ int idx = ReadIndex(r, byteWidth);
+ if (idx < 0 || idx >= paletteCount)
+ throw new InvalidDataException(
+ $"Layer '{name}': pixel index {idx} at position {i} is out of palette range [0, {paletteCount}).");
+ pixels[i] = idx;
+ }
+
+ return new MinintLayer(name, isVisible, opacity, pixels);
+ }
+
+ private static int ReadIndex(BinaryReader r, int byteWidth)
+ {
+ return byteWidth switch
+ {
+ 1 => r.ReadByte(),
+ 2 => r.ReadUInt16(),
+ 3 => r.ReadByte() | (r.ReadByte() << 8) | (r.ReadByte() << 16),
+ 4 => r.ReadInt32(),
+ _ => throw new InvalidDataException($"Invalid index byte width: {byteWidth}")
+ };
+ }
+
+ private static string ReadPrefixedString(BinaryReader r)
+ {
+ byte len = r.ReadByte();
+ if (len == 0) return string.Empty;
+ byte[] bytes = ReadExact(r, len, "string data");
+ return Encoding.UTF8.GetString(bytes);
+ }
+
+ ///
+ /// Reads exactly bytes or throws on premature EOF.
+ ///
+ private static byte[] ReadExact(BinaryReader r, int count, string context)
+ {
+ byte[] buf = r.ReadBytes(count);
+ if (buf.Length < count)
+ throw new InvalidDataException(
+ $"Unexpected end of stream while reading {context} (expected {count} bytes, got {buf.Length}).");
+ return buf;
+ }
+
+ #endregion
+
+ #region Helpers
+
+ ///
+ /// Same logic as ,
+ /// usable when only the palette count is known (during deserialization).
+ ///
+ public static int GetIndexByteWidth(int paletteCount) => paletteCount switch
+ {
+ <= 255 => 1,
+ <= 65_535 => 2,
+ <= 16_777_215 => 3,
+ _ => 4
+ };
+
+ #endregion
+}
+```
+
+### A.26. `Minint.Tests/CompositorTests.cs`
+
+```csharp
+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]);
+ }
+}
+```
+
+### A.27. `Minint.Tests/DrawingTests.cs`
+
+```csharp
+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);
+ });
+ }
+}
+```
+
+### A.28. `Minint.Tests/ExportTests.cs`
+
+```csharp
+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);
+ });
+ }
+}
+```
+
+### A.29. `Minint.Tests/FloodFillTests.cs`
+
+```csharp
+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]);
+ }
+}
+```
+
+### A.30. `Minint.Tests/FragmentServiceTests.cs`
+
+```csharp
+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
+ }
+}
+```
+
+### A.31. `Minint.Tests/ImageEffectsTests.cs`
+
+```csharp
+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);
+ }
+}
+```
+
+### A.32. `Minint.Tests/PatternGeneratorTests.cs`
+
+```csharp
+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);
+ }
+}
+```
+
+### A.33. `Minint.Tests/SerializerTests.cs`
+
+```csharp
+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);
+ }
+}
+```
+
+### A.34. `Minint/App.axaml.cs`
+
+```csharp
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Data.Core;
+using Avalonia.Data.Core.Plugins;
+using System.Linq;
+using Avalonia.Markup.Xaml;
+using Minint.ViewModels;
+using Minint.Views;
+
+namespace Minint;
+
+public partial class App : Application
+{
+ public override void Initialize()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ // Avoid duplicate validations from both Avalonia and the CommunityToolkit.
+ // More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
+ DisableAvaloniaDataAnnotationValidation();
+ desktop.MainWindow = new MainWindow
+ {
+ DataContext = new MainWindowViewModel(),
+ };
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+
+ private void DisableAvaloniaDataAnnotationValidation()
+ {
+ // Get an array of plugins to remove
+ var dataValidationPluginsToRemove =
+ BindingPlugins.DataValidators.OfType().ToArray();
+
+ // remove each entry found
+ foreach (var plugin in dataValidationPluginsToRemove)
+ {
+ BindingPlugins.DataValidators.Remove(plugin);
+ }
+ }
+}
+```
+
+### A.35. `Minint/Controls/EditableTextBlock.cs`
+
+```csharp
+using System;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+
+namespace Minint.Controls;
+
+///
+/// Shows a TextBlock by default; switches to an inline TextBox on double-click.
+/// Commits on Enter or focus loss, cancels on Escape.
+///
+public class EditableTextBlock : Control
+{
+ public static readonly StyledProperty TextProperty =
+ AvaloniaProperty.Register(nameof(Text), defaultBindingMode: Avalonia.Data.BindingMode.TwoWay);
+
+ public string Text
+ {
+ get => GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ private readonly TextBlock _display;
+ private readonly TextBox _editor;
+ private bool _isEditing;
+
+ public EditableTextBlock()
+ {
+ _display = new TextBlock
+ {
+ VerticalAlignment = VerticalAlignment.Center,
+ TextTrimming = TextTrimming.CharacterEllipsis,
+ };
+
+ _editor = new TextBox
+ {
+ VerticalAlignment = VerticalAlignment.Center,
+ Padding = new Thickness(2, 0),
+ BorderThickness = new Thickness(1),
+ MinWidth = 40,
+ IsVisible = false,
+ };
+
+ LogicalChildren.Add(_display);
+ LogicalChildren.Add(_editor);
+ VisualChildren.Add(_display);
+ VisualChildren.Add(_editor);
+
+ _display.Bind(TextBlock.TextProperty, this.GetObservable(TextProperty).ToBinding());
+ _editor.Bind(TextBox.TextProperty, this.GetObservable(TextProperty).ToBinding());
+
+ _editor.KeyDown += OnEditorKeyDown;
+ _editor.LostFocus += OnEditorLostFocus;
+ }
+
+ protected override void OnPointerPressed(PointerPressedEventArgs e)
+ {
+ base.OnPointerPressed(e);
+ if (e.ClickCount == 2 && !_isEditing)
+ {
+ BeginEdit();
+ e.Handled = true;
+ }
+ }
+
+ private void BeginEdit()
+ {
+ _isEditing = true;
+ _editor.Text = Text;
+ _display.IsVisible = false;
+ _editor.IsVisible = true;
+ _editor.Focus();
+ _editor.SelectAll();
+ }
+
+ private void CommitEdit()
+ {
+ if (!_isEditing) return;
+ _isEditing = false;
+ Text = _editor.Text ?? string.Empty;
+ _editor.IsVisible = false;
+ _display.IsVisible = true;
+ }
+
+ private void CancelEdit()
+ {
+ if (!_isEditing) return;
+ _isEditing = false;
+ _editor.Text = Text;
+ _editor.IsVisible = false;
+ _display.IsVisible = true;
+ }
+
+ private void OnEditorKeyDown(object? sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.Enter)
+ {
+ CommitEdit();
+ e.Handled = true;
+ }
+ else if (e.Key == Key.Escape)
+ {
+ CancelEdit();
+ e.Handled = true;
+ }
+ }
+
+ private void OnEditorLostFocus(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ CommitEdit();
+ }
+
+ protected override Size MeasureOverride(Size availableSize)
+ {
+ _display.Measure(availableSize);
+ _editor.Measure(availableSize);
+ return _isEditing ? _editor.DesiredSize : _display.DesiredSize;
+ }
+
+ protected override Size ArrangeOverride(Size finalSize)
+ {
+ var rect = new Rect(finalSize);
+ _display.Arrange(rect);
+ _editor.Arrange(rect);
+ return finalSize;
+ }
+}
+```
+
+### A.36. `Minint/Controls/PixelCanvas.cs`
+
+```csharp
+using System;
+using System.Collections.Generic;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
+using Avalonia.Threading;
+using Minint.Core.Models;
+using Minint.ViewModels;
+
+namespace Minint.Controls;
+
+public class PixelCanvas : Control
+{
+ #region Styled Properties
+
+ public static readonly StyledProperty SourceBitmapProperty =
+ AvaloniaProperty.Register(nameof(SourceBitmap));
+
+ public static readonly StyledProperty ShowGridProperty =
+ AvaloniaProperty.Register(nameof(ShowGrid), defaultValue: false);
+
+ public WriteableBitmap? SourceBitmap
+ {
+ get => GetValue(SourceBitmapProperty);
+ set => SetValue(SourceBitmapProperty, value);
+ }
+
+ public bool ShowGrid
+ {
+ get => GetValue(ShowGridProperty);
+ set => SetValue(ShowGridProperty, value);
+ }
+
+ #endregion
+
+ #region Events for tool interaction
+
+ public event Action? ToolDown;
+ public event Action? ToolDrag;
+ public event Action<(int X, int Y)?>? CursorPixelChanged;
+ public Func?>? GetPreviewMask { get; set; }
+
+ // Selection events
+ public event Action? SelectionStart;
+ public event Action? SelectionUpdate;
+ public event Action? SelectionEnd;
+
+ // Paste events
+ public event Action? PasteMoved;
+ public event Action? PasteCommitted;
+ public event Action? PasteCancelled;
+
+ /// Provides the current EditorViewModel for reading selection/paste state during render.
+ public EditorViewModel? Editor { get; set; }
+
+ #endregion
+
+ private readonly Viewport _viewport = new();
+ private bool _isPanning;
+ private bool _isDrawing;
+ private bool _isSelecting;
+ private Point _panStart;
+ private double _panStartOffsetX, _panStartOffsetY;
+ private bool _viewportInitialized;
+ private int _lastBitmapWidth;
+ private int _lastBitmapHeight;
+ private (int X, int Y)? _lastCursorPixel;
+ private Point? _lastScreenPos;
+
+ private ScrollBar? _hScrollBar;
+ private ScrollBar? _vScrollBar;
+ private bool _suppressScrollSync;
+
+ private const double ScrollPixelsPerTick = 20.0;
+
+ public Viewport Viewport => _viewport;
+
+ static PixelCanvas()
+ {
+ AffectsRender(SourceBitmapProperty, ShowGridProperty);
+ FocusableProperty.OverrideDefaultValue(true);
+ }
+
+ public PixelCanvas()
+ {
+ ClipToBounds = true;
+ }
+
+ public void AttachScrollBars(ScrollBar horizontal, ScrollBar vertical)
+ {
+ if (_hScrollBar is not null) _hScrollBar.ValueChanged -= OnHScrollChanged;
+ if (_vScrollBar is not null) _vScrollBar.ValueChanged -= OnVScrollChanged;
+ _hScrollBar = horizontal;
+ _vScrollBar = vertical;
+ _hScrollBar.ValueChanged += OnHScrollChanged;
+ _vScrollBar.ValueChanged += OnVScrollChanged;
+ }
+
+ #region Rendering
+
+ public override void Render(DrawingContext context)
+ {
+ base.Render(context);
+ context.FillRectangle(Brushes.Transparent, new Rect(Bounds.Size));
+
+ var bmp = SourceBitmap;
+ if (bmp is null) return;
+
+ int imgW = bmp.PixelSize.Width;
+ int imgH = bmp.PixelSize.Height;
+
+ if (!_viewportInitialized)
+ {
+ _viewport.FitToView(imgW, imgH, Bounds.Width, Bounds.Height);
+ _viewportInitialized = true;
+ }
+
+ DrawCheckerboard(context, imgW, imgH);
+
+ var destRect = _viewport.ImageScreenRect(imgW, imgH);
+ var srcRect = new Rect(0, 0, imgW, imgH);
+ RenderOptions.SetBitmapInterpolationMode(this, BitmapInterpolationMode.None);
+ context.DrawImage(bmp, srcRect, destRect);
+
+ if (ShowGrid)
+ DrawPixelGrid(context, imgW, imgH);
+
+ DrawToolPreview(context, imgW, imgH);
+ DrawSelectionOverlay(context);
+ DrawPastePreview(context);
+
+ int w = imgW, h = imgH;
+ Dispatcher.UIThread.Post(() => SyncScrollBars(w, h), DispatcherPriority.Render);
+ }
+
+ private void DrawCheckerboard(DrawingContext context, int imgW, int imgH)
+ {
+ var rect = _viewport.ImageScreenRect(imgW, imgH);
+ var clip = new Rect(0, 0, Bounds.Width, Bounds.Height);
+ var visible = rect.Intersect(clip);
+ if (visible.Width <= 0 || visible.Height <= 0) return;
+
+ const int checkerSize = 8;
+ var light = new SolidColorBrush(Color.FromRgb(204, 204, 204));
+ var dark = new SolidColorBrush(Color.FromRgb(170, 170, 170));
+
+ using (context.PushClip(visible))
+ {
+ context.FillRectangle(light, visible);
+ double startX = visible.X - ((visible.X - rect.X) % (checkerSize * 2));
+ double startY = visible.Y - ((visible.Y - rect.Y) % (checkerSize * 2));
+ for (double y = startY; y < visible.Bottom; y += checkerSize)
+ {
+ for (double x = startX; x < visible.Right; x += checkerSize)
+ {
+ int col = (int)((x - rect.X) / checkerSize);
+ int row = (int)((y - rect.Y) / checkerSize);
+ if ((col + row) % 2 == 1)
+ context.FillRectangle(dark, new Rect(x, y, checkerSize, checkerSize));
+ }
+ }
+ }
+ }
+
+ private void DrawPixelGrid(DrawingContext context, int imgW, int imgH)
+ {
+ double zoom = _viewport.Zoom;
+ if (zoom < 4) return;
+
+ var pen = new Pen(Brushes.Black, 1);
+
+ var clip = new Rect(0, 0, Bounds.Width, Bounds.Height);
+ var imgRect = _viewport.ImageScreenRect(imgW, imgH);
+ var visible = imgRect.Intersect(clip);
+ if (visible.Width <= 0 || visible.Height <= 0) return;
+
+ var (startPx, startPy) = _viewport.ScreenToPixel(visible.X, visible.Y);
+ var (endPx, endPy) = _viewport.ScreenToPixel(visible.Right, visible.Bottom);
+ startPx = Math.Max(0, startPx);
+ startPy = Math.Max(0, startPy);
+ endPx = Math.Min(imgW, endPx + 1);
+ endPy = Math.Min(imgH, endPy + 1);
+
+ using (context.PushClip(visible))
+ {
+ for (int px = startPx; px <= endPx; px++)
+ {
+ var (sx, _) = _viewport.PixelToScreen(px, 0);
+ double x = Math.Floor(sx) + 0.5;
+ context.DrawLine(pen, new Point(x, visible.Top), new Point(x, visible.Bottom));
+ }
+ for (int py = startPy; py <= endPy; py++)
+ {
+ var (_, sy) = _viewport.PixelToScreen(0, py);
+ double y = Math.Floor(sy) + 0.5;
+ context.DrawLine(pen, new Point(visible.Left, y), new Point(visible.Right, y));
+ }
+ }
+ }
+
+ private void DrawToolPreview(DrawingContext context, int imgW, int imgH)
+ {
+ var mask = GetPreviewMask?.Invoke();
+ if (mask is null || mask.Count == 0) return;
+
+ double zoom = _viewport.Zoom;
+ var previewBrush = new SolidColorBrush(Color.FromArgb(80, 255, 255, 255));
+ var outlinePen = new Pen(new SolidColorBrush(Color.FromArgb(160, 255, 255, 255)), 1);
+
+ var clip = new Rect(0, 0, Bounds.Width, Bounds.Height);
+ using (context.PushClip(clip))
+ {
+ foreach (var (px, py) in mask)
+ {
+ var (sx, sy) = _viewport.PixelToScreen(px, py);
+ context.FillRectangle(previewBrush, new Rect(sx, sy, zoom, zoom));
+ }
+
+ if (mask.Count > 0)
+ {
+ int minX = mask[0].X, maxX = mask[0].X;
+ int minY = mask[0].Y, maxY = mask[0].Y;
+ foreach (var (px, py) in mask)
+ {
+ if (px < minX) minX = px;
+ if (px > maxX) maxX = px;
+ if (py < minY) minY = py;
+ if (py > maxY) maxY = py;
+ }
+ var (ox, oy) = _viewport.PixelToScreen(minX, minY);
+ context.DrawRectangle(outlinePen, new Rect(ox, oy, (maxX - minX + 1) * zoom, (maxY - minY + 1) * zoom));
+ }
+ }
+ }
+
+ private void DrawSelectionOverlay(DrawingContext context)
+ {
+ var sel = Editor?.SelectionRectNormalized;
+ if (sel is null) return;
+
+ var (sx, sy, sw, sh) = sel.Value;
+ double zoom = _viewport.Zoom;
+ var (screenX, screenY) = _viewport.PixelToScreen(sx, sy);
+ var rect = new Rect(screenX, screenY, sw * zoom, sh * zoom);
+
+ var fillBrush = new SolidColorBrush(Color.FromArgb(40, 100, 150, 255));
+ context.FillRectangle(fillBrush, rect);
+
+ var borderPen = new Pen(new SolidColorBrush(Color.FromArgb(200, 100, 150, 255)), 1,
+ new DashStyle([4, 4], 0));
+ context.DrawRectangle(borderPen, rect);
+ }
+
+ private void DrawPastePreview(DrawingContext context)
+ {
+ if (Editor is null || !Editor.IsPasting || Editor.Clipboard is null)
+ return;
+
+ var pos = Editor.PastePosition!.Value;
+ var frag = Editor.Clipboard;
+ double zoom = _viewport.Zoom;
+
+ var clip = new Rect(0, 0, Bounds.Width, Bounds.Height);
+ using (context.PushClip(clip))
+ {
+ for (int fy = 0; fy < frag.Height; fy++)
+ {
+ for (int fx = 0; fx < frag.Width; fx++)
+ {
+ var color = frag.Pixels[fy * frag.Width + fx];
+ if (color.A == 0) continue;
+
+ var (sx, sy) = _viewport.PixelToScreen(pos.X + fx, pos.Y + fy);
+ byte dispA = (byte)(color.A * 180 / 255); // semi-transparent preview
+ var brush = new SolidColorBrush(Color.FromArgb(dispA, color.R, color.G, color.B));
+ context.FillRectangle(brush, new Rect(sx, sy, zoom, zoom));
+ }
+ }
+
+ // Border around the floating fragment
+ var (ox, oy) = _viewport.PixelToScreen(pos.X, pos.Y);
+ var borderPen = new Pen(new SolidColorBrush(Color.FromArgb(200, 255, 200, 50)), 1,
+ new DashStyle([3, 3], 0));
+ context.DrawRectangle(borderPen, new Rect(ox, oy, frag.Width * zoom, frag.Height * zoom));
+ }
+ }
+
+ #endregion
+
+ #region Scrollbar Sync
+
+ private void SyncScrollBars(int imgW, int imgH)
+ {
+ if (_hScrollBar is null || _vScrollBar is null) return;
+ _suppressScrollSync = true;
+
+ var (hMin, hMax, hVal, hView) = _viewport.GetScrollInfo(imgW, Bounds.Width, _viewport.OffsetX);
+ _hScrollBar.Minimum = -hMax;
+ _hScrollBar.Maximum = -hMin;
+ _hScrollBar.Value = -hVal;
+ _hScrollBar.ViewportSize = hView;
+
+ var (vMin, vMax, vVal, vView) = _viewport.GetScrollInfo(imgH, Bounds.Height, _viewport.OffsetY);
+ _vScrollBar.Minimum = -vMax;
+ _vScrollBar.Maximum = -vMin;
+ _vScrollBar.Value = -vVal;
+ _vScrollBar.ViewportSize = vView;
+
+ _suppressScrollSync = false;
+ }
+
+ private void OnHScrollChanged(object? sender, RangeBaseValueChangedEventArgs e)
+ {
+ if (_suppressScrollSync) return;
+ var (imgW, imgH) = GetImageSize();
+ _viewport.SetOffset(-e.NewValue, _viewport.OffsetY, imgW, imgH, Bounds.Width, Bounds.Height);
+ RecalcCursorPixel();
+ InvalidateVisual();
+ }
+
+ private void OnVScrollChanged(object? sender, RangeBaseValueChangedEventArgs e)
+ {
+ if (_suppressScrollSync) return;
+ var (imgW, imgH) = GetImageSize();
+ _viewport.SetOffset(_viewport.OffsetX, -e.NewValue, imgW, imgH, Bounds.Width, Bounds.Height);
+ RecalcCursorPixel();
+ InvalidateVisual();
+ }
+
+ #endregion
+
+ #region Mouse Input
+
+ private (int W, int H) GetImageSize()
+ {
+ var bmp = SourceBitmap;
+ return bmp is not null ? (bmp.PixelSize.Width, bmp.PixelSize.Height) : (0, 0);
+ }
+
+ private (int X, int Y)? ScreenToPixelClamped(Point pos)
+ {
+ var (imgW, imgH) = GetImageSize();
+ if (imgW == 0) return null;
+ var (px, py) = _viewport.ScreenToPixel(pos.X, pos.Y);
+ if (px < 0 || px >= imgW || py < 0 || py >= imgH)
+ return null;
+ return (px, py);
+ }
+
+ private void RecalcCursorPixel()
+ {
+ if (_lastScreenPos is null) return;
+ var pixel = ScreenToPixelClamped(_lastScreenPos.Value);
+ if (pixel != _lastCursorPixel)
+ {
+ _lastCursorPixel = pixel;
+ CursorPixelChanged?.Invoke(pixel);
+ }
+ }
+
+ protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
+ {
+ base.OnPointerWheelChanged(e);
+ var (imgW, imgH) = GetImageSize();
+ if (imgW == 0) return;
+
+ bool ctrl = (e.KeyModifiers & KeyModifiers.Control) != 0;
+ bool shift = (e.KeyModifiers & KeyModifiers.Shift) != 0;
+
+ if (ctrl)
+ {
+ var pos = e.GetPosition(this);
+ _viewport.ZoomAtPoint(pos.X, pos.Y, e.Delta.Y, imgW, imgH, Bounds.Width, Bounds.Height);
+ }
+ else
+ {
+ double dx = e.Delta.X * ScrollPixelsPerTick;
+ double dy = e.Delta.Y * ScrollPixelsPerTick;
+ if (shift && Math.Abs(e.Delta.X) < 0.001)
+ {
+ dx = dy;
+ dy = 0;
+ }
+ _viewport.Pan(dx, dy, imgW, imgH, Bounds.Width, Bounds.Height);
+ }
+
+ RecalcCursorPixel();
+ InvalidateVisual();
+ e.Handled = true;
+ }
+
+ protected override void OnPointerPressed(PointerPressedEventArgs e)
+ {
+ base.OnPointerPressed(e);
+ var props = e.GetCurrentPoint(this).Properties;
+
+ if (props.IsMiddleButtonPressed)
+ {
+ _isPanning = true;
+ _panStart = e.GetPosition(this);
+ _panStartOffsetX = _viewport.OffsetX;
+ _panStartOffsetY = _viewport.OffsetY;
+ e.Handled = true;
+ return;
+ }
+
+ if (!props.IsLeftButtonPressed || _isPanning) return;
+
+ var pixel = ScreenToPixelClamped(e.GetPosition(this));
+ if (pixel is null) return;
+
+ // Paste mode: left-click commits
+ if (Editor is not null && Editor.IsPasting)
+ {
+ PasteCommitted?.Invoke();
+ e.Handled = true;
+ return;
+ }
+
+ // Select tool: begin rubber-band
+ if (Editor is not null && Editor.ActiveTool == ToolType.Select)
+ {
+ _isSelecting = true;
+ SelectionStart?.Invoke(pixel.Value.X, pixel.Value.Y);
+ e.Handled = true;
+ return;
+ }
+
+ // Regular drawing tools
+ _isDrawing = true;
+ ToolDown?.Invoke(pixel.Value.X, pixel.Value.Y);
+ e.Handled = true;
+ }
+
+ protected override void OnPointerMoved(PointerEventArgs e)
+ {
+ base.OnPointerMoved(e);
+ var pos = e.GetPosition(this);
+ _lastScreenPos = pos;
+
+ if (_isPanning)
+ {
+ var (imgW, imgH) = GetImageSize();
+ _viewport.SetOffset(
+ _panStartOffsetX + (pos.X - _panStart.X),
+ _panStartOffsetY + (pos.Y - _panStart.Y),
+ imgW, imgH, Bounds.Width, Bounds.Height);
+ RecalcCursorPixel();
+ InvalidateVisual();
+ e.Handled = true;
+ return;
+ }
+
+ var pixel = ScreenToPixelClamped(pos);
+ if (pixel != _lastCursorPixel)
+ {
+ _lastCursorPixel = pixel;
+ CursorPixelChanged?.Invoke(pixel);
+ }
+
+ // Paste mode: floating fragment follows cursor
+ if (Editor is not null && Editor.IsPasting && pixel is not null)
+ {
+ PasteMoved?.Invoke(pixel.Value.X, pixel.Value.Y);
+ InvalidateVisual();
+ e.Handled = true;
+ return;
+ }
+
+ // Selection rubber-band drag
+ if (_isSelecting && pixel is not null)
+ {
+ SelectionUpdate?.Invoke(pixel.Value.X, pixel.Value.Y);
+ InvalidateVisual();
+ e.Handled = true;
+ return;
+ }
+
+ if (_isDrawing && pixel is not null)
+ {
+ ToolDrag?.Invoke(pixel.Value.X, pixel.Value.Y);
+ e.Handled = true;
+ }
+ else
+ {
+ InvalidateVisual();
+ }
+ }
+
+ protected override void OnPointerReleased(PointerReleasedEventArgs e)
+ {
+ base.OnPointerReleased(e);
+
+ if (_isPanning && e.InitialPressMouseButton == MouseButton.Middle)
+ {
+ _isPanning = false;
+ e.Handled = true;
+ }
+ else if (_isSelecting && e.InitialPressMouseButton == MouseButton.Left)
+ {
+ _isSelecting = false;
+ var pixel = ScreenToPixelClamped(e.GetPosition(this));
+ if (pixel is not null)
+ SelectionEnd?.Invoke(pixel.Value.X, pixel.Value.Y);
+ InvalidateVisual();
+ e.Handled = true;
+ }
+ else if (_isDrawing && e.InitialPressMouseButton == MouseButton.Left)
+ {
+ _isDrawing = false;
+ e.Handled = true;
+ }
+ }
+
+ protected override void OnPointerExited(PointerEventArgs e)
+ {
+ base.OnPointerExited(e);
+ _lastScreenPos = null;
+ if (_lastCursorPixel is not null)
+ {
+ _lastCursorPixel = null;
+ CursorPixelChanged?.Invoke(null);
+ InvalidateVisual();
+ }
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+
+ if (e.Key == Key.Escape)
+ {
+ if (Editor is not null && Editor.IsPasting)
+ {
+ PasteCancelled?.Invoke();
+ InvalidateVisual();
+ e.Handled = true;
+ }
+ else if (Editor is not null && Editor.HasSelection)
+ {
+ Editor.ClearSelection();
+ InvalidateVisual();
+ e.Handled = true;
+ }
+ }
+ else if (e.Key == Key.Enter && Editor is not null && Editor.IsPasting)
+ {
+ PasteCommitted?.Invoke();
+ InvalidateVisual();
+ e.Handled = true;
+ }
+ }
+
+ #endregion
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+ if (change.Property == SourceBitmapProperty)
+ {
+ var bmp = change.GetNewValue();
+ int w = bmp?.PixelSize.Width ?? 0;
+ int h = bmp?.PixelSize.Height ?? 0;
+ if (w != _lastBitmapWidth || h != _lastBitmapHeight)
+ {
+ _lastBitmapWidth = w;
+ _lastBitmapHeight = h;
+ _viewportInitialized = false;
+ }
+ }
+ }
+}
+```
+
+### A.37. `Minint/Controls/Viewport.cs`
+
+```csharp
+using System;
+using Avalonia;
+
+namespace Minint.Controls;
+
+///
+/// Manages zoom level and pan offset for the pixel canvas.
+/// Provides screen↔pixel coordinate transforms.
+///
+public sealed class Viewport
+{
+ public double Zoom { get; set; } = 1.0;
+ public double OffsetX { get; set; }
+ public double OffsetY { get; set; }
+
+ public const double MinZoom = 0.25;
+ public const double MaxZoom = 128.0;
+
+ ///
+ /// Zoom base per 1.0 unit of wheel delta. Actual factor = Pow(base, |delta|).
+ /// Touchpad nudge (delta ~0.1) → ~1.01×, mouse tick (delta 1.0) → 1.10×, fast (3.0) → 1.33×.
+ ///
+ private const double ZoomBase = 1.10;
+
+ public (int X, int Y) ScreenToPixel(double screenX, double screenY) =>
+ ((int)Math.Floor((screenX - OffsetX) / Zoom),
+ (int)Math.Floor((screenY - OffsetY) / Zoom));
+
+ public (double X, double Y) PixelToScreen(int pixelX, int pixelY) =>
+ (pixelX * Zoom + OffsetX,
+ pixelY * Zoom + OffsetY);
+
+ public Rect ImageScreenRect(int imageWidth, int imageHeight) =>
+ new(OffsetX, OffsetY, imageWidth * Zoom, imageHeight * Zoom);
+
+ ///
+ /// Zooms keeping the point under cursor fixed.
+ /// Uses the actual magnitude of for proportional zoom.
+ ///
+ public void ZoomAtPoint(double screenX, double screenY, double delta,
+ int imageWidth, int imageHeight, double controlWidth, double controlHeight)
+ {
+ double absDelta = Math.Abs(delta);
+ double factor = delta > 0 ? Math.Pow(ZoomBase, absDelta) : 1.0 / Math.Pow(ZoomBase, absDelta);
+ double newZoom = Math.Clamp(Zoom * factor, MinZoom, MaxZoom);
+ if (Math.Abs(newZoom - Zoom) < 1e-12) return;
+
+ double pixelX = (screenX - OffsetX) / Zoom;
+ double pixelY = (screenY - OffsetY) / Zoom;
+ Zoom = newZoom;
+ OffsetX = screenX - pixelX * Zoom;
+ OffsetY = screenY - pixelY * Zoom;
+
+ ClampOffset(imageWidth, imageHeight, controlWidth, controlHeight);
+ }
+
+ ///
+ /// Pans by screen-space delta, then clamps so the image can't be scrolled out of view.
+ ///
+ public void Pan(double deltaX, double deltaY,
+ int imageWidth, int imageHeight, double controlWidth, double controlHeight)
+ {
+ OffsetX += deltaX;
+ OffsetY += deltaY;
+ ClampOffset(imageWidth, imageHeight, controlWidth, controlHeight);
+ }
+
+ ///
+ /// Sets offset directly (e.g. from middle-mouse drag), then clamps.
+ ///
+ public void SetOffset(double offsetX, double offsetY,
+ int imageWidth, int imageHeight, double controlWidth, double controlHeight)
+ {
+ OffsetX = offsetX;
+ OffsetY = offsetY;
+ ClampOffset(imageWidth, imageHeight, controlWidth, controlHeight);
+ }
+
+ ///
+ /// Ensures at least minVisible pixels of the image remain on screen on each edge.
+ ///
+ public void ClampOffset(int imageWidth, int imageHeight, double controlWidth, double controlHeight)
+ {
+ double extentW = imageWidth * Zoom;
+ double extentH = imageHeight * Zoom;
+
+ double minVisH = Math.Max(32, Math.Min(controlWidth, extentW) * 0.10);
+ double minVisV = Math.Max(32, Math.Min(controlHeight, extentH) * 0.10);
+
+ // Image right edge must be >= minVisH from left of control
+ // Image left edge must be <= controlWidth - minVisH from left
+ OffsetX = Math.Clamp(OffsetX, minVisH - extentW, controlWidth - minVisH);
+ OffsetY = Math.Clamp(OffsetY, minVisV - extentH, controlHeight - minVisV);
+ }
+
+ public void FitToView(int imageWidth, int imageHeight, double controlWidth, double controlHeight)
+ {
+ if (imageWidth <= 0 || imageHeight <= 0 || controlWidth <= 0 || controlHeight <= 0)
+ return;
+
+ double scaleX = controlWidth / imageWidth;
+ double scaleY = controlHeight / imageHeight;
+ Zoom = Math.Max(1.0, Math.Floor(Math.Min(scaleX, scaleY)));
+
+ OffsetX = (controlWidth - imageWidth * Zoom) / 2.0;
+ OffsetY = (controlHeight - imageHeight * Zoom) / 2.0;
+ }
+
+ public (double Min, double Max, double Value, double ViewportSize)
+ GetScrollInfo(int imageSize, double controlSize, double offset)
+ {
+ double extent = imageSize * Zoom;
+ double minVis = Math.Max(32, Math.Min(controlSize, extent) * 0.10);
+ double min = minVis - extent;
+ double max = controlSize - minVis;
+ double viewportSize = Math.Min(controlSize, extent);
+ return (min, max, offset, viewportSize);
+ }
+}
+```
+
+### A.38. `Minint/Program.cs`
+
+```csharp
+using Avalonia;
+using System;
+using System.IO;
+using Minint.Core.Models;
+using Minint.Core.Services.Impl;
+using Minint.Infrastructure.Serialization;
+
+namespace Minint;
+
+sealed class Program
+{
+ [STAThread]
+ public static void Main(string[] args)
+ {
+ // TODO: remove --test branch after verification
+ if (args.Length > 0 && args[0] == "--test")
+ {
+ RunRoundTripTest();
+ RunCompositorTest();
+ RunPaletteServiceTest();
+ return;
+ }
+
+ BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
+ }
+
+ public static AppBuilder BuildAvaloniaApp()
+ => AppBuilder.Configure()
+ .UsePlatformDetect()
+ .WithInterFont()
+ .LogToTrace()
+ .With(new X11PlatformOptions { OverlayPopups = true });
+
+ // TODO: temporary tests — remove after verification stages.
+
+ private static void RunRoundTripTest()
+ {
+ Console.WriteLine("=== Minint Round-Trip Test ===\n");
+
+ var container = new MinintContainer(8, 4);
+
+ var doc1 = container.AddNewDocument("Frame 1");
+ doc1.FrameDelayMs = 200;
+ doc1.Palette.Add(new RgbaColor(255, 0, 0, 255)); // idx 1 = red
+ doc1.Palette.Add(new RgbaColor(0, 255, 0, 255)); // idx 2 = green
+ doc1.Palette.Add(new RgbaColor(0, 0, 255, 128)); // idx 3 = semi-transparent blue
+ var layer1 = doc1.Layers[0];
+ for (int i = 0; i < layer1.Pixels.Length; i++)
+ layer1.Pixels[i] = i % 4; // cycle 0,1,2,3
+
+ doc1.Layers.Add(new MinintLayer("Overlay", container.PixelCount));
+ var layer2 = doc1.Layers[1];
+ layer2.Opacity = 128;
+ layer2.Pixels[0] = 3;
+ layer2.Pixels[5] = 2;
+
+ var doc2 = container.AddNewDocument("Frame 2");
+ doc2.FrameDelayMs = 150;
+ doc2.Palette.Add(new RgbaColor(255, 255, 0, 255)); // idx 1 = yellow
+ var layer3 = doc2.Layers[0];
+ for (int i = 0; i < layer3.Pixels.Length; i++)
+ layer3.Pixels[i] = i % 2;
+
+ Console.WriteLine($"Original: {container.Width}x{container.Height}, {container.Documents.Count} docs");
+ Console.WriteLine($" Doc1: palette={doc1.Palette.Count} colors, layers={doc1.Layers.Count}, indexWidth={doc1.IndexByteWidth}");
+ Console.WriteLine($" Doc2: palette={doc2.Palette.Count} colors, layers={doc2.Layers.Count}, indexWidth={doc2.IndexByteWidth}");
+
+ var serializer = new MinintSerializer();
+
+ using var ms = new MemoryStream();
+ serializer.Write(ms, container);
+ byte[] data = ms.ToArray();
+ Console.WriteLine($"\nSerialized: {data.Length} bytes");
+ Console.WriteLine($" Signature: {System.Text.Encoding.ASCII.GetString(data, 0, 6)}");
+
+ ms.Position = 0;
+ var loaded = serializer.Read(ms);
+
+ Assert(loaded.Width == container.Width, "Width mismatch");
+ Assert(loaded.Height == container.Height, "Height mismatch");
+ Assert(loaded.Documents.Count == container.Documents.Count, "Document count mismatch");
+
+ for (int d = 0; d < container.Documents.Count; d++)
+ {
+ var orig = container.Documents[d];
+ var copy = loaded.Documents[d];
+ Assert(copy.Name == orig.Name, $"Doc[{d}] name mismatch");
+ Assert(copy.FrameDelayMs == orig.FrameDelayMs, $"Doc[{d}] frameDelay mismatch");
+ Assert(copy.Palette.Count == orig.Palette.Count, $"Doc[{d}] palette count mismatch");
+
+ for (int c = 0; c < orig.Palette.Count; c++)
+ Assert(copy.Palette[c] == orig.Palette[c], $"Doc[{d}] palette[{c}] mismatch");
+
+ Assert(copy.Layers.Count == orig.Layers.Count, $"Doc[{d}] layer count mismatch");
+
+ for (int l = 0; l < orig.Layers.Count; l++)
+ {
+ var oLayer = orig.Layers[l];
+ var cLayer = copy.Layers[l];
+ Assert(cLayer.Name == oLayer.Name, $"Doc[{d}].Layer[{l}] name mismatch");
+ Assert(cLayer.IsVisible == oLayer.IsVisible, $"Doc[{d}].Layer[{l}] visibility mismatch");
+ Assert(cLayer.Opacity == oLayer.Opacity, $"Doc[{d}].Layer[{l}] opacity mismatch");
+ Assert(cLayer.Pixels.Length == oLayer.Pixels.Length, $"Doc[{d}].Layer[{l}] pixel count mismatch");
+
+ for (int p = 0; p < oLayer.Pixels.Length; p++)
+ Assert(cLayer.Pixels[p] == oLayer.Pixels[p],
+ $"Doc[{d}].Layer[{l}].Pixels[{p}] mismatch: expected {oLayer.Pixels[p]}, got {cLayer.Pixels[p]}");
+ }
+ }
+
+ Console.WriteLine("\n✓ All assertions passed — round-trip is correct!");
+
+ // Test invalid signature
+ Console.Write("\nTest: invalid signature... ");
+ data[0] = (byte)'X';
+ try
+ {
+ serializer.Read(new MemoryStream(data));
+ Console.WriteLine("FAIL (no exception)");
+ }
+ catch (InvalidDataException)
+ {
+ Console.WriteLine("OK (InvalidDataException)");
+ }
+ data[0] = (byte)'M'; // restore
+
+ // Test truncated stream
+ Console.Write("Test: truncated stream... ");
+ try
+ {
+ serializer.Read(new MemoryStream(data, 0, 10));
+ Console.WriteLine("FAIL (no exception)");
+ }
+ catch (Exception ex) when (ex is InvalidDataException or EndOfStreamException)
+ {
+ Console.WriteLine($"OK ({ex.GetType().Name})");
+ }
+
+ Console.WriteLine("\n=== All tests passed ===");
+ }
+
+ private static void RunCompositorTest()
+ {
+ Console.WriteLine("\n=== Compositor Test ===\n");
+
+ var compositor = new Compositor();
+ const int W = 2, H = 2;
+
+ // Document: 2 layers on a 2x2 canvas
+ var doc = new MinintDocument("test");
+ doc.Palette.Add(new RgbaColor(255, 0, 0, 255)); // idx 1 = opaque red
+ doc.Palette.Add(new RgbaColor(0, 0, 255, 128)); // idx 2 = semi-transparent blue
+
+ // Bottom layer: all red
+ var bottom = new MinintLayer("bottom", W * H);
+ for (int i = 0; i < bottom.Pixels.Length; i++)
+ bottom.Pixels[i] = 1;
+ doc.Layers.Add(bottom);
+
+ // Top layer: pixel[0] = semi-blue, rest transparent
+ var top = new MinintLayer("top", W * H);
+ top.Pixels[0] = 2;
+ doc.Layers.Add(top);
+
+ uint[] result = compositor.Composite(doc, W, H);
+
+ // Pixel [1],[2],[3]: only bottom visible → opaque red → 0xFFFF0000 (ARGB)
+ uint opaqueRed = 0xFF_FF_00_00;
+ Assert(result[1] == opaqueRed, $"Pixel[1]: expected {opaqueRed:X8}, got {result[1]:X8}");
+ Assert(result[2] == opaqueRed, $"Pixel[2]: expected {opaqueRed:X8}, got {result[2]:X8}");
+ Assert(result[3] == opaqueRed, $"Pixel[3]: expected {opaqueRed:X8}, got {result[3]:X8}");
+
+ // Pixel [0]: red(255,0,0,255) under blue(0,0,255,128)
+ // srcA=128, dstA=255 → outA = 128 + 255*(255-128)/255 = 128+127 = 255
+ // outR = (0*128 + 255*255*(127)/255) / 255 = (0 + 255*127)/255 = 127
+ // outG = 0
+ // outB = (255*128 + 0) / 255 = 128
+ uint blended = result[0];
+ byte bA = (byte)(blended >> 24);
+ byte bR = (byte)((blended >> 16) & 0xFF);
+ byte bG = (byte)((blended >> 8) & 0xFF);
+ byte bB = (byte)(blended & 0xFF);
+
+ Console.WriteLine($" Blended pixel[0]: A={bA} R={bR} G={bG} B={bB}");
+ Assert(bA == 255, $"Pixel[0] A: expected 255, got {bA}");
+ Assert(bR >= 125 && bR <= 129, $"Pixel[0] R: expected ~127, got {bR}");
+ Assert(bG == 0, $"Pixel[0] G: expected 0, got {bG}");
+ Assert(bB >= 126 && bB <= 130, $"Pixel[0] B: expected ~128, got {bB}");
+
+ // Test hidden layer: hide top, result should be all red
+ top.IsVisible = false;
+ uint[] result2 = compositor.Composite(doc, W, H);
+ Assert(result2[0] == opaqueRed, $"Hidden top: Pixel[0] should be red, got {result2[0]:X8}");
+
+ // Test layer opacity=0: make top visible but opacity=0
+ top.IsVisible = true;
+ top.Opacity = 0;
+ uint[] result3 = compositor.Composite(doc, W, H);
+ Assert(result3[0] == opaqueRed, $"Opacity 0: Pixel[0] should be red, got {result3[0]:X8}");
+
+ // Test single transparent layer
+ var emptyDoc = new MinintDocument("empty");
+ emptyDoc.Layers.Add(new MinintLayer("bg", W * H));
+ uint[] result4 = compositor.Composite(emptyDoc, W, H);
+ Assert(result4[0] == 0, $"Empty layer: Pixel[0] should be 0x00000000, got {result4[0]:X8}");
+
+ Console.WriteLine("✓ Compositor tests passed!");
+ }
+
+ private static void RunPaletteServiceTest()
+ {
+ Console.WriteLine("\n=== PaletteService Test ===\n");
+
+ var svc = new PaletteService();
+ var doc = new MinintDocument("test");
+
+ // Palette starts with [Transparent]
+ Assert(doc.Palette.Count == 1, "Initial palette should have 1 entry");
+
+ // EnsureColor: new color
+ var red = new RgbaColor(255, 0, 0, 255);
+ int redIdx = svc.EnsureColor(doc, red);
+ Assert(redIdx == 1, $"Red index: expected 1, got {redIdx}");
+ Assert(doc.Palette.Count == 2, $"Palette count after red: expected 2, got {doc.Palette.Count}");
+
+ // EnsureColor: same color → same index
+ int redIdx2 = svc.EnsureColor(doc, red);
+ Assert(redIdx2 == 1, $"Red re-ensure: expected 1, got {redIdx2}");
+ Assert(doc.Palette.Count == 2, "Palette should not grow on duplicate");
+
+ // FindColor
+ Assert(svc.FindColor(doc, red) == 1, "FindColor red");
+ Assert(svc.FindColor(doc, new RgbaColor(0, 0, 0, 255)) == -1, "FindColor missing");
+
+ // Compact: add unused color, then compact
+ var green = new RgbaColor(0, 255, 0, 255);
+ int greenIdx = svc.EnsureColor(doc, green); // idx 2
+ doc.Layers.Add(new MinintLayer("L", 4));
+ doc.Layers[0].Pixels[0] = 1; // red used
+ doc.Layers[0].Pixels[1] = 0; // transparent used
+ // green (idx 2) is NOT used by any pixel
+
+ Console.WriteLine($" Before compact: palette has {doc.Palette.Count} colors (green at idx {greenIdx})");
+ svc.CompactPalette(doc);
+ Console.WriteLine($" After compact: palette has {doc.Palette.Count} colors");
+
+ Assert(doc.Palette.Count == 2, $"After compact: expected 2 colors, got {doc.Palette.Count}");
+ Assert(doc.Palette[0] == RgbaColor.Transparent, "Palette[0] should be transparent");
+ Assert(doc.Palette[1] == red, $"Palette[1] should be red, got {doc.Palette[1]}");
+ Assert(doc.Layers[0].Pixels[0] == 1, "Pixel[0] should still map to red (idx 1)");
+
+ Console.WriteLine("✓ PaletteService tests passed!");
+ }
+
+ private static void Assert(bool condition, string message)
+ {
+ if (!condition)
+ throw new Exception($"Assertion failed: {message}");
+ }
+}
+```
+
+### A.39. `Minint/ViewLocator.cs`
+
+```csharp
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using Minint.ViewModels;
+
+namespace Minint;
+
+///
+/// Given a view model, returns the corresponding view if possible.
+///
+[RequiresUnreferencedCode(
+ "Default implementation of ViewLocator involves reflection which may be trimmed away.",
+ Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
+public class ViewLocator : IDataTemplate
+{
+ public Control? Build(object? param)
+ {
+ if (param is null)
+ return null;
+
+ var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
+ var type = Type.GetType(name);
+
+ if (type != null)
+ {
+ return (Control)Activator.CreateInstance(type)!;
+ }
+
+ return new TextBlock { Text = "Not Found: " + name };
+ }
+
+ public bool Match(object? data)
+ {
+ return data is ViewModelBase;
+ }
+}
+```
+
+### A.40. `Minint/ViewModels/EditorViewModel.cs`
+
+```csharp
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using Avalonia;
+using Avalonia.Media.Imaging;
+using Avalonia.Threading;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Minint.Core.Models;
+using Minint.Core.Services;
+using Minint.Core.Services.Impl;
+
+namespace Minint.ViewModels;
+
+///
+/// Palette-independent clipboard fragment: stores resolved RGBA pixels.
+///
+public sealed record ClipboardFragment(int Width, int Height, RgbaColor[] Pixels);
+
+public partial class EditorViewModel : ViewModelBase
+{
+ private readonly ICompositor _compositor = new Compositor();
+ private readonly IPaletteService _paletteService = new PaletteService();
+ private readonly IDrawingService _drawingService = new DrawingService();
+ private readonly IFloodFillService _floodFillService = new FloodFillService();
+ private readonly IFragmentService _fragmentService = new FragmentService();
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasContainer))]
+ [NotifyPropertyChangedFor(nameof(Title))]
+ private MinintContainer? _container;
+
+ [ObservableProperty]
+ private MinintDocument? _activeDocument;
+
+ [ObservableProperty]
+ private MinintLayer? _activeLayer;
+
+ private bool _suppressDocumentSync;
+
+ [ObservableProperty]
+ private WriteableBitmap? _canvasBitmap;
+
+ [ObservableProperty]
+ private bool _showGrid;
+
+ // Tool state
+ [ObservableProperty]
+ private ToolType _activeTool = ToolType.Brush;
+
+ [ObservableProperty]
+ private int _brushRadius = 1;
+
+ [ObservableProperty]
+ private (int X, int Y)? _previewCenter;
+
+ private Avalonia.Media.Color _previewColor = Avalonia.Media.Color.FromArgb(255, 0, 0, 0);
+
+ public Avalonia.Media.Color PreviewColor
+ {
+ get => _previewColor;
+ set
+ {
+ if (_previewColor == value) return;
+ _previewColor = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(SelectedColor));
+ }
+ }
+
+ public RgbaColor SelectedColor => new(_previewColor.R, _previewColor.G, _previewColor.B, _previewColor.A);
+
+ // Selection state (Select tool rubber-band)
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasSelection))]
+ private (int X, int Y, int W, int H)? _selectionRect;
+
+ public bool HasSelection => SelectionRect is not null;
+
+ // Clipboard
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasClipboard))]
+ private ClipboardFragment? _clipboard;
+
+ public bool HasClipboard => Clipboard is not null;
+
+ // Paste mode
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsPasting))]
+ private (int X, int Y)? _pastePosition;
+
+ public bool IsPasting => PastePosition is not null;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(Title))]
+ private string? _filePath;
+
+ public bool HasContainer => Container is not null;
+
+ public string Title => FilePath is not null
+ ? $"Minint — {System.IO.Path.GetFileName(FilePath)}"
+ : Container is not null
+ ? "Minint — Untitled"
+ : "Minint";
+
+ public ObservableCollection Documents { get; } = [];
+ public ObservableCollection Layers { get; } = [];
+
+ #region Container / Document management
+
+ public void NewContainer(int width, int height)
+ {
+ var c = new MinintContainer(width, height);
+ c.AddNewDocument("Document 1");
+ LoadContainer(c, null);
+ }
+
+ public void LoadContainer(MinintContainer container, string? path)
+ {
+ Container = container;
+ FilePath = path;
+
+ SyncDocumentsList();
+ SelectDocument(container.Documents.Count > 0 ? container.Documents[0] : null);
+ }
+
+ partial void OnActiveDocumentChanged(MinintDocument? value)
+ {
+ if (_suppressDocumentSync) return;
+ SyncLayersAndCanvas(value);
+ }
+
+ public void SelectDocument(MinintDocument? doc)
+ {
+ _suppressDocumentSync = true;
+ ActiveDocument = doc;
+ _suppressDocumentSync = false;
+ SyncLayersAndCanvas(doc);
+ }
+
+ public void SyncAfterExternalChange() => SyncDocumentsList();
+
+ private void SyncDocumentsList()
+ {
+ Documents.Clear();
+ if (Container is null) return;
+ foreach (var doc in Container.Documents)
+ Documents.Add(doc);
+ }
+
+ private void SyncLayersAndCanvas(MinintDocument? doc)
+ {
+ UnsubscribeLayerVisibility();
+ Layers.Clear();
+ if (doc is not null)
+ {
+ foreach (var layer in doc.Layers)
+ Layers.Add(layer);
+ ActiveLayer = doc.Layers.Count > 0 ? doc.Layers[0] : null;
+ }
+ else
+ {
+ ActiveLayer = null;
+ }
+ SubscribeLayerVisibility();
+ RefreshCanvas();
+ }
+
+ #endregion
+
+ #region Layer visibility change tracking
+
+ private void SubscribeLayerVisibility()
+ {
+ foreach (var layer in Layers)
+ {
+ if (layer is INotifyPropertyChanged npc)
+ npc.PropertyChanged += OnLayerPropertyChanged;
+ }
+ }
+
+ private void UnsubscribeLayerVisibility()
+ {
+ foreach (var layer in Layers)
+ {
+ if (layer is INotifyPropertyChanged npc)
+ npc.PropertyChanged -= OnLayerPropertyChanged;
+ }
+ }
+
+ private void OnLayerPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName is nameof(MinintLayer.IsVisible) or nameof(MinintLayer.Opacity))
+ RefreshCanvas();
+ }
+
+ #endregion
+
+ #region Document commands
+
+ [RelayCommand]
+ private void AddDocument()
+ {
+ if (Container is null) return;
+ int num = Container.Documents.Count + 1;
+ var doc = Container.AddNewDocument($"Document {num}");
+ Documents.Add(doc);
+ SelectDocument(doc);
+ }
+
+ [RelayCommand]
+ private void RemoveDocument()
+ {
+ if (Container is null || ActiveDocument is null) return;
+ if (Container.Documents.Count <= 1) return;
+
+ var doc = ActiveDocument;
+ int idx = Container.Documents.IndexOf(doc);
+ Container.Documents.Remove(doc);
+ Documents.Remove(doc);
+
+ int newIdx = Math.Min(idx, Container.Documents.Count - 1);
+ SelectDocument(newIdx >= 0 ? Container.Documents[newIdx] : null);
+ }
+
+ [RelayCommand]
+ private void RenameDocument() { }
+
+ [RelayCommand]
+ private void MoveDocumentUp()
+ {
+ if (Container is null || ActiveDocument is null) return;
+ var doc = ActiveDocument;
+ int idx = Container.Documents.IndexOf(doc);
+ if (idx <= 0) return;
+ (Container.Documents[idx], Container.Documents[idx - 1]) = (Container.Documents[idx - 1], Container.Documents[idx]);
+ SyncDocumentsList();
+ ReselectDocument(doc);
+ }
+
+ [RelayCommand]
+ private void MoveDocumentDown()
+ {
+ if (Container is null || ActiveDocument is null) return;
+ var doc = ActiveDocument;
+ int idx = Container.Documents.IndexOf(doc);
+ if (idx < 0 || idx >= Container.Documents.Count - 1) return;
+ (Container.Documents[idx], Container.Documents[idx + 1]) = (Container.Documents[idx + 1], Container.Documents[idx]);
+ SyncDocumentsList();
+ ReselectDocument(doc);
+ }
+
+ private void ReselectDocument(MinintDocument doc)
+ {
+ // Force SelectedItem rebinding after list rebuild even when the selected object is the same reference.
+ _suppressDocumentSync = true;
+ ActiveDocument = null;
+ ActiveDocument = doc;
+ _suppressDocumentSync = false;
+ SyncLayersAndCanvas(doc);
+ }
+
+ #endregion
+
+ #region Layer commands
+
+ [RelayCommand]
+ private void AddLayer()
+ {
+ if (Container is null || ActiveDocument is null) return;
+ int num = ActiveDocument.Layers.Count + 1;
+ var layer = new MinintLayer($"Layer {num}", Container.PixelCount);
+ ActiveDocument.Layers.Add(layer);
+ Layers.Add(layer);
+ ActiveLayer = layer;
+ SubscribeLayerVisibility();
+ }
+
+ [RelayCommand]
+ private void RemoveLayer()
+ {
+ if (ActiveDocument is null || ActiveLayer is null) return;
+ if (ActiveDocument.Layers.Count <= 1) return;
+
+ UnsubscribeLayerVisibility();
+ var layer = ActiveLayer;
+ int idx = ActiveDocument.Layers.IndexOf(layer);
+ ActiveDocument.Layers.Remove(layer);
+ Layers.Remove(layer);
+
+ int newIdx = Math.Min(idx, ActiveDocument.Layers.Count - 1);
+ ActiveLayer = newIdx >= 0 ? ActiveDocument.Layers[newIdx] : null;
+ SubscribeLayerVisibility();
+ RefreshCanvas();
+ }
+
+ [RelayCommand]
+ private void MoveLayerUp()
+ {
+ if (ActiveDocument is null || ActiveLayer is null) return;
+ int idx = ActiveDocument.Layers.IndexOf(ActiveLayer);
+ if (idx <= 0) return;
+
+ UnsubscribeLayerVisibility();
+ var layer = ActiveLayer;
+ (ActiveDocument.Layers[idx], ActiveDocument.Layers[idx - 1]) = (ActiveDocument.Layers[idx - 1], ActiveDocument.Layers[idx]);
+ Layers.Move(idx, idx - 1);
+ ActiveLayer = layer;
+ SubscribeLayerVisibility();
+ RefreshCanvas();
+ }
+
+ [RelayCommand]
+ private void MoveLayerDown()
+ {
+ if (ActiveDocument is null || ActiveLayer is null) return;
+ int idx = ActiveDocument.Layers.IndexOf(ActiveLayer);
+ if (idx < 0 || idx >= ActiveDocument.Layers.Count - 1) return;
+
+ UnsubscribeLayerVisibility();
+ var layer = ActiveLayer;
+ (ActiveDocument.Layers[idx], ActiveDocument.Layers[idx + 1]) = (ActiveDocument.Layers[idx + 1], ActiveDocument.Layers[idx]);
+ Layers.Move(idx, idx + 1);
+ ActiveLayer = layer;
+ SubscribeLayerVisibility();
+ RefreshCanvas();
+ }
+
+ [RelayCommand]
+ private void DuplicateLayer()
+ {
+ if (Container is null || ActiveDocument is null || ActiveLayer is null) return;
+ var src = ActiveLayer;
+ var dup = new MinintLayer(src.Name + " copy", src.IsVisible, src.Opacity, (int[])src.Pixels.Clone());
+ int idx = ActiveDocument.Layers.IndexOf(src) + 1;
+ ActiveDocument.Layers.Insert(idx, dup);
+
+ UnsubscribeLayerVisibility();
+ Layers.Insert(idx, dup);
+ ActiveLayer = dup;
+ SubscribeLayerVisibility();
+ RefreshCanvas();
+ }
+
+ #endregion
+
+ #region Drawing
+
+ public void OnToolDown(int px, int py)
+ {
+ if (IsPlaying) return;
+ if (Container is null || ActiveDocument is null || ActiveLayer is null)
+ return;
+
+ int w = Container.Width, h = Container.Height;
+ if (px < 0 || px >= w || py < 0 || py >= h)
+ return;
+
+ if (ActiveTool == ToolType.Select) return; // handled separately
+
+ switch (ActiveTool)
+ {
+ case ToolType.Brush:
+ {
+ int colorIdx = ActiveDocument.EnsureColorCached(SelectedColor);
+ _drawingService.ApplyBrush(ActiveLayer, px, py, BrushRadius, colorIdx, w, h);
+ break;
+ }
+ case ToolType.Eraser:
+ _drawingService.ApplyEraser(ActiveLayer, px, py, BrushRadius, w, h);
+ break;
+ case ToolType.Fill:
+ {
+ int colorIdx = ActiveDocument.EnsureColorCached(SelectedColor);
+ _floodFillService.Fill(ActiveLayer, px, py, colorIdx, w, h);
+ break;
+ }
+ }
+
+ RefreshCanvas();
+ }
+
+ public void OnToolDrag(int px, int py)
+ {
+ if (IsPlaying) return;
+ if (ActiveTool is ToolType.Fill or ToolType.Select) return;
+ OnToolDown(px, py);
+ }
+
+ public List<(int X, int Y)>? GetPreviewMask()
+ {
+ if (PreviewCenter is null || Container is null)
+ return null;
+ if (ActiveTool is ToolType.Fill or ToolType.Select)
+ return null;
+
+ var (cx, cy) = PreviewCenter.Value;
+ return _drawingService.GetBrushMask(cx, cy, BrushRadius, Container.Width, Container.Height);
+ }
+
+ [RelayCommand]
+ private void SelectBrush() { CancelPasteMode(); ActiveTool = ToolType.Brush; }
+
+ [RelayCommand]
+ private void SelectEraser() { CancelPasteMode(); ActiveTool = ToolType.Eraser; }
+
+ [RelayCommand]
+ private void SelectFill() { CancelPasteMode(); ActiveTool = ToolType.Fill; }
+
+ [RelayCommand]
+ private void SelectSelectTool() { CancelPasteMode(); ActiveTool = ToolType.Select; }
+
+ [RelayCommand]
+ private void ToggleGrid() => ShowGrid = !ShowGrid;
+
+ #endregion
+
+ #region Selection + Copy/Paste (A4)
+
+ /// Called by PixelCanvas when selection drag starts.
+ public void BeginSelection(int px, int py)
+ {
+ if (IsPlaying) return;
+ SelectionRect = (px, py, 0, 0);
+ }
+
+ /// Called by PixelCanvas as the user drags.
+ public void UpdateSelection(int px, int py)
+ {
+ if (SelectionRect is null) return;
+ var s = SelectionRect.Value;
+ int x = Math.Min(s.X, px);
+ int y = Math.Min(s.Y, py);
+ int w = Math.Abs(px - s.X) + 1;
+ int h = Math.Abs(py - s.Y) + 1;
+ // Store normalized rect but keep original anchor in _selAnchor
+ _selectionRectNormalized = (x, y, w, h);
+ }
+
+ /// Called by PixelCanvas when mouse is released.
+ public void FinishSelection(int px, int py)
+ {
+ if (SelectionRect is null) return;
+ var s = SelectionRect.Value;
+ int x0 = Math.Min(s.X, px);
+ int y0 = Math.Min(s.Y, py);
+ int rw = Math.Abs(px - s.X) + 1;
+ int rh = Math.Abs(py - s.Y) + 1;
+ if (Container is not null)
+ {
+ x0 = Math.Max(0, x0);
+ y0 = Math.Max(0, y0);
+ rw = Math.Min(rw, Container.Width - x0);
+ rh = Math.Min(rh, Container.Height - y0);
+ }
+ if (rw <= 0 || rh <= 0)
+ {
+ SelectionRect = null;
+ _selectionRectNormalized = null;
+ return;
+ }
+ SelectionRect = (x0, y0, rw, rh);
+ _selectionRectNormalized = SelectionRect;
+ }
+
+ private (int X, int Y, int W, int H)? _selectionRectNormalized;
+
+ /// The normalized (positive W/H, clamped) selection rectangle for rendering.
+ public (int X, int Y, int W, int H)? SelectionRectNormalized => _selectionRectNormalized;
+
+ [RelayCommand]
+ private void CopySelection()
+ {
+ if (SelectionRect is null || ActiveDocument is null || ActiveLayer is null || Container is null)
+ return;
+
+ var (sx, sy, sw, sh) = SelectionRect.Value;
+ int cw = Container.Width;
+ var palette = ActiveDocument.Palette;
+ var srcPixels = ActiveLayer.Pixels;
+ var buf = new RgbaColor[sw * sh];
+
+ for (int dy = 0; dy < sh; dy++)
+ {
+ int srcRow = sy + dy;
+ for (int dx = 0; dx < sw; dx++)
+ {
+ int srcCol = sx + dx;
+ int idx = srcPixels[srcRow * cw + srcCol];
+ buf[dy * sw + dx] = idx < palette.Count ? palette[idx] : RgbaColor.Transparent;
+ }
+ }
+
+ Clipboard = new ClipboardFragment(sw, sh, buf);
+ }
+
+ [RelayCommand]
+ private void PasteClipboard()
+ {
+ if (Clipboard is null) return;
+ PastePosition = (0, 0);
+ }
+
+ public void MovePaste(int px, int py)
+ {
+ if (IsPlaying || !IsPasting) return;
+ PastePosition = (px, py);
+ }
+
+ [RelayCommand]
+ public void CommitPaste()
+ {
+ if (IsPlaying) return;
+ if (!IsPasting || Clipboard is null || ActiveDocument is null || ActiveLayer is null || Container is null)
+ return;
+
+ var (px, py) = PastePosition!.Value;
+ int cw = Container.Width, ch = Container.Height;
+ var frag = Clipboard;
+ var dstPixels = ActiveLayer.Pixels;
+
+ for (int fy = 0; fy < frag.Height; fy++)
+ {
+ int dy = py + fy;
+ if (dy < 0 || dy >= ch) continue;
+ for (int fx = 0; fx < frag.Width; fx++)
+ {
+ int dx = px + fx;
+ if (dx < 0 || dx >= cw) continue;
+ var color = frag.Pixels[fy * frag.Width + fx];
+ if (color.A == 0) continue; // skip transparent
+ int colorIdx = ActiveDocument.EnsureColorCached(color);
+ dstPixels[dy * cw + dx] = colorIdx;
+ }
+ }
+
+ PastePosition = null;
+ SelectionRect = null;
+ _selectionRectNormalized = null;
+ RefreshCanvas();
+ }
+
+ [RelayCommand]
+ public void CancelPaste()
+ {
+ PastePosition = null;
+ }
+
+ public void ClearSelection()
+ {
+ SelectionRect = null;
+ _selectionRectNormalized = null;
+ }
+
+ private void CancelPasteMode()
+ {
+ PastePosition = null;
+ SelectionRect = null;
+ _selectionRectNormalized = null;
+ }
+
+ #endregion
+
+ #region Canvas rendering
+
+ public void RefreshCanvas()
+ {
+ if (Container is null || ActiveDocument is null)
+ {
+ CanvasBitmap = null;
+ return;
+ }
+
+ int w = Container.Width;
+ int h = Container.Height;
+ uint[] argb = _compositor.Composite(ActiveDocument, w, h);
+
+ var bmp = new WriteableBitmap(
+ new PixelSize(w, h),
+ new Vector(96, 96),
+ Avalonia.Platform.PixelFormat.Bgra8888);
+
+ using (var fb = bmp.Lock())
+ {
+ unsafe
+ {
+ var dst = new Span((void*)fb.Address, w * h);
+ for (int i = 0; i < argb.Length; i++)
+ {
+ uint px = argb[i];
+ byte a = (byte)(px >> 24);
+ byte r = (byte)((px >> 16) & 0xFF);
+ byte g = (byte)((px >> 8) & 0xFF);
+ byte b = (byte)(px & 0xFF);
+
+ if (a == 255)
+ {
+ dst[i] = px;
+ }
+ else if (a == 0)
+ {
+ dst[i] = 0;
+ }
+ else
+ {
+ r = (byte)(r * a / 255);
+ g = (byte)(g * a / 255);
+ b = (byte)(b * a / 255);
+ dst[i] = (uint)(b | (g << 8) | (r << 16) | (a << 24));
+ }
+ }
+ }
+ }
+
+ CanvasBitmap = bmp;
+ }
+
+ #endregion
+
+ #region Animation playback
+
+ private DispatcherTimer? _animationTimer;
+ private int _animationFrameIndex;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsNotPlaying))]
+ private bool _isPlaying;
+
+ public bool IsNotPlaying => !IsPlaying;
+
+ [RelayCommand]
+ private void PlayAnimation()
+ {
+ if (Container is null || Container.Documents.Count < 2) return;
+ if (IsPlaying) return;
+
+ IsPlaying = true;
+ _animationFrameIndex = ActiveDocument is not null
+ ? Container.Documents.IndexOf(ActiveDocument)
+ : 0;
+ if (_animationFrameIndex < 0) _animationFrameIndex = 0;
+
+ AdvanceAnimationFrame();
+ }
+
+ [RelayCommand]
+ private void StopAnimation()
+ {
+ _animationTimer?.Stop();
+ _animationTimer = null;
+ IsPlaying = false;
+ if (ActiveDocument is not null)
+ SyncLayersAndCanvas(ActiveDocument);
+ }
+
+ private void AdvanceAnimationFrame()
+ {
+ if (Container is null || !IsPlaying)
+ {
+ StopAnimation();
+ return;
+ }
+
+ var docs = Container.Documents;
+ if (docs.Count == 0)
+ {
+ StopAnimation();
+ return;
+ }
+
+ _animationFrameIndex %= docs.Count;
+ var doc = docs[_animationFrameIndex];
+
+ _suppressDocumentSync = true;
+ ActiveDocument = doc;
+ _suppressDocumentSync = false;
+ RefreshCanvasFor(doc);
+
+ uint delay = doc.FrameDelayMs;
+ if (delay < 10) delay = 10;
+
+ _animationTimer?.Stop();
+ _animationTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(delay) };
+ _animationTimer.Tick += (_, _) =>
+ {
+ _animationTimer?.Stop();
+ _animationFrameIndex++;
+ AdvanceAnimationFrame();
+ };
+ _animationTimer.Start();
+ }
+
+ private void RefreshCanvasFor(MinintDocument doc)
+ {
+ if (Container is null)
+ {
+ CanvasBitmap = null;
+ return;
+ }
+
+ int w = Container.Width;
+ int h = Container.Height;
+ uint[] argb = _compositor.Composite(doc, w, h);
+
+ var bmp = new WriteableBitmap(
+ new PixelSize(w, h),
+ new Vector(96, 96),
+ Avalonia.Platform.PixelFormat.Bgra8888);
+
+ using (var fb = bmp.Lock())
+ {
+ unsafe
+ {
+ var dst = new Span((void*)fb.Address, w * h);
+ for (int i = 0; i < argb.Length; i++)
+ {
+ uint px = argb[i];
+ byte a2 = (byte)(px >> 24);
+ byte r2 = (byte)((px >> 16) & 0xFF);
+ byte g2 = (byte)((px >> 8) & 0xFF);
+ byte b2 = (byte)(px & 0xFF);
+
+ if (a2 == 255) { dst[i] = px; }
+ else if (a2 == 0) { dst[i] = 0; }
+ else
+ {
+ r2 = (byte)(r2 * a2 / 255);
+ g2 = (byte)(g2 * a2 / 255);
+ b2 = (byte)(b2 * a2 / 255);
+ dst[i] = (uint)(b2 | (g2 << 8) | (r2 << 16) | (a2 << 24));
+ }
+ }
+ }
+ }
+
+ CanvasBitmap = bmp;
+ }
+
+ #endregion
+
+ public ICompositor Compositor => _compositor;
+ public IPaletteService PaletteService => _paletteService;
+ public IDrawingService DrawingService => _drawingService;
+ public IFragmentService FragmentService => _fragmentService;
+}
+```
+
+### A.41. `Minint/ViewModels/MainWindowViewModel.cs`
+
+```csharp
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading.Tasks;
+using Avalonia.Controls;
+using Avalonia.Platform.Storage;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Minint.Core.Services;
+using Minint.Core.Services.Impl;
+using Minint.Infrastructure.Export;
+using Minint.Infrastructure.Serialization;
+using Minint.Views;
+
+namespace Minint.ViewModels;
+
+public partial class MainWindowViewModel : ViewModelBase
+{
+ private readonly MinintSerializer _serializer = new();
+ private readonly IImageEffectsService _effects = new ImageEffectsService();
+ private readonly IPatternGenerator _patternGen = new PatternGenerator();
+ private readonly IBmpExporter _bmpExporter = new BmpExporter();
+ private readonly IGifExporter _gifExporter = new GifExporter();
+ private readonly ICompositor _compositor = new Compositor();
+
+ private static readonly FilePickerFileType MinintFileType = new("Minint Files")
+ {
+ Patterns = ["*.minint"],
+ };
+
+ private static readonly FilePickerFileType BmpFileType = new("BMP Image")
+ {
+ Patterns = ["*.bmp"],
+ };
+
+ private static readonly FilePickerFileType GifFileType = new("GIF Animation")
+ {
+ Patterns = ["*.gif"],
+ };
+
+ [ObservableProperty]
+ private EditorViewModel _editor = new();
+
+ [ObservableProperty]
+ private string _statusText = "Ready";
+
+ public TopLevel? Owner { get; set; }
+
+ #region File commands
+
+ [RelayCommand]
+ private async Task NewFileAsync()
+ {
+ if (Owner is not Window window) return;
+
+ var dialog = new NewContainerDialog();
+ var result = await dialog.ShowDialog(window);
+ if (result != true) return;
+
+ int w = dialog.CanvasWidth;
+ int h = dialog.CanvasHeight;
+ Editor.NewContainer(w, h);
+ StatusText = $"New {w}×{h} container created.";
+ }
+
+ [RelayCommand]
+ private async Task OpenFileAsync()
+ {
+ if (Owner?.StorageProvider is not { } sp) return;
+
+ var files = await sp.OpenFilePickerAsync(new FilePickerOpenOptions
+ {
+ Title = "Open .minint file",
+ FileTypeFilter = [MinintFileType],
+ AllowMultiple = false,
+ });
+
+ if (files.Count == 0) return;
+
+ var file = files[0];
+ try
+ {
+ await using var stream = await file.OpenReadAsync();
+ var container = _serializer.Read(stream);
+ var path = file.TryGetLocalPath();
+ Editor.LoadContainer(container, path);
+ StatusText = $"Opened {file.Name}";
+ }
+ catch (Exception ex)
+ {
+ StatusText = $"Error opening file: {ex.Message}";
+ }
+ }
+
+ [RelayCommand]
+ private async Task SaveFileAsync()
+ {
+ if (Editor.Container is null) return;
+
+ if (Editor.FilePath is not null)
+ await SaveToPathAsync(Editor.FilePath);
+ else
+ await SaveFileAsAsync();
+ }
+
+ [RelayCommand]
+ private async Task SaveFileAsAsync()
+ {
+ if (Owner?.StorageProvider is not { } sp || Editor.Container is null) return;
+
+ var file = await sp.SaveFilePickerAsync(new FilePickerSaveOptions
+ {
+ Title = "Save .minint file",
+ DefaultExtension = "minint",
+ FileTypeChoices = [MinintFileType],
+ SuggestedFileName = Editor.FilePath is not null
+ ? Path.GetFileName(Editor.FilePath) : "untitled.minint",
+ });
+
+ if (file is null) return;
+
+ var path = file.TryGetLocalPath();
+ if (path is null)
+ {
+ StatusText = "Error: could not resolve file path.";
+ return;
+ }
+
+ await SaveToPathAsync(path);
+ }
+
+ private async Task SaveToPathAsync(string path)
+ {
+ try
+ {
+ await using var fs = File.Create(path);
+ _serializer.Write(fs, Editor.Container!);
+ Editor.FilePath = path;
+ StatusText = $"Saved {Path.GetFileName(path)}";
+ }
+ catch (Exception ex)
+ {
+ StatusText = $"Error saving file: {ex.Message}";
+ }
+ }
+
+ #endregion
+
+ #region Effects (A1, A2)
+
+ [RelayCommand]
+ private async Task ApplyContrastAsync()
+ {
+ if (Editor.ActiveDocument is null || Owner is not Window window) return;
+
+ var dialog = new ContrastDialog();
+ var result = await dialog.ShowDialog(window);
+ if (result != true) return;
+
+ _effects.ApplyContrast(Editor.ActiveDocument, dialog.Factor);
+ Editor.RefreshCanvas();
+ StatusText = $"Contrast ×{dialog.Factor:F1} applied.";
+ }
+
+ [RelayCommand]
+ private void ApplyGrayscale()
+ {
+ if (Editor.ActiveDocument is null) return;
+
+ _effects.ApplyGrayscale(Editor.ActiveDocument);
+ Editor.RefreshCanvas();
+ StatusText = "Grayscale applied.";
+ }
+
+ #endregion
+
+ #region Pattern generation (Б4)
+
+ [RelayCommand]
+ private async Task GeneratePatternAsync()
+ {
+ if (Editor.Container is null || Owner is not Window window) return;
+
+ var dialog = new PatternDialog();
+ var result = await dialog.ShowDialog(window);
+ if (result != true) return;
+
+ try
+ {
+ var doc = _patternGen.Generate(
+ dialog.SelectedPattern,
+ Editor.Container.Width,
+ Editor.Container.Height,
+ [dialog.PatternColor1, dialog.PatternColor2],
+ dialog.PatternParam1,
+ dialog.PatternParam2);
+
+ Editor.Container.Documents.Add(doc);
+ Editor.SyncAfterExternalChange();
+ Editor.SelectDocument(doc);
+ StatusText = $"Pattern '{dialog.SelectedPattern}' generated.";
+ }
+ catch (Exception ex)
+ {
+ StatusText = $"Pattern generation failed: {ex.Message}";
+ }
+ }
+
+ #endregion
+
+ #region Export (BMP / GIF)
+
+ [RelayCommand]
+ private async Task ExportBmpAsync()
+ {
+ if (Editor.Container is null || Editor.ActiveDocument is null) return;
+ if (Owner?.StorageProvider is not { } sp) return;
+
+ var file = await sp.SaveFilePickerAsync(new FilePickerSaveOptions
+ {
+ Title = "Export document as BMP",
+ DefaultExtension = "bmp",
+ FileTypeChoices = [BmpFileType],
+ SuggestedFileName = $"{Editor.ActiveDocument.Name}.bmp",
+ });
+ if (file is null) return;
+
+ var path = file.TryGetLocalPath();
+ if (path is null) { StatusText = "Error: could not resolve file path."; return; }
+
+ try
+ {
+ int w = Editor.Container.Width, h = Editor.Container.Height;
+ uint[] argb = _compositor.Composite(Editor.ActiveDocument, w, h);
+ await using var fs = File.Create(path);
+ _bmpExporter.Export(fs, argb, w, h);
+ StatusText = $"Exported BMP: {Path.GetFileName(path)}";
+ }
+ catch (Exception ex)
+ {
+ StatusText = $"BMP export failed: {ex.Message}";
+ }
+ }
+
+ [RelayCommand]
+ private async Task ExportGifAsync()
+ {
+ if (Editor.Container is null || Editor.Container.Documents.Count == 0) return;
+ if (Owner?.StorageProvider is not { } sp) return;
+
+ var file = await sp.SaveFilePickerAsync(new FilePickerSaveOptions
+ {
+ Title = "Export animation as GIF",
+ DefaultExtension = "gif",
+ FileTypeChoices = [GifFileType],
+ SuggestedFileName = Editor.FilePath is not null
+ ? Path.GetFileNameWithoutExtension(Editor.FilePath) + ".gif"
+ : "animation.gif",
+ });
+ if (file is null) return;
+
+ var path = file.TryGetLocalPath();
+ if (path is null) { StatusText = "Error: could not resolve file path."; return; }
+
+ try
+ {
+ int w = Editor.Container.Width, h = Editor.Container.Height;
+ var frames = new List<(uint[] Pixels, uint DelayMs)>();
+ foreach (var doc in Editor.Container.Documents)
+ {
+ uint[] argb = _compositor.Composite(doc, w, h);
+ frames.Add((argb, doc.FrameDelayMs));
+ }
+
+ await using var fs = File.Create(path);
+ _gifExporter.Export(fs, frames, w, h);
+ StatusText = $"Exported GIF ({frames.Count} frames): {Path.GetFileName(path)}";
+ }
+ catch (Exception ex)
+ {
+ StatusText = $"GIF export failed: {ex.Message}";
+ }
+ }
+
+ #endregion
+}
+```
+
+### A.42. `Minint/ViewModels/ToolType.cs`
+
+```csharp
+namespace Minint.ViewModels;
+
+public enum ToolType
+{
+ Brush,
+ Eraser,
+ Fill,
+ Select
+}
+```
+
+### A.43. `Minint/ViewModels/ToolTypeConverters.cs`
+
+```csharp
+using System;
+using System.Globalization;
+using Avalonia.Data.Converters;
+
+namespace Minint.ViewModels;
+
+///
+/// Static IValueConverter instances for binding RadioButton.IsChecked to ToolType.
+/// These are one-way (read-only) — the RadioButton Command sets the actual value.
+///
+public static class ToolTypeConverters
+{
+ public static readonly IValueConverter IsBrush = new ToolTypeConverter(ToolType.Brush);
+ public static readonly IValueConverter IsEraser = new ToolTypeConverter(ToolType.Eraser);
+ public static readonly IValueConverter IsFill = new ToolTypeConverter(ToolType.Fill);
+ public static readonly IValueConverter IsSelect = new ToolTypeConverter(ToolType.Select);
+
+ private sealed class ToolTypeConverter(ToolType target) : IValueConverter
+ {
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => value is ToolType t && t == target;
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => target;
+ }
+}
+```
+
+### A.44. `Minint/ViewModels/ViewModelBase.cs`
+
+```csharp
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace Minint.ViewModels;
+
+public abstract class ViewModelBase : ObservableObject
+{
+}
+```
+
+### A.45. `Minint/Views/ContrastDialog.axaml.cs`
+
+```csharp
+using System;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+
+namespace Minint.Views;
+
+public partial class ContrastDialog : Window
+{
+ public double Factor => FactorSlider.Value;
+
+ public ContrastDialog()
+ {
+ InitializeComponent();
+
+ FactorSlider.PropertyChanged += (_, e) =>
+ {
+ if (e.Property == Slider.ValueProperty)
+ FactorLabel.Text = FactorSlider.Value.ToString("F1");
+ };
+
+ OkButton.Click += (_, _) => Close(true);
+ CancelButton.Click += (_, _) => Close(false);
+ }
+}
+```
+
+### A.46. `Minint/Views/MainWindow.axaml.cs`
+
+```csharp
+using System;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Minint.Controls;
+using Minint.ViewModels;
+
+namespace Minint.Views;
+
+public partial class MainWindow : Window
+{
+ public MainWindow()
+ {
+ InitializeComponent();
+ }
+
+ protected override void OnOpened(EventArgs e)
+ {
+ base.OnOpened(e);
+
+ var canvas = this.FindControl("Canvas");
+ var hScroll = this.FindControl("HScroll");
+ var vScroll = this.FindControl("VScroll");
+
+ if (canvas is not null && hScroll is not null && vScroll is not null)
+ canvas.AttachScrollBars(hScroll, vScroll);
+
+ if (canvas is not null && DataContext is MainWindowViewModel vm)
+ WireCanvasEvents(canvas, vm.Editor);
+ }
+
+ protected override void OnDataContextChanged(EventArgs e)
+ {
+ base.OnDataContextChanged(e);
+ if (DataContext is MainWindowViewModel vm)
+ vm.Owner = this;
+ }
+
+ private static void WireCanvasEvents(PixelCanvas canvas, EditorViewModel editor)
+ {
+ canvas.Editor = editor;
+
+ canvas.ToolDown += (px, py) => editor.OnToolDown(px, py);
+ canvas.ToolDrag += (px, py) => editor.OnToolDrag(px, py);
+ canvas.CursorPixelChanged += pixel => editor.PreviewCenter = pixel;
+ canvas.GetPreviewMask = () => editor.GetPreviewMask();
+
+ canvas.SelectionStart += (px, py) => editor.BeginSelection(px, py);
+ canvas.SelectionUpdate += (px, py) => editor.UpdateSelection(px, py);
+ canvas.SelectionEnd += (px, py) => { editor.FinishSelection(px, py); canvas.InvalidateVisual(); };
+
+ canvas.PasteMoved += (px, py) => editor.MovePaste(px, py);
+ canvas.PasteCommitted += () => { editor.CommitPaste(); canvas.InvalidateVisual(); };
+ canvas.PasteCancelled += () => { editor.CancelPaste(); canvas.InvalidateVisual(); };
+ }
+}
+```
+
+### A.47. `Minint/Views/NewContainerDialog.axaml.cs`
+
+```csharp
+using Avalonia.Controls;
+
+namespace Minint.Views;
+
+public partial class NewContainerDialog : Window
+{
+ public int CanvasWidth => (int)(WidthInput.Value ?? 64);
+ public int CanvasHeight => (int)(HeightInput.Value ?? 64);
+
+ public NewContainerDialog()
+ {
+ InitializeComponent();
+
+ OkButton.Click += (_, _) => Close(true);
+ CancelButton.Click += (_, _) => Close(false);
+ }
+}
+```
+
+### A.48. `Minint/Views/PatternDialog.axaml.cs`
+
+```csharp
+using System;
+using System.Linq;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Media;
+using Minint.Core.Models;
+using Minint.Core.Services;
+
+namespace Minint.Views;
+
+public partial class PatternDialog : Window
+{
+ public PatternType SelectedPattern =>
+ PatternCombo.SelectedItem is PatternType pt ? pt : PatternType.Checkerboard;
+
+ public RgbaColor PatternColor1
+ {
+ get
+ {
+ var c = Color1Picker.Color;
+ return new RgbaColor(c.R, c.G, c.B, c.A);
+ }
+ }
+
+ public RgbaColor PatternColor2
+ {
+ get
+ {
+ var c = Color2Picker.Color;
+ return new RgbaColor(c.R, c.G, c.B, c.A);
+ }
+ }
+
+ public int PatternParam1 => (int)(Param1.Value ?? 8);
+ public int PatternParam2 => (int)(Param2.Value ?? 8);
+
+ public PatternDialog()
+ {
+ InitializeComponent();
+
+ PatternCombo.ItemsSource = Enum.GetValues().ToList();
+ PatternCombo.SelectedIndex = 0;
+
+ Color1Picker.Color = Color.FromRgb(0, 0, 0);
+ Color2Picker.Color = Color.FromRgb(255, 255, 255);
+
+ OkButton.Click += (_, _) => Close(true);
+ CancelButton.Click += (_, _) => Close(false);
+ }
+}
+```
+
diff --git a/Report/lab2/zivro-lab2-report.md b/Report/lab2/zivro-lab2-report.md
new file mode 100644
index 0000000..565531a
--- /dev/null
+++ b/Report/lab2/zivro-lab2-report.md
@@ -0,0 +1,147 @@
+# Лабораторная работа 2
+## Способы и средства хранения и обработки графических данных
+### Вариант ИВ1: разработка растрового редактора
+
+## 1. Цель и практический результат
+
+Цель работы - разработать растровый редактор, выполняющий создание, загрузку, редактирование и сохранение графического контейнера с пиксельными данными, а также реализовать базовые инструменты рисования и редактирования фрагментов.
+
+Практический результат:
+
+- разработано настольное приложение `Minint` на `C#` + `Avalonia`;
+- реализован собственный бинарный формат контейнера `.minint` с чтением/записью;
+- реализованы инструменты `Brush`, `Eraser`, `Fill`, `Select`, `Copy/Cut/Paste`;
+- подготовлена документация с UML-диаграммами и приложением исходного кода.
+
+
+
+## 2. Соответствие варианту ИВ1 и замечание по структуре контейнера
+
+По методическим указаниям ИВ1 требуется использовать структуру пикселя и контейнера из одного из вариантов `КВ1–КВ4`.
+
+В текущем проекте фактически реализован палитровый контейнер с `RGBA`-палитрой и индексами пикселей (`MinintContainer`/`MinintDocument`/`MinintLayer`), что не совпадает буквально с описаниями `КВ1–КВ4`, но полностью закрывает функциональные требования ИВ1 (создание, редактирование, загрузка, сохранение, инструменты рисования, работа с фрагментами).
+
+Это ограничение фиксируется в отчёте явно, чтобы не было расхождения между кодом и документацией.
+
+## 3. Выполнение основных требований ИВ1
+
+### 3.1 Создание, загрузка и сохранение контейнера
+
+- создание нового контейнера выполняется через `EditorViewModel.NewContainer(...)`;
+- загрузка/сохранение выполняется через `MinintSerializer` (собственная реализация чтения/записи);
+- контейнер хранит общие размеры, набор документов (кадров), палитры и слои.
+
+Реализация: `Minint/ViewModels/EditorViewModel.cs`, `Minint.Infrastructure/Serialization/MinintSerializer.cs`, `Minint.Core/Models/*`.
+
+### 3.2 Редактирование единичных пикселей
+
+- инструмент `Brush` изменяет значения пикселей маской радиуса;
+- инструмент `Eraser` записывает индекс прозрачного цвета (`0`);
+- выбор цвета выполняется через текущий `SelectedColor` и палитру документа.
+
+Реализация: `Minint.Core/Services/Impl/DrawingService.cs`, `Minint/ViewModels/EditorViewModel.cs`.
+
+### 3.3 Непрерывная отрисовка "кистью"
+
+- при перемещении мыши по зажатой кнопке вызывается последовательная обработка точек;
+- маска кисти вычисляется как круг по радиусу;
+- поддерживается визуальный preview маски инструмента.
+
+Реализация: `Minint/Controls/PixelCanvas.cs`, `Minint/Core/Services/Impl/DrawingService.cs`, `Minint/ViewModels/EditorViewModel.cs`.
+
+### 3.4 Закраска области ("заливка")
+
+- реализован алгоритм flood fill (4-связность);
+- заливка ограничена областью одинакового исходного индекса;
+- алгоритм работает в границах изображения.
+
+Реализация: `Minint.Core/Services/Impl/FloodFillService.cs`.
+
+### 3.5 Выделение, копирование/вырезание и вставка фрагмента
+
+- реализована рамка выделения (`SelectionRect`);
+- данные буфера обмена хранятся в palette-independent виде (`ClipboardFragment`, `RGBA`);
+- вставка поддерживает предпросмотр и подтверждение позиции;
+- прозрачные пиксели фрагмента при вставке пропускаются.
+
+Реализация: `Minint/ViewModels/EditorViewModel.cs`, `Minint/Controls/PixelCanvas.cs`, `Minint.Core/Services/Impl/FragmentService.cs`.
+
+## 4. Структура контейнера и пикселя
+
+### 4.1 Структура контейнера `.minint`
+
+Контейнер состоит из:
+
+1. Заголовка файла:
+ - сигнатура `MININT`;
+ - версия формата;
+ - ширина и высота;
+ - количество документов;
+ - резерв.
+2. Набора документов:
+ - имя документа;
+ - задержка кадра;
+ - палитра `RGBA`;
+ - набор слоёв.
+3. Набора слоёв:
+ - имя слоя;
+ - признак видимости;
+ - непрозрачность;
+ - массив индексов пикселей.
+
+### 4.2 Структура пикселя
+
+Логически пиксель хранится как индекс в палитре документа (`int` в ОЗУ, переменная ширина 1..4 байта в файле), а итоговый цвет формируется по таблице `RgbaColor`.
+
+## 5. Основные алгоритмы
+
+1. Запись контейнера в бинарный поток (`WriteHeader`, `WriteDocument`, `WriteLayer`).
+2. Чтение и валидация контейнера (`ReadHeader`, `ReadDocument`, `ReadLayer`).
+3. Круглая кисть по маске радиуса.
+4. Очистка пикселей ластиком.
+5. Flood fill по очереди.
+6. Копирование/вставка фрагмента с отсечением по границам.
+7. Композиция слоёв при обновлении холста.
+
+## 6. UML-диаграммы (PlantUML)
+
+### 6.1 Основной рабочий цикл редактора
+
+`Report/lab2/uml/lr2-editor-workflow.puml`
+
+
+
+### 6.2 Формат контейнера и сериализация
+
+`Report/lab2/uml/lr2-container-serialization.puml`
+
+
+
+### 6.3 Инструменты рисования и заливки
+
+`Report/lab2/uml/lr2-tools-and-fill.puml`
+
+
+
+### 6.4 Выделение и буфер обмена фрагментов
+
+`Report/lab2/uml/lr2-selection-copy-paste.puml`
+
+
+
+## 7. Проверка работоспособности
+
+Для проверки корректности реализации используются модульные тесты проекта `Minint.Tests`:
+
+- `DrawingTests`;
+- `FloodFillTests`;
+- `FragmentServiceTests`;
+- `SerializerTests`;
+- `CompositorTests`;
+- `ExportTests`.
+
+## 8. Вывод
+
+В рамках ЛР2 (вариант ИВ1) реализовано рабочее приложение-растровый редактор с собственным контейнером данных и базовым набором инструментов редактирования изображения. Практические требования ИВ1 закрыты на уровне пользовательского сценария и программной реализации.
+
+Отдельно зафиксировано, что выбранная структура контейнера является палитровой и не повторяет буквально формулировки `КВ1–КВ4`; при этом это не противоречит задаче разработки редактора и демонстрирует полноценную обработку графических данных.
diff --git a/Report/lab2/zivro-lab2-report.pdf b/Report/lab2/zivro-lab2-report.pdf
new file mode 100644
index 0000000..d76cd15
Binary files /dev/null and b/Report/lab2/zivro-lab2-report.pdf differ
diff --git a/Report/render_uml_png.py b/Report/render_uml_png.py
new file mode 100644
index 0000000..aa071a3
--- /dev/null
+++ b/Report/render_uml_png.py
@@ -0,0 +1,182 @@
+#!/usr/bin/env python3
+"""
+Render PlantUML diagrams (.puml) to high-resolution PNG files via Kroki.
+
+Why this script:
+- local `plantuml` may require X11 in some environments;
+- this script uses Kroki HTTP API and can raise output quality via `skinparam dpi`.
+
+Examples:
+ python3 Report/render_uml_png.py
+ python3 Report/render_uml_png.py --dpi 360 --glob "*.puml"
+ python3 Report/render_uml_png.py --input-dir Report/uml --timeout 120
+"""
+
+from __future__ import annotations
+
+import argparse
+import io
+from pathlib import Path
+import re
+from typing import Iterable
+
+import requests
+from PIL import Image
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Render .puml diagrams to PNG with configurable DPI."
+ )
+ parser.add_argument(
+ "--input-dir",
+ default="Report/uml",
+ help="Directory with .puml files (default: Report/uml)",
+ )
+ parser.add_argument(
+ "--glob",
+ default="*.puml",
+ help="Glob pattern inside input-dir (default: *.puml)",
+ )
+ parser.add_argument(
+ "--dpi",
+ type=int,
+ default=300,
+ help="Output DPI via PlantUML skinparam (default: 300)",
+ )
+ parser.add_argument(
+ "--timeout",
+ type=int,
+ default=120,
+ help="HTTP timeout in seconds (default: 120)",
+ )
+ parser.add_argument(
+ "--kroki-url",
+ default="https://kroki.io/plantuml/png",
+ help="Kroki PlantUML PNG endpoint",
+ )
+ parser.add_argument(
+ "--min-dpi",
+ type=int,
+ default=96,
+ help="Lower bound for auto DPI fallback (default: 96)",
+ )
+ parser.add_argument(
+ "--max-dim",
+ type=int,
+ default=3900,
+ help=(
+ "Target max PNG dimension during auto-fit. "
+ "Keep below backend hard limits (default: 3900)"
+ ),
+ )
+ return parser.parse_args()
+
+
+def iter_puml_files(input_dir: Path, pattern: str) -> Iterable[Path]:
+ return sorted(p for p in input_dir.glob(pattern) if p.is_file())
+
+
+def inject_or_replace_skinparam(plantuml_text: str, key: str, value: int) -> str:
+ """
+ Ensure diagram has the requested `skinparam `.
+ - If skinparam exists, replace it.
+ - Otherwise insert after @startuml line.
+ """
+ line_value = f"skinparam {key} {value}"
+ pattern = rf"(?im)^\s*skinparam\s+{re.escape(key)}\s+\d+\s*$"
+
+ if re.search(pattern, plantuml_text):
+ return re.sub(pattern, line_value, plantuml_text)
+
+ lines = plantuml_text.splitlines()
+ for i, line in enumerate(lines):
+ if line.strip().lower().startswith("@startuml"):
+ lines.insert(i + 1, line_value)
+ return "\n".join(lines) + ("\n" if plantuml_text.endswith("\n") else "")
+
+ # Fallback: if @startuml is missing, prepend anyway.
+ return line_value + "\n" + plantuml_text
+
+
+def render_one(
+ puml_file: Path,
+ kroki_url: str,
+ dpi: int,
+ min_dpi: int,
+ max_dim: int,
+ timeout: int,
+) -> Path:
+ source = puml_file.read_text(encoding="utf-8")
+ source = inject_or_replace_skinparam(source, "padding", 24)
+
+ current_dpi = dpi
+ png_bytes: bytes | None = None
+
+ while True:
+ payload = inject_or_replace_skinparam(source, "dpi", current_dpi)
+ response = requests.post(
+ kroki_url,
+ data=payload.encode("utf-8"),
+ timeout=timeout,
+ )
+ response.raise_for_status()
+ candidate = response.content
+
+ with Image.open(io.BytesIO(candidate)) as im:
+ width, height = im.size
+
+ # Kroki/PlantUML PNG responses may hit hard canvas limits.
+ # If we are close to that ceiling, lower DPI and retry.
+ if (width > max_dim or height > max_dim) and current_dpi > min_dpi:
+ next_dpi = max(min_dpi, int(current_dpi * 0.82))
+ if next_dpi == current_dpi:
+ next_dpi = current_dpi - 1
+ current_dpi = max(min_dpi, next_dpi)
+ continue
+
+ png_bytes = candidate
+ break
+
+ if png_bytes is None:
+ raise RuntimeError(f"Failed to render diagram: {puml_file}")
+
+ out_file = puml_file.with_suffix(".png")
+ out_file.write_bytes(png_bytes)
+ return out_file
+
+
+def main() -> int:
+ args = parse_args()
+
+ input_dir = Path(args.input_dir).resolve()
+ if not input_dir.exists():
+ raise FileNotFoundError(f"Input directory not found: {input_dir}")
+
+ files = list(iter_puml_files(input_dir, args.glob))
+ if not files:
+ print(f"No files matched in {input_dir} with pattern {args.glob}")
+ return 0
+
+ print(f"Rendering {len(files)} diagrams from: {input_dir}")
+ print(f"DPI: {args.dpi}")
+
+ success = 0
+ for puml in files:
+ out = render_one(
+ puml_file=puml,
+ kroki_url=args.kroki_url,
+ dpi=args.dpi,
+ min_dpi=args.min_dpi,
+ max_dim=args.max_dim,
+ timeout=args.timeout,
+ )
+ print(f"created {out}")
+ success += 1
+
+ print(f"Done: {success}/{len(files)} PNG files generated.")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())