feat: output other pages which are not signed

This commit is contained in:
insleker 2025-08-27 16:27:58 +08:00
parent 8ad34023cd
commit 5b0b9d2a02
10 changed files with 459 additions and 36 deletions

View File

@ -1,3 +1,18 @@
# pdf_signature
A GUI app to create a signature on PDF page interactively.
## Features
checkout [`docs/FRs.md`](docs/FRs.md)
## Build
```bash
flutter pub get
flutter run
flutter test
flutter build
```

View File

@ -100,8 +100,16 @@ Feature: save signed PDF
Scenario: Export the signed document to a new file
Given a PDF is open and contains at least one placed signature
When the user saves/exports the document
Then a new PDF file is saved at the chosen location with specified file name
And the signatures appear on the corresponding pages in the output
Then a new PDF file is saved at specified full path, location and file name
And the signatures appear on the corresponding page in the output
And keep other unchanged content(pages) intact in the output
Scenario: Vector-accurate stamping into PDF page coordinates
Given a signature is placed with a position and size relative to the page
When the user saves/exports the document
Then the signature is stamped at the exact PDF page coordinates and size
And the stamp remains crisp at any zoom level (not rasterized by the screen)
And other page content remains vector and unaltered
Scenario: Prevent saving when nothing is placed
Given a PDF is open with no signatures placed

View File

