diff --git a/integration_test/export_flow_test.dart b/integration_test/export_flow_test.dart index bc8bed1..e93e65f 100644 --- a/integration_test/export_flow_test.dart +++ b/integration_test/export_flow_test.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import 'package:image/image.dart' as img; import 'dart:io'; import 'package:file_selector/file_selector.dart' as fs; @@ -18,6 +17,7 @@ import 'package:pdf_signature/ui/features/pdf/view_model/pdf_export_view_model.d import 'package:pdf_signature/ui/features/pdf/widgets/pages_sidebar.dart'; import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:image/image.dart' as img; import 'package:pdf_signature/data/repositories/preferences_repository.dart'; import 'package:pdf_signature/l10n/app_localizations.dart'; @@ -38,7 +38,7 @@ class LightweightExporter extends ExportService { required Size uiPageSize, required Uint8List? signatureImageBytes, Map>? placementsByPage, - Map? libraryBytes, + Map? libraryImages, double targetDpi = 144.0, }) async { // Return minimal non-empty bytes; content isn't used further in tests @@ -147,12 +147,15 @@ void main() { ), signatureAssetRepositoryProvider.overrideWith((ref) { final c = SignatureAssetRepository(); - c.add(sigBytes, name: 'image'); + c.addImage(img.decodeImage(sigBytes)!, name: 'image'); return c; }), signatureCardRepositoryProvider.overrideWith((ref) { final cardRepo = SignatureCardStateNotifier(); - final asset = SignatureAsset(bytes: sigBytes, name: 'image'); + final asset = SignatureAsset( + sigImage: img.decodeImage(sigBytes)!, + name: 'image', + ); cardRepo.addWithAsset(asset, 0.0); return cardRepo; }), diff --git a/integration_test/pdf_view_test.dart b/integration_test/pdf_view_test.dart index c529e4e..69a8aec 100644 --- a/integration_test/pdf_view_test.dart +++ b/integration_test/pdf_view_test.dart @@ -3,7 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'dart:io'; -import 'package:flutter/services.dart'; import 'package:file_selector/file_selector.dart' as fs; import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart'; diff --git a/lib/data/repositories/document_repository.dart b/lib/data/repositories/document_repository.dart index fcc4a45..811f9e2 100644 --- a/lib/data/repositories/document_repository.dart +++ b/lib/data/repositories/document_repository.dart @@ -1,4 +1,5 @@ import 'dart:typed_data'; +import 'package:image/image.dart' as img; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:pdf_signature/data/services/export_service.dart'; @@ -58,7 +59,7 @@ class DocumentStateNotifier extends StateNotifier { list.add( SignaturePlacement( rect: rect, - asset: asset ?? SignatureAsset(bytes: _singleTransparentPng), + asset: asset ?? SignatureAsset(sigImage: _singleTransparentPng), rotationDeg: rotationDeg, graphicAdjust: graphicAdjust ?? const GraphicAdjust(), ), @@ -69,75 +70,7 @@ class DocumentStateNotifier extends StateNotifier { // Tiny 1x1 transparent PNG to avoid decode crashes in tests when no real // signature bytes were provided. - static final Uint8List _singleTransparentPng = Uint8List.fromList([ - 0x89, - 0x50, - 0x4E, - 0x47, - 0x0D, - 0x0A, - 0x1A, - 0x0A, - 0x00, - 0x00, - 0x00, - 0x0D, - 0x49, - 0x48, - 0x44, - 0x52, - 0x00, - 0x00, - 0x00, - 0x01, - 0x00, - 0x00, - 0x00, - 0x01, - 0x08, - 0x06, - 0x00, - 0x00, - 0x00, - 0x1F, - 0x15, - 0xC4, - 0x89, - 0x00, - 0x00, - 0x00, - 0x0A, - 0x49, - 0x44, - 0x41, - 0x54, - 0x78, - 0x9C, - 0x63, - 0x60, - 0x00, - 0x00, - 0x00, - 0x02, - 0x00, - 0x01, - 0xE5, - 0x27, - 0xD4, - 0xA6, - 0x00, - 0x00, - 0x00, - 0x00, - 0x49, - 0x45, - 0x4E, - 0x44, - 0xAE, - 0x42, - 0x60, - 0x82, - ]); + static final img.Image _singleTransparentPng = img.Image(width: 1, height: 1); void updatePlacementRotation({ required int page, diff --git a/lib/data/repositories/signature_asset_repository.dart b/lib/data/repositories/signature_asset_repository.dart index de530e7..d57037c 100644 --- a/lib/data/repositories/signature_asset_repository.dart +++ b/lib/data/repositories/signature_asset_repository.dart @@ -1,4 +1,4 @@ -import 'dart:typed_data'; +import 'package:image/image.dart' as img; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:pdf_signature/domain/models/model.dart'; @@ -6,11 +6,9 @@ import 'package:pdf_signature/domain/models/model.dart'; class SignatureAssetRepository extends StateNotifier> { SignatureAssetRepository() : super(const []); - void add(Uint8List bytes, {String? name}) { - // Always add a new asset (allow duplicates). This lets users create multiple cards - // even when loading the same image repeatedly for different adjustments/usages. - if (bytes.isEmpty) return; - state = List.of(state)..add(SignatureAsset(bytes: bytes, name: name)); + /// Preferred API: add from an already decoded image to avoid re-decodes. + void addImage(img.Image image, {String? name}) { + state = List.of(state)..add(SignatureAsset(sigImage: image, name: name)); } void remove(SignatureAsset asset) { diff --git a/lib/data/repositories/signature_card_repository.dart b/lib/data/repositories/signature_card_repository.dart index 675c195..1269162 100644 --- a/lib/data/repositories/signature_card_repository.dart +++ b/lib/data/repositories/signature_card_repository.dart @@ -1,43 +1,55 @@ -import 'dart:typed_data'; +import 'package:image/image.dart' as img; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../domain/models/model.dart'; import '../../data/services/signature_image_processing_service.dart'; class DisplaySignatureData { - final Uint8List bytes; // bytes to render + final img.Image image; // image to render (image-first path) final List? colorMatrix; // optional GPU color matrix - const DisplaySignatureData({required this.bytes, this.colorMatrix}); + const DisplaySignatureData({required this.image, this.colorMatrix}); } /// CachedSignatureCard extends SignatureCard with an internal processed cache class CachedSignatureCard extends SignatureCard { - Uint8List? _cachedProcessed; + img.Image? _cachedProcessedImage; CachedSignatureCard({ required super.asset, required super.rotationDeg, super.graphicAdjust, - Uint8List? initialProcessed, - }); + img.Image? initialProcessedImage, + }) { + // Seed cache if provided + if (initialProcessedImage != null) { + _cachedProcessedImage = initialProcessedImage; + } + } - /// Returns cached processed bytes for the current [graphicAdjust], computing + /// Returns cached processed image for the current [graphicAdjust], computing /// via [service] if not cached yet. - Uint8List getOrComputeProcessed(SignatureImageProcessingService service) { - final existing = _cachedProcessed; - if (existing != null) return existing; - final computed = service.processImage(asset.bytes, graphicAdjust); - _cachedProcessed = computed; - return computed; + img.Image getOrComputeProcessedImage( + SignatureImageProcessingService service, + ) { + final existing = _cachedProcessedImage; + if (existing != null) { + return existing; + } + final computedImage = service.processImageToImage( + asset.sigImage, + graphicAdjust, + ); + _cachedProcessedImage = computedImage; + return computedImage; } - /// Invalidate the cached processed bytes, forcing recompute next time. + /// Invalidate the cached processed image, forcing recompute next time. void invalidateCache() { - _cachedProcessed = null; + _cachedProcessedImage = null; } - /// Sets/updates the processed bytes explicitly (used after adjustments update) - void setProcessed(Uint8List bytes) { - _cachedProcessed = bytes; + /// Sets/updates the processed image explicitly (used after adjustments update) + void setProcessedImage(img.Image image) { + _cachedProcessedImage = image; } factory CachedSignatureCard.initial() => CachedSignatureCard( @@ -90,8 +102,8 @@ class SignatureCardStateNotifier graphicAdjust: graphicAdjust ?? c.graphicAdjust, ); // Compute and set the single processed bytes for the updated adjust - final processed = _processingService.processImage( - updated.asset.bytes, + final processedImage = _processingService.processImageToImage( + updated.asset.sigImage, updated.graphicAdjust, ); final next = CachedSignatureCard( @@ -99,7 +111,7 @@ class SignatureCardStateNotifier rotationDeg: updated.rotationDeg, graphicAdjust: updated.graphicAdjust, ); - next.setProcessed(processed); + next.setProcessedImage(processedImage); list[i] = next; state = List.unmodifiable(list); return; @@ -117,47 +129,58 @@ class SignatureCardStateNotifier state = const []; } - /// Returns processed image bytes for the given asset + adjustments. - /// Uses an internal cache to avoid re-processing. - Uint8List getProcessedBytes(SignatureAsset asset, GraphicAdjust adjust) { + /// New: Returns processed decoded image for the given asset + adjustments. + img.Image getProcessedImage(SignatureAsset asset, GraphicAdjust adjust) { // Try to find a matching card by asset for (final c in state) { if (c.asset == asset) { - // If requested adjust equals the card's current adjust, use per-card cache if (c.graphicAdjust == adjust) { - return c.getOrComputeProcessed(_processingService); + // If cached bytes exist, decode once; otherwise compute from image + if (c._cachedProcessedImage != null) { + return c._cachedProcessedImage!; + } + return _processingService.processImageToImage( + c.asset.sigImage, + c.graphicAdjust, + ); } - // Previewing unsaved adjustments: compute without caching - return _processingService.processImage(asset.bytes, adjust); + // Previewing unsaved adjustments: compute from image without caching + return _processingService.processImageToImage(asset.sigImage, adjust); } } // Asset not found among cards (e.g., preview in dialog): compute on-the-fly - return _processingService.processImage(asset.bytes, adjust); + return _processingService.processImageToImage(asset.sigImage, adjust); } - /// Provide display data optimized: if bgRemoval false, returns original bytes + matrix; - /// if bgRemoval true, returns processed bytes with baked adjustments and null matrix. + /// Provide display data optimized: if bgRemoval false, returns original image + matrix; + /// if bgRemoval true, returns processed image with baked adjustments and null matrix. DisplaySignatureData getDisplayData( SignatureAsset asset, GraphicAdjust adjust, ) { if (!adjust.bgRemoval) { - // Find card for potential original bytes (identical object) - no CPU processing. - for (final c in state) { - if (c.asset == asset) { - final matrix = _processingService.buildColorMatrix(adjust); - return DisplaySignatureData( - bytes: c.asset.bytes, - colorMatrix: matrix, - ); - } - } + // No CPU processing. Return original image + matrix for consumers. final matrix = _processingService.buildColorMatrix(adjust); - return DisplaySignatureData(bytes: asset.bytes, colorMatrix: matrix); + return DisplaySignatureData(image: asset.sigImage, colorMatrix: matrix); } - // bgRemoval path: need CPU processed bytes (includes brightness/contrast first) - final processed = getProcessedBytes(asset, adjust); - return DisplaySignatureData(bytes: processed, colorMatrix: null); + // bgRemoval path: provide processed image with baked adjustments. + final processed = getProcessedImage(asset, adjust); + return DisplaySignatureData(image: processed, colorMatrix: null); + } + + /// New: Provide display image optimized for UI widgets that can accept img.Image. + /// If bgRemoval is false, returns original image and a GPU color matrix. + /// If bgRemoval is true, returns processed image with baked adjustments and null matrix. + (img.Image image, List? colorMatrix) getDisplayImage( + SignatureAsset asset, + GraphicAdjust adjust, + ) { + if (!adjust.bgRemoval) { + final matrix = _processingService.buildColorMatrix(adjust); + return (asset.sigImage, matrix); + } + final processed = getProcessedImage(asset, adjust); + return (processed, null); } /// Clears all cached processed images. diff --git a/lib/data/services/export_service.dart b/lib/data/services/export_service.dart index 8469707..bdf3809 100644 --- a/lib/data/services/export_service.dart +++ b/lib/data/services/export_service.dart @@ -9,6 +9,7 @@ import 'package:image/image.dart' as img; import '../../domain/models/model.dart'; // math moved to utils in rot import '../../utils/rotation_utils.dart' as rot; +import '../../utils/background_removal.dart' as br; // NOTE: // - This exporter uses a raster snapshot of the UI (RepaintBoundary) and embeds it into a new PDF. @@ -23,109 +24,82 @@ class ExportService { required Size uiPageSize, required Uint8List? signatureImageBytes, Map>? placementsByPage, - Map? libraryBytes, + Map? libraryImages, double targetDpi = 144.0, }) async { // Per-call caches to avoid redundant decode/encode and image embedding work - final Map _processedBytesCache = {}; + final Map _baseImageCache = {}; + final Map _processedImageCache = {}; + final Map _encodedPngCache = {}; final Map _memoryImageCache = {}; final Map _aspectRatioCache = {}; // Returns a stable-ish cache key for bytes within this process (not content-hash, but good enough per-call) - String _baseKeyForBytes(Uint8List b) => - '${identityHashCode(b)}:${b.length}'; + String _baseKeyForImage(img.Image im) => + 'im:${identityHashCode(im)}:${im.width}x${im.height}'; + String _adjustKey(GraphicAdjust adj) => + 'c=${adj.contrast}|b=${adj.brightness}|bg=${adj.bgRemoval}'; - // Fast PNG signature check (no string allocation) - bool _isPng(Uint8List bytes) { - if (bytes.length < 8) return false; - return bytes[0] == 0x89 && - bytes[1] == 0x50 && // P - bytes[2] == 0x4E && // N - bytes[3] == 0x47 && // G - bytes[4] == 0x0D && - bytes[5] == 0x0A && - bytes[6] == 0x1A && - bytes[7] == 0x0A; - } + // Removed: PNG signature helper is no longer needed; we always encode to PNG explicitly. - // Resolve base (unprocessed) bytes for a placement, considering library override. - Uint8List _getBaseBytes(SignaturePlacement placement) { - Uint8List baseBytes = placement.asset.bytes; + // Resolve base (unprocessed) image for a placement, considering library override. + img.Image _getBaseImage(SignaturePlacement placement) { final libKey = placement.asset.name; - if (libKey != null && libraryBytes != null) { - final libBytes = libraryBytes[libKey]; - if (libBytes != null && libBytes.isNotEmpty) { - baseBytes = libBytes; + if (libKey != null && libraryImages != null) { + final cached = _baseImageCache[libKey]; + if (cached != null) return cached; + final provided = libraryImages[libKey]; + if (provided != null) { + _baseImageCache[libKey] = provided; + return provided; } } - return baseBytes; + return placement.asset.sigImage; } - // Get processed bytes for a placement, with caching. - Uint8List _getProcessedBytes(SignaturePlacement placement) { - final Uint8List baseBytes = _getBaseBytes(placement); - - final adj = placement.graphicAdjust; - final cacheKey = - '${_baseKeyForBytes(baseBytes)}|c=${adj.contrast}|b=${adj.brightness}|bg=${adj.bgRemoval}'; - final cached = _processedBytesCache[cacheKey]; + // Get processed image for a placement, with caching. + img.Image _getProcessedImage(SignaturePlacement placement) { + final base = _getBaseImage(placement); + final key = + '${_baseKeyForImage(base)}|${_adjustKey(placement.graphicAdjust)}'; + final cached = _processedImageCache[key]; if (cached != null) return cached; - - // If no graphic changes requested, return bytes as-is (conversion to PNG is deferred to MemoryImage step) - final bool needsAdjust = - (adj.contrast != 1.0 || adj.brightness != 1.0 || adj.bgRemoval); - if (!needsAdjust) { - _processedBytesCache[cacheKey] = baseBytes; - return baseBytes; + final adj = placement.graphicAdjust; + img.Image processed = base; + if (adj.contrast != 1.0 || adj.brightness != 1.0) { + processed = img.adjustColor( + processed, + contrast: adj.contrast, + brightness: adj.brightness, + ); } - - try { - final decoded = img.decodeImage(baseBytes); - if (decoded == null) { - _processedBytesCache[cacheKey] = baseBytes; - return baseBytes; - } - img.Image processed = decoded; - - if (adj.contrast != 1.0 || adj.brightness != 1.0) { - processed = img.adjustColor( - processed, - contrast: adj.contrast, - brightness: adj.brightness, - ); - } - - if (adj.bgRemoval) { - processed = _removeBackground(processed); - } - - final outBytes = Uint8List.fromList(img.encodePng(processed)); - _processedBytesCache[cacheKey] = outBytes; - return outBytes; - } catch (_) { - // If processing fails, fall back to original - _processedBytesCache[cacheKey] = baseBytes; - return baseBytes; + if (adj.bgRemoval) { + processed = br.removeNearWhiteBackground(processed, threshold: 240); } + _processedImageCache[key] = processed; + return processed; } - // Wrap bytes in a pw.MemoryImage with caching, converting to PNG only when necessary. - pw.MemoryImage? _getMemoryImage(Uint8List bytes) { - final key = _baseKeyForBytes(bytes); + // Get PNG bytes for the processed image, caching the encoding. + Uint8List _getProcessedPng(SignaturePlacement placement) { + final base = _getBaseImage(placement); + final key = + '${_baseKeyForImage(base)}|${_adjustKey(placement.graphicAdjust)}'; + final cached = _encodedPngCache[key]; + if (cached != null) return cached; + final processed = _getProcessedImage(placement); + final png = Uint8List.fromList(img.encodePng(processed, level: 6)); + _encodedPngCache[key] = png; + return png; + } + + // Wrap bytes in a pw.MemoryImage with caching. + pw.MemoryImage? _getMemoryImage(Uint8List bytes, String key) { final cached = _memoryImageCache[key]; if (cached != null) return cached; try { - if (_isPng(bytes)) { - final imgObj = pw.MemoryImage(bytes); - _memoryImageCache[key] = imgObj; - return imgObj; - } - // Convert to PNG to preserve transparency if not already PNG - final decoded = img.decodeImage(bytes); - if (decoded == null) return null; - final png = Uint8List.fromList(img.encodePng(decoded, level: 6)); - final imgObj = pw.MemoryImage(png); + final imgObj = pw.MemoryImage(bytes); _memoryImageCache[key] = imgObj; return imgObj; } catch (_) { @@ -133,22 +107,15 @@ class ExportService { } } - // Compute and cache aspect ratio (width/height) for given bytes - double? _getAspectRatioFromBytes(Uint8List bytes) { - final key = _baseKeyForBytes(bytes); + // Compute and cache aspect ratio (width/height) for given image + double? _getAspectRatioFromImage(img.Image image) { + final key = _baseKeyForImage(image); final c = _aspectRatioCache[key]; if (c != null) return c; - try { - final decoded = img.decodeImage(bytes); - if (decoded == null || decoded.width <= 0 || decoded.height <= 0) { - return null; - } - final ar = decoded.width / decoded.height; - _aspectRatioCache[key] = ar; - return ar; - } catch (_) { - return null; - } + if (image.width <= 0 || image.height <= 0) return null; + final ar = image.width / image.height; + _aspectRatioCache[key] = ar; + return ar; } final out = pw.Document(version: pdf.PdfVersion.pdf_1_4, compress: false); @@ -206,20 +173,18 @@ class ExportService { final w = r.width * widthPts; final h = r.height * heightPts; - // Get processed bytes (cached) and then embed as MemoryImage (cached) - Uint8List bytes = _getProcessedBytes(placement); - if (bytes.isEmpty && signatureImageBytes != null) { - bytes = signatureImageBytes; - } - - if (bytes.isNotEmpty) { - final imgObj = _getMemoryImage(bytes); + // Get processed image and embed as MemoryImage (cached) + final processedPng = _getProcessedPng(placement); + final baseImage = _getBaseImage(placement); + final memKey = + '${_baseKeyForImage(baseImage)}|${_adjustKey(placement.graphicAdjust)}'; + if (processedPng.isNotEmpty) { + final imgObj = _getMemoryImage(processedPng, memKey); if (imgObj != null) { // Align with RotatedSignatureImage: counterclockwise positive final angle = rot.radians(placement.rotationDeg); - // Prefer AR from base bytes to avoid extra decode of processed - final baseBytes = _getBaseBytes(placement); - final ar = _getAspectRatioFromBytes(baseBytes); + // Use AR from base image + final ar = _getAspectRatioFromImage(baseImage); final scaleToFit = rot.scaleToFitForAngle(angle, ar: ar); children.add( @@ -292,17 +257,15 @@ class ExportService { final w = r.width * widthPts; final h = r.height * heightPts; - Uint8List bytes = _getProcessedBytes(placement); - if (bytes.isEmpty && signatureImageBytes != null) { - bytes = signatureImageBytes; - } - - if (bytes.isNotEmpty) { - final imgObj = _getMemoryImage(bytes); + final processedPng = _getProcessedPng(placement); + final baseImage = _getBaseImage(placement); + final memKey = + '${_baseKeyForImage(baseImage)}|${_adjustKey(placement.graphicAdjust)}'; + if (processedPng.isNotEmpty) { + final imgObj = _getMemoryImage(processedPng, memKey); if (imgObj != null) { final angle = rot.radians(placement.rotationDeg); - final baseBytes = _getBaseBytes(placement); - final ar = _getAspectRatioFromBytes(baseBytes); + final ar = _getAspectRatioFromImage(baseImage); final scaleToFit = rot.scaleToFitForAngle(angle, ar: ar); children.add( @@ -356,30 +319,5 @@ class ExportService { } } - /// Remove near-white background by making pixels with high brightness transparent - img.Image _removeBackground(img.Image image) { - final result = - image.hasAlpha ? img.Image.from(image) : image.convert(numChannels: 4); - - const int threshold = 245; // Near-white threshold (0-255) - - for (int y = 0; y < result.height; y++) { - for (int x = 0; x < result.width; x++) { - final pixel = result.getPixel(x, y); - - // Get RGB values - final r = pixel.r; - final g = pixel.g; - final b = pixel.b; - - // Check if pixel is near-white (all channels above threshold) - if (r >= threshold && g >= threshold && b >= threshold) { - // Make transparent - result.setPixelRgba(x, y, r, g, b, 0); - } - } - } - - return result; - } + // Background removal implemented in utils/background_removal.dart } diff --git a/lib/data/services/signature_image_processing_service.dart b/lib/data/services/signature_image_processing_service.dart index 4720598..4a75c36 100644 --- a/lib/data/services/signature_image_processing_service.dart +++ b/lib/data/services/signature_image_processing_service.dart @@ -1,8 +1,8 @@ -import 'dart:typed_data'; import 'package:image/image.dart' as img; import 'package:colorfilter_generator/colorfilter_generator.dart'; import 'package:colorfilter_generator/addons.dart'; import '../../domain/models/model.dart' as domain; +import '../../utils/background_removal.dart' as br; /// Service for processing signature images with graphic adjustments class SignatureImageProcessingService { @@ -22,138 +22,27 @@ class SignatureImageProcessingService { return gen.matrix; } - /// For display: if bgRemoval not requested, return original bytes + matrix. - /// If bgRemoval requested, perform full CPU pipeline (brightness/contrast then bg removal) - /// and return processed bytes with null matrix (already baked in). - Uint8List processForDisplay(Uint8List bytes, domain.GraphicAdjust adjust) { - if (!adjust.bgRemoval) { - // No CPU processing unless any color adjust combined with bg removal. - if (adjust.contrast == 1.0 && adjust.brightness == 1.0) { - return bytes; // identity - } - // We let GPU handle; return original bytes. - return bytes; - } - return processImage(bytes, adjust); - } + /// Process an already decoded image and return a new decoded image. + img.Image processImageToImage(img.Image image, domain.GraphicAdjust adjust) { + img.Image processed = img.Image.from(image); - /// Decode image bytes once and reuse the decoded image for preview processing. - img.Image? decode(Uint8List bytes) { - try { - return img.decodeImage(bytes); - } catch (_) { - return null; - } - } - - /// Process image bytes with the given graphic adjustments - Uint8List processImage(Uint8List bytes, domain.GraphicAdjust adjust) { - if (adjust.contrast == 1.0 && - adjust.brightness == 0.0 && - !adjust.bgRemoval) { - return bytes; // No processing needed - } - try { - final decoded = img.decodeImage(bytes); - if (decoded != null) { - img.Image processed = decoded; - - // Apply contrast and brightness first - if (adjust.contrast != 1.0 || adjust.brightness != 0.0) { - processed = img.adjustColor( - processed, - contrast: adjust.contrast, - brightness: adjust.brightness, - ); - } - - // Apply background removal after color adjustments - if (adjust.bgRemoval) { - processed = _removeBackground(processed); - } - - // Encode back to PNG to preserve transparency - return Uint8List.fromList(img.encodePng(processed)); - } else { - return bytes; - } - } catch (e) { - // If processing fails, return original bytes - return bytes; - } - } - - /// Fast preview processing: - /// - Reuses a decoded image - /// - Downscales to a small size for UI preview - /// - Uses low-compression PNG to reduce CPU cost - Uint8List processPreviewFromDecoded( - img.Image decoded, - domain.GraphicAdjust adjust, { - int maxDimension = 256, - }) { - try { - // Create a small working copy for quick adjustments - final int w = decoded.width; - final int h = decoded.height; - final double scale = (w > h ? maxDimension / w : maxDimension / h).clamp( - 0.0, - 1.0, - ); - img.Image work = - (scale < 1.0) - ? img.copyResize(decoded, width: (w * scale).round()) - : img.Image.from(decoded); - - // Apply contrast and brightness - if (adjust.contrast != 1.0 || adjust.brightness != 0.0) { - work = img.adjustColor( - work, - contrast: adjust.contrast, - brightness: adjust.brightness, - ); - } - - // Background removal on downscaled image for speed - if (adjust.bgRemoval) { - work = _removeBackground(work); - } - - // Encode with low compression (level 0) for speed - return Uint8List.fromList(img.encodePng(work, level: 0)); - } catch (_) { - // Fall back to original size path if something goes wrong - return processImage( - Uint8List.fromList(img.encodePng(decoded, level: 0)), - adjust, + // Apply contrast and brightness first (domain neutral is 1.0) + if (adjust.contrast != 1.0 || adjust.brightness != 1.0) { + // performance actually bad due to dual forloops internally + processed = img.adjustColor( + processed, + contrast: adjust.contrast, + brightness: adjust.brightness, ); } - } - /// Remove near-white background using simple threshold approach for maximum speed - img.Image _removeBackground(img.Image image) { - final result = - image.hasAlpha ? img.Image.from(image) : image.convert(numChannels: 4); - - // Simple and fast: single pass through all pixels - for (int y = 0; y < result.height; y++) { - for (int x = 0; x < result.width; x++) { - final pixel = result.getPixel(x, y); - final r = pixel.r; - final g = pixel.g; - final b = pixel.b; - - // Simple threshold: if pixel is close to white, make it transparent - const int threshold = 240; // Very close to white - if (r >= threshold && g >= threshold && b >= threshold) { - result.setPixel( - x, - y, - img.ColorRgba8(r.toInt(), g.toInt(), b.toInt(), 0), - ); - } - } + // Apply background removal after color adjustments + if (adjust.bgRemoval) { + processed = br.removeNearWhiteBackground(processed, threshold: 240); } - return result; + + return processed; } + + // Background removal implemented in utils/background_removal.dart } diff --git a/lib/domain/models/signature_asset.dart b/lib/domain/models/signature_asset.dart index edca0b9..939e8cf 100644 --- a/lib/domain/models/signature_asset.dart +++ b/lib/domain/models/signature_asset.dart @@ -1,27 +1,25 @@ import 'dart:typed_data'; +import 'package:image/image.dart' as img; /// SignatureAsset store image file of a signature, stored in the device or cloud storage class SignatureAsset { - final Uint8List bytes; + final img.Image sigImage; // List>? strokes; final String? name; // optional display name (e.g., filename) - const SignatureAsset({required this.bytes, this.name}); + const SignatureAsset({required this.sigImage, this.name}); + + /// Encode this image to PNG bytes. Use a small compression level for speed by default. + Uint8List toPngBytes({int level = 3}) => + Uint8List.fromList(img.encodePng(sigImage, level: level)); @override bool operator ==(Object other) => identical(this, other) || other is SignatureAsset && name == other.name && - _bytesEqual(bytes, other.bytes); + sigImage == other.sigImage; @override - int get hashCode => name.hashCode ^ bytes.length.hashCode; - - static bool _bytesEqual(Uint8List a, Uint8List b) { - if (a.length != b.length) return false; - for (int i = 0; i < a.length; i++) { - if (a[i] != b[i]) return false; - } - return true; - } + int get hashCode => + name.hashCode ^ sigImage.width.hashCode ^ sigImage.height.hashCode; } diff --git a/lib/domain/models/signature_card.dart b/lib/domain/models/signature_card.dart index 352c02c..389d999 100644 --- a/lib/domain/models/signature_card.dart +++ b/lib/domain/models/signature_card.dart @@ -1,5 +1,5 @@ -import 'dart:typed_data'; import 'signature_asset.dart'; +import 'package:image/image.dart' as img; import 'graphic_adjust.dart'; /** @@ -28,7 +28,7 @@ class SignatureCard { ); factory SignatureCard.initial() => SignatureCard( - asset: SignatureAsset(bytes: Uint8List(0)), + asset: SignatureAsset(sigImage: img.Image(width: 1, height: 1)), rotationDeg: 0.0, graphicAdjust: const GraphicAdjust(), ); diff --git a/lib/ui/features/pdf/widgets/pdf_mock_continuous_list.dart b/lib/ui/features/pdf/widgets/pdf_mock_continuous_list.dart index 62a5003..69e4c48 100644 --- a/lib/ui/features/pdf/widgets/pdf_mock_continuous_list.dart +++ b/lib/ui/features/pdf/widgets/pdf_mock_continuous_list.dart @@ -113,9 +113,9 @@ class _PdfMockContinuousListState extends ConsumerState { .addPlacement( page: pageNum, rect: rect, - asset: dragData.card?.asset, - rotationDeg: dragData.card?.rotationDeg ?? 0.0, - graphicAdjust: dragData.card?.graphicAdjust, + asset: dragData.card.asset, + rotationDeg: dragData.card.rotationDeg, + graphicAdjust: dragData.card.graphicAdjust, ); } }, diff --git a/lib/ui/features/pdf/widgets/pdf_page_overlays.dart b/lib/ui/features/pdf/widgets/pdf_page_overlays.dart index a8a0ccd..2514c52 100644 --- a/lib/ui/features/pdf/widgets/pdf_page_overlays.dart +++ b/lib/ui/features/pdf/widgets/pdf_page_overlays.dart @@ -70,9 +70,9 @@ class PdfPageOverlays extends ConsumerWidget { .addPlacement( page: pageNumber, rect: rect, - asset: d.card?.asset, - rotationDeg: d.card?.rotationDeg ?? 0.0, - graphicAdjust: d.card?.graphicAdjust, + asset: d.card.asset, + rotationDeg: d.card.rotationDeg, + graphicAdjust: d.card.graphicAdjust, ); }, builder: (context, candidateData, rejectedData) { diff --git a/lib/ui/features/pdf/widgets/pdf_screen.dart b/lib/ui/features/pdf/widgets/pdf_screen.dart index f9f918c..4c86cf6 100644 --- a/lib/ui/features/pdf/widgets/pdf_screen.dart +++ b/lib/ui/features/pdf/widgets/pdf_screen.dart @@ -15,6 +15,7 @@ import 'signatures_sidebar.dart'; import '../view_model/pdf_export_view_model.dart'; import 'package:pdf_signature/utils/download.dart'; import '../view_model/pdf_view_model.dart'; +import 'package:image/image.dart' as img; class PdfSignatureHomePage extends ConsumerStatefulWidget { final Future Function() onPickPdf; @@ -97,7 +98,7 @@ class _PdfSignatureHomePageState extends ConsumerState { if (controller.isReady) controller.goToPage(pageNumber: target); } - Future _loadSignatureFromFile() async { + Future _loadSignatureFromFile() async { final typeGroup = fs.XTypeGroup( label: Localizations.of(context, AppLocalizations)?.image, @@ -106,20 +107,31 @@ class _PdfSignatureHomePageState extends ConsumerState { final file = await fs.openFile(acceptedTypeGroups: [typeGroup]); if (file == null) return null; final bytes = await file.readAsBytes(); - return bytes; + try { + var sigImage = img.decodeImage(bytes); + sigImage?.convert(numChannels: 4); + return sigImage; + } catch (_) { + return null; + } } - Future _openDrawCanvas() async { + Future _openDrawCanvas() async { final result = await showModalBottomSheet( context: context, isScrollControlled: true, enableDrag: false, builder: (_) => const DrawCanvas(closeOnConfirmImmediately: false), ); - if (result != null && result.isNotEmpty) { - // In simplified UI, adding to library isn't implemented + if (result == null || result.isEmpty) return null; + // In simplified UI, adding to library isn't implemented + try { + var sigImage = img.decodeImage(result); + sigImage?.convert(numChannels: 4); + return sigImage; + } catch (_) { + return null; } - return result; } Future _saveSignedPdf() async { diff --git a/lib/ui/features/pdf/widgets/signature_overlay.dart b/lib/ui/features/pdf/widgets/signature_overlay.dart index cc8d651..905812e 100644 --- a/lib/ui/features/pdf/widgets/signature_overlay.dart +++ b/lib/ui/features/pdf/widgets/signature_overlay.dart @@ -26,9 +26,9 @@ class SignatureOverlay extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final processedBytes = ref + final processedImage = ref .watch(signatureViewModelProvider) - .getProcessedBytes(placement.asset, placement.graphicAdjust); + .getProcessedImage(placement.asset, placement.graphicAdjust); return LayoutBuilder( builder: (context, constraints) { final pageW = constraints.maxWidth; @@ -133,7 +133,7 @@ class SignatureOverlay extends ConsumerWidget { child: FittedBox( fit: BoxFit.contain, child: RotatedSignatureImage( - bytes: processedBytes, + image: processedImage, rotationDeg: placement.rotationDeg, ), ), diff --git a/lib/ui/features/pdf/widgets/signatures_sidebar.dart b/lib/ui/features/pdf/widgets/signatures_sidebar.dart index 5d0ed51..a844835 100644 --- a/lib/ui/features/pdf/widgets/signatures_sidebar.dart +++ b/lib/ui/features/pdf/widgets/signatures_sidebar.dart @@ -1,7 +1,8 @@ -import 'dart:typed_data'; +// no bytes here; use decoded images import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:pdf_signature/l10n/app_localizations.dart'; +import 'package:image/image.dart' as img; import '../../signature/widgets/signature_drawer.dart'; import '../view_model/pdf_export_view_model.dart'; @@ -14,8 +15,8 @@ class SignaturesSidebar extends ConsumerWidget { required this.onSave, }); - final Future Function() onLoadSignatureFromFile; - final Future Function() onOpenDrawCanvas; + final Future Function() onLoadSignatureFromFile; + final Future Function() onOpenDrawCanvas; final VoidCallback onSave; @override diff --git a/lib/ui/features/signature/view_model/signature_view_model.dart b/lib/ui/features/signature/view_model/signature_view_model.dart index e006cf2..e094631 100644 --- a/lib/ui/features/signature/view_model/signature_view_model.dart +++ b/lib/ui/features/signature/view_model/signature_view_model.dart @@ -1,22 +1,14 @@ -import 'dart:typed_data'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:pdf_signature/domain/models/model.dart' as domain; import 'package:pdf_signature/data/repositories/signature_card_repository.dart' as repo; +import 'package:image/image.dart' as img; class SignatureViewModel { final Ref ref; SignatureViewModel(this.ref); - Uint8List getProcessedBytes( - domain.SignatureAsset asset, - domain.GraphicAdjust adjust, - ) { - final notifier = ref.read(repo.signatureCardRepositoryProvider.notifier); - return notifier.getProcessedBytes(asset, adjust); - } - repo.DisplaySignatureData getDisplaySignatureData( domain.SignatureAsset asset, domain.GraphicAdjust adjust, @@ -25,6 +17,23 @@ class SignatureViewModel { return notifier.getDisplayData(asset, adjust); } + // New image-based accessors + img.Image getProcessedImage( + domain.SignatureAsset asset, + domain.GraphicAdjust adjust, + ) { + final notifier = ref.read(repo.signatureCardRepositoryProvider.notifier); + return notifier.getProcessedImage(asset, adjust); + } + + (img.Image image, List? colorMatrix) getDisplayImage( + domain.SignatureAsset asset, + domain.GraphicAdjust adjust, + ) { + final notifier = ref.read(repo.signatureCardRepositoryProvider.notifier); + return notifier.getDisplayImage(asset, adjust); + } + void clearCache() { final notifier = ref.read(repo.signatureCardRepositoryProvider.notifier); notifier.clearProcessedCache(); diff --git a/lib/ui/features/signature/widgets/image_editor_dialog.dart b/lib/ui/features/signature/widgets/image_editor_dialog.dart index f8cca3d..b879b11 100644 --- a/lib/ui/features/signature/widgets/image_editor_dialog.dart +++ b/lib/ui/features/signature/widgets/image_editor_dialog.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:image/image.dart' as img; import 'package:colorfilter_generator/colorfilter_generator.dart'; @@ -8,6 +7,7 @@ import 'package:pdf_signature/l10n/app_localizations.dart'; import '../../pdf/widgets/adjustments_panel.dart'; import '../../../../domain/models/model.dart' as domain; import 'rotated_signature_image.dart'; +import '../../../../utils/background_removal.dart' as br; class ImageEditorResult { final double rotation; @@ -44,10 +44,9 @@ class _ImageEditorDialogState extends State { late final ValueNotifier _rotation; // Cached image data - late Uint8List _originalBytes; // Original asset bytes (never mutated) - Uint8List? - _processedBgRemovedBytes; // Cached brightness/contrast adjusted then bg-removed bytes - img.Image? _decodedBase; // Decoded original for processing + late img.Image _originalImage; // Original asset image + img.Image? + _processedBgRemovedImage; // Cached brightness/contrast adjusted then bg-removed image // Debounce for background removal (in case we later tie it to brightness/contrast) Timer? _bgRemovalDebounce; @@ -60,17 +59,14 @@ class _ImageEditorDialogState extends State { _contrast = widget.initialGraphicAdjust.contrast; _brightness = widget.initialGraphicAdjust.brightness; _rotation = ValueNotifier(widget.initialRotation); - _originalBytes = widget.asset.bytes; - // Decode lazily only if/when background removal is needed + _originalImage = widget.asset.sigImage; + // If background removal initially enabled, precompute immediately if (_bgRemoval) { _scheduleBgRemovalReprocess(immediate: true); } } - Uint8List get _displayBytes => - _bgRemoval - ? (_processedBgRemovedBytes ?? _originalBytes) - : _originalBytes; + // No _displayBytes cache: the preview now uses img.Image directly. void _onBgRemovalChanged(bool value) { setState(() { @@ -95,9 +91,7 @@ class _ImageEditorDialogState extends State { } void _recomputeBgRemoval() { - _decodedBase ??= img.decodeImage(_originalBytes); - final base = _decodedBase; - if (base == null) return; + final base = _originalImage; // Apply brightness & contrast first (domain uses 1.0 neutral) img.Image working = img.Image.from(base); final needAdjust = _brightness != 1.0 || _contrast != 1.0; @@ -109,22 +103,11 @@ class _ImageEditorDialogState extends State { ); } // Then remove background on adjusted pixels - const int threshold = 240; - if (!working.hasAlpha) { - working = working.convert(numChannels: 4); - } - for (int y = 0; y < working.height; y++) { - for (int x = 0; x < working.width; x++) { - final p = working.getPixel(x, y); - final r = p.r, g = p.g, b = p.b; - if (r >= threshold && g >= threshold && b >= threshold) { - working.setPixelRgba(x, y, r, g, b, 0); - } - } - } - final bytes = Uint8List.fromList(img.encodePng(working)); + working = br.removeNearWhiteBackground(working, threshold: 240); if (!mounted) return; - setState(() => _processedBgRemovedBytes = bytes); + setState(() { + _processedBgRemovedImage = working; + }); } ColorFilter _currentColorFilter() { @@ -211,7 +194,11 @@ class _ImageEditorDialogState extends State { valueListenable: _rotation, builder: (context, rot, child) { final image = RotatedSignatureImage( - bytes: _displayBytes, + image: + _bgRemoval + ? (_processedBgRemovedImage ?? + _originalImage) + : _originalImage, rotationDeg: rot, ); if (_bgRemoval) return image; diff --git a/lib/ui/features/signature/widgets/rotated_signature_image.dart b/lib/ui/features/signature/widgets/rotated_signature_image.dart index f27f693..2159300 100644 --- a/lib/ui/features/signature/widgets/rotated_signature_image.dart +++ b/lib/ui/features/signature/widgets/rotated_signature_image.dart @@ -1,14 +1,16 @@ +import 'dart:async'; import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:image/image.dart' as img; import '../../../../utils/rotation_utils.dart' as rot; /// A lightweight widget to render signature bytes with rotation and an /// angle-aware scale-to-fit so the rotated image stays within its bounds. -/// Aware that `decodeImage` large images can be crazily slow, especially on web. +/// Don't use `decodeImage`, large images can be crazily slow, especially on web. class RotatedSignatureImage extends StatefulWidget { const RotatedSignatureImage({ super.key, - required this.bytes, + required this.image, this.rotationDeg = 0.0, // counterclockwise as positive this.filterQuality = FilterQuality.low, this.semanticLabel, @@ -16,16 +18,19 @@ class RotatedSignatureImage extends StatefulWidget { this.cacheHeight, }); - final Uint8List bytes; + /// Decoded CPU image (from `package:image`). + final img.Image image; + + /// Rotation in degrees. Positive values rotate counterclockwise in math sense. + /// Screen-space is handled via [rot.ccwRadians]. final double rotationDeg; + final FilterQuality filterQuality; - final BoxFit fit = BoxFit.contain; - final bool gaplessPlayback = true; - final Alignment alignment = Alignment.center; - final bool wrapInRepaintBoundary = true; + final String? semanticLabel; - // Hint the decoder to decode at a smaller size to reduce memory/latency. - // On some platforms these may be ignored, but they are safe no-ops. + + /// Optional target size hints to reduce decode cost. + /// If only one is provided, the other is computed to preserve aspect. final int? cacheWidth; final int? cacheHeight; @@ -34,103 +39,126 @@ class RotatedSignatureImage extends StatefulWidget { } class _RotatedSignatureImageState extends State { - ImageStream? _stream; - ImageStreamListener? _listener; - double? _derivedAspectRatio; // width / height - - MemoryImage get _provider { - return MemoryImage(widget.bytes); - } + Uint8List? _encodedBytes; // PNG-encoded bytes for Image.memory + img.Image? _lastSrc; // To detect changes cheaply + int? _lastW; + int? _lastH; @override - void didChangeDependencies() { - super.didChangeDependencies(); - _resolveImage(); + void initState() { + super.initState(); + _prepare(); } @override void didUpdateWidget(covariant RotatedSignatureImage oldWidget) { super.didUpdateWidget(oldWidget); - // Only re-resolve when the bytes change. Rotation does not affect - // intrinsic aspect ratio, so avoid expensive decode/resolve on slider drags. - if (!identical(oldWidget.bytes, widget.bytes)) { - _derivedAspectRatio = null; - _resolveImage(); + final srcChanged = + !identical(widget.image, _lastSrc) || + widget.image.width != (oldWidget.image.width) || + widget.image.height != (oldWidget.image.height); + final sizeHintChanged = + widget.cacheWidth != oldWidget.cacheWidth || + widget.cacheHeight != oldWidget.cacheHeight; + if (srcChanged || sizeHintChanged) { + _prepare(); } } - void _setAspectRatio(double ar) { - if (mounted && _derivedAspectRatio != ar) { - setState(() => _derivedAspectRatio = ar); - } - } - - void _resolveImage() { - _unlisten(); - // Resolve via ImageProvider; when first frame arrives, capture intrinsic size. - // Avoid synchronous decode on UI thread to keep rotation smooth. - if (widget.bytes.isEmpty) { - _setAspectRatio(1.0); // safe fallback - return; - } - final stream = _provider.resolve(createLocalImageConfiguration(context)); - _stream = stream; - _listener = ImageStreamListener((ImageInfo info, bool sync) { - final w = info.image.width; - final h = info.image.height; - if (w > 0 && h > 0) { - _setAspectRatio(w / h); - } - }); - stream.addListener(_listener!); - } - - void _unlisten() { - if (_stream != null && _listener != null) { - _stream!.removeListener(_listener!); - } - _stream = null; - _listener = null; - } - @override void dispose() { - _unlisten(); super.dispose(); } + Future _prepare() async { + final src = widget.image; + _lastSrc = src; + + // Compute target decode size preserving aspect if hints provided. + int targetW = src.width; + int targetH = src.height; + if (widget.cacheWidth != null || widget.cacheHeight != null) { + if (widget.cacheWidth != null && widget.cacheHeight != null) { + targetW = widget.cacheWidth!.clamp(1, src.width); + targetH = widget.cacheHeight!.clamp(1, src.height); + } else if (widget.cacheWidth != null) { + targetW = widget.cacheWidth!.clamp(1, src.width); + targetH = (targetW * src.height / src.width).round().clamp( + 1, + src.height, + ); + } else if (widget.cacheHeight != null) { + targetH = widget.cacheHeight!.clamp(1, src.height); + targetW = (targetH * src.width / src.height).round().clamp( + 1, + src.width, + ); + } + } + + img.Image working = src; + if (working.width != targetW || working.height != targetH) { + // High-quality resize; image package chooses a reasonable default. + working = img.copyResize(working, width: targetW, height: targetH); + } + + // Ensure RGBA (4 channels) so alpha is preserved when encoding. + working = working.convert(numChannels: 4); + + _lastW = working.width; + _lastH = working.height; + + // Encode to PNG with low compression level for faster encode. + // This avoids manual decode in the widget; Flutter will decode the PNG. + final pngEncoder = img.PngEncoder(level: 1); + final bytes = Uint8List.fromList(pngEncoder.encode(working)); + if (!mounted) return; + setState(() => _encodedBytes = bytes); + } + @override Widget build(BuildContext context) { - final angle = rot.ccwRadians(widget.rotationDeg); - Widget img = Image.memory( - widget.bytes, - fit: widget.fit, - gaplessPlayback: widget.gaplessPlayback, - filterQuality: widget.filterQuality, - alignment: widget.alignment, - semanticLabel: widget.semanticLabel, - // Provide at most one dimension to preserve aspect ratio if only one is set - cacheWidth: widget.cacheWidth, - cacheHeight: widget.cacheHeight, - isAntiAlias: false, - errorBuilder: (context, error, stackTrace) { - // Return a placeholder for invalid images - return Container( - color: Colors.grey[300], - child: const Icon(Icons.broken_image, color: Colors.grey), - ); - }, - ); + // Compute angle-aware scale so rotated image stays within bounds. + final double angleRad = rot.ccwRadians(widget.rotationDeg); + final double ar = + widget.image.width == 0 + ? 1.0 + : widget.image.width / widget.image.height; + final double k = rot.scaleToFitForAngle(angleRad, ar: ar); - if (angle != 0.0) { - final scaleToFit = rot.scaleToFitForAngle(angle, ar: _derivedAspectRatio); - img = Transform.scale( - scale: scaleToFit, - child: Transform.rotate(angle: angle, child: img), - ); + Widget core = + _encodedBytes == null + ? const SizedBox.shrink() + : Image.memory( + _encodedBytes!, + fit: BoxFit.contain, + filterQuality: widget.filterQuality, + gaplessPlayback: true, + ); + if (widget.semanticLabel != null) { + core = Semantics(label: widget.semanticLabel, child: core); } - if (!widget.wrapInRepaintBoundary) return img; - return RepaintBoundary(child: img); + // Order: scale first, then rotate. Scale ensures rotated bounds fit. + Widget transformed = Transform.scale( + scale: k, + alignment: Alignment.center, + child: Transform.rotate( + angle: angleRad, + alignment: Alignment.center, + child: core, + ), + ); + + // Allow parent to size; we simply contain within available space. + return FittedBox( + fit: BoxFit.contain, + alignment: Alignment.center, + child: SizedBox( + width: _lastW?.toDouble() ?? widget.image.width.toDouble(), + height: _lastH?.toDouble() ?? widget.image.height.toDouble(), + child: transformed, + ), + ); } } diff --git a/lib/ui/features/signature/widgets/signature_card_view.dart b/lib/ui/features/signature/widgets/signature_card_view.dart index 7cd9680..87c8800 100644 --- a/lib/ui/features/signature/widgets/signature_card_view.dart +++ b/lib/ui/features/signature/widgets/signature_card_view.dart @@ -1,4 +1,3 @@ -import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:pdf_signature/domain/models/model.dart' as domain; @@ -31,7 +30,6 @@ class SignatureCardView extends ConsumerStatefulWidget { } class _SignatureCardViewState extends ConsumerState { - Uint8List? _lastBytesRef; Future _showContextMenu(BuildContext context, Offset position) async { final selected = await showMenu( context: context, @@ -61,39 +59,27 @@ class _SignatureCardViewState extends ConsumerState { } } - void _maybePrecache(Uint8List bytes) { - if (identical(_lastBytesRef, bytes)) return; - _lastBytesRef = bytes; - // Schedule after frame to avoid doing work during build. - WidgetsBinding.instance.addPostFrameCallback((_) { - // Use single-dimension hints to preserve aspect ratio. - final img128 = ResizeImage(MemoryImage(bytes), height: 128); - final img256 = ResizeImage(MemoryImage(bytes), height: 256); - precacheImage(img128, context); - precacheImage(img256, context); - }); - } + // No precache needed when using decoded images directly. @override Widget build(BuildContext context) { - final displayData = ref + final (displayImage, colorMatrix) = ref .watch(signatureViewModelProvider) - .getDisplaySignatureData(widget.asset, widget.graphicAdjust); - _maybePrecache(displayData.bytes); + .getDisplayImage(widget.asset, widget.graphicAdjust); // Fit inside 96x64 with 6px padding using the shared rotated image widget const boxW = 96.0, boxH = 64.0, pad = 6.0; // Hint decoder with small target size to reduce decode cost. // The card shows inside 96x64 with 6px padding; request ~128px max. Widget coreImage = RotatedSignatureImage( - bytes: displayData.bytes, + image: displayImage, rotationDeg: widget.rotationDeg, // Only set one dimension to keep aspect ratio cacheHeight: 128, ); Widget img = - (displayData.colorMatrix != null) + (colorMatrix != null) ? ColorFiltered( - colorFilter: ColorFilter.matrix(displayData.colorMatrix!), + colorFilter: ColorFilter.matrix(colorMatrix), child: coreImage, ) : coreImage; @@ -180,19 +166,17 @@ class _SignatureCardViewState extends ConsumerState { child: Padding( padding: const EdgeInsets.all(6.0), child: - (displayData.colorMatrix != null) + (colorMatrix != null) ? ColorFiltered( - colorFilter: ColorFilter.matrix( - displayData.colorMatrix!, - ), + colorFilter: ColorFilter.matrix(colorMatrix), child: RotatedSignatureImage( - bytes: displayData.bytes, + image: displayImage, rotationDeg: widget.rotationDeg, cacheHeight: 256, ), ) : RotatedSignatureImage( - bytes: displayData.bytes, + image: displayImage, rotationDeg: widget.rotationDeg, cacheHeight: 256, ), diff --git a/lib/ui/features/signature/widgets/signature_drawer.dart b/lib/ui/features/signature/widgets/signature_drawer.dart index da3b4c0..edfb0cf 100644 --- a/lib/ui/features/signature/widgets/signature_drawer.dart +++ b/lib/ui/features/signature/widgets/signature_drawer.dart @@ -1,4 +1,4 @@ -import 'dart:typed_data'; +// no bytes here; image-first import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:pdf_signature/l10n/app_localizations.dart'; @@ -7,6 +7,7 @@ import 'package:pdf_signature/l10n/app_localizations.dart'; import 'package:pdf_signature/data/repositories/signature_asset_repository.dart'; import 'package:pdf_signature/data/repositories/signature_card_repository.dart'; import 'package:pdf_signature/domain/models/signature_asset.dart'; +import 'package:image/image.dart' as img; import 'image_editor_dialog.dart'; import 'signature_card_view.dart'; import '../../pdf/view_model/pdf_view_model.dart'; @@ -22,10 +23,10 @@ class SignatureDrawer extends ConsumerStatefulWidget { }); final bool disabled; - // Return the loaded bytes (if any) so we can add the exact image to the library immediately. - final Future Function() onLoadSignatureFromFile; - // Return the drawn bytes (if any) so we can add it to the library immediately. - final Future Function() onOpenDrawCanvas; + // Return decoded image so inner layers don't decode. + final Future Function() onLoadSignatureFromFile; + // Return decoded image so inner layers don't decode. + final Future Function() onOpenDrawCanvas; @override ConsumerState createState() => _SignatureDrawerState(); @@ -120,12 +121,11 @@ class _SignatureDrawerState extends ConsumerState { disabled ? null : () async { - final loaded = + final image = await widget.onLoadSignatureFromFile(); - final b = loaded; - if (b != null) { + if (image != null) { final asset = SignatureAsset( - bytes: b, + sigImage: image, name: 'image', ); ref @@ -133,7 +133,7 @@ class _SignatureDrawerState extends ConsumerState { signatureAssetRepositoryProvider .notifier, ) - .add(b, name: 'image'); + .addImage(image, name: 'image'); ref .read( signatureCardRepositoryProvider @@ -151,11 +151,10 @@ class _SignatureDrawerState extends ConsumerState { disabled ? null : () async { - final drawn = await widget.onOpenDrawCanvas(); - final b = drawn; - if (b != null) { + final image = await widget.onOpenDrawCanvas(); + if (image != null) { final asset = SignatureAsset( - bytes: b, + sigImage: image, name: 'drawing', ); ref @@ -163,7 +162,7 @@ class _SignatureDrawerState extends ConsumerState { signatureAssetRepositoryProvider .notifier, ) - .add(b, name: 'drawing'); + .addImage(image, name: 'drawing'); ref .read( signatureCardRepositoryProvider diff --git a/lib/utils/background_removal.dart b/lib/utils/background_removal.dart new file mode 100644 index 0000000..4a48edf --- /dev/null +++ b/lib/utils/background_removal.dart @@ -0,0 +1,34 @@ +import 'package:image/image.dart' as img; + +/// Removes near-white background by making pixels with high RGB values transparent. +/// +/// - Ensures the image has an alpha channel (RGBA) before modification. +/// - Returns a new img.Image instance; does not mutate the input reference. +/// - threshold: 0..255; pixels with r,g,b >= threshold become fully transparent. +img.Image removeNearWhiteBackground(img.Image image, {int threshold = 240}) { + // Ensure truecolor RGBA; paletted images won't apply per-pixel alpha properly. + final hadAlpha = image.hasAlpha; + img.Image out = + (image.hasPalette || !image.hasAlpha) + ? image.convert(numChannels: 4) + : img.Image.from(image); + + for (int y = 0; y < out.height; y++) { + for (int x = 0; x < out.width; x++) { + final p = out.getPixel(x, y); + final r = p.r; + final g = p.g; + final b = p.b; + if (r >= threshold && g >= threshold && b >= threshold) { + out.setPixelRgba(x, y, r, g, b, 0); + } else { + // Keep original alpha if input had alpha; otherwise force fully opaque. + final a = hadAlpha ? p.a : 255; + if (p.a != a) { + out.setPixelRgba(x, y, r, g, b, a); + } + } + } + } + return out; +} diff --git a/lib/utils/download_web.dart b/lib/utils/download_web.dart index b9f6ac8..5088023 100644 --- a/lib/utils/download_web.dart +++ b/lib/utils/download_web.dart @@ -1,3 +1,4 @@ +// ignore_for_file: deprecated_member_use // ignore: avoid_web_libraries_in_flutter import 'dart:html' as html; import 'dart:typed_data'; diff --git a/test/data/test_signature_image.png b/test/data/test_signature_image.png new file mode 100644 index 0000000..4e6c14a Binary files /dev/null and b/test/data/test_signature_image.png differ diff --git a/test/features/_test_helper.dart b/test/features/_test_helper.dart index ba6b28e..1a79b65 100644 --- a/test/features/_test_helper.dart +++ b/test/features/_test_helper.dart @@ -10,12 +10,13 @@ import 'package:pdf_signature/data/repositories/document_repository.dart'; import 'package:pdf_signature/ui/features/pdf/view_model/pdf_export_view_model.dart'; import 'package:pdf_signature/data/services/export_service.dart'; import 'package:pdf_signature/domain/models/model.dart'; +import 'package:image/image.dart' as img; class FakeExportService extends ExportService { bool exported = false; @override Future exportSignedPdfFromBytes({ - Map? libraryBytes, + Map? libraryImages, required Uint8List srcBytes, required Size uiPageSize, required Uint8List? signatureImageBytes, diff --git a/test/features/step/a_created_signature_card.dart b/test/features/step/a_created_signature_card.dart index eb4e852..276d2d3 100644 --- a/test/features/step/a_created_signature_card.dart +++ b/test/features/step/a_created_signature_card.dart @@ -1,4 +1,4 @@ -import 'dart:typed_data'; +import 'package:image/image.dart' as img; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:pdf_signature/data/repositories/signature_asset_repository.dart'; @@ -10,8 +10,11 @@ Future aCreatedSignatureCard(WidgetTester tester) async { final container = TestWorld.container ?? ProviderContainer(); TestWorld.container = container; // Create a dummy signature asset - final asset = SignatureAsset(bytes: Uint8List(100), name: 'Test Card'); + final asset = SignatureAsset( + sigImage: img.Image(width: 1, height: 1), + name: 'Test Card', + ); container .read(signatureAssetRepositoryProvider.notifier) - .add(asset.bytes, name: asset.name); + .addImage(asset.sigImage, name: asset.name); } diff --git a/test/features/step/a_document_is_open_and_contains_at_least_one_signature_placement.dart b/test/features/step/a_document_is_open_and_contains_at_least_one_signature_placement.dart index 58ed31e..4bf6406 100644 --- a/test/features/step/a_document_is_open_and_contains_at_least_one_signature_placement.dart +++ b/test/features/step/a_document_is_open_and_contains_at_least_one_signature_placement.dart @@ -1,4 +1,4 @@ -import 'dart:typed_data'; +import 'package:image/image.dart' as img; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -12,14 +12,15 @@ Future aDocumentIsOpenAndContainsAtLeastOneSignaturePlacement( ) async { final container = TestWorld.container ?? ProviderContainer(); TestWorld.container = container; - container - .read(documentRepositoryProvider.notifier) - .openPicked(pageCount: 5); + container.read(documentRepositoryProvider.notifier).openPicked(pageCount: 5); container .read(documentRepositoryProvider.notifier) .addPlacement( page: 1, rect: Rect.fromLTWH(10, 10, 100, 50), - asset: SignatureAsset(bytes: Uint8List(0), name: 'sig.png'), + asset: SignatureAsset( + sigImage: img.Image(width: 1, height: 1), + name: 'sig.png', + ), ); } diff --git a/test/features/step/a_document_is_open_and_contains_multiple_placed_signature_placements_across_pages.dart b/test/features/step/a_document_is_open_and_contains_multiple_placed_signature_placements_across_pages.dart index 182c456..4f014cf 100644 --- a/test/features/step/a_document_is_open_and_contains_multiple_placed_signature_placements_across_pages.dart +++ b/test/features/step/a_document_is_open_and_contains_multiple_placed_signature_placements_across_pages.dart @@ -1,4 +1,4 @@ -import 'dart:typed_data'; +import 'package:image/image.dart' as img; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -19,7 +19,10 @@ aDocumentIsOpenAndContainsMultiplePlacedSignaturePlacementsAcrossPages( .addPlacement( page: 1, rect: Rect.fromLTWH(0.1, 0.1, 0.2, 0.1), - asset: SignatureAsset(bytes: Uint8List(0), name: 'sig1.png'), + asset: SignatureAsset( + sigImage: img.Image(width: 1, height: 1), + name: 'sig1.png', + ), ); await tester.pumpAndSettle(); container @@ -27,7 +30,10 @@ aDocumentIsOpenAndContainsMultiplePlacedSignaturePlacementsAcrossPages( .addPlacement( page: 2, rect: Rect.fromLTWH(0.2, 0.2, 0.2, 0.1), - asset: SignatureAsset(bytes: Uint8List(0), name: 'sig2.png'), + asset: SignatureAsset( + sigImage: img.Image(width: 1, height: 1), + name: 'sig2.png', + ), ); await tester.pumpAndSettle(); container @@ -35,7 +41,10 @@ aDocumentIsOpenAndContainsMultiplePlacedSignaturePlacementsAcrossPages( .addPlacement( page: 3, rect: Rect.fromLTWH(0.3, 0.3, 0.2, 0.1), - asset: SignatureAsset(bytes: Uint8List(0), name: 'sig3.png'), + asset: SignatureAsset( + sigImage: img.Image(width: 1, height: 1), + name: 'sig3.png', + ), ); await tester.pumpAndSettle(); } diff --git a/test/features/step/a_signature_asset_is_loaded_or_drawn.dart b/test/features/step/a_signature_asset_is_loaded_or_drawn.dart index 6a056fa..b820db4 100644 --- a/test/features/step/a_signature_asset_is_loaded_or_drawn.dart +++ b/test/features/step/a_signature_asset_is_loaded_or_drawn.dart @@ -1,4 +1,4 @@ -import 'dart:typed_data'; +import 'package:image/image.dart' as img; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:pdf_signature/data/repositories/signature_asset_repository.dart'; @@ -17,78 +17,9 @@ Future aSignatureAssetIsLoadedOrDrawn(WidgetTester tester) async { container.read(signatureCardRepositoryProvider.notifier).state = [ CachedSignatureCard.initial(), ]; - // Use a tiny valid PNG so any later image decoding succeeds. - final bytes = Uint8List.fromList([ - 0x89, - 0x50, - 0x4E, - 0x47, - 0x0D, - 0x0A, - 0x1A, - 0x0A, - 0x00, - 0x00, - 0x00, - 0x0D, - 0x49, - 0x48, - 0x44, - 0x52, - 0x00, - 0x00, - 0x00, - 0x01, - 0x00, - 0x00, - 0x00, - 0x01, - 0x08, - 0x06, - 0x00, - 0x00, - 0x00, - 0x1F, - 0x15, - 0xC4, - 0x89, - 0x00, - 0x00, - 0x00, - 0x0A, - 0x49, - 0x44, - 0x41, - 0x54, - 0x78, - 0x9C, - 0x63, - 0x60, - 0x00, - 0x00, - 0x00, - 0x02, - 0x00, - 0x01, - 0xE5, - 0x27, - 0xD4, - 0xA6, - 0x00, - 0x00, - 0x00, - 0x00, - 0x49, - 0x45, - 0x4E, - 0x44, - 0xAE, - 0x42, - 0x60, - 0x82, - ]); + final image = img.Image(width: 1, height: 1); container .read(signatureAssetRepositoryProvider.notifier) - .add(bytes, name: 'test.png'); + .addImage(image, name: 'test.png'); await tester.pump(); } diff --git a/test/features/step/a_signature_asset_is_placed_on_the_page.dart b/test/features/step/a_signature_asset_is_placed_on_the_page.dart index 655e738..251686a 100644 --- a/test/features/step/a_signature_asset_is_placed_on_the_page.dart +++ b/test/features/step/a_signature_asset_is_placed_on_the_page.dart @@ -1,4 +1,4 @@ -import 'dart:typed_data'; +import 'package:image/image.dart' as img; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -26,10 +26,10 @@ Future aSignatureAssetIsPlacedOnThePage(WidgetTester tester) async { if (library.isNotEmpty) { asset = library.first; } else { - final bytes = Uint8List.fromList([1, 2, 3, 4, 5]); + final image = img.Image(width: 1, height: 1); container .read(signatureAssetRepositoryProvider.notifier) - .add(bytes, name: 'test.png'); + .addImage(image, name: 'test.png'); asset = container .read(signatureAssetRepositoryProvider) .firstWhere((a) => a.name == 'test.png'); diff --git a/test/features/step/a_signature_asset_is_selected.dart b/test/features/step/a_signature_asset_is_selected.dart index 87b6dbd..6ff95b0 100644 --- a/test/features/step/a_signature_asset_is_selected.dart +++ b/test/features/step/a_signature_asset_is_selected.dart @@ -1,4 +1,4 @@ -import 'dart:typed_data'; +import 'package:image/image.dart' as img; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:pdf_signature/data/repositories/signature_asset_repository.dart'; @@ -14,7 +14,7 @@ Future aSignatureAssetIsSelected(WidgetTester tester) async { if (library.isEmpty) { container .read(signatureAssetRepositoryProvider.notifier) - .add(Uint8List(100), name: 'Selected Asset'); + .addImage(img.Image(width: 1, height: 1), name: 'Selected Asset'); // Re-read the library library = container.read(signatureAssetRepositoryProvider); } diff --git a/test/features/step/a_signature_asset_loaded_or_drawn_is_wrapped_in_a_signature_card.dart b/test/features/step/a_signature_asset_loaded_or_drawn_is_wrapped_in_a_signature_card.dart index 2df1a9c..84863b0 100644 --- a/test/features/step/a_signature_asset_loaded_or_drawn_is_wrapped_in_a_signature_card.dart +++ b/test/features/step/a_signature_asset_loaded_or_drawn_is_wrapped_in_a_signature_card.dart @@ -1,4 +1,4 @@ -import 'dart:typed_data'; +import 'package:image/image.dart' as img; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:pdf_signature/data/repositories/signature_asset_repository.dart'; @@ -19,10 +19,9 @@ Future aSignatureAssetLoadedOrDrawnIsWrappedInASignatureCard( container.read(signatureCardRepositoryProvider.notifier).state = [ CachedSignatureCard.initial(), ]; - final bytes = Uint8List.fromList([1, 2, 3, 4, 5]); container .read(signatureAssetRepositoryProvider.notifier) - .add(bytes, name: 'test.png'); + .addImage(img.Image(width: 1, height: 1), name: 'test.png'); // Allow provider scheduler to flush any pending timers await tester.pump(); } diff --git a/test/features/step/a_signature_placement_is_placed_on_page.dart b/test/features/step/a_signature_placement_is_placed_on_page.dart index 323160b..92ec293 100644 --- a/test/features/step/a_signature_placement_is_placed_on_page.dart +++ b/test/features/step/a_signature_placement_is_placed_on_page.dart @@ -1,4 +1,4 @@ -import 'dart:typed_data'; +import 'package:image/image.dart' as img; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -25,7 +25,10 @@ Future aSignaturePlacementIsPlacedOnPage( .addPlacement( page: page, rect: Rect.fromLTWH(20, 20, 100, 50), - asset: SignatureAsset(bytes: Uint8List(0), name: 'test.png'), + asset: SignatureAsset( + sigImage: img.Image(width: 1, height: 1), + name: 'test.png', + ), ); await tester.pumpAndSettle(); } diff --git a/test/features/step/a_signature_placement_is_placed_with_a_position_and_size_relative_to_the_page.dart b/test/features/step/a_signature_placement_is_placed_with_a_position_and_size_relative_to_the_page.dart index 0843689..8b5b161 100644 --- a/test/features/step/a_signature_placement_is_placed_with_a_position_and_size_relative_to_the_page.dart +++ b/test/features/step/a_signature_placement_is_placed_with_a_position_and_size_relative_to_the_page.dart @@ -1,6 +1,6 @@ -import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:pdf_signature/data/repositories/document_repository.dart'; import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart'; @@ -25,7 +25,10 @@ Future aSignaturePlacementIsPlacedWithAPositionAndSizeRelativeToThePage( page: currentPage, // Use normalized 0..1 fractions relative to page size as required rect: const Rect.fromLTWH(0.2, 0.3, 0.4, 0.2), - asset: SignatureAsset(bytes: Uint8List(0), name: 'test.png'), + asset: SignatureAsset( + sigImage: img.Image(width: 1, height: 1), + name: 'test.png', + ), ); await tester.pumpAndSettle(); } diff --git a/test/features/step/nearwhite_background_becomes_transparent_in_the_preview.dart b/test/features/step/nearwhite_background_becomes_transparent_in_the_preview.dart index cd33b10..cdfd802 100644 --- a/test/features/step/nearwhite_background_becomes_transparent_in_the_preview.dart +++ b/test/features/step/nearwhite_background_becomes_transparent_in_the_preview.dart @@ -1,4 +1,3 @@ -import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -23,10 +22,8 @@ Future nearwhiteBackgroundBecomesTransparentInThePreview( src.setPixelRgba(0, 0, 250, 250, 250, 255); // Solid black stays opaque src.setPixelRgba(1, 0, 0, 0, 0, 255); - final png = Uint8List.fromList(img.encodePng(src, level: 6)); - - // Create a widget with the image - final widget = RotatedSignatureImage(bytes: png); + // Create a widget with the decoded image + final widget = RotatedSignatureImage(image: src); // Pump the widget await tester.pumpWidget(MaterialApp(home: Scaffold(body: widget))); @@ -40,14 +37,11 @@ Future nearwhiteBackgroundBecomesTransparentInThePreview( expect(find.byType(RotatedSignatureImage), findsOneWidget); // Test the processing logic directly - final decoded = img.decodeImage(png); - expect(decoded, isNotNull); - final processedImg = _removeBackground(decoded!); - final processed = Uint8List.fromList(img.encodePng(processedImg)); - expect(processed, isNotNull); - final outImg = img.decodeImage(processed); - expect(outImg, isNotNull); - final resultImg = outImg!.hasAlpha ? outImg : outImg.convert(numChannels: 4); + final processedImg = _removeBackground(src); + final resultImg = + processedImg.hasAlpha + ? img.Image.from(processedImg) + : processedImg.convert(numChannels: 4); final p0 = resultImg.getPixel(0, 0); final p1 = resultImg.getPixel(1, 0); diff --git a/test/features/step/the_user_chooses_a_image_file_as_a_signature_asset.dart b/test/features/step/the_user_chooses_a_image_file_as_a_signature_asset.dart index 8479745..258f60e 100644 --- a/test/features/step/the_user_chooses_a_image_file_as_a_signature_asset.dart +++ b/test/features/step/the_user_chooses_a_image_file_as_a_signature_asset.dart @@ -1,4 +1,4 @@ -import 'dart:typed_data'; +import 'package:image/image.dart' as img; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:pdf_signature/data/repositories/signature_asset_repository.dart'; @@ -10,8 +10,8 @@ Future theUserChoosesAImageFileAsASignatureAsset( ) async { final container = TestWorld.container ?? ProviderContainer(); TestWorld.container = container; - final bytes = Uint8List.fromList([1, 2, 3, 4, 5]); + final image = img.Image(width: 1, height: 1); container .read(signatureAssetRepositoryProvider.notifier) - .add(bytes, name: 'chosen.png'); + .addImage(image, name: 'chosen.png'); } diff --git a/test/features/step/the_user_chooses_a_signature_asset_to_created_a_signature_card.dart b/test/features/step/the_user_chooses_a_signature_asset_to_created_a_signature_card.dart index e210913..29a1e35 100644 --- a/test/features/step/the_user_chooses_a_signature_asset_to_created_a_signature_card.dart +++ b/test/features/step/the_user_chooses_a_signature_asset_to_created_a_signature_card.dart @@ -1,4 +1,4 @@ -import 'dart:typed_data'; +import 'package:image/image.dart' as img; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:pdf_signature/data/repositories/signature_asset_repository.dart'; @@ -10,8 +10,7 @@ Future theUserChoosesASignatureAssetToCreatedASignatureCard( ) async { final container = TestWorld.container ?? ProviderContainer(); TestWorld.container = container; - final bytes = Uint8List.fromList([1, 2, 3, 4, 5]); container .read(signatureAssetRepositoryProvider.notifier) - .add(bytes, name: 'card.png'); + .addImage(img.Image(width: 1, height: 1), name: 'card.png'); } diff --git a/test/features/step/the_user_drags_it_on_the_page_of_the_document_to_place_signature_placements_in_multiple_locations_in_the_document.dart b/test/features/step/the_user_drags_it_on_the_page_of_the_document_to_place_signature_placements_in_multiple_locations_in_the_document.dart index 13f8b63..17da18c 100644 --- a/test/features/step/the_user_drags_it_on_the_page_of_the_document_to_place_signature_placements_in_multiple_locations_in_the_document.dart +++ b/test/features/step/the_user_drags_it_on_the_page_of_the_document_to_place_signature_placements_in_multiple_locations_in_the_document.dart @@ -1,4 +1,4 @@ -import 'dart:typed_data'; +import 'package:image/image.dart' as img; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pdf_signature/data/repositories/document_repository.dart'; @@ -16,7 +16,10 @@ theUserDragsItOnThePageOfTheDocumentToPlaceSignaturePlacementsInMultipleLocation final asset = lib.isNotEmpty ? lib.first - : SignatureAsset(bytes: Uint8List(0), name: 'shared.png'); + : SignatureAsset( + sigImage: img.Image(width: 1, height: 1), + name: 'shared.png', + ); // Ensure PDF is open if (!container.read(documentRepositoryProvider).loaded) { diff --git a/test/features/step/the_user_drags_this_signature_card_on_the_page_of_the_document_to_place_a_signature_placement.dart b/test/features/step/the_user_drags_this_signature_card_on_the_page_of_the_document_to_place_a_signature_placement.dart index 104cf8f..7448cc5 100644 --- a/test/features/step/the_user_drags_this_signature_card_on_the_page_of_the_document_to_place_a_signature_placement.dart +++ b/test/features/step/the_user_drags_this_signature_card_on_the_page_of_the_document_to_place_a_signature_placement.dart @@ -1,4 +1,4 @@ -import 'dart:typed_data'; +import 'package:image/image.dart' as img; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -30,10 +30,9 @@ theUserDragsThisSignatureCardOnThePageOfTheDocumentToPlaceASignaturePlacement( if (library.isNotEmpty) { asset = library.first; } else { - final bytes = Uint8List.fromList([1, 2, 3, 4, 5]); container .read(signatureAssetRepositoryProvider.notifier) - .add(bytes, name: 'placement.png'); + .addImage(img.Image(width: 1, height: 1), name: 'placement.png'); asset = container .read(signatureAssetRepositoryProvider) .firstWhere((a) => a.name == 'placement.png'); diff --git a/test/features/step/the_user_draws_strokes_and_confirms.dart b/test/features/step/the_user_draws_strokes_and_confirms.dart index c73633e..b322dc1 100644 --- a/test/features/step/the_user_draws_strokes_and_confirms.dart +++ b/test/features/step/the_user_draws_strokes_and_confirms.dart @@ -1,4 +1,4 @@ -import 'dart:typed_data'; +import 'package:image/image.dart' as img; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pdf_signature/data/repositories/signature_asset_repository.dart'; @@ -44,78 +44,6 @@ Future theUserDrawsStrokesAndConfirms(WidgetTester tester) async { if (container != null) { container .read(signatureAssetRepositoryProvider.notifier) - .add( - // Tiny 1x1 transparent PNG (duplicated constant for test clarity) - Uint8List.fromList([ - 0x89, - 0x50, - 0x4E, - 0x47, - 0x0D, - 0x0A, - 0x1A, - 0x0A, - 0x00, - 0x00, - 0x00, - 0x0D, - 0x49, - 0x48, - 0x44, - 0x52, - 0x00, - 0x00, - 0x00, - 0x01, - 0x00, - 0x00, - 0x00, - 0x01, - 0x08, - 0x06, - 0x00, - 0x00, - 0x00, - 0x1F, - 0x15, - 0xC4, - 0x89, - 0x00, - 0x00, - 0x00, - 0x0A, - 0x49, - 0x44, - 0x41, - 0x54, - 0x78, - 0x9C, - 0x63, - 0x60, - 0x00, - 0x00, - 0x00, - 0x02, - 0x00, - 0x01, - 0xE5, - 0x27, - 0xD4, - 0xA6, - 0x00, - 0x00, - 0x00, - 0x00, - 0x49, - 0x45, - 0x4E, - 0x44, - 0xAE, - 0x42, - 0x60, - 0x82, - ]), - name: 'drawing', - ); + .addImage(img.Image(width: 1, height: 1), name: 'drawing'); } } diff --git a/test/features/step/the_user_navigates_to_page_and_places_another_signature_placement.dart b/test/features/step/the_user_navigates_to_page_and_places_another_signature_placement.dart index e583fef..07d962f 100644 --- a/test/features/step/the_user_navigates_to_page_and_places_another_signature_placement.dart +++ b/test/features/step/the_user_navigates_to_page_and_places_another_signature_placement.dart @@ -1,4 +1,4 @@ -import 'dart:typed_data'; +import 'package:image/image.dart' as img; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -25,7 +25,10 @@ Future theUserNavigatesToPageAndPlacesAnotherSignaturePlacement( .addPlacement( page: page, rect: Rect.fromLTWH(40, 40, 100, 50), - asset: SignatureAsset(bytes: Uint8List(0), name: 'another.png'), + asset: SignatureAsset( + sigImage: img.Image(width: 1, height: 1), + name: 'another.png', + ), ); await tester.pumpAndSettle(); } diff --git a/test/features/step/the_user_places_a_signature_placement_from_asset_on_page.dart b/test/features/step/the_user_places_a_signature_placement_from_asset_on_page.dart index 35e2046..7cb2977 100644 --- a/test/features/step/the_user_places_a_signature_placement_from_asset_on_page.dart +++ b/test/features/step/the_user_places_a_signature_placement_from_asset_on_page.dart @@ -1,4 +1,4 @@ -import 'dart:typed_data'; +import 'package:image/image.dart' as img; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -20,7 +20,7 @@ Future theUserPlacesASignaturePlacementFromAssetOnPage( // add dummy asset container .read(signatureAssetRepositoryProvider.notifier) - .add(Uint8List(100), name: assetName); + .addImage(img.Image(width: 1, height: 1), name: assetName); final updatedLibrary = container.read(signatureAssetRepositoryProvider); asset = updatedLibrary.firstWhere((a) => a.name == assetName); } diff --git a/test/features/step/the_user_places_a_signature_placement_on_page.dart b/test/features/step/the_user_places_a_signature_placement_on_page.dart index f973ee5..4a5fb6d 100644 --- a/test/features/step/the_user_places_a_signature_placement_on_page.dart +++ b/test/features/step/the_user_places_a_signature_placement_on_page.dart @@ -1,4 +1,4 @@ -import 'dart:typed_data'; +import 'package:image/image.dart' as img; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -19,7 +19,10 @@ Future theUserPlacesASignaturePlacementOnPage( .addPlacement( page: page, rect: Rect.fromLTWH(20, 20, 100, 50), - asset: SignatureAsset(bytes: Uint8List(0), name: 'test.png'), + asset: SignatureAsset( + sigImage: img.Image(width: 1, height: 1), + name: 'test.png', + ), ); // Allow Riverpod's scheduler to flush any pending microtasks/timers await tester.pumpAndSettle(); diff --git a/test/features/step/the_user_places_two_signature_placements_on_the_same_page.dart b/test/features/step/the_user_places_two_signature_placements_on_the_same_page.dart index 7e85d90..9e3fced 100644 --- a/test/features/step/the_user_places_two_signature_placements_on_the_same_page.dart +++ b/test/features/step/the_user_places_two_signature_placements_on_the_same_page.dart @@ -1,4 +1,4 @@ -import 'dart:typed_data'; +import 'package:image/image.dart' as img; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -21,16 +21,7 @@ Future theUserPlacesTwoSignaturePlacementsOnTheSamePage( page: page, rect: Rect.fromLTWH(10, 10, 100, 50), asset: SignatureAsset( - bytes: Uint8List.fromList([ - 0x89, - 0x50, - 0x4E, - 0x47, - 0x0D, - 0x0A, - 0x1A, - 0x0A, - ]), + sigImage: img.Image(width: 1, height: 1), name: 'sig1.png', ), ); @@ -41,17 +32,7 @@ Future theUserPlacesTwoSignaturePlacementsOnTheSamePage( page: page, rect: Rect.fromLTWH(120, 10, 100, 50), asset: SignatureAsset( - bytes: Uint8List.fromList([ - 0x89, - 0x50, - 0x4E, - 0x47, - 0x0D, - 0x0A, - 0x1A, - 0x0A, - 0x00, - ]), + sigImage: img.Image(width: 1, height: 1), name: 'sig2.png', ), ); diff --git a/test/features/step/three_signature_placements_are_placed_on_the_current_page.dart b/test/features/step/three_signature_placements_are_placed_on_the_current_page.dart index c834f69..57439a8 100644 --- a/test/features/step/three_signature_placements_are_placed_on_the_current_page.dart +++ b/test/features/step/three_signature_placements_are_placed_on_the_current_page.dart @@ -1,4 +1,4 @@ -import 'dart:typed_data'; +import 'package:image/image.dart' as img; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -28,19 +28,28 @@ Future threeSignaturePlacementsArePlacedOnTheCurrentPage( pdfN.addPlacement( page: page, rect: Rect.fromLTWH(10, 10, 50, 50), - asset: SignatureAsset(bytes: Uint8List(0), name: 'test1'), + asset: SignatureAsset( + sigImage: img.Image(width: 1, height: 1), + name: 'test1', + ), ); await tester.pumpAndSettle(); pdfN.addPlacement( page: page, rect: Rect.fromLTWH(70, 10, 50, 50), - asset: SignatureAsset(bytes: Uint8List(0), name: 'test2'), + asset: SignatureAsset( + sigImage: img.Image(width: 1, height: 1), + name: 'test2', + ), ); await tester.pumpAndSettle(); pdfN.addPlacement( page: page, rect: Rect.fromLTWH(130, 10, 50, 50), - asset: SignatureAsset(bytes: Uint8List(0), name: 'test3'), + asset: SignatureAsset( + sigImage: img.Image(width: 1, height: 1), + name: 'test3', + ), ); await tester.pumpAndSettle(); } diff --git a/test/utils/background_removal_test.dart b/test/utils/background_removal_test.dart new file mode 100644 index 0000000..2feae76 --- /dev/null +++ b/test/utils/background_removal_test.dart @@ -0,0 +1,138 @@ +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'package:pdf_signature/utils/background_removal.dart'; + +void main() { + group('removeNearWhiteBackground', () { + test('makes pure white transparent and keeps black opaque', () { + final im = img.Image(width: 2, height: 1); + // Left pixel white, right pixel black + im.setPixel(0, 0, img.ColorRgb8(255, 255, 255)); + im.setPixel(1, 0, img.ColorRgb8(0, 0, 0)); + + final out = removeNearWhiteBackground(im, threshold: 240); + + final pWhite = out.getPixel(0, 0); + final pBlack = out.getPixel(1, 0); + expect(pWhite.a, 0, reason: 'white should become transparent'); + expect(pBlack.a, 255, reason: 'black should remain opaque'); + }); + + test( + 'near-white above threshold becomes transparent, below stays opaque', + () { + final im = img.Image(width: 3, height: 1); + im.setPixel(0, 0, img.ColorRgb8(239, 239, 239)); // below 240 + im.setPixel(1, 0, img.ColorRgb8(240, 240, 240)); // at threshold + im.setPixel(2, 0, img.ColorRgb8(250, 250, 250)); // above threshold + + final out = removeNearWhiteBackground(im, threshold: 240); + + expect(out.getPixel(0, 0).a, 255, reason: '239 should stay opaque'); + expect(out.getPixel(1, 0).a, 0, reason: '240 should be transparent'); + expect(out.getPixel(2, 0).a, 0, reason: '250 should be transparent'); + }, + ); + + test('preserves color channels while zeroing alpha for near-white', () { + final im = img.Image(width: 1, height: 1); + im.setPixel(0, 0, img.ColorRgb8(245, 246, 247)); + + final out = removeNearWhiteBackground(im, threshold: 240); + final p = out.getPixel(0, 0); + expect(p.r, 245); + expect(p.g, 246); + expect(p.b, 247); + expect(p.a, 0); + }); + + test('works when input already has alpha channel', () { + final im = img.Image(width: 1, height: 2, numChannels: 4); + im.setPixel(0, 0, img.ColorRgba8(255, 255, 255, 200)); + im.setPixel(0, 1, img.ColorRgba8(10, 10, 10, 123)); + + final out = removeNearWhiteBackground(im, threshold: 240); + expect(out.getPixel(0, 0).a, 0, reason: 'white alpha -> 0'); + expect(out.getPixel(0, 1).a, 123, reason: 'non-white alpha preserved'); + }); + + test( + 'real image: test/data/test_signature_image.png background becomes transparent', + () { + final path = 'test/data/test_signature_image.png'; + final file = File(path); + if (!file.existsSync()) { + // Fallback: create a simple signature-like PNG if missing + Directory('test/data').createSync(recursive: true); + final w = 200, h = 100; + final canvas = img.Image(width: w, height: h); + img.fill(canvas, color: img.ColorRgb8(255, 255, 255)); + for (int dy = -1; dy <= 1; dy++) { + img.drawLine( + canvas, + x1: 20, + y1: h ~/ 2 + dy, + x2: w - 20, + y2: h ~/ 2 + dy, + color: img.ColorRgb8(0, 0, 0), + ); + } + img.drawLine( + canvas, + x1: w - 50, + y1: h ~/ 2 - 10, + x2: w - 10, + y2: h ~/ 2 - 20, + color: img.ColorRgb8(0, 0, 0), + ); + file.writeAsBytesSync(img.encodePng(canvas)); + } + + final bytes = file.readAsBytesSync(); + final decoded = img.decodeImage(bytes); + expect(decoded, isNotNull, reason: 'should decode test image'); + final processed = removeNearWhiteBackground(decoded!, threshold: 240); + + // Corners are often paper margin: expect transparency where near-white + final c00 = processed.getPixel(0, 0); + final c10 = processed.getPixel(processed.width - 1, 0); + final c01 = processed.getPixel(0, processed.height - 1); + final c11 = processed.getPixel( + processed.width - 1, + processed.height - 1, + ); + // If any corner is near-white, it should be transparent + bool anyCornerTransparent = false; + for (final p in [c00, c10, c01, c11]) { + if (p.r >= 240 && p.g >= 240 && p.b >= 240) { + expect(p.a, 0, reason: 'near-white corner should be transparent'); + anyCornerTransparent = true; + } + } + expect( + anyCornerTransparent, + isTrue, + reason: 'expected at least one near-white corner in the test image', + ); + + // Find a dark pixel and assert it remains opaque + bool foundDarkOpaque = false; + for (int y = 0; y < processed.height && !foundDarkOpaque; y++) { + for (int x = 0; x < processed.width && !foundDarkOpaque; x++) { + final p = processed.getPixel(x, y); + if (p.r < 50 && p.g < 50 && p.b < 50) { + expect(p.a, 255, reason: 'dark stroke pixel should stay opaque'); + foundDarkOpaque = true; + } + } + } + expect( + foundDarkOpaque, + isTrue, + reason: 'expected at least one dark stroke pixel in the test image', + ); + }, + ); + }); +} diff --git a/test/widget/background_removal_test.dart b/test/widget/background_removal_test.dart index 868819e..da125e1 100644 --- a/test/widget/background_removal_test.dart +++ b/test/widget/background_removal_test.dart @@ -1,4 +1,4 @@ -import 'dart:typed_data'; +import 'package:image/image.dart' as img; import 'package:flutter_test/flutter_test.dart'; import 'package:pdf_signature/ui/features/signature/widgets/image_editor_dialog.dart'; import 'package:pdf_signature/domain/models/model.dart' as domain; @@ -8,7 +8,7 @@ void main() { test('should create ImageEditorDialog with background removal enabled', () { // Create test data final testAsset = domain.SignatureAsset( - bytes: Uint8List(0), + sigImage: img.Image(width: 1, height: 1), name: 'test', ); final testGraphicAdjust = domain.GraphicAdjust(bgRemoval: true); @@ -35,7 +35,7 @@ void main() { () { // Create test data final testAsset = domain.SignatureAsset( - bytes: Uint8List(0), + sigImage: img.Image(width: 1, height: 1), name: 'test', ); final testGraphicAdjust = domain.GraphicAdjust(bgRemoval: false); diff --git a/test/widget/export_flow_test.dart b/test/widget/export_flow_test.dart index f27cba0..1e4bed0 100644 --- a/test/widget/export_flow_test.dart +++ b/test/widget/export_flow_test.dart @@ -14,6 +14,8 @@ import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart'; import 'package:pdf_signature/data/repositories/document_repository.dart'; import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart'; import 'package:pdf_signature/l10n/app_localizations.dart'; +import 'package:image/image.dart' as img; +import 'package:pdf_signature/domain/models/model.dart'; class RecordingExporter extends ExportService { bool called = false; @@ -22,8 +24,8 @@ class RecordingExporter extends ExportService { required Uint8List srcBytes, required Size uiPageSize, required Uint8List? signatureImageBytes, - Map>? placementsByPage, - Map? libraryBytes, + Map>? placementsByPage, + Map? libraryImages, double targetDpi = 144.0, }) async { // Return tiny dummy PDF bytes diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index 231d3ad..4f7b599 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -378,19 +378,22 @@ Future pumpWithOpenPdfAndSig(WidgetTester tester) async { notifier.addPlacement( page: 1, rect: const Rect.fromLTWH(0.1, 0.1, 0.3, 0.2), - asset: SignatureAsset(bytes: Uint8List.fromList(bytes)), + asset: SignatureAsset( + sigImage: img.decodeImage(Uint8List.fromList(bytes))!, + ), ); return notifier; }), signatureAssetRepositoryProvider.overrideWith((ref) { final repo = SignatureAssetRepository(); - repo.add(Uint8List.fromList(bytes), name: 'test'); + final image = img.decodeImage(Uint8List.fromList(bytes))!; + repo.addImage(image, name: 'test'); return repo; }), signatureCardRepositoryProvider.overrideWith((ref) { final cardRepo = SignatureCardStateNotifier(); final asset = SignatureAsset( - bytes: Uint8List.fromList(bytes), + sigImage: img.decodeImage(Uint8List.fromList(bytes))!, name: 'test', ); cardRepo.addWithAsset(asset, 0.0); diff --git a/test/widget/pdf_page_area_test.dart b/test/widget/pdf_page_area_test.dart index dc08759..0135669 100644 --- a/test/widget/pdf_page_area_test.dart +++ b/test/widget/pdf_page_area_test.dart @@ -1,7 +1,7 @@ import 'dart:typed_data'; -import 'package:image/image.dart' as img; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:pdf_signature/ui/features/pdf/widgets/pdf_page_area.dart'; @@ -108,7 +108,7 @@ void main() { .addPlacement( page: 1, rect: const Rect.fromLTWH(0.25, 0.50, 0.10, 0.10), - asset: SignatureAsset(bytes: bytes), + asset: SignatureAsset(sigImage: img.decodeImage(bytes)!), ); await tester.pumpAndSettle(); diff --git a/test/widget/rotated_signature_image_test.dart b/test/widget/rotated_signature_image_test.dart index 7042146..8d9087b 100644 --- a/test/widget/rotated_signature_image_test.dart +++ b/test/widget/rotated_signature_image_test.dart @@ -1,22 +1,21 @@ -import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:image/image.dart' as img; import 'package:pdf_signature/ui/features/signature/widgets/rotated_signature_image.dart'; -/// Generates a simple solid-color PNG with given width/height. -Uint8List makePng({required int w, required int h}) { +/// Generates a simple solid-color image with given width/height. +img.Image makeImage({required int w, required int h}) { final im = img.Image(width: w, height: h); // Fill with opaque white img.fill(im, color: img.ColorRgba8(255, 255, 255, 255)); - return Uint8List.fromList(img.encodePng(im)); + return im; } void main() { testWidgets('4:3 image rotated -90 deg scales to 3/4', (tester) async { // 4:3 aspect image -> width/height = 4/3 - final bytes = makePng(w: 400, h: 300); + final image = makeImage(w: 400, h: 300); // Pump widget under a fixed-size parent so Transform.scale is applied await tester.pumpWidget( @@ -26,7 +25,7 @@ void main() { child: SizedBox( width: 200, height: 150, // same aspect as image bounds (4:3) - child: RotatedSignatureImage(bytes: bytes, rotationDeg: -90), + child: RotatedSignatureImage(image: image, rotationDeg: -90), ), ), ), diff --git a/test/widget/signature_overlay_test.dart b/test/widget/signature_overlay_test.dart index 7786257..c24b049 100644 --- a/test/widget/signature_overlay_test.dart +++ b/test/widget/signature_overlay_test.dart @@ -29,7 +29,10 @@ void main() { color: img.ColorUint8.rgb(0, 0, 0), ); final bytes = img.encodePng(canvas); - testAsset = SignatureAsset(bytes: bytes, name: 'test_signature.png'); + testAsset = SignatureAsset( + sigImage: img.decodeImage(bytes)!, + name: 'test_signature.png', + ); container = ProviderContainer( overrides: [