refactor: use image object to replace bytes
This commit is contained in:
parent
81a352a513
commit
bc524e958f
|
|
@ -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<int, List<SignaturePlacement>>? placementsByPage,
|
||||
Map<String, Uint8List>? libraryBytes,
|
||||
Map<String, img.Image>? 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;
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<Document> {
|
|||
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<Document> {
|
|||
|
||||
// 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,
|
||||
|
|
|
|||
|
|
@ -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<List<SignatureAsset>> {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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<double>? 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<CachedSignatureCard>.unmodifiable(list);
|
||||
return;
|
||||
|
|
@ -117,47 +129,58 @@ class SignatureCardStateNotifier
|
|||
state = const <CachedSignatureCard>[];
|
||||
}
|
||||
|
||||
/// 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<double>? 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.
|
||||
|
|
|
|||
|
|
@ -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<int, List<SignaturePlacement>>? placementsByPage,
|
||||
Map<String, Uint8List>? libraryBytes,
|
||||
Map<String, img.Image>? libraryImages,
|
||||
double targetDpi = 144.0,
|
||||
}) async {
|
||||
// Per-call caches to avoid redundant decode/encode and image embedding work
|
||||
final Map<String, Uint8List> _processedBytesCache = <String, Uint8List>{};
|
||||
final Map<String, img.Image> _baseImageCache = <String, img.Image>{};
|
||||
final Map<String, img.Image> _processedImageCache = <String, img.Image>{};
|
||||
final Map<String, Uint8List> _encodedPngCache = <String, Uint8List>{};
|
||||
final Map<String, pw.MemoryImage> _memoryImageCache =
|
||||
<String, pw.MemoryImage>{};
|
||||
final Map<String, double> _aspectRatioCache = <String, double>{};
|
||||
|
||||
// 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<List<Offset>>? 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -113,9 +113,9 @@ class _PdfMockContinuousListState extends ConsumerState<PdfMockContinuousList> {
|
|||
.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,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<void> Function() onPickPdf;
|
||||
|
|
@ -97,7 +98,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
|||
if (controller.isReady) controller.goToPage(pageNumber: target);
|
||||
}
|
||||
|
||||
Future<Uint8List?> _loadSignatureFromFile() async {
|
||||
Future<img.Image?> _loadSignatureFromFile() async {
|
||||
final typeGroup = fs.XTypeGroup(
|
||||
label:
|
||||
Localizations.of<AppLocalizations>(context, AppLocalizations)?.image,
|
||||
|
|
@ -106,20 +107,31 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
|||
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<Uint8List?> _openDrawCanvas() async {
|
||||
Future<img.Image?> _openDrawCanvas() async {
|
||||
final result = await showModalBottomSheet<Uint8List>(
|
||||
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<void> _saveSignedPdf() async {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<Uint8List?> Function() onLoadSignatureFromFile;
|
||||
final Future<Uint8List?> Function() onOpenDrawCanvas;
|
||||
final Future<img.Image?> Function() onLoadSignatureFromFile;
|
||||
final Future<img.Image?> Function() onOpenDrawCanvas;
|
||||
final VoidCallback onSave;
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -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<double>? 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();
|
||||
|
|
|
|||
|
|
@ -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<ImageEditorDialog> {
|
|||
late final ValueNotifier<double> _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<ImageEditorDialog> {
|
|||
_contrast = widget.initialGraphicAdjust.contrast;
|
||||
_brightness = widget.initialGraphicAdjust.brightness;
|
||||
_rotation = ValueNotifier<double>(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<ImageEditorDialog> {
|
|||
}
|
||||
|
||||
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<ImageEditorDialog> {
|
|||
);
|
||||
}
|
||||
// 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<ImageEditorDialog> {
|
|||
valueListenable: _rotation,
|
||||
builder: (context, rot, child) {
|
||||
final image = RotatedSignatureImage(
|
||||
bytes: _displayBytes,
|
||||
image:
|
||||
_bgRemoval
|
||||
? (_processedBgRemovedImage ??
|
||||
_originalImage)
|
||||
: _originalImage,
|
||||
rotationDeg: rot,
|
||||
);
|
||||
if (_bgRemoval) return image;
|
||||
|
|
|
|||
|
|
@ -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<RotatedSignatureImage> {
|
||||
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<void> _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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SignatureCardView> {
|
||||
Uint8List? _lastBytesRef;
|
||||
Future<void> _showContextMenu(BuildContext context, Offset position) async {
|
||||
final selected = await showMenu<String>(
|
||||
context: context,
|
||||
|
|
@ -61,39 +59,27 @@ class _SignatureCardViewState extends ConsumerState<SignatureCardView> {
|
|||
}
|
||||
}
|
||||
|
||||
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<SignatureCardView> {
|
|||
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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<Uint8List?> Function() onLoadSignatureFromFile;
|
||||
// Return the drawn bytes (if any) so we can add it to the library immediately.
|
||||
final Future<Uint8List?> Function() onOpenDrawCanvas;
|
||||
// Return decoded image so inner layers don't decode.
|
||||
final Future<img.Image?> Function() onLoadSignatureFromFile;
|
||||
// Return decoded image so inner layers don't decode.
|
||||
final Future<img.Image?> Function() onOpenDrawCanvas;
|
||||
|
||||
@override
|
||||
ConsumerState<SignatureDrawer> createState() => _SignatureDrawerState();
|
||||
|
|
@ -120,12 +121,11 @@ class _SignatureDrawerState extends ConsumerState<SignatureDrawer> {
|
|||
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<SignatureDrawer> {
|
|||
signatureAssetRepositoryProvider
|
||||
.notifier,
|
||||
)
|
||||
.add(b, name: 'image');
|
||||
.addImage(image, name: 'image');
|
||||
ref
|
||||
.read(
|
||||
signatureCardRepositoryProvider
|
||||
|
|
@ -151,11 +151,10 @@ class _SignatureDrawerState extends ConsumerState<SignatureDrawer> {
|
|||
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<SignatureDrawer> {
|
|||
signatureAssetRepositoryProvider
|
||||
.notifier,
|
||||
)
|
||||
.add(b, name: 'drawing');
|
||||
.addImage(image, name: 'drawing');
|
||||
ref
|
||||
.read(
|
||||
signatureCardRepositoryProvider
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
|
|
@ -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<Uint8List?> exportSignedPdfFromBytes({
|
||||
Map<String, Uint8List>? libraryBytes,
|
||||
Map<String, img.Image>? libraryImages,
|
||||
required Uint8List srcBytes,
|
||||
required Size uiPageSize,
|
||||
required Uint8List? signatureImageBytes,
|
||||
|
|
|
|||
|
|
@ -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<void> 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> 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',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> 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');
|
||||
|
|
|
|||
|
|
@ -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<void> 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> 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<void> 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);
|
||||
|
|
|
|||
|
|
@ -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<void> 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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> 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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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<void> 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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> 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();
|
||||
|
|
|
|||
|
|
@ -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<void> 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<void> 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',
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<void> 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<int, List<dynamic>>? placementsByPage,
|
||||
Map<String, Uint8List>? libraryBytes,
|
||||
Map<int, List<SignaturePlacement>>? placementsByPage,
|
||||
Map<String, img.Image>? libraryImages,
|
||||
double targetDpi = 144.0,
|
||||
}) async {
|
||||
// Return tiny dummy PDF bytes
|
||||
|
|
|
|||
|
|
@ -378,19 +378,22 @@ Future<void> 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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
Loading…
Reference in New Issue