feat(domain): Add crop_to_image() and extract_meta() to RasterDocument
- crop_to_image(): Non-destructive crop returning DynamicImage - extract_meta(): Extract BasicMeta and EXIF metadata - Completes migration of RasterDocument features from app/ to domain/ Refs: Migration Step 1.2
This commit is contained in:
parent
3cf99ad19d
commit
8ff43ea5d7
2 changed files with 525 additions and 0 deletions
167
DEVNOTE/Feature-Comparison.md
Normal file
167
DEVNOTE/Feature-Comparison.md
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
# Feature-Vergleich: app/document vs domain/document/types
|
||||
|
||||
**Ziel:** Identifizieren welche Features von `src/app/document/` nach `src/domain/document/types/` portiert werden müssen.
|
||||
|
||||
---
|
||||
|
||||
## RasterDocument
|
||||
|
||||
### Struct-Felder
|
||||
|
||||
| Feld | app/ | domain/ | Status |
|
||||
|------|------|---------|--------|
|
||||
| `document: DynamicImage` | ✅ | ✅ | OK |
|
||||
| `native_width: u32` | ✅ | ✅ | OK |
|
||||
| `native_height: u32` | ✅ | ✅ | OK |
|
||||
| `transform: TransformState` | ✅ | ✅ | OK |
|
||||
| `handle: ImageHandle` | ✅ pub | ✅ private | **⚠️ domain: public machen oder getter** |
|
||||
| `fine_rotation_angle: f32` | ❌ | ✅ | ℹ️ Extra feature in domain |
|
||||
| `interpolation_quality` | ❌ | ✅ | ℹ️ Extra feature in domain |
|
||||
|
||||
**Entscheidung:** Domain-Version hat mehr Features → Domain behalten, `handle` public machen
|
||||
|
||||
---
|
||||
|
||||
### Methoden-Vergleich
|
||||
|
||||
| Methode | app/ | domain/ | Aktion |
|
||||
|---------|------|---------|--------|
|
||||
| **Core Operations** | | | |
|
||||
| `open()` | ✅ | ✅ | ✅ OK |
|
||||
| `render()` | ✅ | ✅ | ✅ OK |
|
||||
| `save()` | ✅ | ✅ | ✅ OK |
|
||||
| | | | |
|
||||
| **Transformations (Trait)** | | | |
|
||||
| `rotate()` | ✅ | ✅ | ✅ OK |
|
||||
| `flip()` | ✅ | ✅ | ✅ OK |
|
||||
| `transform_state()` | ✅ | ✅ | ✅ OK |
|
||||
| | | | |
|
||||
| **Renderable (Trait)** | | | |
|
||||
| `info()` | ✅ | ✅ | ✅ OK |
|
||||
| | | | |
|
||||
| **Dimensions** | | | |
|
||||
| `dimensions()` | ✅ | ✅ | ✅ OK (beide haben es!) |
|
||||
| `native_dimensions()` | ❌ | ✅ | ℹ️ Extra in domain |
|
||||
| | | | |
|
||||
| **Crop** | | | |
|
||||
| `crop()` | ✅ | ✅ | ✅ OK (beide haben es!) |
|
||||
| `crop_to_image()` | ✅ | ❌ | 📋 **Portieren nach domain/** |
|
||||
| | | | |
|
||||
| **Handle/Image Access** | | | |
|
||||
| `handle` (field pub) | ✅ | ❌ | 📋 **Public machen oder getter** |
|
||||
| `handle()` (getter) | ❌ | ✅ | ✅ OK (domain hat getter) |
|
||||
| `image()` | ❌ | ✅ | ℹ️ Extra in domain |
|
||||
| `get_rendered_image()` | ❌ | ✅ | ℹ️ Extra in domain |
|
||||
| | | | |
|
||||
| **Metadata** | | | |
|
||||
| `extract_meta()` | ✅ | ❌ | 📋 **Portieren nach domain/** |
|
||||
| | | | |
|
||||
| **Internal Helpers** | | | |
|
||||
| `refresh_handle()` | ✅ private | ❌ | ℹ️ Evtl. bereits integriert |
|
||||
| `apply_rotation()` | ❌ | ✅ | ℹ️ Extra in domain |
|
||||
| `apply_flip()` | ❌ | ✅ | ℹ️ Extra in domain |
|
||||
| `create_image_handle_from_image()` | ❌ | ✅ | ℹ️ Extra in domain |
|
||||
| | | | |
|
||||
| **Extra Features (domain)** | | | |
|
||||
| `rotate_fine()` | ❌ | ✅ | ℹ️ Feature in domain |
|
||||
| `reset_fine_rotation()` | ❌ | ✅ | ℹ️ Feature in domain |
|
||||
| `set_interpolation_quality()` | ❌ | ✅ | ℹ️ Feature in domain |
|
||||
| `resize_to_format()` | ❌ | ✅ | ℹ️ Feature in domain |
|
||||
|
||||
---
|
||||
|
||||
### Zusammenfassung RasterDocument
|
||||
|
||||
**Domain-Version ist fortgeschrittener** ✅
|
||||
- Mehr Features (fine rotation, interpolation quality, resize)
|
||||
- Bessere API (getter statt public fields)
|
||||
- Saubere Helper-Funktionen
|
||||
|
||||
**Aus app/ portieren:**
|
||||
1. ✅ `crop_to_image()` - Nicht-destruktives Crop
|
||||
2. ✅ `extract_meta()` - Metadaten-Extraktion
|
||||
3. ✅ `handle` public machen ODER getter `handle()` nutzen (bereits vorhanden!)
|
||||
|
||||
**Entscheidung:** Domain-Version als Basis, nur 2 Methoden fehlen
|
||||
|
||||
---
|
||||
|
||||
## VectorDocument
|
||||
|
||||
### Methoden-Vergleich
|
||||
|
||||
| Methode | app/ | domain/ | Aktion |
|
||||
|---------|------|---------|--------|
|
||||
| `open()` | ✅ | ✅ | ✅ OK |
|
||||
| `render()` | ✅ | ✅ | ✅ OK |
|
||||
| `dimensions()` | ✅ | ❌ | 📋 **Portieren** |
|
||||
| `handle` (pub field) | ✅ | ❌ private | 📋 **Public machen oder getter** |
|
||||
| `extract_meta()` | ✅ | ❌ | 📋 **Portieren** |
|
||||
| `crop()` | ❌ | ❌ | 📋 **Neu implementieren** (Design-Entscheidung) |
|
||||
|
||||
**Aus app/ portieren:**
|
||||
1. `dimensions()`
|
||||
2. `extract_meta()`
|
||||
3. `handle()` getter oder public
|
||||
4. NEU: `crop()` implementieren (render-based)
|
||||
|
||||
---
|
||||
|
||||
## PortableDocument
|
||||
|
||||
### Methoden-Vergleich
|
||||
|
||||
| Methode | app/ | domain/ | Aktion |
|
||||
|---------|------|---------|--------|
|
||||
| `open()` | ✅ | ✅ | ✅ OK |
|
||||
| `render()` | ✅ | ✅ | ✅ OK |
|
||||
| `dimensions()` | ✅ | ❌ | 📋 **Portieren** |
|
||||
| `handle` (pub field) | ✅ | ❌ private | 📋 **Public machen oder getter** |
|
||||
| `extract_meta()` | ✅ | ❌ | 📋 **Portieren** |
|
||||
| `crop()` | ❌ | ❌ | 📋 **Neu implementieren** (Design-Entscheidung) |
|
||||
| Thumbnails | ✅ | ✅ | ℹ️ Prüfen ob identisch |
|
||||
|
||||
**Aus app/ portieren:**
|
||||
1. `dimensions()`
|
||||
2. `extract_meta()`
|
||||
3. `handle()` getter oder public
|
||||
4. NEU: `crop()` implementieren (render-based)
|
||||
|
||||
---
|
||||
|
||||
## Action Items für Schritt 1.2-1.4
|
||||
|
||||
### Schritt 1.2: RasterDocument (60 Min)
|
||||
- [x] `crop()` - Bereits vorhanden! ✅
|
||||
- [x] `dimensions()` - Bereits vorhanden! ✅
|
||||
- [x] `crop_to_image()` hinzufügen ✅
|
||||
- [x] `extract_meta()` hinzufügen ✅ (oder in core/metadata.rs)
|
||||
- [x] `handle()` getter - Bereits vorhanden! ✅
|
||||
|
||||
### Schritt 1.3: VectorDocument (45 Min)
|
||||
- [ ] `dimensions()` implementieren
|
||||
- [ ] `handle()` getter hinzufügen
|
||||
- [ ] `extract_meta()` implementieren
|
||||
- [ ] `crop()` implementieren (render-based, neu!)
|
||||
|
||||
### Schritt 1.4: PortableDocument (45 Min)
|
||||
- [ ] `dimensions()` implementieren
|
||||
- [ ] `handle()` getter hinzufügen
|
||||
- [ ] `extract_meta()` implementieren
|
||||
- [ ] `crop()` implementieren (render-based, neu!)
|
||||
|
||||
---
|
||||
|
||||
## Überraschende Erkenntnisse
|
||||
|
||||
1. **Domain hat bereits crop() für Raster!** ✅
|
||||
2. **Domain hat bereits dimensions()!** ✅
|
||||
3. **Domain hat bereits handle() getter!** ✅
|
||||
4. **Domain hat MEHR Features** (fine rotation, interpolation) ✅
|
||||
|
||||
**→ Domain-Implementierung ist besser! Nur 2-3 Methoden fehlen pro Type.**
|
||||
|
||||
---
|
||||
|
||||
**Status:** Vergleich abgeschlossen
|
||||
**Nächster Schritt:** 1.2 - RasterDocument ergänzen
|
||||
358
src/domain/document/types/raster.rs
Normal file
358
src/domain/document/types/raster.rs
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/domain/document/types/raster.rs
|
||||
//
|
||||
// Raster image document support (PNG, JPEG, WebP, etc.).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use image::{DynamicImage, GenericImageView, ImageReader};
|
||||
|
||||
use cosmic::widget::image::Handle as ImageHandle;
|
||||
|
||||
use crate::domain::document::core::document::{
|
||||
DocResult, DocumentInfo, FlipDirection, InterpolationQuality, Renderable, RenderOutput,
|
||||
Rotation, RotationMode, TransformState, Transformable,
|
||||
};
|
||||
|
||||
/// Represents a raster image document (PNG, JPEG, WebP, ...).
|
||||
pub struct RasterDocument {
|
||||
/// The decoded image document.
|
||||
document: DynamicImage,
|
||||
/// Native width (original, before transforms).
|
||||
native_width: u32,
|
||||
/// Native height (original, before transforms).
|
||||
native_height: u32,
|
||||
/// Current transformation state.
|
||||
transform: TransformState,
|
||||
/// Cached handle for rendering.
|
||||
handle: ImageHandle,
|
||||
/// Accumulated fine rotation angle in degrees.
|
||||
fine_rotation_angle: f32,
|
||||
/// Interpolation quality for fine rotation and resize operations.
|
||||
interpolation_quality: InterpolationQuality,
|
||||
}
|
||||
|
||||
impl RasterDocument {
|
||||
/// Load a raster document from disk.
|
||||
pub fn open(path: &Path) -> image::ImageResult<Self> {
|
||||
let document = ImageReader::open(path)?.decode()?;
|
||||
let (native_width, native_height) = document.dimensions();
|
||||
let handle = Self::create_image_handle_from_image(&document);
|
||||
|
||||
Ok(Self {
|
||||
document,
|
||||
native_width,
|
||||
native_height,
|
||||
transform: TransformState::default(),
|
||||
handle,
|
||||
fine_rotation_angle: 0.0,
|
||||
interpolation_quality: InterpolationQuality::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the current pixel dimensions (width, height) after transforms.
|
||||
#[must_use]
|
||||
pub fn dimensions(&self) -> (u32, u32) {
|
||||
self.document.dimensions()
|
||||
}
|
||||
|
||||
/// Get the current image handle.
|
||||
#[must_use]
|
||||
pub fn handle(&self) -> ImageHandle {
|
||||
self.handle.clone()
|
||||
}
|
||||
|
||||
/// Save the current document to disk.
|
||||
#[allow(dead_code)]
|
||||
pub fn save(&self, path: &Path) -> image::ImageResult<()> {
|
||||
self.document.save(path)
|
||||
}
|
||||
|
||||
/// Get the underlying `DynamicImage`.
|
||||
#[must_use]
|
||||
pub fn image(&self) -> &DynamicImage {
|
||||
&self.document
|
||||
}
|
||||
|
||||
/// Get native dimensions (before transformations).
|
||||
#[must_use]
|
||||
pub fn native_dimensions(&self) -> (u32, u32) {
|
||||
(self.native_width, self.native_height)
|
||||
}
|
||||
|
||||
/// Get a reference to the rendered image (for cropping from screen coordinates).
|
||||
pub fn get_rendered_image(&self) -> &DynamicImage {
|
||||
&self.document
|
||||
}
|
||||
|
||||
/// Crop the document to a specified rectangular region (in-place).
|
||||
///
|
||||
/// Coordinates are in pixels relative to the current image dimensions.
|
||||
/// The crop region is clamped to image bounds if it extends beyond.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the crop region is completely outside the image bounds.
|
||||
pub fn crop(&mut self, x: u32, y: u32, width: u32, height: u32) -> Result<(), String> {
|
||||
let (img_width, img_height) = self.document.dimensions();
|
||||
|
||||
// Validate crop region
|
||||
if x >= img_width || y >= img_height {
|
||||
return Err(format!(
|
||||
"Crop region ({}, {}) is outside image bounds ({}, {})",
|
||||
x, y, img_width, img_height
|
||||
));
|
||||
}
|
||||
|
||||
// Clamp dimensions to image bounds
|
||||
let crop_width = width.min(img_width - x);
|
||||
let crop_height = height.min(img_height - y);
|
||||
|
||||
if crop_width == 0 || crop_height == 0 {
|
||||
return Err("Crop region has zero width or height".to_string());
|
||||
}
|
||||
|
||||
// Apply crop
|
||||
self.document = self.document.crop_imm(x, y, crop_width, crop_height);
|
||||
|
||||
// Update native dimensions to the cropped size
|
||||
self.native_width = crop_width;
|
||||
self.native_height = crop_height;
|
||||
|
||||
// Reset transformations since we have a new "native" image
|
||||
self.transform = TransformState::default();
|
||||
self.fine_rotation_angle = 0.0;
|
||||
|
||||
// Regenerate handle
|
||||
self.handle = Self::create_image_handle_from_image(&self.document);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
/// Crop the image to the specified rectangle and return as DynamicImage.
|
||||
///
|
||||
/// This does NOT modify the document - it's used for exporting cropped images.
|
||||
pub fn crop_to_image(
|
||||
&self,
|
||||
x: u32,
|
||||
y: u32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<DynamicImage, String> {
|
||||
let (img_width, img_height) = self.document.dimensions();
|
||||
|
||||
// Validate crop region
|
||||
if x >= img_width || y >= img_height {
|
||||
return Err(format!(
|
||||
"Crop rectangle out of bounds: {}x{} at ({}, {}) exceeds image size {}x{}",
|
||||
width, height, x, y, img_width, img_height
|
||||
));
|
||||
}
|
||||
|
||||
// Clamp dimensions to image bounds
|
||||
let crop_width = width.min(img_width - x);
|
||||
let crop_height = height.min(img_height - y);
|
||||
|
||||
if crop_width == 0 || crop_height == 0 {
|
||||
return Err("Crop region has zero width or height".to_string());
|
||||
}
|
||||
|
||||
let cropped = self.document.crop_imm(x, y, crop_width, crop_height);
|
||||
Ok(cropped)
|
||||
}
|
||||
|
||||
/// Extract metadata for this raster document.
|
||||
///
|
||||
/// Returns basic metadata (dimensions, format, file size) and EXIF data if available.
|
||||
pub fn extract_meta(&self, path: &Path) -> crate::domain::document::core::metadata::DocumentMeta {
|
||||
use crate::domain::document::core::metadata::{BasicMeta, DocumentMeta, ExifMeta};
|
||||
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let file_path = path.to_string_lossy().to_string();
|
||||
|
||||
let file_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
|
||||
|
||||
// Detect format from path
|
||||
let format = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_uppercase();
|
||||
|
||||
let color_type = format!("{:?}", self.document.color());
|
||||
|
||||
let basic = BasicMeta {
|
||||
file_name,
|
||||
file_path,
|
||||
format,
|
||||
width: self.native_width,
|
||||
height: self.native_height,
|
||||
file_size,
|
||||
color_type,
|
||||
};
|
||||
|
||||
// Try to extract EXIF data
|
||||
let exif = std::fs::read(path)
|
||||
.ok()
|
||||
.and_then(|bytes| ExifMeta::from_bytes(&bytes));
|
||||
|
||||
DocumentMeta { basic, exif }
|
||||
}
|
||||
|
||||
/// Resize the document to specific dimensions (for format conversion).
|
||||
///
|
||||
/// This is useful for converting images to standard paper formats (A4, US Letter, etc.).
|
||||
pub fn resize_to_format(&mut self, target_width: u32, target_height: u32) {
|
||||
use image::imageops::FilterType;
|
||||
|
||||
let filter = match self.interpolation_quality {
|
||||
InterpolationQuality::Fast => FilterType::Nearest,
|
||||
InterpolationQuality::Balanced => FilterType::Triangle,
|
||||
InterpolationQuality::Best => FilterType::CatmullRom,
|
||||
};
|
||||
|
||||
self.document = self
|
||||
.document
|
||||
.resize_exact(target_width, target_height, filter);
|
||||
self.handle = Self::create_image_handle_from_image(&self.document);
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
fn create_image_handle_from_image(img: &DynamicImage) -> ImageHandle {
|
||||
let (width, height) = img.dimensions();
|
||||
let pixels = img.to_rgba8().into_raw();
|
||||
ImageHandle::from_rgba(width, height, pixels)
|
||||
}
|
||||
|
||||
fn apply_rotation(img: DynamicImage, rotation: Rotation) -> DynamicImage {
|
||||
use image::imageops::{rotate180, rotate270, rotate90};
|
||||
match rotation {
|
||||
Rotation::None => img,
|
||||
Rotation::Cw90 => DynamicImage::ImageRgba8(rotate90(&img.to_rgba8())),
|
||||
Rotation::Cw180 => DynamicImage::ImageRgba8(rotate180(&img.to_rgba8())),
|
||||
Rotation::Cw270 => DynamicImage::ImageRgba8(rotate270(&img.to_rgba8())),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_flip(img: DynamicImage, direction: FlipDirection) -> DynamicImage {
|
||||
use image::imageops::{flip_horizontal, flip_vertical};
|
||||
match direction {
|
||||
FlipDirection::Horizontal => DynamicImage::ImageRgba8(flip_horizontal(&img.to_rgba8())),
|
||||
FlipDirection::Vertical => DynamicImage::ImageRgba8(flip_vertical(&img.to_rgba8())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Trait Implementations
|
||||
// ============================================================================
|
||||
|
||||
impl Renderable for RasterDocument {
|
||||
fn render(&mut self, _scale: f64) -> DocResult<RenderOutput> {
|
||||
// Raster images don't re-render at different scales (lossy),
|
||||
// we just return the current handle.
|
||||
let (width, height) = self.dimensions();
|
||||
Ok(RenderOutput {
|
||||
handle: self.handle.clone(),
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
|
||||
fn info(&self) -> DocumentInfo {
|
||||
DocumentInfo {
|
||||
width: self.native_width,
|
||||
height: self.native_height,
|
||||
format: "Raster".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Transformable for RasterDocument {
|
||||
fn rotate(&mut self, rotation: Rotation) {
|
||||
// Extract current rotation in degrees
|
||||
let current_deg = match self.transform.rotation {
|
||||
RotationMode::Standard(r) => r.to_degrees(),
|
||||
RotationMode::Fine(_) => {
|
||||
// If we have fine rotation, reset it and apply standard rotation
|
||||
self.fine_rotation_angle = 0.0;
|
||||
0
|
||||
}
|
||||
};
|
||||
|
||||
let new_deg = rotation.to_degrees();
|
||||
let diff_deg = (new_deg - current_deg + 360) % 360;
|
||||
|
||||
if diff_deg != 0 {
|
||||
let rotation_to_apply = match diff_deg {
|
||||
90 => Rotation::Cw90,
|
||||
180 => Rotation::Cw180,
|
||||
270 => Rotation::Cw270,
|
||||
_ => unreachable!("Invalid rotation diff: {}", diff_deg),
|
||||
};
|
||||
self.document = Self::apply_rotation(
|
||||
std::mem::replace(&mut self.document, DynamicImage::new_rgb8(1, 1)),
|
||||
rotation_to_apply,
|
||||
);
|
||||
}
|
||||
|
||||
// Set to standard rotation mode
|
||||
self.transform.rotation = RotationMode::Standard(rotation);
|
||||
self.handle = Self::create_image_handle_from_image(&self.document);
|
||||
}
|
||||
|
||||
fn flip(&mut self, direction: FlipDirection) {
|
||||
self.document = Self::apply_flip(
|
||||
std::mem::replace(&mut self.document, DynamicImage::new_rgb8(1, 1)),
|
||||
direction,
|
||||
);
|
||||
match direction {
|
||||
FlipDirection::Horizontal => self.transform.flip_h = !self.transform.flip_h,
|
||||
FlipDirection::Vertical => self.transform.flip_v = !self.transform.flip_v,
|
||||
}
|
||||
self.handle = Self::create_image_handle_from_image(&self.document);
|
||||
}
|
||||
|
||||
fn transform_state(&self) -> TransformState {
|
||||
self.transform
|
||||
}
|
||||
|
||||
fn rotate_fine(&mut self, angle_degrees: f32) {
|
||||
use imageproc::geometric_transformations::{rotate_about_center, Interpolation};
|
||||
|
||||
let interpolation = match self.interpolation_quality {
|
||||
InterpolationQuality::Fast => Interpolation::Nearest,
|
||||
InterpolationQuality::Balanced => Interpolation::Bilinear,
|
||||
InterpolationQuality::Best => Interpolation::Bicubic,
|
||||
};
|
||||
|
||||
// Convert to RGBA8 for imageproc
|
||||
let rgba_img = self.document.to_rgba8();
|
||||
|
||||
// Rotate with transparent background
|
||||
let rotated = rotate_about_center(
|
||||
&rgba_img,
|
||||
angle_degrees.to_radians(),
|
||||
interpolation,
|
||||
image::Rgba([255, 255, 255, 0]),
|
||||
);
|
||||
|
||||
self.document = DynamicImage::ImageRgba8(rotated);
|
||||
self.fine_rotation_angle += angle_degrees;
|
||||
self.transform.rotation = RotationMode::Fine(self.fine_rotation_angle);
|
||||
self.handle = Self::create_image_handle_from_image(&self.document);
|
||||
}
|
||||
|
||||
fn reset_fine_rotation(&mut self) {
|
||||
self.fine_rotation_angle = 0.0;
|
||||
self.transform.rotation = RotationMode::Standard(Rotation::None);
|
||||
}
|
||||
|
||||
fn set_interpolation_quality(&mut self, quality: InterpolationQuality) {
|
||||
self.interpolation_quality = quality;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue