All checks were successful
CI / Windows build (push) Successful in 11m34s
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>
305 lines
8.0 KiB
Dart
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));
|
|
}
|
|
}
|