import 'dart:math'; import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:perfect_freehand/perfect_freehand.dart' as pf; import '../models/ink_point.dart'; import '../models/ink_stroke.dart'; import '../models/pen_tool.dart'; import '../models/pressure_curve.dart'; /// Renders ink strokes to a PNG byte array for local OCR. class StrokeRasterizer { static const _padding = 24.0; static const _defaultPressureCurve = PressureCurve.linear; /// Render [strokes] onto a white canvas and return PNG bytes, or null if empty. static Future render(List strokes) async { final drawable = strokes .where((s) => s.tool != PenTool.eraser && s.points.isNotEmpty) .toList(); if (drawable.isEmpty) return null; final bounds = _computeBounds(drawable); if (bounds == null) return null; final width = (bounds.width + _padding * 2).ceil().clamp(1, 4096); final height = (bounds.height + _padding * 2).ceil().clamp(1, 4096); final offset = Offset(_padding - bounds.left, _padding - bounds.top); final recorder = ui.PictureRecorder(); final canvas = Canvas(recorder); canvas.drawRect( Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()), Paint()..color = Colors.white, ); for (final stroke in drawable) { _drawStroke(canvas, stroke, offset); } final picture = recorder.endRecording(); final image = await picture.toImage(width, height); final byteData = await image.toByteData(format: ui.ImageByteFormat.png); return byteData?.buffer.asUint8List(); } static Rect? _computeBounds(List strokes) { double? minX, minY, maxX, maxY; for (final stroke in strokes) { for (final p in stroke.points) { minX = minX == null ? p.x : min(minX, p.x); minY = minY == null ? p.y : min(minY, p.y); maxX = maxX == null ? p.x : max(maxX, p.x); maxY = maxY == null ? p.y : max(maxY, p.y); } } if (minX == null || minY == null || maxX == null || maxY == null) { return null; } return Rect.fromLTRB(minX, minY, maxX, maxY); } static List _offsetPoints(List points, Offset offset) { return points .map( (p) => InkPoint( x: p.x + offset.dx, y: p.y + offset.dy, pressure: p.pressure, timestamp: p.timestamp, ), ) .toList(); } static void _drawStroke(Canvas canvas, InkStroke stroke, Offset offset) { final points = _offsetPoints(stroke.points, offset); final color = Color(stroke.color); final tool = stroke.tool; switch (tool) { case PenTool.pen: case PenTool.marker: case PenTool.highlighter: _drawFreehand(canvas, points, tool, color, stroke.strokeWidth); break; case PenTool.rectangle: if (points.length >= 2) { _drawRect(canvas, points, color, stroke.strokeWidth, stroke.filled); } else { _drawFreehand(canvas, points, tool, color, stroke.strokeWidth); } break; case PenTool.ellipse: if (points.length >= 2) { _drawOval(canvas, points, color, stroke.strokeWidth, stroke.filled); } else { _drawFreehand(canvas, points, tool, color, stroke.strokeWidth); } break; case PenTool.line: if (points.length >= 2) { _drawLine(canvas, points, color, stroke.strokeWidth); } else { _drawFreehand(canvas, points, tool, color, stroke.strokeWidth); } break; case PenTool.arrow: if (points.length >= 2) { _drawArrow(canvas, points, color, stroke.strokeWidth); } else { _drawFreehand(canvas, points, tool, color, stroke.strokeWidth); } break; case PenTool.text: if (stroke.textContent != null && stroke.textContent!.isNotEmpty) { _drawText( canvas, points, stroke.textContent!, stroke.fontSize, color, ); } break; case PenTool.eraser: break; } } static void _drawFreehand( Canvas canvas, List points, PenTool tool, Color color, double strokeWidth, ) { final pfPoints = points .map( (p) => pf.Point( p.x, p.y, _defaultPressureCurve.apply(p.pressure).clamp(0.0, 1.0), ), ) .toList(); final thinning = (tool == PenTool.marker || tool == PenTool.highlighter) ? 0.0 : 0.7; final outline = pf.getStroke( pfPoints, size: strokeWidth, thinning: thinning, smoothing: 0.5, streamline: 0.5, simulatePressure: tool != PenTool.marker && tool != PenTool.highlighter, isComplete: true, ); if (outline.isEmpty) return; final path = Path()..moveTo(outline[0].x, outline[0].y); for (var i = 1; i < outline.length; i++) { path.lineTo(outline[i].x, outline[i].y); } path.close(); canvas.drawPath( path, Paint() ..color = color ..style = PaintingStyle.fill ..isAntiAlias = true, ); } static void _drawRect( Canvas canvas, List points, Color color, double strokeWidth, bool filled, ) { final rect = Rect.fromPoints( Offset(points[0].x, points[0].y), Offset(points[1].x, points[1].y), ); canvas.drawRect( rect, Paint() ..color = color ..strokeWidth = strokeWidth ..style = filled ? PaintingStyle.fill : PaintingStyle.stroke ..isAntiAlias = true, ); } static void _drawOval( Canvas canvas, List points, Color color, double strokeWidth, bool filled, ) { final rect = Rect.fromPoints( Offset(points[0].x, points[0].y), Offset(points[1].x, points[1].y), ); canvas.drawOval( rect, Paint() ..color = color ..strokeWidth = strokeWidth ..style = filled ? PaintingStyle.fill : PaintingStyle.stroke ..isAntiAlias = true, ); } static void _drawLine( Canvas canvas, List points, Color color, double strokeWidth, ) { canvas.drawLine( Offset(points[0].x, points[0].y), Offset(points[1].x, points[1].y), Paint() ..color = color ..strokeWidth = strokeWidth ..strokeCap = StrokeCap.round ..isAntiAlias = true, ); } static void _drawArrow( Canvas canvas, List points, Color color, double strokeWidth, ) { final start = Offset(points[0].x, points[0].y); final end = Offset(points[1].x, points[1].y); canvas.drawLine( start, end, Paint() ..color = color ..strokeWidth = strokeWidth ..strokeCap = StrokeCap.round ..isAntiAlias = true, ); final angle = atan2(end.dy - start.dy, end.dx - start.dx); const headLength = 12.0; const headAngle = pi / 6; final p1 = end + Offset( -headLength * cos(angle - headAngle), -headLength * sin(angle - headAngle), ); final p2 = end + Offset( -headLength * cos(angle + headAngle), -headLength * sin(angle + headAngle), ); final head = Path() ..moveTo(end.dx, end.dy) ..lineTo(p1.dx, p1.dy) ..lineTo(p2.dx, p2.dy) ..close(); canvas.drawPath( head, Paint() ..color = color ..style = PaintingStyle.fill, ); } static void _drawText( Canvas canvas, List points, String text, double fontSize, Color color, ) { if (points.isEmpty) return; final painter = TextPainter( text: TextSpan( text: text, style: TextStyle(color: color, fontSize: fontSize), ), textDirection: TextDirection.ltr, )..layout(); painter.paint(canvas, Offset(points[0].x, points[0].y)); } }