fix: crop save always JPEG q93 + save rotated view 1:1 (v1.4.1)
Build & Release / build-linux (push) Has been cancelled
Build & Release / build-windows (push) Has been cancelled
Build & Release / create-release (push) Has been cancelled

- 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)
This commit is contained in:
Alhan committed 2026-10-11 22:16:25 +03:00
1 parent 268dc879c8
commit c34a3bfd95
11 files changed
+225 -70

No files matched your search

+6
View File
@@ -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
+2 -2
View File
@@ -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": {
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -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) {
+2 -2
View File
@@ -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) {
+2 -2
View File
@@ -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": {
+3 -3
View File
@@ -198,14 +198,14 @@ class _ViewerScreenState extends State<ViewerScreen> 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);
+95 -21
View File
@@ -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<img.Image> 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<String> saveCrop({
required String srcPath,
required img.Image source,
required Rect rect,
required CropViewParams vp,
List<double>? 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<double> m) {
for (final pixel in image) {
final r = pixel.r.toDouble(), g = pixel.g.toDouble(), b = pixel.b.toDouble();
+22 -35
View File
@@ -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<ImageCanvas> {
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,
);
}
+1 -1
View File
@@ -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
+88
View File
@@ -0,0 +1,88 @@
import 'dart:io';
import 'package:image/image.dart' as img;
import 'package:imajviewer/services/crop_save_service.dart';
Future<void> 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));
}