fix: rotation and scale of placed signature on PDF are not sync

This commit is contained in:
insleker 2025-09-08 20:28:14 +08:00
parent 4f149656bd
commit fba880e1be
19 changed files with 142 additions and 179 deletions

View File

@ -10,8 +10,9 @@ checkout [`docs/FRs.md`](docs/FRs.md)
```bash
# flutter clean
# arb_translate
flutter pub get
# arb_translate
# flutter gen-l10n
# > to generate gherkin test
flutter pub run build_runner build --delete-conflicting-outputs
# > to remove unused step definitions

View File

@ -56,8 +56,8 @@ Design notes:
- Right pane: signatures drawer displaying saved signatures as cards.
- able to drag and drop signature cards onto the PDF as placed signatures.
- Each signature card shows a preview.
- long tap/right-click will show menu with options to delete, adjust graphic of image.
- "adjust graphic" opens a simple image editor, which can remove backgrounds, Rotate (rotation handle).
- long tap/right-click will show menu with options to delete, Adjust graphic of image.
- "Adjust graphic" opens a simple image editor, which can remove backgrounds, Rotate (rotation handle).
- There is an empty card with "new signature" prompt and 2 buttons: "from file" and "draw".
- "from file" opens a file picker to select an image as a signature card.
- "draw" opens a simple drawing interface (draw canvas) to create a signature card.

View File

@ -1,4 +1,5 @@
{
"adjustGraphic": "Grafik anpassen",
"appTitle": "PDF-Signatur",
"backgroundRemoval": "Hintergrund entfernen",
"brightness": "Helligkeit",

View File

@ -1,5 +1,7 @@
{
"@@locale": "en",
"adjustGraphic": "Adjust graphic",
"@adjustGraphic": {},
"appTitle": "PDF Signature",
"@appTitle": {},
"backgroundRemoval": "Background removal",

View File

@ -1,4 +1,5 @@
{
"adjustGraphic": "Ajustar gráfico",
"appTitle": "Firma PDF",
"backgroundRemoval": "Eliminar fondo",
"brightness": "Brillo",

View File

@ -1,4 +1,5 @@
{
"adjustGraphic": "Ajuster le graphique",
"appTitle": "Signature PDF",
"backgroundRemoval": "Suppression de l'arrière-plan",
"brightness": "Luminosité",

View File

@ -1,4 +1,5 @@
{
"adjustGraphic": "グラフィックを調整する",
"appTitle": "PDF署名",
"backgroundRemoval": "背景除去",
"brightness": "明るさ",

View File

@ -1,4 +1,5 @@
{
"adjustGraphic": "그래픽 조정",
"appTitle": "PDF 서명",
"backgroundRemoval": "배경 제거",
"brightness": "밝기",

View File

@ -1,4 +1,5 @@
{
"adjustGraphic": "Регулювати графіку",
"appTitle": "Підпис PDF",
"backgroundRemoval": "Видалення фону",
"brightness": "Яскравість",

View File

@ -1,5 +1,6 @@
{
"@@locale": "zh",
"adjustGraphic": "調整圖形",
"appTitle": "PDF 簽名",
"backgroundRemoval": "去除背景",
"brightness": "亮度",

View File

@ -1,4 +1,5 @@
{
"adjustGraphic": "调整图形",
"appTitle": "PDF 签名",
"backgroundRemoval": "背景移除",
"brightness": "亮度",

View File

@ -1,5 +1,6 @@
{
"@@locale": "zh_TW",
"adjustGraphic": "調整圖形",
"appTitle": "PDF 簽名",
"backgroundRemoval": "去除背景",
"brightness": "亮度",

View File

@ -1,14 +0,0 @@
import 'package:flutter/widgets.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
/// Centralized accessors for context menu labels to avoid duplication.
class MenuLabels {
static String confirm(BuildContext context) =>
AppLocalizations.of(context).confirm;
static String delete(BuildContext context) =>
AppLocalizations.of(context).delete;
// Not yet localized in l10n; keep here for single source of truth.
static String adjustGraphic(BuildContext context) => 'Adjust graphic';
}

View File

@ -51,9 +51,6 @@ class ImageEditorDialog extends ConsumerWidget {
return RotatedSignatureImage(
bytes: bytes,
rotationDeg: sig.rotation,
enableAngleAwareScale: true,
fit: BoxFit.contain,
wrapInRepaintBoundary: true,
);
},
),

View File

@ -9,7 +9,6 @@ import '../../signature/view_model/signature_controller.dart';
import '../view_model/pdf_controller.dart';
import '../../signature/view_model/signature_library.dart';
import 'image_editor_dialog.dart';
import '../../../common/menu_labels.dart';
import '../../signature/widgets/rotated_signature_image.dart';
/// Renders a single signature overlay (either interactive or placed) on a page.
@ -82,41 +81,52 @@ class SignatureOverlay extends ConsumerWidget {
final Color borderColor = isPlaced ? Colors.red : Colors.indigo;
final double borderWidth = isPlaced ? (isSelected ? 3.0 : 2.0) : 2.0;
Widget content = DecoratedBox(
decoration: BoxDecoration(
color: Color.fromRGBO(
0,
0,
0,
0.05 + math.min(0.25, (sig.contrast - 1.0).abs()),
),
border: Border.all(color: borderColor, width: borderWidth),
),
child: Stack(
alignment: Alignment.center,
children: [
_SignatureImage(
interactive: interactive,
placedIndex: placedIndex,
pageNumber: pageNumber,
sig: sig,
),
if (interactive)
Positioned(
right: 0,
bottom: 0,
child: GestureDetector(
key: const Key('signature_handle'),
behavior: HitTestBehavior.opaque,
onPanUpdate:
(d) => onResizeSignature?.call(
Offset(d.delta.dx / scaleX, d.delta.dy / scaleY),
),
child: const Icon(Icons.open_in_full, size: 20),
),
// Instead of DecoratedBox, use a Stack to control layering
Widget content = Stack(
alignment: Alignment.center,
children: [
// Background layer (semi-transparent color)
Positioned.fill(
child: Container(
color: Color.fromRGBO(
0,
0,
0,
0.05 + math.min(0.25, (sig.contrast - 1.0).abs()),
),
],
),
),
),
// Signature image layer
_SignatureImage(
interactive: interactive,
placedIndex: placedIndex,
pageNumber: pageNumber,
sig: sig,
),
// Border layer (on top, using Positioned.fill with a transparent background)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
border: Border.all(color: borderColor, width: borderWidth),
),
),
),
// Resize handle (only for interactive mode, on top of everything)
if (interactive)
Positioned(
right: 0,
bottom: 0,
child: GestureDetector(
key: const Key('signature_handle'),
behavior: HitTestBehavior.opaque,
onPanUpdate:
(d) => onResizeSignature?.call(
Offset(d.delta.dx / scaleX, d.delta.dy / scaleY),
),
child: const Icon(Icons.open_in_full, size: 20),
),
),
],
);
if (interactive) {
@ -128,8 +138,10 @@ class SignatureOverlay extends ConsumerWidget {
(d) => onDragSignature?.call(
Offset(d.delta.dx / scaleX, d.delta.dy / scaleY),
),
onSecondaryTapDown: (d) => _showActiveMenu(context, d.globalPosition),
onLongPressStart: (d) => _showActiveMenu(context, d.globalPosition),
onSecondaryTapDown:
(d) => _showActiveMenu(context, d.globalPosition, ref, null),
onLongPressStart:
(d) => _showActiveMenu(context, d.globalPosition, ref, null),
child: content,
);
} else {
@ -139,12 +151,12 @@ class SignatureOverlay extends ConsumerWidget {
onTap: () => onSelectPlaced?.call(placedIndex),
onSecondaryTapDown: (d) {
if (placedIndex != null) {
_showPlacedMenu(context, ref, d.globalPosition);
_showActiveMenu(context, d.globalPosition, ref, placedIndex);
}
},
onLongPressStart: (d) {
if (placedIndex != null) {
_showPlacedMenu(context, ref, d.globalPosition);
_showActiveMenu(context, d.globalPosition, ref, placedIndex);
}
},
child: content,
@ -153,7 +165,12 @@ class SignatureOverlay extends ConsumerWidget {
return content;
}
void _showActiveMenu(BuildContext context, Offset globalPos) {
void _showActiveMenu(
BuildContext context,
Offset globalPos,
WidgetRef ref,
int? placedIndex,
) {
showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
@ -163,74 +180,44 @@ class SignatureOverlay extends ConsumerWidget {
globalPos.dy,
),
items: [
PopupMenuItem<String>(
key: const Key('ctx_active_confirm'),
value: 'confirm',
child: Text(MenuLabels.confirm(context)),
),
// if not placed, show Adjust and Confirm option
if (placedIndex == null) ...[
PopupMenuItem<String>(
key: const Key('ctx_active_confirm'),
value: 'confirm',
child: Text(AppLocalizations.of(context).confirm),
),
PopupMenuItem<String>(
key: const Key('ctx_active_adjust'),
value: 'adjust',
child: Text(AppLocalizations.of(context).adjustGraphic),
),
],
PopupMenuItem<String>(
key: const Key('ctx_active_delete'),
value: 'delete',
child: Text(MenuLabels.delete(context)),
),
PopupMenuItem<String>(
key: const Key('ctx_active_adjust'),
value: 'adjust',
child: Text(MenuLabels.adjustGraphic(context)),
child: Text(AppLocalizations.of(context).delete),
),
],
).then((choice) {
if (choice == 'confirm') {
onConfirmSignature?.call();
if (placedIndex == null) {
onConfirmSignature?.call();
}
// For placed, confirm does nothing
} else if (choice == 'delete') {
onClearActiveOverlay?.call();
if (placedIndex == null) {
onClearActiveOverlay?.call();
} else {
ref
.read(pdfProvider.notifier)
.removePlacement(page: pageNumber, index: placedIndex);
}
} else if (choice == 'adjust') {
showDialog(context: context, builder: (_) => const ImageEditorDialog());
}
});
}
void _showPlacedMenu(BuildContext context, WidgetRef ref, Offset globalPos) {
showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
globalPos.dx,
globalPos.dy,
globalPos.dx,
globalPos.dy,
),
items: [
PopupMenuItem<String>(
key: const Key('ctx_placed_delete'),
value: 'delete',
child: Text(MenuLabels.delete(context)),
),
PopupMenuItem<String>(
key: const Key('ctx_placed_adjust'),
value: 'adjust',
child: Text(MenuLabels.adjustGraphic(context)),
),
],
).then((choice) {
switch (choice) {
case 'delete':
if (placedIndex != null) {
ref
.read(pdfProvider.notifier)
.removePlacement(page: pageNumber, index: placedIndex!);
}
break;
case 'adjust':
showDialog(
context: context,
builder: (ctx) => const ImageEditorDialog(),
);
break;
default:
break;
}
});
}
}
class _SignatureImage extends ConsumerWidget {
@ -291,12 +278,6 @@ class _SignatureImage extends ConsumerWidget {
rotationDeg = placementList[placedIndex!].rotationDeg;
}
}
return RotatedSignatureImage(
bytes: bytes,
rotationDeg: rotationDeg,
enableAngleAwareScale: interactive,
fit: BoxFit.contain,
wrapInRepaintBoundary: true,
);
return RotatedSignatureImage(bytes: bytes, rotationDeg: rotationDeg);
}
}

View File

@ -1,6 +1,7 @@
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:image/image.dart' as img;
/// A lightweight widget to render signature bytes with rotation and an
/// angle-aware scale-to-fit so the rotated image stays within its bounds.
@ -9,30 +10,18 @@ class RotatedSignatureImage extends StatefulWidget {
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 BoxFit fit = BoxFit.contain;
final bool gaplessPlayback = true;
final Alignment alignment = Alignment.center;
final bool wrapInRepaintBoundary = true;
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<RotatedSignatureImage> createState() => _RotatedSignatureImageState();
@ -60,20 +49,30 @@ class _RotatedSignatureImageState extends State<RotatedSignatureImage> {
}
}
void _setAspectRatio(double ar) {
if (mounted && _derivedAspectRatio != ar) {
setState(() => _derivedAspectRatio = ar);
}
}
void _resolveImage() {
_unlisten();
// Only derive AR if not provided
if (widget.intrinsicAspectRatio != null) return;
// Decode synchronously to get aspect ratio
final decoded = img.decodePng(widget.bytes);
if (decoded != null) {
final w = decoded.width;
final h = decoded.height;
if (w > 0 && h > 0) {
_setAspectRatio(w / h);
}
}
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);
}
_setAspectRatio(w / h);
}
});
stream.addListener(_listener!);
@ -106,24 +105,20 @@ class _RotatedSignatureImageState extends State<RotatedSignatureImage> {
);
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),
);
final double c = math.cos(angle).abs();
final double s = math.sin(angle).abs();
final ar = _derivedAspectRatio;
double scaleToFit;
if (ar != null && ar > 0) {
scaleToFit = math.min(ar / (ar * c + s), 1.0 / (ar * s + c));
} else {
img = Transform.rotate(angle: angle, child: img);
// 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),
);
}
if (!widget.wrapInRepaintBoundary) return img;

View File

@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import '../view_model/signature_library.dart';
import 'signature_drag_data.dart';
import '../../../common/menu_labels.dart';
import 'rotated_signature_image.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
class SignatureCard extends StatelessWidget {
const SignatureCard({
@ -30,9 +30,6 @@ class SignatureCard extends StatelessWidget {
Widget img = RotatedSignatureImage(
bytes: asset.bytes,
rotationDeg: rotationDeg,
enableAngleAwareScale: true,
fit: BoxFit.contain,
wrapInRepaintBoundary: true,
);
Widget base = SizedBox(
width: 96,
@ -92,12 +89,12 @@ class SignatureCard extends StatelessWidget {
PopupMenuItem(
key: const Key('mi_signature_adjust'),
value: 'adjust',
child: Text(MenuLabels.adjustGraphic(context)),
child: Text(AppLocalizations.of(context).adjustGraphic),
),
PopupMenuItem(
key: const Key('mi_signature_delete'),
value: 'delete',
child: Text(MenuLabels.delete(context)),
child: Text(AppLocalizations.of(context).delete),
),
],
);
@ -123,12 +120,12 @@ class SignatureCard extends StatelessWidget {
PopupMenuItem(
key: const Key('mi_signature_adjust'),
value: 'adjust',
child: Text(MenuLabels.adjustGraphic(context)),
child: Text(AppLocalizations.of(context).adjustGraphic),
),
PopupMenuItem(
key: const Key('mi_signature_delete'),
value: 'delete',
child: Text(MenuLabels.delete(context)),
child: Text(AppLocalizations.of(context).delete),
),
],
);
@ -163,9 +160,6 @@ class SignatureCard extends StatelessWidget {
child: RotatedSignatureImage(
bytes: asset.bytes,
rotationDeg: rotationDeg,
enableAngleAwareScale: true,
fit: BoxFit.contain,
wrapInRepaintBoundary: true,
),
),
),

View File

@ -46,6 +46,11 @@ void main() {
// Verify the context menu shows "Adjust graphic"
expect(find.byKey(const Key('mi_signature_adjust')), findsOneWidget);
// before confirm, adjust must be visible
expect(find.text('Adjust graphic'), findsOneWidget);
// after confirm, adjust must be visible
expect(find.text('Adjust graphic'), findsOneWidget);
// Do not proceed to open the dialog here; the goal is just to verify menu content.

View File

@ -26,14 +26,7 @@ void main() {
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
),
child: RotatedSignatureImage(bytes: bytes, rotationDeg: -90),
),
),
),