@ -3,7 +3,7 @@ import 'package:file_selector/file_selector.dart' as fs;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdfrx/pdfrx.dart';
import 'dart:io' show Platform;
import 'package:path_provider/path_provider.dart' as pp;
import 'dart:typed_data';
import '../share/export_service.dart';
@ -12,6 +12,36 @@ part 'viewer_widgets.dart';
// Testing hook: allow using a mock viewer instead of pdfrx to avoid async I/O in widget tests
final useMockViewerProvider = Provider<bool>((_) => false);
// Export service injection for testability
final exportServiceProvider = Provider<ExportService>((_) => ExportService());
// Controls whether signature overlay is visible (used to hide on non-stamped pages during export)
final signatureVisibilityProvider = StateProvider<bool>((_) => true);
// Save path picker (injected for tests)
final savePathPickerProvider = Provider<Future<String?> Function()>((ref) {
return () async {
String? initialDir;
try {
final d = await pp.getDownloadsDirectory();
initialDir = d?.path;
} catch (_) {}
if (initialDir == null) {
try {
final d = await pp.getApplicationDocumentsDirectory();
initialDir = d.path;
} catch (_) {}
}
final location = await fs.getSaveLocation(
suggestedName: 'signed.pdf',
acceptedTypeGroups: [
const fs.XTypeGroup(label: 'PDF', extensions: ['pdf']),
],
initialDirectory: initialDir,
);
if (location == null) return null;
final path = location.path;
return path.toLowerCase().endsWith('.pdf') ? path : '$path.pdf';
};
});
class PdfSignatureHomePage extends ConsumerStatefulWidget {
const PdfSignatureHomePage({super.key});
@ -94,20 +124,38 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
);
return;
}
// Pick a directory to save (fallback when save-as dialog API isn't available)
final dir = await fs.getDirectoryPath();
if (dir == null) return;
final sep = Platform.pathSeparator;
final path = '$dir${sep}signed.pdf';
final exporter = ExportService();
final ok = await exporter.exportSignedPdfFromBoundary(
final pick = ref.read(savePathPickerProvider);
final path = await pick();
if (path == null || path.trim().isEmpty) return;
final fullPath = _ensurePdfExtension(path.trim());
final exporter = ref.read(exportServiceProvider);
// Multi-page export: iterate pages by navigating the viewer
final controller = ref.read(pdfProvider.notifier);
final current = pdf.currentPage;
final targetPage = pdf.signedPage; // may be null if not marked
final ok = await exporter.exportMultiPageFromBoundary(
boundaryKey: _captureKey,
outputPath: path,
outputPath: fullPath,
pageCount: pdf.pageCount,
onGotoPage: (p) async {
controller.jumpTo(p);
// Show overlay only on the signed page (if any)
// If a target page is specified, show overlay only on that page.
// If not specified, keep overlay visible (backwards compatible single-page case).
final show = targetPage == null ? true : (targetPage == p);
ref.read(signatureVisibilityProvider.notifier).state = show;
// Allow build to occur
await Future<void>.delayed(const Duration(milliseconds: 20));
},
);
// Restore page
controller.jumpTo(current);
// Restore visibility
ref.read(signatureVisibilityProvider.notifier).state = true;
if (ok) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Saved: $path')));
).showSnackBar(SnackBar(content: Text('Saved: $fullPath')));
} else {
ScaffoldMessenger.of(
context,
@ -115,6 +163,13 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
}
}
// Removed manual full-path dialog; using file_selector.getSaveLocation via provider
String _ensurePdfExtension(String name) {
if (!name.toLowerCase().endsWith('.pdf')) return '$name.pdf';
return name;
}
@override
Widget build(BuildContext context) {
final pdf = ref.watch(pdfProvider);
@ -208,11 +263,6 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
onPressed: _loadSignatureFromFile,
child: const Text('Load Signature from file'),
),
OutlinedButton(
key: const Key('btn_load_invalid_signature'),
onPressed: _loadInvalidSignature,
child: const Text('Load Invalid'),
),
ElevatedButton(
key: const Key('btn_draw_signature'),
onPressed: _openDrawCanvas,
@ -254,7 +304,8 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
Consumer(
builder: (context, ref, _) {
final sig = ref.watch(signatureProvider);
return sig.rect != null
final visible = ref.watch(signatureVisibilityProvider);
return sig.rect != null && visible
? _buildSignatureOverlay(sig)
: const SizedBox.shrink();
},
@ -301,7 +352,8 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
Consumer(
builder: (context, ref, _) {
final sig = ref.watch(signatureProvider);
return sig.rect != null
final visible = ref.watch(signatureVisibilityProvider);
return sig.rect != null && visible
? _buildSignatureOverlay(sig)
: const SizedBox.shrink();
},

View File

@ -6,18 +6,21 @@ class PdfState {
final int currentPage;
final bool markedForSigning;
final String? pickedPdfPath;
final int? signedPage;
const PdfState({
required this.loaded,
required this.pageCount,
required this.currentPage,
required this.markedForSigning,
this.pickedPdfPath,
this.signedPage,
});
factory PdfState.initial() => const PdfState(
loaded: false,
pageCount: 0,
currentPage: 1,
markedForSigning: false,
signedPage: null,
);
PdfState copyWith({
bool? loaded,
@ -25,12 +28,14 @@ class PdfState {
int? currentPage,
bool? markedForSigning,
String? pickedPdfPath,
int? signedPage,
}) => PdfState(
loaded: loaded ?? this.loaded,
pageCount: pageCount ?? this.pageCount,
currentPage: currentPage ?? this.currentPage,
markedForSigning: markedForSigning ?? this.markedForSigning,
pickedPdfPath: pickedPdfPath ?? this.pickedPdfPath,
signedPage: signedPage ?? this.signedPage,
);
}
@ -44,6 +49,7 @@ class PdfController extends StateNotifier<PdfState> {
currentPage: 1,
markedForSigning: false,
pickedPdfPath: null,
signedPage: null,
);
}
@ -54,6 +60,7 @@ class PdfController extends StateNotifier<PdfState> {
currentPage: 1,
markedForSigning: false,
pickedPdfPath: path,
signedPage: null,
);
}
@ -65,7 +72,14 @@ class PdfController extends StateNotifier<PdfState> {
void toggleMark() {
if (!state.loaded) return;
state = state.copyWith(markedForSigning: !state.markedForSigning);
if (state.signedPage != null) {
state = state.copyWith(markedForSigning: false, signedPage: null);
} else {
state = state.copyWith(
markedForSigning: true,
signedPage: state.currentPage,
);
}
}
void setPageCount(int count) {
@ -168,24 +182,55 @@ class SignatureController extends StateNotifier<SignatureState> {
void resize(Offset delta) {
if (state.rect == null) return;
final r = state.rect!;
double newW = (r.width + delta.dx).clamp(20, pageSize.width);
double newH = (r.height + delta.dy).clamp(20, pageSize.height);
double newW = r.width + delta.dx;
double newH = r.height + delta.dy;
if (state.aspectLocked) {
final aspect = r.width / r.height;
if ((delta.dx / r.width).abs() >= (delta.dy / r.height).abs()) {
// Keep ratio based on the dominant proportional delta
final dxRel = (delta.dx / r.width).abs();
final dyRel = (delta.dy / r.height).abs();
if (dxRel >= dyRel) {
newW = newW.clamp(20.0, double.infinity);
newH = newW / aspect;
} else {
newH = newH.clamp(20.0, double.infinity);
newW = newH * aspect;
}
// Scale down to fit within page bounds while preserving ratio
final scaleW = pageSize.width / newW;
final scaleH = pageSize.height / newH;
final scale = math.min(1.0, math.min(scaleW, scaleH));
newW *= scale;
newH *= scale;
// Ensure minimum size of 20x20, scaling up proportionally if needed
final minScale = math.max(1.0, math.max(20.0 / newW, 20.0 / newH));
newW *= minScale;
newH *= minScale;
Rect resized = Rect.fromLTWH(r.left, r.top, newW, newH);
resized = _clampRectPositionToPage(resized);
state = state.copyWith(rect: resized);
return;
}
// Unlocked aspect: clamp each dimension independently
newW = newW.clamp(20.0, pageSize.width);
newH = newH.clamp(20.0, pageSize.height);
Rect resized = Rect.fromLTWH(r.left, r.top, newW, newH);
resized = _clampRectToPage(resized);
state = state.copyWith(rect: resized);
}
Rect _clampRectToPage(Rect r) {
double left = r.left.clamp(0.0, pageSize.width - r.width);
double top = r.top.clamp(0.0, pageSize.height - r.height);
// Ensure size never exceeds page bounds first, to avoid invalid clamp ranges
final double w = r.width.clamp(20.0, pageSize.width);
final double h = r.height.clamp(20.0, pageSize.height);
final double left = r.left.clamp(0.0, pageSize.width - w);
final double top = r.top.clamp(0.0, pageSize.height - h);
return Rect.fromLTWH(left, top, w, h);
}
Rect _clampRectPositionToPage(Rect r) {
final double left = r.left.clamp(0.0, pageSize.width - r.width);
final double top = r.top.clamp(0.0, pageSize.height - r.height);
return Rect.fromLTWH(left, top, r.width, r.height);
}

View File

@ -10,6 +10,7 @@ class DrawCanvas extends StatefulWidget {
class _DrawCanvasState extends State<DrawCanvas> {
late List<List<Offset>> _strokes;
final GlobalKey _canvasKey = GlobalKey();
@override
void initState() {
@ -17,12 +18,13 @@ class _DrawCanvasState extends State<DrawCanvas> {
_strokes = widget.strokes.map((s) => List.of(s)).toList();
}
void _onPanStart(DragStartDetails d) {
setState(() => _strokes.add([d.localPosition]));
void _startStroke(Offset localPosition) {
setState(() => _strokes.add([localPosition]));
}
void _onPanUpdate(DragUpdateDetails d) {
setState(() => _strokes.last.add(d.localPosition));
void _extendStroke(Offset localPosition) {
if (_strokes.isEmpty) return;
setState(() => _strokes.last.add(localPosition));
}
void _undo() {
@ -67,14 +69,30 @@ class _DrawCanvasState extends State<DrawCanvas> {
SizedBox(
key: const Key('draw_canvas'),
height: 240,
child: GestureDetector(
onPanStart: _onPanStart,
onPanUpdate: _onPanUpdate,
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: Colors.black26),
child: Focus(
// prevent text selection focus stealing on desktop
canRequestFocus: false,
child: Listener(
key: _canvasKey,
behavior: HitTestBehavior.opaque,
onPointerDown: (e) {
final box =
_canvasKey.currentContext!.findRenderObject()
as RenderBox;
_startStroke(box.globalToLocal(e.position));
},
onPointerMove: (e) {
final box =
_canvasKey.currentContext!.findRenderObject()
as RenderBox;
_extendStroke(box.globalToLocal(e.position));
},
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: Colors.black26),
),
child: CustomPaint(painter: StrokesPainter(_strokes)),
),
child: CustomPaint(painter: StrokesPainter(_strokes)),
),
),
),

View File

@ -4,6 +4,12 @@ import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:pdf/widgets.dart' as pw;
// NOTE:
// - This exporter uses a raster snapshot of the UI (RepaintBoundary) and embeds it into a new PDF.
// - It does NOT perform vector-accurate stamping into the source PDF.
// - Vector stamping remains unimplemented with FOSS-only constraints because the `pdf` package
// cannot import/modify existing PDF pages. If/when a suitable FOSS library exists, wire it here.
class ExportService {
Future<bool> exportSignedPdfFromBoundary({
required GlobalKey boundaryKey,
@ -38,4 +44,55 @@ class ExportService {
return false;
}
}
/// Multi-page export by navigating the viewer and capturing each page.
/// onGotoPage must navigate the UI to the requested page and return when the
/// page is ready to render. We'll still wait for a frame for safety.
Future<bool> exportMultiPageFromBoundary({
required GlobalKey boundaryKey,
required String outputPath,
required int pageCount,
required Future<void> Function(int page) onGotoPage,
double pixelRatio = 3.0,
}) async {
try {
final doc = pw.Document();
for (int i = 1; i <= pageCount; i++) {
await onGotoPage(i);
// Give Flutter and the PDF viewer time to render the page
await Future<void>.delayed(const Duration(milliseconds: 120));
for (int f = 0; f < 2; f++) {
try {
await WidgetsBinding.instance.endOfFrame;
} catch (_) {
// Best-effort if not in a frame-driven context
await Future<void>.delayed(const Duration(milliseconds: 16));
}
}
final boundary =
boundaryKey.currentContext?.findRenderObject()
as RenderRepaintBoundary?;
if (boundary == null) return false;
final ui.Image image = await boundary.toImage(pixelRatio: pixelRatio);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
if (byteData == null) return false;
final pngBytes = byteData.buffer.asUint8List();
final img = pw.MemoryImage(pngBytes);
doc.addPage(
pw.Page(
build:
(context) =>
pw.Center(child: pw.Image(img, fit: pw.BoxFit.contain)),
),
);
}
final bytes = await doc.save();
final file = File(outputPath);
await file.writeAsBytes(bytes, flush: true);
return true;
} catch (e) {
return false;
}
}
}

View File

@ -41,6 +41,7 @@ dependencies:
path_provider: ^2.1.5
pdfrx: ^1.3.5
pdf: ^3.10.8
hand_signature: ^3.1.0+2
dev_dependencies:
flutter_test:

44
test/pdf_state_test.dart Normal file
View File

@ -0,0 +1,44 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() {
test('openPicked loads document and initializes state', () {
final container = ProviderContainer();
addTearDown(container.dispose);
final notifier = container.read(pdfProvider.notifier);
notifier.openPicked(path: 'test.pdf', pageCount: 7);
final state = container.read(pdfProvider);
expect(state.loaded, isTrue);
expect(state.pickedPdfPath, 'test.pdf');
expect(state.pageCount, 7);
expect(state.currentPage, 1);
expect(state.markedForSigning, isFalse);
});
test('jumpTo clamps within page boundaries', () {
final container = ProviderContainer();
addTearDown(container.dispose);
final notifier = container.read(pdfProvider.notifier);
notifier.openPicked(path: 'test.pdf', pageCount: 5);
notifier.jumpTo(10);
expect(container.read(pdfProvider).currentPage, 5);
notifier.jumpTo(0);
expect(container.read(pdfProvider).currentPage, 1);
notifier.jumpTo(3);
expect(container.read(pdfProvider).currentPage, 3);
});
test('setPageCount updates count without toggling other flags', () {
final container = ProviderContainer();
addTearDown(container.dispose);
final notifier = container.read(pdfProvider.notifier);
notifier.openPicked(path: 'test.pdf', pageCount: 2);
notifier.toggleMark();
notifier.setPageCount(9);
final s = container.read(pdfProvider);
expect(s.pageCount, 9);
expect(s.loaded, isTrue);
expect(s.markedForSigning, isTrue);
});
}

View File

@ -0,0 +1,77 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
void main() {
test('placeDefaultRect centers a reasonable default rect', () {
final container = ProviderContainer();
addTearDown(container.dispose);
final sig = container.read(signatureProvider);
// Should be null initially
expect(sig.rect, isNull);
// Place using default pageSize (400x560)
container.read(signatureProvider.notifier).placeDefaultRect();
final placed = container.read(signatureProvider).rect!;
// Default should be within bounds and not tiny
expect(placed.left, greaterThanOrEqualTo(0));
expect(placed.top, greaterThanOrEqualTo(0));
expect(placed.right, lessThanOrEqualTo(400));
expect(placed.bottom, lessThanOrEqualTo(560));
expect(placed.width, greaterThan(50));
expect(placed.height, greaterThan(20));
});
test('drag clamps to canvas bounds', () {
final container = ProviderContainer();
addTearDown(container.dispose);
container.read(signatureProvider.notifier).placeDefaultRect();
final before = container.read(signatureProvider).rect!;
// Drag far outside bounds
container
.read(signatureProvider.notifier)
.drag(const Offset(10000, -10000));
final after = container.read(signatureProvider).rect!;
expect(after.left, greaterThanOrEqualTo(0));
expect(after.top, greaterThanOrEqualTo(0));
expect(after.right, lessThanOrEqualTo(400));
expect(after.bottom, lessThanOrEqualTo(560));
// Ensure it actually moved
expect(after.center, isNot(equals(before.center)));
});
test('resize respects aspect lock and clamps', () {
final container = ProviderContainer();
addTearDown(container.dispose);
final notifier = container.read(signatureProvider.notifier);
notifier.placeDefaultRect();
final before = container.read(signatureProvider).rect!;
notifier.toggleAspect(true);
notifier.resize(const Offset(1000, 1000));
final after = container.read(signatureProvider).rect!;
// With aspect lock the ratio should remain approximately the same
final ratioBefore = before.width / before.height;
final ratioAfter = after.width / after.height;
expect((ratioBefore - ratioAfter).abs(), lessThan(0.05));
// Still within bounds
expect(after.left, greaterThanOrEqualTo(0));
expect(after.top, greaterThanOrEqualTo(0));
expect(after.right, lessThanOrEqualTo(400));
expect(after.bottom, lessThanOrEqualTo(560));
});
test('setImageBytes ensures a rect exists for display', () {
final container = ProviderContainer();
addTearDown(container.dispose);
final notifier = container.read(signatureProvider.notifier);
expect(container.read(signatureProvider).rect, isNull);
notifier.setImageBytes(Uint8List.fromList([0, 1, 2]));
expect(container.read(signatureProvider).imageBytes, isNotNull);
// placeDefaultRect is called when bytes are set if rect was null
expect(container.read(signatureProvider).rect, isNotNull);
});
}

View File

@ -10,6 +10,44 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/features/share/export_service.dart';
// Fakes for export service (top-level; Dart does not allow local class declarations)
class RecordingExporter extends ExportService {
bool called = false;
@override
Future<bool> exportMultiPageFromBoundary({
required GlobalKey boundaryKey,
required String outputPath,
required int pageCount,
required Future<void> Function(int page) onGotoPage,
double pixelRatio = 2.0,
}) async {
called = true;
// Ensure extension
expect(outputPath.toLowerCase().endsWith('.pdf'), isTrue);
for (var i = 1; i <= pageCount; i++) {
await onGotoPage(i);
}
return true;
}
}
class BasicExporter extends ExportService {
@override
Future<bool> exportMultiPageFromBoundary({
required GlobalKey boundaryKey,
required String outputPath,
required int pageCount,
required Future<void> Function(int page) onGotoPage,
double pixelRatio = 2.0,
}) async {
for (var i = 1; i <= pageCount; i++) {
await onGotoPage(i);
}
return true;
}
}
void main() {
Future<void> _pumpWithOpenPdf(WidgetTester tester) async {
@ -199,4 +237,72 @@ void main() {
// Overlay present with drawn strokes painter
expect(find.byKey(const Key('signature_overlay')), findsOneWidget);
});
testWidgets('Save uses file selector (via provider) and injected exporter', (
tester,
) async {
final fake = RecordingExporter();
await tester.pumpWidget(
ProviderScope(
overrides: [
pdfProvider.overrideWith(
(ref) => PdfController()..openPicked(path: 'test.pdf'),
),
signatureProvider.overrideWith(
(ref) => SignatureController()..placeDefaultRect(),
),
useMockViewerProvider.overrideWith((ref) => true),
exportServiceProvider.overrideWith((_) => fake),
savePathPickerProvider.overrideWith(
(_) => () async => 'C:/tmp/output.pdf',
),
],
child: const MaterialApp(home: PdfSignatureHomePage()),
),
);
await tester.pump();
// Mark signing to set signedPage
await tester.tap(find.byKey(const Key('btn_mark_signing')));
await tester.pump();
// Trigger save
await tester.tap(find.byKey(const Key('btn_save_pdf')));
await tester.pumpAndSettle();
expect(fake.called, isTrue);
expect(find.textContaining('Saved:'), findsOneWidget);
});
testWidgets('Only signed page shows overlay during export flow', (
tester,
) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
pdfProvider.overrideWith(
(ref) => PdfController()..openPicked(path: 'test.pdf'),
),
signatureProvider.overrideWith(
(ref) => SignatureController()..placeDefaultRect(),
),
useMockViewerProvider.overrideWith((ref) => true),
exportServiceProvider.overrideWith((_) => BasicExporter()),
savePathPickerProvider.overrideWith(
(_) => () async => 'C:/tmp/output.pdf',
),
],
child: const MaterialApp(home: PdfSignatureHomePage()),
),
);
await tester.pump();
// Mark signing on page 1
await tester.tap(find.byKey(const Key('btn_mark_signing')));
await tester.pump();
// Save -> open dialog -> confirm
await tester.tap(find.byKey(const Key('btn_save_pdf')));
await tester.pumpAndSettle();
// After export, overlay visible again
expect(find.byKey(const Key('signature_overlay')), findsOneWidget);
});
}