From ffb9e357555b1db6f0015e31482e25b11bbde14d Mon Sep 17 00:00:00 2001 From: Akiba So Date: Tue, 23 Jun 2026 10:27:09 +0800 Subject: [PATCH] feat(slide): rebuild PPT annotator on the pen-first canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PPT slides now annotate with the single performant inking engine (PenCanvas) instead of the old ink_canvas, per "all note features on the pen-first canvas". - PenSlideScreen: per-slide normalized strokes over each slide image, prev/next + slider nav, undo/redo, shared M3 palette, and the pressure curve / eraser size+mode / palm rejection from the shared canvas. - slide_export: pure, tested export geometry. Because strokes are now normalized to the page rect, the PDF exporter maps them straight into each slide's draw rect — fixing the old exporter's known ink misalignment (it guessed live-widget size). - Route PPT import + open -> PenSlideScreen; delete the dead old ppt_annotator_screen. (ink_canvas/annotation_toolbar remain for split_view, the last old-canvas screen.) Tests: slide_export geometry (4). flutter analyze: 0 issues. Suite: 269/269. --- lib/editor/canvas/pen_slide_screen.dart | 544 ++++++++++++++++++++++++ lib/editor/pdf/slide_export.dart | 36 ++ lib/screens/home_screen.dart | 10 +- lib/screens/ppt_annotator_screen.dart | 528 ----------------------- test/slide_export_test.dart | 45 ++ 5 files changed, 630 insertions(+), 533 deletions(-) create mode 100644 lib/editor/canvas/pen_slide_screen.dart create mode 100644 lib/editor/pdf/slide_export.dart delete mode 100644 lib/screens/ppt_annotator_screen.dart create mode 100644 test/slide_export_test.dart diff --git a/lib/editor/canvas/pen_slide_screen.dart b/lib/editor/canvas/pen_slide_screen.dart new file mode 100644 index 0000000..b70eb4d --- /dev/null +++ b/lib/editor/canvas/pen_slide_screen.dart @@ -0,0 +1,544 @@ +// lib/editor/canvas/pen_slide_screen.dart +// +// Pen-first slide (PPT) annotator. Reuses the single performant inking engine +// (PenCanvas) over each slide image, with per-slide normalized strokes and +// prev/next navigation. Export to PDF maps the normalized strokes into each +// slide's draw rect (slide_export.dart) — which also fixes the old exporter's +// known ink-misalignment bug. In-memory only (PPT ink is not auto-saved; Export +// to PDF is how annotations are kept), matching the previous behavior. + +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; +import 'package:syncfusion_flutter_pdf/pdf.dart'; + +import '../input/pen_config.dart'; +import '../input/pen_input_service.dart'; +import '../input/pressure_curve.dart' show kNaturalPressureGamma; +import '../layout/viewport_fit.dart'; +import '../pdf/slide_export.dart'; +import 'pen_canvas.dart'; +import 'pen_palette_widgets.dart'; +import 'pen_stroke.dart'; + +class PenSlideScreen extends StatefulWidget { + const PenSlideScreen({ + super.key, + required this.filePath, + required this.slideImagePaths, + this.extractedText, + }); + + final String filePath; + final List slideImagePaths; + final String? extractedText; + + @override + State createState() => _PenSlideScreenState(); +} + +class _PenSlideScreenState extends State { + int _slideIndex = 0; + final Map> _strokesBySlide = {}; + final Map>> _undo = {}; + final Map>> _redo = {}; + + /// Intrinsic pixel size of each slide image, loaded async so the page rect + /// keeps the slide's aspect (no distortion). Null until loaded. + Map? _slideSizes; + + CanvasTool _tool = CanvasTool.pen; + Color _color = Colors.black; + bool _allowFingerDrawing = false; + bool _needsCenter = true; + bool _showSlider = false; + double? _scrub; + + PenConfigController? _penConfig; + final TransformationController _transform = TransformationController(); + + static const double _penWidthFraction = 0.006; + static const double _highlighterWidthFraction = 0.02; + static const Size _fallbackSlide = Size(1600, 900); + + static const List _palette = [ + Colors.black, + Colors.red, + Colors.blue, + Colors.green, + Colors.orange, + ]; + + int get _slideCount => widget.slideImagePaths.length; + List get _currentStrokes => _strokesBySlide[_slideIndex] ?? const []; + + @override + void initState() { + super.initState(); + PenInputService.instance.start(); + _loadSlideSizes(); + _initPenConfig(); + } + + Future _loadSlideSizes() async { + final sizes = {}; + for (var i = 0; i < _slideCount; i++) { + try { + final bytes = await File(widget.slideImagePaths[i]).readAsBytes(); + final codec = await ui.instantiateImageCodec(bytes); + final frame = await codec.getNextFrame(); + sizes[i] = Size( + frame.image.width.toDouble(), frame.image.height.toDouble()); + frame.image.dispose(); + } catch (_) { + sizes[i] = _fallbackSlide; + } + } + if (mounted) setState(() => _slideSizes = sizes); + } + + Future _initPenConfig() async { + final controller = await PenConfigController.load(); + if (!mounted) { + controller.dispose(); + return; + } + controller.addListener(_onPenConfigChanged); + setState(() { + _penConfig = controller; + _allowFingerDrawing = controller.value.fingerDrawing; + }); + } + + void _onPenConfigChanged() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + _penConfig?.removeListener(_onPenConfigChanged); + _penConfig?.dispose(); + _transform.dispose(); + super.dispose(); + } + + // ── Mutations ────────────────────────────────────────────────────────────── + + void _pushUndo() { + (_undo[_slideIndex] ??= []).add(List.from(_currentStrokes)); + _redo[_slideIndex]?.clear(); + } + + void _commitStroke(PenStroke stroke) { + setState(() { + _pushUndo(); + _strokesBySlide[_slideIndex] = [..._currentStrokes, stroke]; + }); + } + + void _eraseStroke(int index, List replacements) { + final strokes = _currentStrokes; + if (index < 0 || index >= strokes.length) return; + setState(() { + _pushUndo(); + _strokesBySlide[_slideIndex] = [ + ...strokes.sublist(0, index), + ...replacements, + ...strokes.sublist(index + 1), + ]; + }); + } + + void _performUndo() { + final stack = _undo[_slideIndex]; + if (stack == null || stack.isEmpty) return; + setState(() { + (_redo[_slideIndex] ??= []).add(List.from(_currentStrokes)); + _strokesBySlide[_slideIndex] = stack.removeLast(); + }); + } + + void _performRedo() { + final stack = _redo[_slideIndex]; + if (stack == null || stack.isEmpty) return; + setState(() { + (_undo[_slideIndex] ??= []).add(List.from(_currentStrokes)); + _strokesBySlide[_slideIndex] = stack.removeLast(); + }); + } + + void _toggleFingerDrawing() { + final next = !_allowFingerDrawing; + setState(() => _allowFingerDrawing = next); + _penConfig?.setFingerDrawing(next); + } + + void _goToSlide(int i) { + final clamped = i.clamp(0, _slideCount - 1); + if (clamped == _slideIndex) return; + setState(() { + _slideIndex = clamped; + _needsCenter = true; + }); + } + + // ── Export ────────────────────────────────────────────────────────────────── + + Future _exportPdf() async { + final messenger = ScaffoldMessenger.of(context); + messenger.showSnackBar( + const SnackBar(content: Text('Exporting PDF...'))); + try { + final bytes = await _buildPdfBytes(); + final dir = await _exportDir(); + final base = p.basenameWithoutExtension(widget.filePath); + final outPath = p.join(dir.path, '${base}_annotated.pdf'); + await File(outPath).writeAsBytes(bytes); + if (!mounted) return; + messenger.showSnackBar(SnackBar(content: Text('PDF saved: $outPath'))); + } catch (e) { + if (!mounted) return; + messenger.showSnackBar(SnackBar(content: Text('Export failed: $e'))); + } + } + + Future _exportDir() async { + try { + final home = Platform.environment['HOME']; + if (home != null) { + final dir = Directory(p.join(home, 'Documents', 'BadNote')); + if (!await dir.exists()) await dir.create(recursive: true); + return dir; + } + } catch (_) {} + return Directory.current; + } + + Future _buildPdfBytes() async { + final doc = PdfDocument(); + doc.pageSettings.margins.all = 0; + final sizes = _slideSizes ?? const {}; + for (var i = 0; i < _slideCount; i++) { + final page = doc.pages.add(); + final pageSize = page.getClientSize(); + try { + final imgBytes = await File(widget.slideImagePaths[i]).readAsBytes(); + final bitmap = PdfBitmap(imgBytes); + final imageSize = sizes[i] ?? + Size(bitmap.width.toDouble(), bitmap.height.toDouble()); + final draw = slideDrawRect( + Size(pageSize.width, pageSize.height), imageSize); + page.graphics.drawImage(bitmap, draw); + + for (final stroke in _strokesBySlide[i] ?? const []) { + if (stroke.points.length < 2) continue; + final r = (stroke.color >> 16) & 0xFF; + final g = (stroke.color >> 8) & 0xFF; + final b = stroke.color & 0xFF; + final path = PdfPath(); + path.startFigure(); + for (var j = 0; j < stroke.points.length - 1; j++) { + final p1 = stroke.points[j]; + final p2 = stroke.points[j + 1]; + path.addLine( + normToSlide(p1.x, p1.y, draw), + normToSlide(p2.x, p2.y, draw), + ); + } + page.graphics.drawPath( + path, + pen: PdfPen(PdfColor(r, g, b), + width: slideStrokeWidth(stroke.width, draw)), + ); + } + } catch (_) { + page.graphics.drawRectangle( + brush: PdfSolidBrush(PdfColor(230, 230, 230)), + bounds: Rect.fromLTWH(0, 0, pageSize.width, pageSize.height), + ); + } + } + final bytes = await doc.save(); + doc.dispose(); + return Uint8List.fromList(bytes); + } + + // ── Layout ──────────────────────────────────────────────────────────────── + + void _centerPage(Size viewport, Size pageSize) { + final o = centerOffset(pageSize, viewport, 1.0); + _transform.value = Matrix4.identity()..translateByDouble(o.dx, o.dy, 0, 1); + } + + double get _strokeWidth => _tool == CanvasTool.highlighter + ? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction) + : (_penConfig?.value.penWidth ?? _penWidthFraction); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + body: Stack( + children: [ + Positioned.fill(child: _buildCanvas()), + SafeArea( + child: Align( + alignment: Alignment.topCenter, + child: Padding( + padding: const EdgeInsets.only(top: 8), + child: _buildToolPalette(cs), + ), + ), + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.all(8), + child: RoundIconButton( + icon: Icons.arrow_back, + tooltip: 'Back', + onPressed: () => Navigator.of(context).maybePop(), + ), + ), + ), + SafeArea( + child: Align( + alignment: Alignment.topRight, + child: Padding( + padding: const EdgeInsets.all(8), + child: RoundIconButton( + icon: Icons.picture_as_pdf_outlined, + tooltip: 'Export to PDF', + onPressed: _exportPdf, + ), + ), + ), + ), + if (_slideCount > 0) + SafeArea( + child: Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.only(bottom: 16), + child: _buildSlidePill(cs), + ), + ), + ), + ], + ), + ); + } + + Widget _buildCanvas() { + final sizes = _slideSizes; + if (sizes == null) { + return const Center(child: CircularProgressIndicator()); + } + if (_slideCount == 0) { + return const Center(child: Text('No slides.')); + } + final slide = sizes[_slideIndex] ?? _fallbackSlide; + return LayoutBuilder( + builder: (context, constraints) { + final fitW = constraints.maxWidth / slide.width; + final fitH = constraints.maxHeight / slide.height; + final scale = fitW < fitH ? fitW : fitH; + final pageSize = Size(slide.width * scale, slide.height * scale); + + if (_needsCenter) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _centerPage( + Size(constraints.maxWidth, constraints.maxHeight), pageSize); + setState(() => _needsCenter = false); + }); + } + + return PenCanvas( + key: ValueKey(_slideIndex), + pageSize: pageSize, + strokes: _currentStrokes, + transformationController: _transform, + tool: _tool, + color: _color, + strokeWidth: _strokeWidth, + pressureGamma: + _penConfig?.value.pressureGamma ?? kNaturalPressureGamma, + eraserRadius: _penConfig?.value.eraserRadius ?? kDefaultEraserRadius, + eraserWholeStroke: _penConfig?.value.eraserWholeStroke ?? false, + sideButtonAction: + _penConfig?.value.sideButton ?? PenButtonAction.eraser, + eraserEndAction: + _penConfig?.value.eraserEnd ?? PenButtonAction.eraser, + allowFingerDrawing: _allowFingerDrawing, + onStrokeComplete: _commitStroke, + onEraseStroke: _eraseStroke, + pageWidget: Image.file( + File(widget.slideImagePaths[_slideIndex]), + fit: BoxFit.fill, + gaplessPlayback: true, + ), + ); + }, + ); + } + + Widget _buildToolPalette(ColorScheme cs) { + return Material( + color: cs.surfaceContainerHigh, + elevation: 3, + borderRadius: BorderRadius.circular(28), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ToolButton( + icon: Icons.edit_outlined, + selected: _tool == CanvasTool.pen, + tooltip: 'Pen', + onPressed: () => setState(() => _tool = CanvasTool.pen), + ), + ToolButton( + icon: Icons.brush_outlined, + selected: _tool == CanvasTool.highlighter, + tooltip: 'Highlighter', + onPressed: () => setState(() => _tool = CanvasTool.highlighter), + ), + ToolButton( + icon: Icons.cleaning_services_outlined, + selected: _tool == CanvasTool.eraser, + tooltip: 'Eraser', + onPressed: () => setState(() => _tool = CanvasTool.eraser), + ), + PaletteDivider(cs: cs), + ToolButton( + icon: Icons.undo, + selected: false, + tooltip: 'Undo', + onPressed: + (_undo[_slideIndex]?.isNotEmpty ?? false) ? _performUndo : null, + ), + ToolButton( + icon: Icons.redo, + selected: false, + tooltip: 'Redo', + onPressed: + (_redo[_slideIndex]?.isNotEmpty ?? false) ? _performRedo : null, + ), + PaletteDivider(cs: cs), + for (final c in _palette) _colorDot(c, cs), + PaletteDivider(cs: cs), + ToolButton( + icon: _allowFingerDrawing ? Icons.touch_app : Icons.do_not_touch, + selected: _allowFingerDrawing, + tooltip: _allowFingerDrawing + ? 'Finger drawing ON' + : 'Finger drawing OFF (pen only)', + onPressed: _toggleFingerDrawing, + ), + ], + ), + ), + ); + } + + Widget _colorDot(Color c, ColorScheme cs) { + final selected = + _color.toARGB32() == c.toARGB32() && _tool != CanvasTool.eraser; + return GestureDetector( + onTap: () => setState(() { + _color = c; + if (_tool == CanvasTool.eraser) _tool = CanvasTool.pen; + }), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + margin: const EdgeInsets.symmetric(horizontal: 3), + width: 24, + height: 24, + decoration: BoxDecoration( + color: c, + shape: BoxShape.circle, + border: Border.all( + color: selected ? cs.onSurface : cs.outlineVariant, + width: selected ? 3 : 1, + ), + ), + ), + ); + } + + Widget _buildSlidePill(ColorScheme cs) { + final shown = (_scrub ?? (_slideIndex + 1).toDouble()).round(); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_showSlider && _slideCount > 1) + Container( + margin: const EdgeInsets.only(bottom: 8), + constraints: const BoxConstraints(maxWidth: 420), + child: Material( + color: cs.surfaceContainerHigh, + elevation: 3, + borderRadius: BorderRadius.circular(28), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Slider( + min: 1, + max: _slideCount.toDouble(), + value: (_scrub ?? (_slideIndex + 1).toDouble()) + .clamp(1, _slideCount.toDouble()), + divisions: _slideCount > 1 ? _slideCount - 1 : null, + onChanged: (v) => setState(() => _scrub = v), + onChangeEnd: (v) { + setState(() => _scrub = null); + _goToSlide(v.round() - 1); + }, + ), + ), + ), + ), + Material( + color: cs.surfaceContainerHigh, + elevation: 3, + borderRadius: BorderRadius.circular(28), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'Previous slide', + icon: const Icon(Icons.chevron_left), + onPressed: + _slideIndex > 0 ? () => _goToSlide(_slideIndex - 1) : null, + ), + TextButton( + onPressed: _slideCount > 1 + ? () => setState(() => _showSlider = !_showSlider) + : null, + child: Text( + '$shown / $_slideCount', + style: TextStyle( + color: cs.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + tooltip: 'Next slide', + icon: const Icon(Icons.chevron_right), + onPressed: _slideIndex < _slideCount - 1 + ? () => _goToSlide(_slideIndex + 1) + : null, + ), + ], + ), + ), + ), + ], + ); + } +} diff --git a/lib/editor/pdf/slide_export.dart b/lib/editor/pdf/slide_export.dart new file mode 100644 index 0000000..ad66839 --- /dev/null +++ b/lib/editor/pdf/slide_export.dart @@ -0,0 +1,36 @@ +// lib/editor/pdf/slide_export.dart +// +// Pure geometry for exporting pen-first slide annotations to PDF. Because the +// pen canvas captures strokes NORMALIZED to the page rect ([0,1]), the export +// just maps each normalized point into the slide image's draw rectangle on the +// PDF page — no live-widget-size guessing, which is what made the old PPT +// exporter misalign ink (see the removed ppt_annotator_screen comment). + +import 'dart:ui' show Offset, Rect, Size; + +/// The rectangle a slide [image] occupies when drawn "contain"-fit and centered +/// on a PDF page of size [page]. Mirrors the live canvas's fit-to-view so the +/// exported ink lands exactly where it was drawn. +Rect slideDrawRect(Size page, Size image) { + final iw = image.width <= 0 ? 1.0 : image.width; + final ih = image.height <= 0 ? 1.0 : image.height; + final scale = (page.width / iw) < (page.height / ih) + ? page.width / iw + : page.height / ih; + final drawW = iw * scale; + final drawH = ih * scale; + final offX = (page.width - drawW) / 2; + final offY = (page.height - drawH) / 2; + return Rect.fromLTWH(offX, offY, drawW, drawH); +} + +/// Map a normalized stroke point ([0,1] of the page rect) to an absolute point +/// inside the slide's [drawRect] on the PDF page. +Offset normToSlide(double nx, double ny, Rect drawRect) => + Offset(drawRect.left + nx * drawRect.width, + drawRect.top + ny * drawRect.height); + +/// Absolute pen width (PDF units) for a stroke whose width is a fraction of the +/// page width, scaled into [drawRect]. +double slideStrokeWidth(double normalizedWidth, Rect drawRect) => + normalizedWidth * drawRect.width; diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 742f991..12dd86a 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -10,7 +10,7 @@ import '../editor/canvas/pen_editor_screen.dart'; import '../services/pdf_service.dart'; import '../services/pptx_service.dart'; import '../editor/canvas/pen_note_screen.dart'; -import 'ppt_annotator_screen.dart'; +import '../editor/canvas/pen_slide_screen.dart'; import 'search_screen.dart'; import 'settings_screen.dart'; import 'split_view_screen.dart'; @@ -222,7 +222,7 @@ class HomeScreen extends ConsumerWidget { if (context.mounted) { Navigator.of(context).push( MaterialPageRoute( - builder: (_) => PptAnnotatorScreen( + builder: (_) => PenSlideScreen( filePath: filePath, slideImagePaths: slideImages, extractedText: extractedText.isEmpty ? null : extractedText, @@ -527,7 +527,7 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> { ); } - // [L2] Route by docType: pdf → PenEditorScreen (pen-first), ppt/pptx → PptAnnotatorScreen + // [L2] Route by docType: pdf → PenEditorScreen (pen-first), ppt/pptx → PenSlideScreen Future _openDocument(BuildContext context) async { final document = widget.document; final isPdf = document.docType == 'pdf'; @@ -539,7 +539,7 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> { ), ); } else { - // PPT/PPTX: convert to images then push PptAnnotatorScreen + // PPT/PPTX: convert to images then push PenSlideScreen if (mounted) { ScaffoldMessenger.of(this.context).showSnackBar( const SnackBar(content: Text('Processing presentation...')), @@ -557,7 +557,7 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> { } Navigator.of(this.context).push( MaterialPageRoute( - builder: (_) => PptAnnotatorScreen( + builder: (_) => PenSlideScreen( filePath: document.filePath, slideImagePaths: slideImages, extractedText: extractedText.isEmpty ? null : extractedText, diff --git a/lib/screens/ppt_annotator_screen.dart b/lib/screens/ppt_annotator_screen.dart deleted file mode 100644 index 6344be7..0000000 --- a/lib/screens/ppt_annotator_screen.dart +++ /dev/null @@ -1,528 +0,0 @@ -import 'dart:io'; -import 'dart:math'; -import 'dart:typed_data'; - -import 'package:flutter/material.dart'; -import 'package:path/path.dart' as p; -import 'package:syncfusion_flutter_pdf/pdf.dart'; - -import '../models/ink_stroke.dart'; -import '../models/pen_tool.dart'; -import '../models/pressure_curve.dart'; -import '../services/undo_manager.dart'; -import '../utils/stroke_stabilizer.dart'; -import '../widgets/annotation_toolbar.dart'; -import '../widgets/ink_canvas.dart'; - -/// Per-slide annotation state. The [UndoManager] is the single source of -/// truth for a slide's strokes; [strokes] reflects its current contents so -/// the live canvas and the PDF export always render what was actually drawn. -class _SlideAnnotations { - final UndoManager undoManager = UndoManager(); - List get strokes => undoManager.currentStrokes; -} - -/// Screen that displays PPTX slides with an ink annotation overlay. -/// -/// Each slide is shown as an image in a [PageView]. A transparent [InkCanvas] -/// sits on top of each slide so the user can annotate freely. Annotations are -/// stored per-slide and can be exported as a PDF. -class PptAnnotatorScreen extends StatefulWidget { - final String filePath; - final List slideImagePaths; - final String? extractedText; - - const PptAnnotatorScreen({ - super.key, - required this.filePath, - required this.slideImagePaths, - this.extractedText, - }); - - @override - State createState() => _PptAnnotatorScreenState(); -} - -class _PptAnnotatorScreenState extends State { - late final PageController _pageController; - late final Map _annotations; - int _currentPage = 0; - bool _isDrawing = false; - bool _showTextPanel = false; - // Set to true once the unsaved-annotations warning SnackBar has been shown. - bool _hasShownUnsavedWarning = false; - - // Toolbar state - PenTool _currentTool = PenTool.pen; - Color _currentColor = Colors.black; - double _currentStrokeWidth = 2.0; - bool _filled = false; - PressureCurveType _pressureCurveType = PressureCurveType.linear; - StabilizationLevel _stabilizationLevel = StabilizationLevel.none; - - // Derived - late final String _fileName; - late final int _slideCount; - late final String _extractedText; - - PressureCurve get _pressureCurve { - switch (_pressureCurveType) { - case PressureCurveType.linear: - return PressureCurve.linear; - case PressureCurveType.soft: - return PressureCurve.soft; - case PressureCurveType.hard: - return PressureCurve.hard; - case PressureCurveType.custom: - return const PressureCurve(type: PressureCurveType.custom); - } - } - - UndoManager get _currentUndoManager => - _annotations.putIfAbsent(_currentPage, _SlideAnnotations.new).undoManager; - - @override - void initState() { - super.initState(); - _fileName = p.basename(widget.filePath); - _slideCount = widget.slideImagePaths.length; - _extractedText = widget.extractedText ?? ''; - - _pageController = PageController(); - _annotations = {}; - for (var i = 0; i < _slideCount; i++) { - _annotations[i] = _SlideAnnotations(); - } - } - - @override - void dispose() { - _pageController.dispose(); - super.dispose(); - } - - // -- Drawing callbacks -- - - void _onStrokeComplete(InkStroke stroke) { - setState(() { - _currentUndoManager.addStroke(stroke); - }); - // Warn once per session that PPT annotations are not auto-saved. - if (!_hasShownUnsavedWarning) { - _hasShownUnsavedWarning = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - "PPT ink isn't saved automatically — use Export to PDF to keep your annotations.", - ), - duration: Duration(seconds: 5), - ), - ); - }); - } - } - - void _onErase(String strokeId, List replacements) { - setState(() { - final original = _currentUndoManager.currentStrokes - .where((s) => s.id == strokeId) - .firstOrNull; - if (original != null) { - _currentUndoManager.removeStroke(original, replacements: replacements); - } - }); - } - - // -- Export -- - - Future _exportPdf() async { - if (!mounted) return; - - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Exporting PDF...'))); - - try { - final bytes = await _buildPdfBytes(); - if (!mounted) return; - - final dir = await _getExportDir(); - final baseName = p.basenameWithoutExtension(_fileName); - final outPath = p.join(dir.path, '${baseName}_annotated.pdf'); - await File(outPath).writeAsBytes(bytes); - - if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('PDF saved: $outPath'))); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Export failed: $e'))); - } - } - - Future _getExportDir() async { - try { - final home = Platform.environment['HOME']; - if (home != null) { - final dir = Directory(p.join(home, 'Documents', 'BadNote')); - if (!await dir.exists()) { - await dir.create(recursive: true); - } - return dir; - } - } catch (_) {} - return Directory.current; - } - - Future _buildPdfBytes() async { - final doc = PdfDocument(); - doc.pageSettings.margins.all = 0; - - for (var i = 0; i < _slideCount; i++) { - final page = doc.pages.add(); - final pageSize = page.getClientSize(); - - // Draw slide image - final imgPath = widget.slideImagePaths[i]; - try { - final imgBytes = await File(imgPath).readAsBytes(); - final bitmap = PdfBitmap(imgBytes); - - final imgW = bitmap.width.toDouble(); - final imgH = bitmap.height.toDouble(); - final scale = min(pageSize.width / imgW, pageSize.height / imgH); - final drawW = imgW * scale; - final drawH = imgH * scale; - final offX = (pageSize.width - drawW) / 2; - final offY = (pageSize.height - drawH) / 2; - final imgRect = Rect.fromLTWH(offX, offY, drawW, drawH); - - page.graphics.drawImage(bitmap, imgRect); - - // Draw ink strokes - final annots = _annotations[i]; - if (annots != null && annots.strokes.isNotEmpty) { - // KNOWN LIMITATION: strokes are captured in the live viewer's - // full-fill pixel space (the InkCanvas is Positioned.fill over the - // whole slide area, while the slide image is BoxFit.contain inside - // it). The scale below is derived from the PDF page layout, not the - // live widget size, so exported ink can be misaligned/scaled wrong. - // A correct fix normalizes strokes to [0,1] of the *rendered image - // rect* at capture time (mirroring PdfAnnotationLayer) and maps that - // to the PDF draw rect here. Requires on-device visual verification. - final imgAspect = imgW / imgH; - final pageAspect = pageSize.width / pageSize.height; - double widgetW, widgetH; - if (imgAspect > pageAspect) { - widgetW = pageSize.width; - widgetH = pageSize.width / imgAspect; - } else { - widgetH = pageSize.height; - widgetW = pageSize.height * imgAspect; - } - final scaleX = drawW / widgetW; - final scaleY = drawH / widgetH; - - for (final stroke in annots.strokes) { - if (stroke.tool == PenTool.eraser) continue; - if (stroke.points.length < 2) continue; - - final r = (stroke.color >> 16) & 0xFF; - final g = (stroke.color >> 8) & 0xFF; - final b = stroke.color & 0xFF; - final pdfColor = PdfColor(r, g, b); - - final path = PdfPath(); - path.startFigure(); - for (var j = 0; j < stroke.points.length - 1; j++) { - final pt1 = stroke.points[j]; - final pt2 = stroke.points[j + 1]; - path.addLine( - Offset(offX + pt1.x * scaleX, offY + pt1.y * scaleY), - Offset(offX + pt2.x * scaleX, offY + pt2.y * scaleY), - ); - } - - page.graphics.drawPath( - path, - pen: PdfPen(pdfColor, width: stroke.strokeWidth), - ); - } - } - } catch (_) { - page.graphics.drawRectangle( - brush: PdfSolidBrush(PdfColor(230, 230, 230)), - bounds: Rect.fromLTWH(0, 0, pageSize.width, pageSize.height), - ); - } - } - - final bytes = await doc.save(); - doc.dispose(); - return Uint8List.fromList(bytes); - } - - // -- UI -- - - @override - Widget build(BuildContext context) { - // No slides to annotate: show an empty state and skip the toolbar, which - // would otherwise dereference a non-existent slide's annotation state. - if (_slideCount == 0) { - return Scaffold( - appBar: AppBar( - title: Text(_fileName, style: const TextStyle(fontSize: 16)), - ), - body: const Center(child: Text('No slides to display')), - ); - } - - return Scaffold( - appBar: AppBar( - title: Text(_fileName, style: const TextStyle(fontSize: 16)), - actions: [ - if (_extractedText.isNotEmpty) - IconButton( - icon: Icon( - _showTextPanel - ? Icons.text_snippet - : Icons.text_snippet_outlined, - ), - tooltip: 'Toggle extracted text', - onPressed: () => setState(() => _showTextPanel = !_showTextPanel), - ), - IconButton( - icon: const Icon(Icons.picture_as_pdf), - tooltip: 'Export as PDF', - onPressed: _exportPdf, - ), - ], - ), - body: Column( - children: [ - AnnotationToolbar( - currentTool: _currentTool, - currentColor: _currentColor, - currentStrokeWidth: _currentStrokeWidth, - filled: _filled, - pressureCurveType: _pressureCurveType, - stabilizationLevel: _stabilizationLevel, - canUndo: _currentUndoManager.canUndo, - canRedo: _currentUndoManager.canRedo, - onToolChanged: (tool) => setState(() => _currentTool = tool), - onColorChanged: (color) => setState(() => _currentColor = color), - onStrokeWidthChanged: (w) => - setState(() => _currentStrokeWidth = w), - onFilledChanged: (f) => setState(() => _filled = f), - onPressureCurveChanged: (v) => - setState(() => _pressureCurveType = v), - onStabilizationChanged: (v) => - setState(() => _stabilizationLevel = v), - onUndo: _undo, - onRedo: _redo, - ), - Expanded( - child: Row( - children: [ - Expanded(child: _buildSlideViewer()), - if (_showTextPanel) _buildTextPanel(), - ], - ), - ), - _buildPageIndicator(), - ], - ), - ); - } - - Widget _buildSlideViewer() { - if (_slideCount == 0) { - return const Center(child: Text('No slides to display')); - } - - return Listener( - onPointerDown: (_) => setState(() => _isDrawing = true), - onPointerUp: (_) => setState(() => _isDrawing = false), - child: PageView.builder( - controller: _pageController, - physics: _isDrawing ? const NeverScrollableScrollPhysics() : null, - itemCount: _slideCount, - onPageChanged: (page) => setState(() => _currentPage = page), - itemBuilder: (context, index) { - return Padding( - padding: const EdgeInsets.all(8), - child: Stack( - children: [ - // Slide image (background) - Positioned.fill( - child: Image.file( - File(widget.slideImagePaths[index]), - fit: BoxFit.contain, - errorBuilder: (context, error, stackTrace) => Container( - color: Colors.grey.shade200, - child: Center( - child: Text( - 'Slide ${index + 1}', - style: TextStyle( - fontSize: 24, - color: Colors.grey.shade500, - ), - ), - ), - ), - ), - ), - // Ink annotation overlay (foreground) - Positioned.fill( - child: InkCanvas( - strokes: _annotations[index]?.strokes ?? [], - onStrokeComplete: _onStrokeComplete, - onErase: _onErase, - tool: _currentTool, - color: _currentColor, - strokeWidth: _currentStrokeWidth, - pressureCurve: _pressureCurve, - stabilizationLevel: _stabilizationLevel, - filled: _filled, - ), - ), - ], - ), - ); - }, - ), - ); - } - - Widget _buildPageIndicator() { - if (_slideCount == 0) return const SizedBox.shrink(); - - return Container( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - color: Theme.of(context).colorScheme.surface, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // Previous button — always present for both modes. - IconButton( - icon: const Icon(Icons.chevron_left), - onPressed: _currentPage > 0 - ? () => _pageController.previousPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ) - : null, - ), - // Dot row for small decks; compact text counter for large decks. - if (_slideCount <= 12) - ...List.generate(_slideCount, (i) { - final isActive = i == _currentPage; - return GestureDetector( - onTap: () => _pageController.animateToPage( - i, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ), - child: Container( - width: isActive ? 12 : 8, - height: isActive ? 12 : 8, - margin: const EdgeInsets.symmetric(horizontal: 4), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isActive - ? Theme.of(context).colorScheme.primary - : Colors.grey.shade400, - ), - ), - ); - }) - else - Text( - '${_currentPage + 1} / $_slideCount', - style: TextStyle(fontSize: 13, color: Colors.grey.shade600), - ), - // Next button — always present for both modes. - IconButton( - icon: const Icon(Icons.chevron_right), - onPressed: _currentPage < _slideCount - 1 - ? () => _pageController.nextPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ) - : null, - ), - const Spacer(), - // Slide counter is always shown at the trailing end for dot mode; - // the compact text above already serves this role for large decks. - if (_slideCount <= 12) - Text( - '${_currentPage + 1} / $_slideCount', - style: TextStyle(fontSize: 13, color: Colors.grey.shade600), - ), - ], - ), - ); - } - - Widget _buildTextPanel() { - return SizedBox( - width: 280, - child: Card( - margin: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(12), - ), - ), - child: Row( - children: [ - const Icon(Icons.text_fields, size: 18), - const SizedBox(width: 8), - Text( - 'Extracted Text', - style: Theme.of(context).textTheme.titleSmall, - ), - const Spacer(), - IconButton( - icon: const Icon(Icons.close, size: 18), - onPressed: () => setState(() => _showTextPanel = false), - ), - ], - ), - ), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(12), - child: SelectableText( - _extractedText, - style: Theme.of(context).textTheme.bodySmall, - ), - ), - ), - ], - ), - ), - ); - } - - // -- Dialogs -- - - void _undo() { - setState(() => _currentUndoManager.undo()); - } - - void _redo() { - setState(() => _currentUndoManager.redo()); - } -} diff --git a/test/slide_export_test.dart b/test/slide_export_test.dart new file mode 100644 index 0000000..3b6b51d --- /dev/null +++ b/test/slide_export_test.dart @@ -0,0 +1,45 @@ +// Proves the pen-first slide export geometry: contain-fit centering and the +// normalized->slide point mapping that replaces the old misaligned exporter. + +import 'dart:ui' show Rect, Size; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:badnote/editor/pdf/slide_export.dart'; + +void main() { + test('wide image is letterboxed top/bottom on a square page', () { + final r = slideDrawRect(const Size(1000, 1000), const Size(1600, 900)); + // scale = min(1000/1600, 1000/900) = 0.625 -> 1000 x 562.5, centered. + expect(r.width, closeTo(1000, 1e-6)); + expect(r.height, closeTo(562.5, 1e-6)); + expect(r.left, closeTo(0, 1e-6)); + expect(r.top, closeTo((1000 - 562.5) / 2, 1e-6)); + }); + + test('tall image is pillarboxed left/right', () { + final r = slideDrawRect(const Size(1000, 1000), const Size(900, 1600)); + expect(r.height, closeTo(1000, 1e-6)); + expect(r.width, closeTo(562.5, 1e-6)); + expect(r.top, closeTo(0, 1e-6)); + expect(r.left, closeTo((1000 - 562.5) / 2, 1e-6)); + }); + + test('normalized corners map to the draw rect corners', () { + final r = const Rect.fromLTWH(100, 50, 800, 600); + final tl = normToSlide(0, 0, r); + final br = normToSlide(1, 1, r); + final mid = normToSlide(0.5, 0.5, r); + expect(tl.dx, closeTo(100, 1e-9)); + expect(tl.dy, closeTo(50, 1e-9)); + expect(br.dx, closeTo(900, 1e-9)); + expect(br.dy, closeTo(650, 1e-9)); + expect(mid.dx, closeTo(500, 1e-9)); + expect(mid.dy, closeTo(350, 1e-9)); + }); + + test('stroke width scales with the draw width', () { + final r = const Rect.fromLTWH(0, 0, 800, 600); + expect(slideStrokeWidth(0.01, r), closeTo(8, 1e-9)); + }); +}