From c34a3bfd956991357f4a9d099e5c32a206f9a3a6 Mon Sep 17 00:00:00 2001 From: Alhan Date: Sun, 11 Oct 2026 22:16:25 +0300 Subject: [PATCH] fix: crop save always JPEG q93 + save rotated view 1:1 (v1.4.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Output extension is always .jpg regardless of source format (was: PNG in -> PNG out) - Rotation now saved correctly: viewport grid resampled bilinearly in source space (was: unrotated bounding box copy) - Quality 90 -> 93; l10n strings updated (JPEG 93) - New: tool/crop_test.dart — pure-Dart end-to-end test (dart run tool/crop_test.dart) --- CHANGELOG.md | 6 ++ lib/l10n/app_en.arb | 4 +- lib/l10n/app_localizations.dart | 4 +- lib/l10n/app_localizations_en.dart | 4 +- lib/l10n/app_localizations_tr.dart | 4 +- lib/l10n/app_tr.arb | 4 +- lib/screens/viewer_screen.dart | 6 +- lib/services/crop_save_service.dart | 116 +++++++++++++++++++++++----- lib/widgets/image_canvas.dart | 57 ++++++-------- pubspec.yaml | 2 +- tool/crop_test.dart | 88 +++++++++++++++++++++ 11 files changed, 225 insertions(+), 70 deletions(-) create mode 100644 tool/crop_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 6390bf8..95fb163 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to imajViewer are documented in this file. ## [Unreleased] +## [1.4.1] - 2026-10-11 + +### Fixed +- Crop save always writes JPEG (previously kept the source extension, e.g. PNG) — quality 93 +- Crop save with rotation now saves the rotated view 1:1 instead of an unrotated bounding box + ## [1.4.0] - 2026-10-11 ### Added diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f8f5742..98b100f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -6,10 +6,10 @@ "sortByName": "By name", "appDescription": "Ultra-lightweight and fast image viewer. Open images and use arrows or keyboard keys to navigate.", "shortcuts": "Shortcuts", - "shortcutsList": "• Left/Right arrow — previous/next image\n• Ctrl+S — save visible area (JPEG 90)\n• Ctrl+Q — close window\n• Ctrl+Shift+Q — close all windows\n• Double click — toggle fill/fit\n• Right-drag — contrast\n• Shift+right-drag — rotate\n• Ctrl+right-drag — saturation\n• Ctrl+Shift+right-drag — brightness", + "shortcutsList": "• Left/Right arrow — previous/next image\n• Ctrl+S — save visible area (JPEG 93)\n• Ctrl+Q — close window\n• Ctrl+Shift+Q — close all windows\n• Double click — toggle fill/fit\n• Right-drag — contrast\n• Shift+right-drag — rotate\n• Ctrl+right-drag — saturation\n• Ctrl+Shift+right-drag — brightness", "print": "Print", "close": "Close", - "saveCrop": "Save visible area (JPEG 90)", + "saveCrop": "Save visible area (JPEG 93)", "cropSaved": "Saved: {path}", "@cropSaved": { "placeholders": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 20dd637..2b6dffc 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -137,7 +137,7 @@ abstract class AppLocalizations { /// No description provided for @shortcutsList. /// /// In en, this message translates to: - /// **'• Left/Right arrow — previous/next image\n• Ctrl+S — save visible area (JPEG 90)\n• Ctrl+Q — close window\n• Ctrl+Shift+Q — close all windows\n• Double click — toggle fill/fit\n• Right-drag — contrast\n• Shift+right-drag — rotate\n• Ctrl+right-drag — saturation\n• Ctrl+Shift+right-drag — brightness'** + /// **'• Left/Right arrow — previous/next image\n• Ctrl+S — save visible area (JPEG 93)\n• Ctrl+Q — close window\n• Ctrl+Shift+Q — close all windows\n• Double click — toggle fill/fit\n• Right-drag — contrast\n• Shift+right-drag — rotate\n• Ctrl+right-drag — saturation\n• Ctrl+Shift+right-drag — brightness'** String get shortcutsList; /// No description provided for @print. @@ -155,7 +155,7 @@ abstract class AppLocalizations { /// No description provided for @saveCrop. /// /// In en, this message translates to: - /// **'Save visible area (JPEG 90)'** + /// **'Save visible area (JPEG 93)'** String get saveCrop; /// No description provided for @cropSaved. diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index eddff66..76e9ad2 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -29,7 +29,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get shortcutsList => - '• Left/Right arrow — previous/next image\n• Ctrl+S — save visible area (JPEG 90)\n• Ctrl+Q — close window\n• Ctrl+Shift+Q — close all windows\n• Double click — toggle fill/fit\n• Right-drag — contrast\n• Shift+right-drag — rotate\n• Ctrl+right-drag — saturation\n• Ctrl+Shift+right-drag — brightness'; + '• Left/Right arrow — previous/next image\n• Ctrl+S — save visible area (JPEG 93)\n• Ctrl+Q — close window\n• Ctrl+Shift+Q — close all windows\n• Double click — toggle fill/fit\n• Right-drag — contrast\n• Shift+right-drag — rotate\n• Ctrl+right-drag — saturation\n• Ctrl+Shift+right-drag — brightness'; @override String get print => 'Print'; @@ -38,7 +38,7 @@ class AppLocalizationsEn extends AppLocalizations { String get close => 'Close'; @override - String get saveCrop => 'Save visible area (JPEG 90)'; + String get saveCrop => 'Save visible area (JPEG 93)'; @override String cropSaved(String path) { diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index ee24b95..c42767a 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -29,7 +29,7 @@ class AppLocalizationsTr extends AppLocalizations { @override String get shortcutsList => - '• Sol/Sağ ok — önceki/sonraki resim\n• Ctrl+S — görünen alanı kaydet (JPEG 90)\n• Ctrl+Q — pencereyi kapat\n• Ctrl+Shift+Q — tüm pencereleri kapat\n• Çift tık — doldur/sığdır geçişi\n• Sağ tık sürükle — kontrast\n• Shift+sağ tık — döndür\n• Ctrl+sağ tık — doygunluk\n• Ctrl+Shift+sağ tık — parlaklık'; + '• Sol/Sağ ok — önceki/sonraki resim\n• Ctrl+S — görünen alanı kaydet (JPEG 93)\n• Ctrl+Q — pencereyi kapat\n• Ctrl+Shift+Q — tüm pencereleri kapat\n• Çift tık — doldur/sığdır geçişi\n• Sağ tık sürükle — kontrast\n• Shift+sağ tık — döndür\n• Ctrl+sağ tık — doygunluk\n• Ctrl+Shift+sağ tık — parlaklık'; @override String get print => 'Yazdır'; @@ -38,7 +38,7 @@ class AppLocalizationsTr extends AppLocalizations { String get close => 'Kapat'; @override - String get saveCrop => 'Görünen alanı kaydet (JPEG 90)'; + String get saveCrop => 'Görünen alanı kaydet (JPEG 93)'; @override String cropSaved(String path) { diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 61e56a6..a892e19 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -6,10 +6,10 @@ "sortByName": "İsme göre", "appDescription": "Ultra hafif ve hızlı resim görüntüleyici. Görselleri açın, gezinti için okları veya klavye tuşlarını kullanın.", "shortcuts": "Kısayollar", - "shortcutsList": "• Sol/Sağ ok — önceki/sonraki resim\n• Ctrl+S — görünen alanı kaydet (JPEG 90)\n• Ctrl+Q — pencereyi kapat\n• Ctrl+Shift+Q — tüm pencereleri kapat\n• Çift tık — doldur/sığdır geçişi\n• Sağ tık sürükle — kontrast\n• Shift+sağ tık — döndür\n• Ctrl+sağ tık — doygunluk\n• Ctrl+Shift+sağ tık — parlaklık", + "shortcutsList": "• Sol/Sağ ok — önceki/sonraki resim\n• Ctrl+S — görünen alanı kaydet (JPEG 93)\n• Ctrl+Q — pencereyi kapat\n• Ctrl+Shift+Q — tüm pencereleri kapat\n• Çift tık — doldur/sığdır geçişi\n• Sağ tık sürükle — kontrast\n• Shift+sağ tık — döndür\n• Ctrl+sağ tık — doygunluk\n• Ctrl+Shift+sağ tık — parlaklık", "print": "Yazdır", "close": "Kapat", - "saveCrop": "Görünen alanı kaydet (JPEG 90)", + "saveCrop": "Görünen alanı kaydet (JPEG 93)", "cropSaved": "Kaydedildi: {path}", "@cropSaved": { "placeholders": { diff --git a/lib/screens/viewer_screen.dart b/lib/screens/viewer_screen.dart index cfd1858..e6686d9 100644 --- a/lib/screens/viewer_screen.dart +++ b/lib/screens/viewer_screen.dart @@ -198,14 +198,14 @@ class _ViewerScreenState extends State with WindowListener { String? message; try { final source = await CropSaveService.load(path); - final rect = state.cropSourceRect(source.width, source.height); - if (rect == null || rect.isEmpty || !rect.isFinite) { + final vp = state.cropViewParams(source.width, source.height); + if (vp == null) { message = l10n.cropFailed; } else { final outPath = await CropSaveService.saveCrop( srcPath: path, source: source, - rect: rect, + vp: vp, matrix: state.activeColorMatrix, ); message = l10n.cropSaved(outPath); diff --git a/lib/services/crop_save_service.dart b/lib/services/crop_save_service.dart index ad4e962..da6009d 100644 --- a/lib/services/crop_save_service.dart +++ b/lib/services/crop_save_service.dart @@ -1,5 +1,5 @@ import 'dart:io'; -import 'dart:ui' show Rect; +import 'dart:math' as math; import 'package:image/image.dart' as img; import 'package:path/path.dart' as p; @@ -11,8 +11,34 @@ class CropSaveError implements Exception { String toString() => 'CropSaveError($code)'; } +/// Viewport -> tam cozunurluk kaynak eslemesi (image_canvas'tan gelir). +class CropViewParams { + final int w, h; // kaynak dosya boyutu + final double vw, vh; // viewport boyutu + final double tx, ty, sc; // pan/zoom + final double angleDeg; // donus (derece) + final double cx, cy; // donus merkezi (viewport ortasi) + final double k; // kaynak -> layout olcegi + final double ox, oy; // layout offset + const CropViewParams({ + required this.w, + required this.h, + required this.vw, + required this.vh, + required this.tx, + required this.ty, + required this.sc, + required this.angleDeg, + required this.cx, + required this.cy, + required this.k, + required this.ox, + required this.oy, + }); +} + class CropSaveService { - /// Tam çözünürlükte decode + EXIF yönünü gömme (engine görüntüsüyle tutarlı). + /// Tam cozunurlukte decode + EXIF yonunu gomme (engine goruntusuyle tutarli). static Future load(String srcPath) async { final bytes = await File(srcPath).readAsBytes(); final src = img.decodeImage(bytes); @@ -20,50 +46,98 @@ class CropSaveService { return img.bakeOrientation(src); } - /// Hedef yol: `resim.jpg` -> `resim_143025.jpg` (çakışmada null). + /// Hedef yol: `resim.png` -> `resim_143025.jpg` (hep JPEG; cakismada null). static String? buildOutputPath(String srcPath) { - final ext = p.extension(srcPath); final base = p.basenameWithoutExtension(srcPath); final dir = p.dirname(srcPath); final now = DateTime.now(); final hh = now.hour.toString().padLeft(2, '0'); final mm = now.minute.toString().padLeft(2, '0'); final ss = now.second.toString().padLeft(2, '0'); - final candidate = p.join(dir, '${base}_$hh$mm$ss$ext'); + final candidate = p.join(dir, '${base}_$hh$mm$ss.jpg'); return File(candidate).existsSync() ? null : candidate; } - /// Crop + aktif renk matrisi + JPEG q90. - /// [matrix] 4x5 row-major renk matrisi (image_canvas ile aynı form, 0-255 alanı); - /// null = gömme yok. + /// Ekranda gorunen bolgeyi birebir (donus dahil) tam cozunurlukte kaydedir. + /// Duzlem: viewport -> (tx,ty,sc) -> (vp ortasi etrafinda donus) -> (k,ox,oy) -> kaynak px. static Future saveCrop({ required String srcPath, required img.Image source, - required Rect rect, + required CropViewParams vp, List? matrix, + int jpegQuality = 93, }) async { final outPath = buildOutputPath(srcPath); if (outPath == null) throw const CropSaveError('file_exists'); - final iw = source.width, ih = source.height; - final x = rect.left.round().clamp(0, iw - 1); - final y = rect.top.round().clamp(0, ih - 1); - final w = rect.right.round().clamp(x + 1, iw) - x; - final h = rect.bottom.round().clamp(y + 1, ih) - y; - if (w <= 0 || h <= 0) throw const CropSaveError('empty_rect'); - - final crop = img.copyCrop(source, x: x, y: y, width: w, height: h); + final out = vp.angleDeg == 0 + ? _cropAxisAligned(source, vp) + : _cropRotated(source, vp); // Çıktıda yön fiziksel — EXIF tag'ini temizle (çift dönüşü önler). - crop.exif.imageIfd.orientation = null; + out.exif.imageIfd.orientation = null; - if (matrix != null) _applyMatrix(crop, matrix); + if (matrix != null) _applyMatrix(out, matrix); - final jpeg = img.JpegEncoder(quality: 90).encode(crop); + final jpeg = img.JpegEncoder(quality: jpegQuality).encode(out); await File(outPath).writeAsBytes(jpeg); return outPath; } - /// Flutter'la aynı 0-255 alan/semantiğindeki 4x5 matrisi uygular. + /// Donus yok: gorulen dikdortgeni birebir kes (hizli yol). + static img.Image _cropAxisAligned(img.Image src, CropViewParams vp) { + double toX(double vx) => ((vx - vp.tx) / vp.sc - vp.ox) / vp.k; + double toY(double vy) => ((vy - vp.ty) / vp.sc - vp.oy) / vp.k; + final x0 = toX(0), x1 = toX(vp.vw), y0 = toY(0), y1 = toY(vp.vh); + final x = x0.round().clamp(0, src.width - 1); + final y = y0.round().clamp(0, src.height - 1); + final xr = x1.round().clamp(x + 1, src.width); + final yr = y1.round().clamp(y + 1, src.height); + return img.copyCrop(src, x: x, y: y, width: xr - x, height: yr - y); + } + + /// Donus var: ekran grid'ini kaynaga birebir ornekler (bilinear). + static img.Image _cropRotated(img.Image src, CropViewParams vp) { + final step = vp.sc * vp.k; // ekran px basina kaynak px + if (step <= 0) throw const CropSaveError('empty_rect'); + final ow = (vp.vw / step).round().clamp(1, 1 << 14); + final oh = (vp.vh / step).round().clamp(1, 1 << 14); + final angle = vp.angleDeg * math.pi / 180; + final cos = math.cos(-angle), sin = math.sin(-angle); + + (double, double) toSrc(double vx, double vy) { + final x = (vx - vp.tx) / vp.sc - vp.cx; + final y = (vy - vp.ty) / vp.sc - vp.cy; + return ((cos * x - sin * y + vp.cx - vp.ox) / vp.k, + (sin * x + cos * y + vp.cy - vp.oy) / vp.k); + } + + final out = img.Image(width: ow, height: oh); + for (var y = 0; y < oh; y++) { + final vy = (y + 0.5) * step; + for (var x = 0; x < ow; x++) { + final s = toSrc((x + 0.5) * step, vy); + final c = _sampleBilinear(src, s.$1, s.$2); + out.setPixelRgb(x, y, c.$1, c.$2, c.$3); + } + } + return out; + } + + /// Manuel bilinear ornekleme (package:image 4.x API'si). + static (int, int, int) _sampleBilinear(img.Image src, double fx, double fy) { + final x0 = fx.floor(), y0 = fy.floor(); + final tx = (fx - x0).clamp(0.0, 1.0), ty = (fy - y0).clamp(0.0, 1.0); + final c00 = src.getPixelClamped(x0, y0), c10 = src.getPixelClamped(x0 + 1, y0), + c01 = src.getPixelClamped(x0, y0 + 1), c11 = src.getPixelClamped(x0 + 1, y0 + 1); + int lerp(num a, num b, double t) => (a + (b - a) * t).toInt(); + return ( + lerp(lerp(c00.r, c10.r, tx), lerp(c01.r, c11.r, tx), ty), + lerp(lerp(c00.g, c10.g, tx), lerp(c01.g, c11.g, tx), ty), + lerp(lerp(c00.b, c10.b, tx), lerp(c01.b, c11.b, tx), ty), + ); + } + + /// Flutter'la ayni 0-255 alan/semantigindaki 4x5 matrisi uygular. static void _applyMatrix(img.Image image, List m) { for (final pixel in image) { final r = pixel.r.toDouble(), g = pixel.g.toDouble(), b = pixel.b.toDouble(); diff --git a/lib/widgets/image_canvas.dart b/lib/widgets/image_canvas.dart index b6c530f..f9c02a6 100644 --- a/lib/widgets/image_canvas.dart +++ b/lib/widgets/image_canvas.dart @@ -5,6 +5,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import '../l10n/app_localizations.dart'; +import '../services/crop_save_service.dart'; class ImageCanvas extends StatefulWidget { final String filePath; @@ -396,42 +397,28 @@ class ImageCanvasState extends State { return _cachedMatrix; } - /// Viewport'un görünen bölgesini kaynak (tam çözünürlük) uzayına eşler. - /// [w]x[h] = gerçek dosya boyutu. Degrade durumda null. - Rect? cropSourceRect(int w, int h) { + /// Viewport'un gorunen bolgesinin tam cozunurluktaki eslemesi (crop_save_service'e). + /// Degrade durumda null. + CropViewParams? cropViewParams(int w, int h) { if (_vpSize.width <= 0 || _vpSize.height <= 0 || w <= 0 || h <= 0) return null; - final cx = _vpSize.width / 2, cy = _vpSize.height / 2; - final rad = -_angle * math.pi / 180; - final cos = math.cos(rad), sin = math.sin(rad); - - Offset toSrc(Offset p) { - final double qx = (p.dx - _tx) / _sc, qy = (p.dy - _ty) / _sc; - final double rx = (_angle == 0) ? qx : (cos * (qx - cx) - sin * (qy - cy) + cx); - final double ry = (_angle == 0) ? qy : (sin * (qx - cx) + cos * (qy - cy) + cy); - final double k = _isFilled - ? math.max(_vpSize.width / w, _vpSize.height / h) - : math.min(_vpSize.width / w, _vpSize.height / h); - final double ox = (_vpSize.width - w * k) / 2, oy = (_vpSize.height - h * k) / 2; - return Offset((rx - ox) / k, (ry - oy) / k); - } - - final corners = [ - toSrc(Offset.zero), - toSrc(Offset(_vpSize.width, 0)), - toSrc(Offset(_vpSize.width, _vpSize.height)), - toSrc(Offset(0, _vpSize.height)), - ]; - double xmin = double.infinity, xmax = -double.infinity; - double ymin = double.infinity, ymax = -double.infinity; - for (final c in corners) { - xmin = math.min(xmin, c.dx); xmax = math.max(xmax, c.dx); - ymin = math.min(ymin, c.dy); ymax = math.max(ymax, c.dy); - } - return Rect.fromLTRB( - xmin.clamp(0.0, w.toDouble()), - ymin.clamp(0.0, h.toDouble()), - xmax.clamp(0.0, w.toDouble()), - ymax.clamp(0.0, h.toDouble()), + final k = _isFilled + ? math.max(_vpSize.width / w, _vpSize.height / h) + : math.min(_vpSize.width / w, _vpSize.height / h); + final ox = (_vpSize.width - w * k) / 2, oy = (_vpSize.height - h * k) / 2; + return CropViewParams( + w: w, + h: h, + vw: _vpSize.width, + vh: _vpSize.height, + tx: _tx, + ty: _ty, + sc: _sc, + angleDeg: _angle, + cx: _vpSize.width / 2, + cy: _vpSize.height / 2, + k: k, + ox: ox, + oy: oy, ); } diff --git a/pubspec.yaml b/pubspec.yaml index 2f677ca..ffa3651 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: imajviewer description: "Ultra-lightweight image viewer for Linux and Windows." publish_to: 'none' -version: 1.4.0+8 +version: 1.4.1+9 msix_config: display_name: ImajViewer diff --git a/tool/crop_test.dart b/tool/crop_test.dart new file mode 100644 index 0000000..956ab65 --- /dev/null +++ b/tool/crop_test.dart @@ -0,0 +1,88 @@ +import 'dart:io'; +import 'package:image/image.dart' as img; +import 'package:imajviewer/services/crop_save_service.dart'; + +Future main() async { + final tmp = Directory.systemTemp.createTempSync('crop_test'); + final srcPath = '${tmp.path}/test.png'; + + final src = img.Image(width: 100, height: 50); + void fill(int x, int y, int r, int g, int b) { + for (var dy = 0; dy < 5; dy++) + for (var dx = 0; dx < 5; dx++) src.setPixelRgb(x + dx, y + dy, r, g, b); + } + fill(0, 0, 255, 0, 0); // sol-ust: KIRMIZI + fill(95, 0, 0, 255, 0); // sag-ust: YESIL + fill(0, 45, 0, 0, 255); // sol-alt: MAVI + fill(95, 45, 255, 255, 0); // sag-alt: SARI + File(srcPath).writeAsBytesSync(img.encodePng(src)!); + + var fails = 0; + void checkPix(String label, (int, int, int) px, int r, int g, int b) { + int d(int a, int c) => (a - c).abs(); + final ok = d(px.$1, r) <= 60 && d(px.$2, g) <= 60 && d(px.$3, b) <= 60; + if (!ok) fails++; + print('${ok ? 'OK ' : 'FAIL'} $label: got ${px.$1},${px.$2},${px.$3} want ~$r,$g,$b'); + } + + // 1) Duz, fit, zoom yok: gorunen = kaynakin kendisi + final outDir1 = '${tmp.path}/a'; + Directory(outDir1).createSync(); + final f1 = '${outDir1}/src.jpg'; + File(f1).writeAsBytesSync(File(srcPath).readAsBytesSync()); + final p1 = CropViewParams(w: 100, h: 50, vw: 100, vh: 50, tx: 0, ty: 0, sc: 1, + angleDeg: 0, cx: 50, cy: 25, k: 1, ox: 0, oy: 0); + final path1 = await CropSaveService.saveCrop(srcPath: f1, source: await CropSaveService.load(f1), vp: p1); + final r1 = img.decodeImage(File(path1).readAsBytesSync())!; + print('1) dims ${r1.width}x${r1.height} (100x50 beklenir)'); + if (r1.width != 100 || r1.height != 50 || !path1.endsWith('.jpg')) fails++; + checkPix('1) sol-ust', imgPx(r1, 2, 2), 255, 0, 0); + checkPix('1) sag-alt', imgPx(r1, 97, 47), 255, 255, 0); + + // 2) 90 derece saat yelkolevi: cikti 50x100, orij sol-alt -> yeni sol-ust + final out2 = '${tmp.path}/b'; + Directory(out2).createSync(); + final f2 = '$out2/src.jpg'; + File(f2).writeAsBytesSync(File(srcPath).readAsBytesSync()); + final p2 = CropViewParams(w: 100, h: 50, vw: 50, vh: 100, tx: 0, ty: 0, sc: 1, + angleDeg: 90, cx: 25, cy: 50, k: 1, ox: -25, oy: 25); + final path2 = await CropSaveService.saveCrop(srcPath: f2, source: await CropSaveService.load(f2), vp: p2); + final r2 = img.decodeImage(File(path2).readAsBytesSync())!; + print('2) dims ${r2.width}x${r2.height} (50x100 beklenir)'); + if (r2.width != 50 || r2.height != 100) fails++; + checkPix('2) sol-ust (orij sol-alt MAVI)', imgPx(r2, 2, 2), 0, 0, 255); + checkPix('2) sag-ust (orij sol-ust KIRMIZI)', imgPx(r2, 47, 2), 255, 0, 0); + checkPix('2) sol-alt (orij sag-alt SARI)', imgPx(r2, 2, 97), 255, 255, 0); + checkPix('2) sag-alt (orij sag-ust YESIL)', imgPx(r2, 47, 97), 0, 255, 0); + + // 3) Zoom x2, 45 derece, fill: orneklenen alan ortada + final out3 = '${tmp.path}/c'; + Directory(out3).createSync(); + final f3 = '$out3/src.jpg'; + File(f3).writeAsBytesSync(File(srcPath).readAsBytesSync()); + final p3 = CropViewParams(w: 100, h: 50, vw: 100, vh: 100, tx: 0, ty: 0, sc: 2, + angleDeg: 45, cx: 50, cy: 50, k: 2, ox: 0, oy: -25); + final path3 = await CropSaveService.saveCrop(srcPath: f3, source: await CropSaveService.load(f3), vp: p3); + final r3 = img.decodeImage(File(path3).readAsBytesSync())!; + print('3) dims ${r3.width}x${r3.height} (25x25 beklenir: zoom x2 + fill k=2 -> 4 src px/ekran px)'); + if (r3.width != 25 || r3.height != 25) fails++; + + tmp.deleteSync(recursive: true); + print(fails == 0 ? 'ALL PASS' : '$fails FAILURES'); + exitCode = fails == 0 ? 0 : 1; +} + +// jpeg kaybi oldugu icin 5x5 bolge ortalamasi al +(int, int, int) imgPx(img.Image im, int x, int y) { + int avg(int f) { + var s = 0, n = 0; + for (var dy = -2; dy <= 2; dy++) + for (var dx = -2; dx <= 2; dx++) { + final px = im.getPixelClamped(x + dx, y + dy); + s += f == 0 ? px.r.toInt() : (f == 1 ? px.g.toInt() : px.b.toInt()); + n++; + } + return s ~/ n; + } + return (avg(0), avg(1), avg(2)); +}