Files
BadNote/lib/services/stroke_rasterizer.dart
Akiba So 3295018ee3
All checks were successful
CI / Windows build (push) Successful in 11m34s
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons)
W1 — Custom pen width + pressure sensitivity (Saber-style):
- Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure
  (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen
  force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated
  all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset).
- De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by
  the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity
  with a Pressure Sensitivity slider; live-applies via a config listener.

W3 — Native Windows pen plugin (tilt + barrel/eraser buttons):
- windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler
  (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read
  GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming.
- PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer
  correlation); graceful no-op off-Windows.
- pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd
  (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt.

W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim);
definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3).

Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md
(Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE).

Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline +
tilt-adapter round-trip. flutter analyze clean; linux debug build OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00

305 lines
8.0 KiB
Dart

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<Uint8List?> render(List<InkStroke> 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<InkStroke> 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<InkPoint> _offsetPoints(List<InkPoint> 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<InkPoint> points,
PenTool tool,
Color color,
double strokeWidth,
) {
final pfPoints = points
.map(
(p) => pf.PointVector(
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,
options: pf.StrokeOptions(
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].dx, outline[0].dy);
for (var i = 1; i < outline.length; i++) {
path.lineTo(outline[i].dx, outline[i].dy);
}
path.close();
canvas.drawPath(
path,
Paint()
..color = color
..style = PaintingStyle.fill
..isAntiAlias = true,
);
}
static void _drawRect(
Canvas canvas,
List<InkPoint> 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<InkPoint> 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<InkPoint> 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<InkPoint> 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<InkPoint> 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));
}
}