feat: output other pages which are not signed
This commit is contained in:
parent
8ad34023cd
commit
5b0b9d2a02
15
README.md
15
README.md
|
@ -1,3 +1,18 @@
|
||||||
# pdf_signature
|
# pdf_signature
|
||||||
|
|
||||||
A GUI app to create a signature on PDF page interactively.
|
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
|
||||||
|
```
|
||||||
|
|
|
@ -100,8 +100,16 @@ Feature: save signed PDF
|
||||||
Scenario: Export the signed document to a new file
|
Scenario: Export the signed document to a new file
|
||||||
Given a PDF is open and contains at least one placed signature
|
Given a PDF is open and contains at least one placed signature
|
||||||
When the user saves/exports the document
|
When the user saves/exports the document
|
||||||
Then a new PDF file is saved at the chosen location with specified file name
|
Then a new PDF file is saved at specified full path, location and file name
|
||||||
And the signatures appear on the corresponding pages in the output
|
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
|
Scenario: Prevent saving when nothing is placed
|
||||||
Given a PDF is open with no signatures placed
|
Given a PDF is open with no signatures placed
|
||||||
|
|
|
@ -3,7 +3,7 @@ import 'package:file_selector/file_selector.dart' as fs;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:pdfrx/pdfrx.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 'dart:typed_data';
|
||||||
import '../share/export_service.dart';
|
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
|
// Testing hook: allow using a mock viewer instead of pdfrx to avoid async I/O in widget tests
|
||||||
final useMockViewerProvider = Provider<bool>((_) => false);
|
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 {
|
class PdfSignatureHomePage extends ConsumerStatefulWidget {
|
||||||
const PdfSignatureHomePage({super.key});
|
const PdfSignatureHomePage({super.key});
|
||||||
|
@ -94,20 +124,38 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Pick a directory to save (fallback when save-as dialog API isn't available)
|
final pick = ref.read(savePathPickerProvider);
|
||||||
final dir = await fs.getDirectoryPath();
|
final path = await pick();
|
||||||
if (dir == null) return;
|
if (path == null || path.trim().isEmpty) return;
|
||||||
final sep = Platform.pathSeparator;
|
final fullPath = _ensurePdfExtension(path.trim());
|
||||||
final path = '$dir${sep}signed.pdf';
|
final exporter = ref.read(exportServiceProvider);
|
||||||
final exporter = ExportService();
|
// Multi-page export: iterate pages by navigating the viewer
|
||||||
final ok = await exporter.exportSignedPdfFromBoundary(
|
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,
|
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) {
|
if (ok) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(SnackBar(content: Text('Saved: $path')));
|
).showSnackBar(SnackBar(content: Text('Saved: $fullPath')));
|
||||||
} else {
|
} else {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final pdf = ref.watch(pdfProvider);
|
final pdf = ref.watch(pdfProvider);
|
||||||
|
@ -208,11 +263,6 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
onPressed: _loadSignatureFromFile,
|
onPressed: _loadSignatureFromFile,
|
||||||
child: const Text('Load Signature from file'),
|
child: const Text('Load Signature from file'),
|
||||||
),
|
),
|
||||||
OutlinedButton(
|
|
||||||
key: const Key('btn_load_invalid_signature'),
|
|
||||||
onPressed: _loadInvalidSignature,
|
|
||||||
child: const Text('Load Invalid'),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
key: const Key('btn_draw_signature'),
|
key: const Key('btn_draw_signature'),
|
||||||
onPressed: _openDrawCanvas,
|
onPressed: _openDrawCanvas,
|
||||||
|
@ -254,7 +304,8 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
Consumer(
|
Consumer(
|
||||||
builder: (context, ref, _) {
|
builder: (context, ref, _) {
|
||||||
final sig = ref.watch(signatureProvider);
|
final sig = ref.watch(signatureProvider);
|
||||||
return sig.rect != null
|
final visible = ref.watch(signatureVisibilityProvider);
|
||||||
|
return sig.rect != null && visible
|
||||||
? _buildSignatureOverlay(sig)
|
? _buildSignatureOverlay(sig)
|
||||||
: const SizedBox.shrink();
|
: const SizedBox.shrink();
|
||||||
},
|
},
|
||||||
|
@ -301,7 +352,8 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
Consumer(
|
Consumer(
|
||||||
builder: (context, ref, _) {
|
builder: (context, ref, _) {
|
||||||
final sig = ref.watch(signatureProvider);
|
final sig = ref.watch(signatureProvider);
|
||||||
return sig.rect != null
|
final visible = ref.watch(signatureVisibilityProvider);
|
||||||
|
return sig.rect != null && visible
|
||||||
? _buildSignatureOverlay(sig)
|
? _buildSignatureOverlay(sig)
|
||||||
: const SizedBox.shrink();
|
: const SizedBox.shrink();
|
||||||
},
|
},
|
||||||
|
|
|
@ -6,18 +6,21 @@ class PdfState {
|
||||||
final int currentPage;
|
final int currentPage;
|
||||||
final bool markedForSigning;
|
final bool markedForSigning;
|
||||||
final String? pickedPdfPath;
|
final String? pickedPdfPath;
|
||||||
|
final int? signedPage;
|
||||||
const PdfState({
|
const PdfState({
|
||||||
required this.loaded,
|
required this.loaded,
|
||||||
required this.pageCount,
|
required this.pageCount,
|
||||||
required this.currentPage,
|
required this.currentPage,
|
||||||
required this.markedForSigning,
|
required this.markedForSigning,
|
||||||
this.pickedPdfPath,
|
this.pickedPdfPath,
|
||||||
|
this.signedPage,
|
||||||
});
|
});
|
||||||
factory PdfState.initial() => const PdfState(
|
factory PdfState.initial() => const PdfState(
|
||||||
loaded: false,
|
loaded: false,
|
||||||
pageCount: 0,
|
pageCount: 0,
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
markedForSigning: false,
|
markedForSigning: false,
|
||||||
|
signedPage: null,
|
||||||
);
|
);
|
||||||
PdfState copyWith({
|
PdfState copyWith({
|
||||||
bool? loaded,
|
bool? loaded,
|
||||||
|
@ -25,12 +28,14 @@ class PdfState {
|
||||||
int? currentPage,
|
int? currentPage,
|
||||||
bool? markedForSigning,
|
bool? markedForSigning,
|
||||||
String? pickedPdfPath,
|
String? pickedPdfPath,
|
||||||
|
int? signedPage,
|
||||||
}) => PdfState(
|
}) => PdfState(
|
||||||
loaded: loaded ?? this.loaded,
|
loaded: loaded ?? this.loaded,
|
||||||
pageCount: pageCount ?? this.pageCount,
|
pageCount: pageCount ?? this.pageCount,
|
||||||
currentPage: currentPage ?? this.currentPage,
|
currentPage: currentPage ?? this.currentPage,
|
||||||
markedForSigning: markedForSigning ?? this.markedForSigning,
|
markedForSigning: markedForSigning ?? this.markedForSigning,
|
||||||
pickedPdfPath: pickedPdfPath ?? this.pickedPdfPath,
|
pickedPdfPath: pickedPdfPath ?? this.pickedPdfPath,
|
||||||
|
signedPage: signedPage ?? this.signedPage,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -44,6 +49,7 @@ class PdfController extends StateNotifier<PdfState> {
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
markedForSigning: false,
|
markedForSigning: false,
|
||||||
pickedPdfPath: null,
|
pickedPdfPath: null,
|
||||||
|
signedPage: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -54,6 +60,7 @@ class PdfController extends StateNotifier<PdfState> {
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
markedForSigning: false,
|
markedForSigning: false,
|
||||||
pickedPdfPath: path,
|
pickedPdfPath: path,
|
||||||
|
signedPage: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -65,7 +72,14 @@ class PdfController extends StateNotifier<PdfState> {
|
||||||
|
|
||||||
void toggleMark() {
|
void toggleMark() {
|
||||||
if (!state.loaded) return;
|
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) {
|
void setPageCount(int count) {
|
||||||
|
@ -168,24 +182,55 @@ class SignatureController extends StateNotifier<SignatureState> {
|
||||||
void resize(Offset delta) {
|
void resize(Offset delta) {
|
||||||
if (state.rect == null) return;
|
if (state.rect == null) return;
|
||||||
final r = state.rect!;
|
final r = state.rect!;
|
||||||
double newW = (r.width + delta.dx).clamp(20, pageSize.width);
|
double newW = r.width + delta.dx;
|
||||||
double newH = (r.height + delta.dy).clamp(20, pageSize.height);
|
double newH = r.height + delta.dy;
|
||||||
if (state.aspectLocked) {
|
if (state.aspectLocked) {
|
||||||
final aspect = r.width / r.height;
|
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;
|
newH = newW / aspect;
|
||||||
} else {
|
} else {
|
||||||
|
newH = newH.clamp(20.0, double.infinity);
|
||||||
newW = newH * aspect;
|
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);
|
Rect resized = Rect.fromLTWH(r.left, r.top, newW, newH);
|
||||||
resized = _clampRectToPage(resized);
|
resized = _clampRectToPage(resized);
|
||||||
state = state.copyWith(rect: resized);
|
state = state.copyWith(rect: resized);
|
||||||
}
|
}
|
||||||
|
|
||||||
Rect _clampRectToPage(Rect r) {
|
Rect _clampRectToPage(Rect r) {
|
||||||
double left = r.left.clamp(0.0, pageSize.width - r.width);
|
// Ensure size never exceeds page bounds first, to avoid invalid clamp ranges
|
||||||
double top = r.top.clamp(0.0, pageSize.height - r.height);
|
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);
|
return Rect.fromLTWH(left, top, r.width, r.height);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -10,6 +10,7 @@ class DrawCanvas extends StatefulWidget {
|
||||||
|
|
||||||
class _DrawCanvasState extends State<DrawCanvas> {
|
class _DrawCanvasState extends State<DrawCanvas> {
|
||||||
late List<List<Offset>> _strokes;
|
late List<List<Offset>> _strokes;
|
||||||
|
final GlobalKey _canvasKey = GlobalKey();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
@ -17,12 +18,13 @@ class _DrawCanvasState extends State<DrawCanvas> {
|
||||||
_strokes = widget.strokes.map((s) => List.of(s)).toList();
|
_strokes = widget.strokes.map((s) => List.of(s)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onPanStart(DragStartDetails d) {
|
void _startStroke(Offset localPosition) {
|
||||||
setState(() => _strokes.add([d.localPosition]));
|
setState(() => _strokes.add([localPosition]));
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onPanUpdate(DragUpdateDetails d) {
|
void _extendStroke(Offset localPosition) {
|
||||||
setState(() => _strokes.last.add(d.localPosition));
|
if (_strokes.isEmpty) return;
|
||||||
|
setState(() => _strokes.last.add(localPosition));
|
||||||
}
|
}
|
||||||
|
|
||||||
void _undo() {
|
void _undo() {
|
||||||
|
@ -67,9 +69,24 @@ class _DrawCanvasState extends State<DrawCanvas> {
|
||||||
SizedBox(
|
SizedBox(
|
||||||
key: const Key('draw_canvas'),
|
key: const Key('draw_canvas'),
|
||||||
height: 240,
|
height: 240,
|
||||||
child: GestureDetector(
|
child: Focus(
|
||||||
onPanStart: _onPanStart,
|
// prevent text selection focus stealing on desktop
|
||||||
onPanUpdate: _onPanUpdate,
|
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(
|
child: DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: Colors.black26),
|
border: Border.all(color: Colors.black26),
|
||||||
|
@ -78,6 +95,7 @@ class _DrawCanvasState extends State<DrawCanvas> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
@ -4,6 +4,12 @@ import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:pdf/widgets.dart' as pw;
|
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 {
|
class ExportService {
|
||||||
Future<bool> exportSignedPdfFromBoundary({
|
Future<bool> exportSignedPdfFromBoundary({
|
||||||
required GlobalKey boundaryKey,
|
required GlobalKey boundaryKey,
|
||||||
|
@ -38,4 +44,55 @@ class ExportService {
|
||||||
return false;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,6 +41,7 @@ dependencies:
|
||||||
path_provider: ^2.1.5
|
path_provider: ^2.1.5
|
||||||
pdfrx: ^1.3.5
|
pdfrx: ^1.3.5
|
||||||
pdf: ^3.10.8
|
pdf: ^3.10.8
|
||||||
|
hand_signature: ^3.1.0+2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
|
@ -10,6 +10,44 @@ import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import 'package:pdf_signature/features/pdf/viewer.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() {
|
void main() {
|
||||||
Future<void> _pumpWithOpenPdf(WidgetTester tester) async {
|
Future<void> _pumpWithOpenPdf(WidgetTester tester) async {
|
||||||
|
@ -199,4 +237,72 @@ void main() {
|
||||||
// Overlay present with drawn strokes painter
|
// Overlay present with drawn strokes painter
|
||||||
expect(find.byKey(const Key('signature_overlay')), findsOneWidget);
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue