From 6dc095e23e8366bfc8517fc071fbfa575604189a Mon Sep 17 00:00:00 2001 From: insleker Date: Fri, 5 Sep 2025 15:33:53 +0800 Subject: [PATCH] refactor: signature rotation and improve its performance --- README.md | 3 +- lib/data/services/export_service.dart | 36 ++++- .../features/pdf/view_model/view_model.dart | 105 ++++++++------ .../pdf/widgets/image_editor_dialog.dart | 9 +- lib/ui/features/pdf/widgets/pdf_screen.dart | 38 ++++- .../pdf/widgets/rotated_signature_image.dart | 132 +++++++++++++++++ .../features/pdf/widgets/signature_card.dart | 30 +++- .../pdf/widgets/signature_drawer.dart | 2 + .../pdf/widgets/signature_overlay.dart | 16 ++- test/widget/e2e_place_confirm_test.dart | 136 ------------------ .../widgets/rotated_signature_image_test.dart | 73 ++++++++++ 11 files changed, 386 insertions(+), 194 deletions(-) create mode 100644 lib/ui/features/pdf/widgets/rotated_signature_image.dart delete mode 100644 test/widget/e2e_place_confirm_test.dart create mode 100644 test/widgets/rotated_signature_image_test.dart diff --git a/README.md b/README.md index cb32728..5c10f4d 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,8 @@ flutter run -d ### build -For Windows +#### Windows + ```bash flutter build windows # create windows installer diff --git a/lib/data/services/export_service.dart b/lib/data/services/export_service.dart index 6cefcc8..9370300 100644 --- a/lib/data/services/export_service.dart +++ b/lib/data/services/export_service.dart @@ -171,7 +171,14 @@ class ExportService { pw.Positioned( left: left, top: top, - child: pw.Image(imgObj, width: w, height: h), + child: pw.SizedBox( + width: w, + height: h, + child: pw.FittedBox( + fit: pw.BoxFit.contain, + child: pw.Image(imgObj), + ), + ), ), ); } @@ -187,7 +194,14 @@ class ExportService { pw.Positioned( left: left, top: top, - child: pw.Image(sigImgObj, width: w, height: h), + child: pw.SizedBox( + width: w, + height: h, + child: pw.FittedBox( + fit: pw.BoxFit.contain, + child: pw.Image(sigImgObj), + ), + ), ), ); } @@ -294,7 +308,14 @@ class ExportService { pw.Positioned( left: left, top: top, - child: pw.Image(imgObj, width: w, height: h), + child: pw.SizedBox( + width: w, + height: h, + child: pw.FittedBox( + fit: pw.BoxFit.contain, + child: pw.Image(imgObj), + ), + ), ), ); } @@ -310,7 +331,14 @@ class ExportService { pw.Positioned( left: left, top: top, - child: pw.Image(sigImgObj, width: w, height: h), + child: pw.SizedBox( + width: w, + height: h, + child: pw.FittedBox( + fit: pw.BoxFit.contain, + child: pw.Image(sigImgObj), + ), + ), ), ); } diff --git a/lib/ui/features/pdf/view_model/view_model.dart b/lib/ui/features/pdf/view_model/view_model.dart index 3135412..315a49b 100644 --- a/lib/ui/features/pdf/view_model/view_model.dart +++ b/lib/ui/features/pdf/view_model/view_model.dart @@ -385,21 +385,31 @@ class SignatureController extends StateNotifier { // Bind the processed image at placement time (so placed preview matches adjustments). // If processed bytes exist, always create a new asset for this placement. String id = ''; - final processed = ref.read(processedSignatureImageProvider); - if (processed != null && processed.isNotEmpty) { + // Compose final bytes for placement: apply adjustments (processed) then rotation. + Uint8List? srcBytes = ref.read(processedSignatureImageProvider); + srcBytes ??= state.imageBytes; + // If still null, fall back to asset reference only. + if (srcBytes != null && srcBytes.isNotEmpty) { + final rot = state.rotation % 360; + Uint8List finalBytes = srcBytes; + if (rot != 0) { + try { + final decoded = img.decodeImage(srcBytes); + if (decoded != null) { + var out = img.copyRotate( + decoded, + angle: rot, + interpolation: img.Interpolation.linear, + ); + finalBytes = Uint8List.fromList(img.encodePng(out, level: 6)); + } + } catch (_) {} + } id = ref .read(signatureLibraryProvider.notifier) - .add(processed, name: 'image'); + .add(finalBytes, name: 'image'); } else { - // Fallback to current image source - final bytes = state.imageBytes; - if (bytes != null && bytes.isNotEmpty) { - id = ref - .read(signatureLibraryProvider.notifier) - .add(bytes, name: 'image'); - } else { - id = state.assetId ?? 'default.png'; - } + id = state.assetId ?? 'default.png'; } // Store as UI-space rect (consistent with export and rendering paths) ref @@ -426,20 +436,29 @@ class SignatureController extends StateNotifier { final pdf = container.read(pdfProvider); if (!pdf.loaded) return null; String id = ''; - final processed = container.read(processedSignatureImageProvider); - if (processed != null && processed.isNotEmpty) { + Uint8List? srcBytes = container.read(processedSignatureImageProvider); + srcBytes ??= state.imageBytes; + if (srcBytes != null && srcBytes.isNotEmpty) { + final rot = state.rotation % 360; + Uint8List finalBytes = srcBytes; + if (rot != 0) { + try { + final decoded = img.decodeImage(srcBytes); + if (decoded != null) { + var out = img.copyRotate( + decoded, + angle: rot, + interpolation: img.Interpolation.linear, + ); + finalBytes = Uint8List.fromList(img.encodePng(out, level: 6)); + } + } catch (_) {} + } id = container .read(signatureLibraryProvider.notifier) - .add(processed, name: 'image'); + .add(finalBytes, name: 'image'); } else { - final bytes = state.imageBytes; - if (bytes != null && bytes.isNotEmpty) { - id = container - .read(signatureLibraryProvider.notifier) - .add(bytes, name: 'image'); - } else { - id = state.assetId ?? 'default.png'; - } + id = state.assetId ?? 'default.png'; } container .read(pdfProvider.notifier) @@ -473,19 +492,33 @@ final signatureProvider = /// current adjustment settings (contrast/brightness) and background removal. /// Returns null if no image is loaded. The output is a PNG to preserve alpha. final processedSignatureImageProvider = Provider((ref) { - final s = ref.watch(signatureProvider); + // Watch only the fields that affect pixel processing to avoid recompute on rotation. + final String? assetId = ref.watch(signatureProvider.select((s) => s.assetId)); + final Uint8List? directBytes = ref.watch( + signatureProvider.select((s) => s.imageBytes), + ); + final double contrast = ref.watch( + signatureProvider.select((s) => s.contrast), + ); + final double brightness = ref.watch( + signatureProvider.select((s) => s.brightness), + ); + final bool bgRemoval = ref.watch( + signatureProvider.select((s) => s.bgRemoval), + ); + // If active overlay is based on a library asset, pull its bytes Uint8List? bytes; - if (s.assetId != null) { + if (assetId != null) { final lib = ref.watch(signatureLibraryProvider); for (final a in lib) { - if (a.id == s.assetId) { + if (a.id == assetId) { bytes = a.bytes; break; } } } else { - bytes = s.imageBytes; + bytes = directBytes; } if (bytes == null || bytes.isEmpty) return null; @@ -501,9 +534,7 @@ final processedSignatureImageProvider = Provider((ref) { } // Parameters - final double contrast = s.contrast; // [0..2], 1 = neutral - final double brightness = s.brightness; // [-1..1], 0 = neutral - final double rotationDeg = s.rotation; // degrees + // Rotation is not applied here (UI uses Transform; export applies once). const int thrLow = 220; // begin soft transparency from this avg luminance const int thrHigh = 245; // fully transparent from this avg luminance @@ -529,7 +560,7 @@ final processedSignatureImageProvider = Provider((ref) { // Near-white background removal (compute average luminance) final int avg = ((r + g + b) / 3).round(); int remAlpha = 255; // 255 = fully opaque, 0 = transparent - if (s.bgRemoval) { + if (bgRemoval) { if (avg >= thrHigh) { remAlpha = 0; } else if (avg >= thrLow) { @@ -548,15 +579,9 @@ final processedSignatureImageProvider = Provider((ref) { } } - // Apply rotation if any (around center) using bilinear interpolation and keep size - if (rotationDeg % 360 != 0) { - // The image package rotates counter-clockwise; positive degrees rotate CCW - out = img.copyRotate( - out, - angle: rotationDeg, - interpolation: img.Interpolation.linear, - ); - } + // NOTE: Do not rotate here to keep UI responsive while dragging the slider. + // Rotation is applied in the UI using Transform.rotate for preview and + // performed once on confirm/export to avoid per-frame recomputation. // Encode as PNG to preserve transparency final png = img.encodePng(out, level: 6); diff --git a/lib/ui/features/pdf/widgets/image_editor_dialog.dart b/lib/ui/features/pdf/widgets/image_editor_dialog.dart index 1f6ae41..0f8be52 100644 --- a/lib/ui/features/pdf/widgets/image_editor_dialog.dart +++ b/lib/ui/features/pdf/widgets/image_editor_dialog.dart @@ -4,6 +4,7 @@ import 'package:pdf_signature/l10n/app_localizations.dart'; import '../view_model/view_model.dart'; import 'adjustments_panel.dart'; +import 'rotated_signature_image.dart'; class ImageEditorDialog extends ConsumerWidget { const ImageEditorDialog({super.key}); @@ -47,7 +48,13 @@ class ImageEditorDialog extends ConsumerWidget { if (bytes == null) { return Text(l.noSignatureLoaded); } - return Image.memory(bytes, fit: BoxFit.contain); + return RotatedSignatureImage( + bytes: bytes, + rotationDeg: sig.rotation, + enableAngleAwareScale: true, + fit: BoxFit.contain, + wrapInRepaintBoundary: true, + ); }, ), ), diff --git a/lib/ui/features/pdf/widgets/pdf_screen.dart b/lib/ui/features/pdf/widgets/pdf_screen.dart index 650959e..f30e470 100644 --- a/lib/ui/features/pdf/widgets/pdf_screen.dart +++ b/lib/ui/features/pdf/widgets/pdf_screen.dart @@ -9,6 +9,7 @@ import 'package:pdfrx/pdfrx.dart'; import 'package:multi_split_view/multi_split_view.dart'; import '../../../../data/services/export_providers.dart'; +import 'package:image/image.dart' as img; import '../view_model/view_model.dart'; import 'draw_canvas.dart'; import 'pdf_toolbar.dart'; @@ -137,16 +138,39 @@ class _PdfSignatureHomePageState extends ConsumerState { final useMock = ref.read(useMockViewerProvider); bool ok = false; String? savedPath; + // Helper to apply rotation to bytes for export (single-signature path only) + Uint8List? _rotatedForExport(Uint8List? src, double deg) { + if (src == null || src.isEmpty) return src; + final r = deg % 360; + if (r == 0) return src; + try { + final decoded = img.decodeImage(src); + if (decoded == null) return src; + final out = img.copyRotate( + decoded, + angle: r, + interpolation: img.Interpolation.linear, + ); + return Uint8List.fromList(img.encodePng(out, level: 6)); + } catch (_) { + return src; + } + } + if (kIsWeb) { Uint8List? src = pdf.pickedPdfBytes; if (src != null) { final processed = ref.read(processedSignatureImageProvider); + final rotated = _rotatedForExport( + processed ?? sig.imageBytes, + sig.rotation, + ); final bytes = await exporter.exportSignedPdfFromBytes( srcBytes: src, signedPage: pdf.signedPage, signatureRectUi: sig.rect, uiPageSize: SignatureController.pageSize, - signatureImageBytes: processed ?? sig.imageBytes, + signatureImageBytes: rotated, placementsByPage: pdf.placementsByPage, placementImageByPage: pdf.placementImageByPage, libraryBytes: { @@ -174,12 +198,16 @@ class _PdfSignatureHomePageState extends ConsumerState { savedPath = fullPath; if (pdf.pickedPdfBytes != null) { final processed = ref.read(processedSignatureImageProvider); + final rotated = _rotatedForExport( + processed ?? sig.imageBytes, + sig.rotation, + ); final out = await exporter.exportSignedPdfFromBytes( srcBytes: pdf.pickedPdfBytes!, signedPage: pdf.signedPage, signatureRectUi: sig.rect, uiPageSize: SignatureController.pageSize, - signatureImageBytes: processed ?? sig.imageBytes, + signatureImageBytes: rotated, placementsByPage: pdf.placementsByPage, placementImageByPage: pdf.placementImageByPage, libraryBytes: { @@ -200,13 +228,17 @@ class _PdfSignatureHomePageState extends ConsumerState { ok = true; } else { final processed = ref.read(processedSignatureImageProvider); + final rotated = _rotatedForExport( + processed ?? sig.imageBytes, + sig.rotation, + ); ok = await exporter.exportSignedPdfFromFile( inputPath: pdf.pickedPdfPath!, outputPath: fullPath, signedPage: pdf.signedPage, signatureRectUi: sig.rect, uiPageSize: SignatureController.pageSize, - signatureImageBytes: processed ?? sig.imageBytes, + signatureImageBytes: rotated, placementsByPage: pdf.placementsByPage, placementImageByPage: pdf.placementImageByPage, libraryBytes: { diff --git a/lib/ui/features/pdf/widgets/rotated_signature_image.dart b/lib/ui/features/pdf/widgets/rotated_signature_image.dart new file mode 100644 index 0000000..2643bf1 --- /dev/null +++ b/lib/ui/features/pdf/widgets/rotated_signature_image.dart @@ -0,0 +1,132 @@ +import 'dart:math' as math; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; + +/// A lightweight widget to render signature bytes with rotation and an +/// angle-aware scale-to-fit so the rotated image stays within its bounds. +class RotatedSignatureImage extends StatefulWidget { + const RotatedSignatureImage({ + super.key, + required this.bytes, + this.rotationDeg = 0.0, + this.enableAngleAwareScale = true, + this.fit = BoxFit.contain, + this.gaplessPlayback = true, + this.filterQuality = FilterQuality.low, + this.wrapInRepaintBoundary = true, + this.alignment = Alignment.center, + this.semanticLabel, + this.intrinsicAspectRatio, + }); + + final Uint8List bytes; + final double rotationDeg; + final bool enableAngleAwareScale; + final BoxFit fit; + final bool gaplessPlayback; + final FilterQuality filterQuality; + final bool wrapInRepaintBoundary; + final AlignmentGeometry alignment; + final String? semanticLabel; + // Optional: intrinsic aspect ratio (width / height). If provided, we compute + // an angle-aware scale for non-square images to ensure the rotated rectangle + // (W,H) fits back into its (W,H) bounds. If null, we attempt to derive it + // from the image stream; only fall back to the square heuristic if unknown. + final double? intrinsicAspectRatio; + + @override + State createState() => _RotatedSignatureImageState(); +} + +class _RotatedSignatureImageState extends State { + ImageStream? _stream; + ImageStreamListener? _listener; + double? _derivedAspectRatio; // width / height + + MemoryImage get _provider => MemoryImage(widget.bytes); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _resolveImage(); + } + + @override + void didUpdateWidget(covariant RotatedSignatureImage oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.bytes, widget.bytes)) { + _derivedAspectRatio = null; + _resolveImage(); + } + } + + void _resolveImage() { + _unlisten(); + // Only derive AR if not provided + if (widget.intrinsicAspectRatio != null) 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) { + final ar = w / h; + if (mounted && _derivedAspectRatio != ar) { + setState(() => _derivedAspectRatio = ar); + } + } + }); + stream.addListener(_listener!); + } + + void _unlisten() { + if (_stream != null && _listener != null) { + _stream!.removeListener(_listener!); + } + _stream = null; + _listener = null; + } + + @override + void dispose() { + _unlisten(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final angle = widget.rotationDeg * math.pi / 180.0; + Widget img = Image.memory( + widget.bytes, + fit: widget.fit, + gaplessPlayback: widget.gaplessPlayback, + filterQuality: widget.filterQuality, + alignment: widget.alignment, + semanticLabel: widget.semanticLabel, + ); + + if (angle != 0.0) { + if (widget.enableAngleAwareScale) { + final double c = math.cos(angle).abs(); + final double s = math.sin(angle).abs(); + final ar = widget.intrinsicAspectRatio ?? _derivedAspectRatio; + double scaleToFit; + if (ar != null && ar > 0) { + scaleToFit = math.min(ar / (ar * c + s), 1.0 / (ar * s + c)); + } else { + // Fallback: square approximation + scaleToFit = 1.0 / (c + s).clamp(1.0, double.infinity); + } + img = Transform.scale( + scale: scaleToFit, + child: Transform.rotate(angle: angle, child: img), + ); + } else { + img = Transform.rotate(angle: angle, child: img); + } + } + + if (!widget.wrapInRepaintBoundary) return img; + return RepaintBoundary(child: img); + } +} diff --git a/lib/ui/features/pdf/widgets/signature_card.dart b/lib/ui/features/pdf/widgets/signature_card.dart index ccc818a..30a0206 100644 --- a/lib/ui/features/pdf/widgets/signature_card.dart +++ b/lib/ui/features/pdf/widgets/signature_card.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../view_model/view_model.dart'; import 'signature_drag_data.dart'; import '../../../common/menu_labels.dart'; +import 'rotated_signature_image.dart'; class SignatureCard extends StatelessWidget { const SignatureCard({ @@ -12,6 +13,7 @@ class SignatureCard extends StatelessWidget { this.onTap, this.onAdjust, this.useCurrentBytesForDrag = false, + this.rotationDeg = 0.0, }); final SignatureAsset asset; final bool disabled; @@ -19,10 +21,19 @@ class SignatureCard extends StatelessWidget { final VoidCallback? onTap; final VoidCallback? onAdjust; final bool useCurrentBytesForDrag; + final double rotationDeg; @override Widget build(BuildContext context) { - final img = Image.memory(asset.bytes, fit: BoxFit.contain); + // Fit inside 96x64 with 6px padding using the shared rotated image widget + const boxW = 96.0, boxH = 64.0, pad = 6.0; + Widget img = RotatedSignatureImage( + bytes: asset.bytes, + rotationDeg: rotationDeg, + enableAngleAwareScale: true, + fit: BoxFit.contain, + wrapInRepaintBoundary: true, + ); Widget base = SizedBox( width: 96, height: 64, @@ -36,7 +47,14 @@ class SignatureCard extends StatelessWidget { border: Border.all(color: Theme.of(context).dividerColor), borderRadius: BorderRadius.circular(8), ), - child: Padding(padding: const EdgeInsets.all(6), child: img), + child: Padding( + padding: const EdgeInsets.all(pad), + child: SizedBox( + width: boxW - pad * 2, + height: boxH - pad * 2, + child: img, + ), + ), ), ), Positioned( @@ -142,7 +160,13 @@ class SignatureCard extends StatelessWidget { ), child: Padding( padding: const EdgeInsets.all(6.0), - child: Image.memory(asset.bytes, fit: BoxFit.contain), + child: RotatedSignatureImage( + bytes: asset.bytes, + rotationDeg: rotationDeg, + enableAngleAwareScale: true, + fit: BoxFit.contain, + wrapInRepaintBoundary: true, + ), ), ), ), diff --git a/lib/ui/features/pdf/widgets/signature_drawer.dart b/lib/ui/features/pdf/widgets/signature_drawer.dart index 13bec59..b29ce17 100644 --- a/lib/ui/features/pdf/widgets/signature_drawer.dart +++ b/lib/ui/features/pdf/widgets/signature_drawer.dart @@ -58,6 +58,7 @@ class _SignatureDrawerState extends ConsumerState { name: a.name, ) : a, + rotationDeg: (sig.assetId == a.id) ? sig.rotation : 0.0, disabled: disabled, onDelete: () => ref @@ -95,6 +96,7 @@ class _SignatureDrawerState extends ConsumerState { ? Text(l.noSignatureLoaded) : SignatureCard( asset: SignatureAsset(id: '', bytes: bytes, name: ''), + rotationDeg: sig.rotation, disabled: disabled, useCurrentBytesForDrag: true, onDelete: () { diff --git a/lib/ui/features/pdf/widgets/signature_overlay.dart b/lib/ui/features/pdf/widgets/signature_overlay.dart index 42accef..b730558 100644 --- a/lib/ui/features/pdf/widgets/signature_overlay.dart +++ b/lib/ui/features/pdf/widgets/signature_overlay.dart @@ -8,6 +8,7 @@ import '../../../../data/model/model.dart'; import '../view_model/view_model.dart'; import 'image_editor_dialog.dart'; import '../../../common/menu_labels.dart'; +import 'rotated_signature_image.dart'; /// Renders a single signature overlay (either interactive or placed) on a page. class SignatureOverlay extends ConsumerWidget { @@ -90,6 +91,7 @@ class SignatureOverlay extends ConsumerWidget { border: Border.all(color: borderColor, width: borderWidth), ), child: Stack( + alignment: Alignment.center, children: [ _SignatureImage( interactive: interactive, @@ -115,7 +117,7 @@ class SignatureOverlay extends ConsumerWidget { ), ); - if (interactive && sig.editingEnabled) { + if (interactive) { content = GestureDetector( key: const Key('signature_overlay'), behavior: HitTestBehavior.opaque, @@ -277,10 +279,12 @@ class _SignatureImage extends ConsumerWidget { return Center(child: Text(label)); } - Widget im = Image.memory(bytes, fit: BoxFit.contain); - if (sig.rotation % 360 != 0) { - im = Transform.rotate(angle: sig.rotation * math.pi / 180.0, child: im); - } - return im; + return RotatedSignatureImage( + bytes: bytes, + rotationDeg: interactive ? sig.rotation : 0.0, + enableAngleAwareScale: interactive, + fit: BoxFit.contain, + wrapInRepaintBoundary: true, + ); } } diff --git a/test/widget/e2e_place_confirm_test.dart b/test/widget/e2e_place_confirm_test.dart deleted file mode 100644 index 49a8469..0000000 --- a/test/widget/e2e_place_confirm_test.dart +++ /dev/null @@ -1,136 +0,0 @@ -import 'dart:ui' as ui; -import 'package:flutter/gestures.dart' show kSecondaryMouseButton; -import 'dart:typed_data'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:image/image.dart' as img; - -import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart'; -import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart'; -import 'package:pdf_signature/data/services/export_providers.dart'; -import 'package:pdf_signature/l10n/app_localizations.dart'; - -void main() { - // Open the active overlay context menu robustly (mouse right-click, fallback to long-press) - Future _openActiveMenuAndConfirm(WidgetTester tester) async { - final overlay = find.byKey(const Key('signature_overlay')); - expect(overlay, findsOneWidget); - // Ensure visible before interacting - await tester.ensureVisible(overlay); - await tester.pumpAndSettle(); - - // Try right-click first - final center = tester.getCenter(overlay); - final TestGesture mouse = await tester.createGesture( - kind: ui.PointerDeviceKind.mouse, - buttons: kSecondaryMouseButton, - ); - await mouse.addPointer(location: center); - addTearDown(mouse.removePointer); - await tester.pump(); - await mouse.down(center); - await tester.pump(const Duration(milliseconds: 30)); - await mouse.up(); - await tester.pumpAndSettle(); - - // If menu didn't appear, try long-press - if (find.byKey(const Key('ctx_active_confirm')).evaluate().isEmpty) { - await tester.longPress(overlay, warnIfMissed: false); - await tester.pumpAndSettle(); - } - await tester.tap(find.byKey(const Key('ctx_active_confirm'))); - await tester.pumpAndSettle(); - } - - // Build a simple in-memory PNG as a signature image - Uint8List _makeSig() { - final canvas = img.Image(width: 80, height: 40); - img.fill(canvas, color: img.ColorUint8.rgb(255, 255, 255)); - img.drawLine( - canvas, - x1: 6, - y1: 20, - x2: 74, - y2: 20, - color: img.ColorUint8.rgb(0, 0, 0), - ); - return Uint8List.fromList(img.encodePng(canvas)); - } - - testWidgets('E2E: select, place default, and confirm signature', ( - tester, - ) async { - final sigBytes = _makeSig(); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - // Open a PDF - pdfProvider.overrideWith( - (ref) => PdfController()..openPicked(path: 'test.pdf'), - ), - // Provide one signature asset in the library - signatureLibraryProvider.overrideWith((ref) { - final c = SignatureLibraryController(); - c.add(sigBytes, name: 'image'); - return c; - }), - // Use mock continuous viewer for deterministic layout in widget tests - useMockViewerProvider.overrideWithValue(true), - ], - child: MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: const PdfSignatureHomePage(), - ), - ), - ); - await tester.pumpAndSettle(); - - // Tap the signature card to set it as active overlay - final card = find.byKey(const Key('gd_signature_card_area')).first; - expect(card, findsOneWidget); - await tester.tap(card); - await tester.pump(); - - // Active overlay should appear - final active = find.byKey(const Key('signature_overlay')); - expect(active, findsOneWidget); - final sizeBefore = tester.getSize(active); - - // Bring the overlay into the viewport (it's near the bottom of the page by default) - final listFinder = find.byKey(const Key('pdf_continuous_mock_list')); - if (listFinder.evaluate().isNotEmpty) { - // Ensure the active overlay is fully visible within the scrollable viewport - await tester.ensureVisible(active); - await tester.pumpAndSettle(); - } - - // Open context menu and confirm using a robust flow - await _openActiveMenuAndConfirm(tester); - - // Verify active overlay gone and placed overlay shown - expect(find.byKey(const Key('signature_overlay')), findsNothing); - final placed = find.byKey(const Key('placed_signature_0')); - expect(placed, findsOneWidget); - final sizeAfter = tester.getSize(placed); - - // Compare sizes: should be roughly equal (allowing small layout variance) - expect( - (sizeAfter.width - sizeBefore.width).abs() < sizeBefore.width * 0.15, - isTrue, - ); - expect( - (sizeAfter.height - sizeBefore.height).abs() < sizeBefore.height * 0.15, - isTrue, - ); - - // Verify provider state reflects one placement on current page - final ctx = tester.element(find.byType(PdfSignatureHomePage)); - final container = ProviderScope.containerOf(ctx); - final pdf = container.read(pdfProvider); - final list = pdf.placementsByPage[pdf.currentPage] ?? const []; - expect(list.length, 1); - }); -} diff --git a/test/widgets/rotated_signature_image_test.dart b/test/widgets/rotated_signature_image_test.dart new file mode 100644 index 0000000..884bacd --- /dev/null +++ b/test/widgets/rotated_signature_image_test.dart @@ -0,0 +1,73 @@ +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/pdf/widgets/rotated_signature_image.dart'; + +/// Generates a simple solid-color PNG with given width/height. +Uint8List makePng({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)); +} + +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); + + // Pump widget under a fixed-size parent so Transform.scale is applied + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 200, + height: 150, // same aspect as image bounds (4:3) + child: RotatedSignatureImage( + bytes: bytes, + rotationDeg: -90, + enableAngleAwareScale: true, + intrinsicAspectRatio: 4 / 3, + fit: BoxFit.contain, + wrapInRepaintBoundary: false, // make Transform visible + ), + ), + ), + ), + ), + ); + + // Find the Transform widget that applies the scale (the outer Transform.scale) + final transformFinder = find.byType(Transform); + expect(transformFinder, findsWidgets); + + // Among the Transforms, we expect one to be a scale-only matrix. + // Grab the first Transform and assert the scale on x (m4x4 matrix) equals 0.75. + Transform? scaleTransform; + for (final e in tester.widgetList(transformFinder)) { + final m = e.transform.storage; + // A scale-only matrix will have m[0] and m[5] as scale factors on x/y, with zeros elsewhere (except last row/column) + // Also rotation transform will have off-diagonal terms; we want the one with zeros in 1,4 and 4,1 positions approximately. + final isLikelyScale = + (m[1].abs() < 1e-6) && + (m[4].abs() < 1e-6) && + (m[12].abs() < 1e-6) && + (m[13].abs() < 1e-6); + if (isLikelyScale) { + scaleTransform = e; + break; + } + } + expect(scaleTransform, isNotNull, reason: 'Scale Transform not found'); + + final scale = scaleTransform!.transform.storage[0]; + expect( + (scale - 0.75).abs() < 1e-6, + isTrue, + reason: 'Expected scale 0.75 for 4:3 rotated -90°', + ); + }); +}