From 168e087c8bd54c190a0b5abb18f6cb770225a553 Mon Sep 17 00:00:00 2001 From: Alhan Date: Sun, 11 Oct 2026 21:36:20 +0300 Subject: [PATCH] feat: crop save (save visible area at original resolution) v1.4.0 - Save visible area at original resolution as JPEG q90 (title bar button or Ctrl+S) - Output name: original name + _HHmmss suffix; no overwrite, warning shown - Active color effects baked in; rotation saves bounding box - New: crop_save_service, l10n strings TR/EN, docs/plan-crop-save.md --- .gitignore | 5 +- CHANGELOG.md | 9 +++ docs/index.md | 1 + docs/plan-crop-save.md | 98 +++++++++++++++++++++++++++++ lib/l10n/app_en.arb | 12 +++- lib/l10n/app_localizations.dart | 32 +++++++++- lib/l10n/app_localizations_en.dart | 20 +++++- lib/l10n/app_localizations_tr.dart | 19 +++++- lib/l10n/app_tr.arb | 12 +++- lib/screens/viewer_screen.dart | 48 ++++++++++++++ lib/services/crop_save_service.dart | 75 ++++++++++++++++++++++ lib/shortcuts/app_shortcuts.dart | 11 +++- lib/widgets/custom_title_bar.dart | 8 ++- lib/widgets/image_canvas.dart | 58 +++++++++++++++-- pubspec.lock | 2 +- pubspec.yaml | 3 +- 16 files changed, 399 insertions(+), 14 deletions(-) create mode 100644 docs/plan-crop-save.md create mode 100644 lib/services/crop_save_service.dart diff --git a/.gitignore b/.gitignore index 1125271..58a0f54 100644 --- a/.gitignore +++ b/.gitignore @@ -49,4 +49,7 @@ app.*.map.json *.tar.gz *.exe deb/usr/lib/imajviewer/ -release/ \ No newline at end of file +release/ + +# Local backup snapshots (see soul.md §5) +docs/.bak/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index ad89b53..6390bf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to imajViewer are documented in this file. ## [Unreleased] +## [1.4.0] - 2026-10-11 + +### Added +- Crop save: saves the visible area at original resolution as JPEG quality 90 — title bar save button or Ctrl+S + - Output name: original name + `_HHmmss` time suffix (e.g. `photo.jpg` → `photo_143025.jpg`) + - If a file with the same name exists, nothing is written and a warning is shown + - Active color effects (contrast/saturation/brightness) are baked into the output + - With rotation active, the bounding box around the visible area is saved + ## [1.3.2] - 2026-08-28 ### Fixed diff --git a/docs/index.md b/docs/index.md index eaa0df2..00b7955 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,6 +3,7 @@ | Tarih | Dosya | Konu | ||---|---|---| | 2026-08-06 | [rapor-github-acik-kaynak.md](rapor-github-acik-kaynak.md) | **Rapor** — GitHub açık kaynak hazırlığı (gereksinimler, riskler, plan) | +| 2026-10-11 | [plan-crop-save.md](plan-crop-save.md) | **Plan (1.4.0)** — Crop Save: görünen alanı orijinal çözünürlükte JPEG q90 kaydetme (uygulandı) | | 2026-08-06 | [Implementation.md](Implementation.md) | Teknik uygulama dokümanı (kökten taşındı) | | 2026-08-06 | [PRD.md](PRD.md) | Product Requirements Document (kökten taşındı) | | 2026-08-06 | [update_plan_01.md](update_plan_01.md) | Pan sınırlama & zoom merkezi sorunu (kökten taşındı) | diff --git a/docs/plan-crop-save.md b/docs/plan-crop-save.md new file mode 100644 index 0000000..8abc8b3 --- /dev/null +++ b/docs/plan-crop-save.md @@ -0,0 +1,98 @@ +# Plan — Crop Save (görünen alanı kaydet) + +Tarih: 2026-10-11 · Versiyon: 1.4.0 · Durum: UYGULANDI (2026-10-11) — manual test + commit/push bekleniyor + +## Not — Uygulama Sırası +- `image` v3 kullanılamadı: `msix 3.18.0` `image ^4.0.0` gerektiriyor → v4 async API kullanıldı + (`decodeImage` Uint8List ile, `copyCrop(x:,y:,width:,height:)`, `JpegEncoder(quality:90)`, + `bakeOrientation`, pixel kanalları `r/g/b/a`). +- v4'te renk matrisi filtresi yok → matris `crop_save_service.dart:_applyMatrix` ile elle + uygulandı (aynı 0-255 alanı). +- Boyut okuma ayrı descriptor yerine tam decode (`load()`) üzerinden yapılıyor. + +## Gereksinim + Kararlar +- Pencerenin görünen kısmı (image canvas viewport'u) → **orijinal çözünürlükte** crop +- JPEG **quality 90** +- Dosya adı: orijinal ismin sonuna **saat only** eki → `resim.jpg` → `resim_143025.jpg` +- **Çakışma:** dosya zaten varsa yazı YAPILMAZ, uyarı gösterilir (mevcut dosya korunur) +- **Renk efektleri:** kayda **GÖMÜLÜR** (contrast/saturation/brightness, ekrandaki gibi) +- **Rotasyon aktifken:** yapılsın — görünür bölgeyi saran dikdörtgen (bounding box) kaydedilir +- Title bar: Save butonu minimize'in SOLUNDA (sıralama: **save · minimize · x**) +- Klavye: **Ctrl+S** de tetikler + +## Mimari Analiz + +### 1) Görünen alan → kaynak piksel eşlemesi +`image_canvas.dart` render zinciri: `Transform(translate(_tx,_ty) scale(_sc))` içinde +opsiyonel `Transform(rotate(_angle), vpCenter)` → `Image(fit: cover|contain)`. + +Canvas lokal koordinatındaki bir nokta `p` için geri dönüş: +1. `q = (p - (tx,ty)) / sc` +2. `_angle != 0` ise `r` = `q`'yi vpCenter ekseninde `-angle` kadar döndür (yoksa `r = q`) +3. **cover** (`_isFilled`): `k = max(vpW/iw, vpH/ih)`; **contain**: `k = min(vpW/iw, vpH/ih)`; + her ikisinde de `ox=(vpW-iw*k)/2`, `oy=(vpH-ih*k)/2` → `src = ((r.x-ox)/k, (r.y-oy)/k)` +4. Viewport'un 4 köşesi → `src` uzayda → min/max bounding box → `[0,iw]×[0,ih]`'a clamp → int +5. Rotasyonlu durumda bounding box, ekranda görünür bölgeyi kapsayan dikdörtgendir + (çıkış aspect/boyutu pencereden farklı olabilir — onaylı davranış) + +- `iw,ih` = **gerçek** dosya boyutu (descriptor ile). `_imageSize` downsample'lı (2048) + olabilir → eşlemede kullanılmaz. +- Görünen alan = **tamamı canvas viewport** (title bar 24px bindirilmiş overlay, görsel + arkasında görünüyor → kesime dahil). + +### 2) Encode zinciri (yeni bağımlılık: `image` ^4.x, saf Dart) +``` +File → img.decodeImage(bytes, keepOrientation: true) + → img.cropRect(rect) + → aktif renk matrisi var ise img.ColorMatrixFilter(mat4x5).applyTo(crop) + → img.encodeJpeg(crop, quality: 90) + → hedef yol var ise → uyarı (yazılmaz); yoksa File.writeAsBytes +``` +- **Renk matrisi:** `image_canvas.dart`'daki mevcut `_buildCombinedFilter()` 4x5 matrisi + (contrast×brightness×saturation) aynı zamanda `ColorFilter.matrix` olarak kullanılıyor. + State'e `List? activeColorMatrix` getter'ı eklenir (filtre identity ise null). + Flutter ve `image` paketi aynı 4x5 row-major / 0-255 alanı kullanır → matris aynen + taşınır, yeniden türetilmez. +- **Tuzak — EXIF yönü:** Flutter engine JPEG'lerde EXIF orientation uygular; + `keepOrientation: true` verilmezse 90° döndürülmüş fotoğraflarda crop yanlış yerden + kesilir. Test listesinde zorunlu. +- **WBMP:** `image` WBMP çözer (uygulama destekliyor); decode hatası → "desteklenmiyor" + mesajı, çökme yok. + +### 3) Bileşenler +| Dosya | Değişiklik | +|---|---| +| `pubspec.yaml` | `image: ^4.x`; versiyon `1.4.0+8` | +| `lib/services/crop_save_service.dart` | **YENİ** — `imageSize(path)` (descriptor), `saveCrop({srcPath, rect, matrix, outPath})`, ad üretimi (`_HHmmss`), çakışma kontrolü (exists → uyarı), hata mesajları | +| `lib/widgets/image_canvas.dart` | State → `ImageCanvasState` (public); `Rect? cropSourceRect(int w, int h)`; `List? activeColorMatrix` getter | +| `lib/screens/viewer_screen.dart` | `GlobalKey`, `_cropSave()`, sonuç geri bildirimi (kısa overlay/Snackbar: yol veya hata) | +| `lib/widgets/custom_title_bar.dart` | `onSaveCrop` param; save ikonu minimize'in solunda; mevcut `_BarButton` stili (hover kırmızı) | +| `lib/shortcuts/app_shortcuts.dart` | `Ctrl+S` → crop save (callback param ile) | +| `lib/l10n/app_{en,tr}.arb` | `saveCrop` (tooltip), `cropSaved`, `cropFailed`, `cropFileExists`, `cropUnsupported` + `shortcutsList` güncelleme; `flutter gen-l10n` | +| `CHANGELOG.md` | `[1.4.0]` | + +### 4) Etkileşim +- Buton yalnızca görsel yüklüyken aktif (yoksa null → pas geçilir). +- Başarıda: kaydedilen tam yol gösterilir; hata/çakışmada: hata metni. +- Tam decode kayıt anında, tek seferlik (downsample swap ile ilişkisi yok). + +## Sınır Durumları +1. Görsel yok → buton pasif, Ctrl+S no-op +2. Crop rect boş/degenerate → hata mesajı +3. Yazılamayan klasör → hata mesajı, çökme yok +4. **Çakışma: dosya var → yazılmaz, uyarı** (karar) +5. WBMP/decode hatası → `cropUnsupported` +6. Rotasyonlu bounding box (karar: kaydedilir) +7. Büyük görsellerde tam decode RAM'i — tek seferlik, kabul edilebilir + +## Doğrulama +1. `flutter analyze` (otomatik izinli) +2. `flutter build linux --debug` + `./install.sh` (otomatik izinli) +3. Kullanıcı manuel test: fit/cover, zoom, pan, rotasyon + kaydet, renk efektleri + kaydet, + EXIF 90° görsel, WBMP, çakışan isim (uyarı + dosya korunur), salt okunur dizin +4. Commit + push: yalnızca senin komutunla + +## Kapsam Dışı +- Kullanıcı alanı seçerek crop (her zaman "görünen bölge") +- PNG/özel format seçimi (sabit JPEG q90) +- Gitea CI release / tag (senin komutunla) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 6996426..f8f5742 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -6,9 +6,19 @@ "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+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 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", "print": "Print", "close": "Close", + "saveCrop": "Save visible area (JPEG 90)", + "cropSaved": "Saved: {path}", + "@cropSaved": { + "placeholders": { + "path": { "type": "String" } + } + }, + "cropFileExists": "A file with the same name already exists — nothing was written", + "cropUnsupported": "This image format cannot be saved as a crop", + "cropFailed": "Failed to save crop", "dragDropHint": "Drag images here\nor press Ctrl+O to open", "dropHere": "Drop", "imageLoadError": "Failed to load image" diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 53d2ef2..20dd637 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+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 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'** String get shortcutsList; /// No description provided for @print. @@ -152,6 +152,36 @@ abstract class AppLocalizations { /// **'Close'** String get close; + /// No description provided for @saveCrop. + /// + /// In en, this message translates to: + /// **'Save visible area (JPEG 90)'** + String get saveCrop; + + /// No description provided for @cropSaved. + /// + /// In en, this message translates to: + /// **'Saved: {path}'** + String cropSaved(String path); + + /// No description provided for @cropFileExists. + /// + /// In en, this message translates to: + /// **'A file with the same name already exists — nothing was written'** + String get cropFileExists; + + /// No description provided for @cropUnsupported. + /// + /// In en, this message translates to: + /// **'This image format cannot be saved as a crop'** + String get cropUnsupported; + + /// No description provided for @cropFailed. + /// + /// In en, this message translates to: + /// **'Failed to save crop'** + String get cropFailed; + /// No description provided for @dragDropHint. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 5ec2cdf..eddff66 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+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 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'; @override String get print => 'Print'; @@ -37,6 +37,24 @@ class AppLocalizationsEn extends AppLocalizations { @override String get close => 'Close'; + @override + String get saveCrop => 'Save visible area (JPEG 90)'; + + @override + String cropSaved(String path) { + return 'Saved: $path'; + } + + @override + String get cropFileExists => + 'A file with the same name already exists — nothing was written'; + + @override + String get cropUnsupported => 'This image format cannot be saved as a crop'; + + @override + String get cropFailed => 'Failed to save crop'; + @override String get dragDropHint => 'Drag images here\nor press Ctrl+O to open'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index b1b048a..ee24b95 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+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 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'; @override String get print => 'Yazdır'; @@ -37,6 +37,23 @@ class AppLocalizationsTr extends AppLocalizations { @override String get close => 'Kapat'; + @override + String get saveCrop => 'Görünen alanı kaydet (JPEG 90)'; + + @override + String cropSaved(String path) { + return 'Kaydedildi: $path'; + } + + @override + String get cropFileExists => 'Aynı adlı dosya zaten var — yazılmadı'; + + @override + String get cropUnsupported => 'Bu resim formatı crop olarak kaydedilemiyor'; + + @override + String get cropFailed => 'Crop kaydedilemedi'; + @override String get dragDropHint => 'Resimleri buraya sürükleyin\nveya Ctrl+O ile açın'; diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 173da9f..61e56a6 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -6,9 +6,19 @@ "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+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 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", "print": "Yazdır", "close": "Kapat", + "saveCrop": "Görünen alanı kaydet (JPEG 90)", + "cropSaved": "Kaydedildi: {path}", + "@cropSaved": { + "placeholders": { + "path": { "type": "String" } + } + }, + "cropFileExists": "Aynı adlı dosya zaten var — yazılmadı", + "cropUnsupported": "Bu resim formatı crop olarak kaydedilemiyor", + "cropFailed": "Crop kaydedilemedi", "dragDropHint": "Resimleri buraya sürükleyin\nveya Ctrl+O ile açın", "dropHere": "Bırakın", "imageLoadError": "Resim yüklenemedi" diff --git a/lib/screens/viewer_screen.dart b/lib/screens/viewer_screen.dart index 411402b..cfd1858 100644 --- a/lib/screens/viewer_screen.dart +++ b/lib/screens/viewer_screen.dart @@ -8,6 +8,7 @@ import 'package:pdf/widgets.dart' as pw; import 'package:printing/printing.dart'; import '../services/image_manager.dart'; import '../services/file_handler.dart'; +import '../services/crop_save_service.dart'; import '../widgets/custom_title_bar.dart'; import '../widgets/image_canvas.dart'; import '../widgets/window_resize_zones.dart'; @@ -28,6 +29,7 @@ class _ViewerScreenState extends State with WindowListener { bool _controlsVisible = false; Timer? _saveDebounce; Rect _lastBounds = Rect.zero; + final GlobalKey _canvasKey = GlobalKey(); @override void initState() { @@ -188,9 +190,53 @@ class _ViewerScreenState extends State with WindowListener { ); } + Future _cropSave() async { + final path = ImageManager.instance.currentImagePath; + final state = _canvasKey.currentState; + if (path.isEmpty || state == null) return; + final l10n = AppLocalizations.of(context)!; + String? message; + try { + final source = await CropSaveService.load(path); + final rect = state.cropSourceRect(source.width, source.height); + if (rect == null || rect.isEmpty || !rect.isFinite) { + message = l10n.cropFailed; + } else { + final outPath = await CropSaveService.saveCrop( + srcPath: path, + source: source, + rect: rect, + matrix: state.activeColorMatrix, + ); + message = l10n.cropSaved(outPath); + } + } on CropSaveError catch (e) { + switch (e.code) { + case 'file_exists': + message = l10n.cropFileExists; + case 'decode': + message = l10n.cropUnsupported; + default: + message = l10n.cropFailed; + } + } catch (_) { + message = l10n.cropFailed; + } + if (!mounted) return; + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar( + content: Text(message, style: const TextStyle(fontSize: 13)), + duration: const Duration(seconds: 2), + behavior: SnackBarBehavior.floating, + width: 520, + )); + } + @override Widget build(BuildContext context) { return AppShortcuts( + onCropSave: _hasImages ? _cropSave : null, child: Scaffold( backgroundColor: Colors.transparent, body: WindowResizeZones( @@ -223,6 +269,7 @@ class _ViewerScreenState extends State with WindowListener { color: Colors.transparent, child: _hasImages ? ImageCanvas( + key: _canvasKey, filePath: ImageManager.instance.currentImagePath, ) : Center( @@ -242,6 +289,7 @@ class _ViewerScreenState extends State with WindowListener { ), CustomTitleBar( onSettings: _showSettings, + onSaveCrop: _hasImages ? _cropSave : null, visible: _controlsVisible, ), if (_hasImages && _controlsVisible) ...[ diff --git a/lib/services/crop_save_service.dart b/lib/services/crop_save_service.dart new file mode 100644 index 0000000..ad4e962 --- /dev/null +++ b/lib/services/crop_save_service.dart @@ -0,0 +1,75 @@ +import 'dart:io'; +import 'dart:ui' show Rect; +import 'package:image/image.dart' as img; +import 'package:path/path.dart' as p; + +class CropSaveError implements Exception { + final String code; + const CropSaveError(this.code); + + @override + String toString() => 'CropSaveError($code)'; +} + +class CropSaveService { + /// Tam çözünürlükte decode + EXIF yönünü gömme (engine görüntüsüyle tutarlı). + static Future load(String srcPath) async { + final bytes = await File(srcPath).readAsBytes(); + final src = img.decodeImage(bytes); + if (src == null) throw const CropSaveError('decode'); + return img.bakeOrientation(src); + } + + /// Hedef yol: `resim.jpg` -> `resim_143025.jpg` (çakışmada 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'); + 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. + static Future saveCrop({ + required String srcPath, + required img.Image source, + required Rect rect, + List? matrix, + }) 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); + // Çıktıda yön fiziksel — EXIF tag'ini temizle (çift dönüşü önler). + crop.exif.imageIfd.orientation = null; + + if (matrix != null) _applyMatrix(crop, matrix); + + final jpeg = img.JpegEncoder(quality: 90).encode(crop); + await File(outPath).writeAsBytes(jpeg); + return outPath; + } + + /// Flutter'la aynı 0-255 alan/semantiğindeki 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(); + pixel.r = (m[0] * r + m[1] * g + m[2] * b + m[4]).clamp(0, 255).round(); + pixel.g = (m[5] * r + m[6] * g + m[7] * b + m[9]).clamp(0, 255).round(); + pixel.b = (m[10] * r + m[11] * g + m[12] * b + m[13]).clamp(0, 255).round(); + } + } +} diff --git a/lib/shortcuts/app_shortcuts.dart b/lib/shortcuts/app_shortcuts.dart index 78c8509..eb98eca 100644 --- a/lib/shortcuts/app_shortcuts.dart +++ b/lib/shortcuts/app_shortcuts.dart @@ -7,8 +7,9 @@ import '../services/file_handler.dart'; class AppShortcuts extends StatelessWidget { final Widget child; + final VoidCallback? onCropSave; - const AppShortcuts({super.key, required this.child}); + const AppShortcuts({super.key, required this.child, this.onCropSave}); @override Widget build(BuildContext context) { @@ -35,6 +36,14 @@ class AppShortcuts extends StatelessWidget { return KeyEventResult.handled; } + // Ctrl+S: görünen alanı kaydet (crop save) + if (ctrl && !shift && event.logicalKey == LogicalKeyboardKey.keyS) { + if (onCropSave != null) { + onCropSave!(); + return KeyEventResult.handled; + } + } + switch (event.logicalKey) { case LogicalKeyboardKey.arrowLeft: ImageManager.instance.previousImage(); diff --git a/lib/widgets/custom_title_bar.dart b/lib/widgets/custom_title_bar.dart index ef59920..971109e 100644 --- a/lib/widgets/custom_title_bar.dart +++ b/lib/widgets/custom_title_bar.dart @@ -3,9 +3,10 @@ import 'package:window_manager/window_manager.dart'; class CustomTitleBar extends StatelessWidget { final VoidCallback? onSettings; + final VoidCallback? onSaveCrop; final bool visible; - const CustomTitleBar({super.key, this.onSettings, this.visible = true}); + const CustomTitleBar({super.key, this.onSettings, this.onSaveCrop, this.visible = true}); @override Widget build(BuildContext context) { @@ -43,6 +44,11 @@ class CustomTitleBar extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ + if (onSaveCrop != null) + _BarButton( + icon: Icons.save_outlined, + onTap: onSaveCrop!, + ), _BarButton( icon: Icons.horizontal_rule, onTap: () => windowManager.minimize(), diff --git a/lib/widgets/image_canvas.dart b/lib/widgets/image_canvas.dart index 97fe2cc..b6c530f 100644 --- a/lib/widgets/image_canvas.dart +++ b/lib/widgets/image_canvas.dart @@ -10,10 +10,10 @@ class ImageCanvas extends StatefulWidget { final String filePath; const ImageCanvas({super.key, required this.filePath}); @override - State createState() => _ImageCanvasState(); + State createState() => ImageCanvasState(); } -class _ImageCanvasState extends State { +class ImageCanvasState extends State { bool _isFilled = true; final GlobalKey _imageKey = GlobalKey(); double _tx = 0, _ty = 0, _sc = 1.0; @@ -314,6 +314,7 @@ class _ImageCanvasState extends State { static const double _bLum = 0.0722; ColorFilter? _cachedFilter; + List? _cachedMatrix; double _cachedSat = -1; double _cachedCon = -1; double _cachedBri = double.nan; @@ -380,11 +381,60 @@ class _ImageCanvasState extends State { ]; // M_final = M_contrast * M_brightness * M_saturation - _cachedFilter = ColorFilter.matrix( - _multiply4x5(_multiply4x5(contrastMatrix, brightnessMatrix), saturationMatrix)); + final matrix = + _multiply4x5(_multiply4x5(contrastMatrix, brightnessMatrix), saturationMatrix); + _cachedMatrix = matrix; + _cachedFilter = ColorFilter.matrix(matrix); return _cachedFilter!; } + /// Aktif renk matrisi (4x5 row-major); identity ise null. + /// CropSaveService'e aynen taşınır (aynı alan/semantik: 0-255). + List? get activeColorMatrix { + if (_saturation == 1.0 && _contrast == 1.0 && _brightness == 0.0) return null; + _buildCombinedFilter(); + 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) { + 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()), + ); + } + FileImage? _cachedFileImage; ImageProvider? _cachedProvider; String? _cachedPath; diff --git a/pubspec.lock b/pubspec.lock index 9274b10..381f121 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -198,7 +198,7 @@ packages: source: hosted version: "4.1.2" image: - dependency: transitive + dependency: "direct main" description: name: image sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52" diff --git a/pubspec.yaml b/pubspec.yaml index a951d54..2f677ca 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.3.2+7 +version: 1.4.0+8 msix_config: display_name: ImajViewer @@ -24,6 +24,7 @@ dependencies: file_picker: ^8.0.0 path: ^1.9.0 desktop_drop: ^0.7.1 + image: ^4.0.0 msix: ^3.18.0 printing: ^5.13.4 pdf: ^3.11.1