feat(slide): rebuild PPT annotator on the pen-first canvas
All checks were successful
CI / Windows build (push) Successful in 14m34s
All checks were successful
CI / Windows build (push) Successful in 14m34s
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.
This commit is contained in:
544
lib/editor/canvas/pen_slide_screen.dart
Normal file
544
lib/editor/canvas/pen_slide_screen.dart
Normal file
@@ -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<String> slideImagePaths;
|
||||
final String? extractedText;
|
||||
|
||||
@override
|
||||
State<PenSlideScreen> createState() => _PenSlideScreenState();
|
||||
}
|
||||
|
||||
class _PenSlideScreenState extends State<PenSlideScreen> {
|
||||
int _slideIndex = 0;
|
||||
final Map<int, List<PenStroke>> _strokesBySlide = {};
|
||||
final Map<int, List<List<PenStroke>>> _undo = {};
|
||||
final Map<int, List<List<PenStroke>>> _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<int, Size>? _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<Color> _palette = [
|
||||
Colors.black,
|
||||
Colors.red,
|
||||
Colors.blue,
|
||||
Colors.green,
|
||||
Colors.orange,
|
||||
];
|
||||
|
||||
int get _slideCount => widget.slideImagePaths.length;
|
||||
List<PenStroke> get _currentStrokes => _strokesBySlide[_slideIndex] ?? const [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
PenInputService.instance.start();
|
||||
_loadSlideSizes();
|
||||
_initPenConfig();
|
||||
}
|
||||
|
||||
Future<void> _loadSlideSizes() async {
|
||||
final sizes = <int, Size>{};
|
||||
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<void> _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<PenStroke>.from(_currentStrokes));
|
||||
_redo[_slideIndex]?.clear();
|
||||
}
|
||||
|
||||
void _commitStroke(PenStroke stroke) {
|
||||
setState(() {
|
||||
_pushUndo();
|
||||
_strokesBySlide[_slideIndex] = [..._currentStrokes, stroke];
|
||||
});
|
||||
}
|
||||
|
||||
void _eraseStroke(int index, List<PenStroke> 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<PenStroke>.from(_currentStrokes));
|
||||
_strokesBySlide[_slideIndex] = stack.removeLast();
|
||||
});
|
||||
}
|
||||
|
||||
void _performRedo() {
|
||||
final stack = _redo[_slideIndex];
|
||||
if (stack == null || stack.isEmpty) return;
|
||||
setState(() {
|
||||
(_undo[_slideIndex] ??= []).add(List<PenStroke>.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<void> _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<Directory> _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<Uint8List> _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 <PenStroke>[]) {
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
36
lib/editor/pdf/slide_export.dart
Normal file
36
lib/editor/pdf/slide_export.dart
Normal file
@@ -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;
|
||||
Reference in New Issue
Block a user