From bc524e958f2179c0087194162698f624a30fc076 Mon Sep 17 00:00:00 2001 From: insleker Date: Sat, 20 Sep 2025 15:38:34 +0800 Subject: [PATCH] refactor: use image object to replace bytes --- integration_test/export_flow_test.dart | 11 +- integration_test/pdf_view_test.dart | 1 - .../repositories/document_repository.dart | 73 +----- .../signature_asset_repository.dart | 10 +- .../signature_card_repository.dart | 113 +++++---- lib/data/services/export_service.dart | 218 +++++++----------- .../signature_image_processing_service.dart | 147 ++---------- lib/domain/models/signature_asset.dart | 22 +- lib/domain/models/signature_card.dart | 4 +- .../pdf/widgets/pdf_mock_continuous_list.dart | 6 +- .../pdf/widgets/pdf_page_overlays.dart | 6 +- lib/ui/features/pdf/widgets/pdf_screen.dart | 24 +- .../pdf/widgets/signature_overlay.dart | 6 +- .../pdf/widgets/signatures_sidebar.dart | 7 +- .../view_model/signature_view_model.dart | 27 ++- .../widgets/image_editor_dialog.dart | 47 ++-- .../widgets/rotated_signature_image.dart | 202 +++++++++------- .../widgets/signature_card_view.dart | 36 +-- .../signature/widgets/signature_drawer.dart | 29 ++- lib/utils/background_removal.dart | 34 +++ lib/utils/download_web.dart | 1 + test/data/test_signature_image.png | Bin 0 -> 56092 bytes test/features/_test_helper.dart | 3 +- .../step/a_created_signature_card.dart | 9 +- ...ains_at_least_one_signature_placement.dart | 11 +- ...ced_signature_placements_across_pages.dart | 17 +- .../a_signature_asset_is_loaded_or_drawn.dart | 75 +----- ...signature_asset_is_placed_on_the_page.dart | 6 +- .../step/a_signature_asset_is_selected.dart | 4 +- ..._drawn_is_wrapped_in_a_signature_card.dart | 5 +- ...signature_placement_is_placed_on_page.dart | 7 +- ...osition_and_size_relative_to_the_page.dart | 7 +- ...nd_becomes_transparent_in_the_preview.dart | 20 +- ...ses_a_image_file_as_a_signature_asset.dart | 6 +- ...ure_asset_to_created_a_signature_card.dart | 5 +- ...in_multiple_locations_in_the_document.dart | 7 +- ...cument_to_place_a_signature_placement.dart | 5 +- .../the_user_draws_strokes_and_confirms.dart | 76 +----- ...nd_places_another_signature_placement.dart | 7 +- ...ignature_placement_from_asset_on_page.dart | 4 +- ..._places_a_signature_placement_on_page.dart | 7 +- ...signature_placements_on_the_same_page.dart | 25 +- ...ements_are_placed_on_the_current_page.dart | 17 +- test/utils/background_removal_test.dart | 138 +++++++++++ test/widget/background_removal_test.dart | 6 +- test/widget/export_flow_test.dart | 6 +- test/widget/helpers.dart | 9 +- test/widget/pdf_page_area_test.dart | 4 +- test/widget/rotated_signature_image_test.dart | 11 +- test/widget/signature_overlay_test.dart | 5 +- 50 files changed, 688 insertions(+), 838 deletions(-) create mode 100644 lib/utils/background_removal.dart create mode 100644 test/data/test_signature_image.png create mode 100644 test/utils/background_removal_test.dart 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 0000000000000000000000000000000000000000..4e6c14a597efa7393f7992f8e209bd185af776b4 GIT binary patch literal 56092 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≦PD>bJa=-yd9TG-4J?|< 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� z{}foGyHp(&8gNA5BTZa;2~%3&=`t}GZ!rk-4i4Z1L{~#{wruxHd3bg#dbe2es_>a) zv(M12TA!Cc{u+;}1tT%3(pH1l(SV2x!b^c{sWmbr3SyL*_zwGtt1y!IrtuzrEj$sZ zoZZn3;tWN3jnd!@-a7oKD9n;!aa2PAM6l%ukTeh~RJ69>SWnoYQ4Iq(s|Kq_(s{`D zF@k_*adMRT7@#b#{!#sYTSW-JEo(*`Q^-*NgsoI(8@6mTBzksNUBqyM9g>K9fgX)a zIYYteqNYPXep&3o)riiSc$EA(+ofJi$4KjmSzfIlB3iZ^VH*KT5QyZx1^c$xs9*`p zEPitxze(TSL`U+Fn!!8dv`t`P(2QemMUMnlc)EF#T9>o8?5}_I{+YPRf^?IGHh-K~ z>pnAVq-zTKtm(945JUeVs2c~DF<=e!drKZoVj5_2u+Mb6M@&3YPVcI!=$eFEQHF{q z=B`yBxb(j|1PlSF*Z>R^RtZXIsb}GqIpv=sB*$F(R_{`|IP5i$q~L0abhe7dox~;r zg-bn9OeSknq=puQj;ivJi5)aqxj{P{=9Zd3fcs>)4d04)2Oad{&E=K|!+UbqXwTQGoIru|o;PznL>QXXP6vG26^c+==k zYB<=N>_e%I*#ePh>A(ei&UuaB3^fO`(j-Xdo4vdAWwDu^3BGu;>N~n865jhBFB8KX zx{mJj4mqon;LLEq;{Vm)J_6Q;N$ls~>6wfLU1s8S7Z|ji2D0WQQL5SqX2TFSjOU9@ z67yk5Qv(HHhi!<)S8rjR$1hT`ZpCOFIRWLL6;g8|8XRrP6Wbz)^6QmVy7Uj5iov IF?0+6Kk5W_tN;K2 literal 0 HcmV?d00001 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: [