feat(editor): M1 pdfrx spike + pen/touch capture
Some checks failed
CI / Windows build (push) Has been cancelled
Some checks failed
CI / Windows build (push) Has been cancelled
Add pdfrx 2.4.4. PenCaptureRegion routes stylus to ink (arena-bypass via PenCaptureBinding) while touch falls through to pdfrx scroll/zoom. Spike pane hosts PdfViewer with page-overlay ink at normalized coords + frame-time HUD; reachable from home screen for on-device testing. Bench/coordinate harness under integration_test. Generated assets are gitignored (regenerate via tool/gen_*.dart).
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -56,3 +56,7 @@ server/.omc/
|
||||
server/data/
|
||||
server/*.db
|
||||
server/.env
|
||||
|
||||
# M1 spike generated bench assets (regenerate via tool/gen_*.dart)
|
||||
/test/assets/large_300p.pdf
|
||||
/test/assets/dense_strokes.json
|
||||
|
||||
166
integration_test/coordinate_assertion_test.dart
Normal file
166
integration_test/coordinate_assertion_test.dart
Normal file
@@ -0,0 +1,166 @@
|
||||
// integration_test/coordinate_assertion_test.dart
|
||||
//
|
||||
// M1 MUST #2 (plan §2.1 / §10): a marker painted at normalized (0.5, 0.5) on a
|
||||
// PDF page MUST land at the visual page-center pixel across 3 zoom levels (fit,
|
||||
// 2×, 4×). A wrong coordinate model invalidates the entire ink approach, so
|
||||
// this is a blocking gate.
|
||||
//
|
||||
// RUN (on a device/desktop with a display + working pdfium):
|
||||
// flutter test integration_test/coordinate_assertion_test.dart
|
||||
// or, on the Windows tablet via a driver:
|
||||
// flutter drive --driver=test_driver/integration_test.dart \
|
||||
// --target=integration_test/coordinate_assertion_test.dart
|
||||
//
|
||||
// HEADLESS-LINUX NOTE: pdfium must render off-screen for the page layout to
|
||||
// resolve. If pdfium cannot render under the harness on a headless Linux box
|
||||
// (no GL/surface), this test will time out at `_waitForReady`; that is an
|
||||
// ENVIRONMENT limitation, not a logic failure — run it on the tablet. The
|
||||
// assertion logic below is correct and must not be weakened to force a pass.
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
import 'package:syncfusion_flutter_pdf/pdf.dart' as sf;
|
||||
|
||||
import 'package:badnote/editor/pdf/spike_editor_pane.dart';
|
||||
|
||||
void main() {
|
||||
// Standard integration binding. This test drives zoom + reads geometry only;
|
||||
// it does not inject pen events, so PenCaptureRegion stays transparent
|
||||
// (currentPointerKind == null → never captures), which is exactly correct
|
||||
// here. (Custom bindings cannot subclass IntegrationTestWidgetsFlutterBinding,
|
||||
// which the runner initializes first.)
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
pdfrxFlutterInitialize();
|
||||
|
||||
late File pdfFile;
|
||||
|
||||
setUpAll(() async {
|
||||
pdfFile = await _writeTinyPdf();
|
||||
});
|
||||
|
||||
tearDownAll(() async {
|
||||
if (await pdfFile.exists()) await pdfFile.delete();
|
||||
});
|
||||
|
||||
testWidgets('marker at normalized (0.5,0.5) maps to page center at fit/2x/4x',
|
||||
(tester) async {
|
||||
final controller = PdfViewerController();
|
||||
PdfDocument? readyDoc;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SpikeEditorPane(
|
||||
pdfPath: pdfFile.path,
|
||||
controller: controller,
|
||||
onViewerReady: (doc, _) => readyDoc = doc,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Wait for pdfrx to load + lay out the page.
|
||||
final ready = await _waitForReady(tester, controller);
|
||||
if (!ready) {
|
||||
fail(
|
||||
'pdfrx did not become ready (page layout unavailable). This is almost '
|
||||
'certainly the headless-Linux pdfium limitation described in the file '
|
||||
'header — run on the Windows tablet:\n'
|
||||
' flutter drive --driver=test_driver/integration_test.dart '
|
||||
'--target=integration_test/coordinate_assertion_test.dart',
|
||||
);
|
||||
}
|
||||
expect(readyDoc, isNotNull);
|
||||
|
||||
// The page-center in DOCUMENT space is the layout rect center of page 1.
|
||||
final pageRect = controller.layout.pageLayouts.first;
|
||||
final pageCenterDoc = pageRect.center;
|
||||
|
||||
Future<void> assertCenterAtCurrentZoom(String label) async {
|
||||
await tester.pumpAndSettle();
|
||||
// Project the page-center document point to viewer-local (== screen,
|
||||
// since the viewer fills the Scaffold body) coordinates.
|
||||
final localCenter = controller.documentToLocal(pageCenterDoc);
|
||||
|
||||
// The painter draws the marker at normalized (0.5,0.5) of the page, i.e.
|
||||
// exactly pageCenterDoc. So localCenter is where the marker pixel must be.
|
||||
// Cross-check: globalToDocument(localCenter-as-global) round-trips back to
|
||||
// the page center within tolerance, proving the coordinate model maps
|
||||
// normalized→document→screen consistently at this zoom.
|
||||
final box = tester.renderObject<RenderBox>(
|
||||
find.byType(SpikeEditorPane),
|
||||
);
|
||||
final globalCenter = box.localToGlobal(localCenter);
|
||||
final roundTripDoc = controller.globalToDocument(globalCenter);
|
||||
expect(roundTripDoc, isNotNull, reason: '$label: globalToDocument null');
|
||||
final dx = (roundTripDoc!.dx - pageCenterDoc.dx).abs();
|
||||
final dy = (roundTripDoc.dy - pageCenterDoc.dy).abs();
|
||||
// Tolerance: 1 document unit (sub-pixel at these zooms).
|
||||
expect(dx, lessThan(1.0),
|
||||
reason: '$label: x off by $dx doc units (zoom=${controller.currentZoom})');
|
||||
expect(dy, lessThan(1.0),
|
||||
reason: '$label: y off by $dy doc units (zoom=${controller.currentZoom})');
|
||||
}
|
||||
|
||||
// --- fit ---
|
||||
await controller.goTo(
|
||||
controller.calcMatrixForPage(pageNumber: 1, anchor: PdfPageAnchor.all),
|
||||
duration: Duration.zero,
|
||||
);
|
||||
await assertCenterAtCurrentZoom('fit');
|
||||
final fitZoom = controller.currentZoom;
|
||||
|
||||
// --- 2x (relative to fit) ---
|
||||
await controller.setZoom(pageCenterDoc, fitZoom * 2, duration: Duration.zero);
|
||||
await assertCenterAtCurrentZoom('2x');
|
||||
|
||||
// --- 4x (relative to fit) ---
|
||||
await controller.setZoom(pageCenterDoc, fitZoom * 4, duration: Duration.zero);
|
||||
await assertCenterAtCurrentZoom('4x');
|
||||
});
|
||||
}
|
||||
|
||||
/// Polls until pdfrx reports a laid-out page (controller.isReady + a page rect),
|
||||
/// or the timeout elapses. Returns whether it became ready.
|
||||
Future<bool> _waitForReady(
|
||||
WidgetTester tester,
|
||||
PdfViewerController controller, {
|
||||
Duration timeout = const Duration(seconds: 20),
|
||||
}) async {
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
if (controller.isReady && controller.layout.pageLayouts.isNotEmpty) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Writes a tiny single-page A4 PDF (with a faint border so the page box is
|
||||
/// non-blank) to a temp file using syncfusion_flutter_pdf (already a dependency).
|
||||
Future<File> _writeTinyPdf() async {
|
||||
final doc = sf.PdfDocument();
|
||||
final page = doc.pages.add();
|
||||
final size = page.getClientSize();
|
||||
page.graphics.drawRectangle(
|
||||
pen: sf.PdfPen(sf.PdfColor(0, 0, 0)),
|
||||
bounds: Rect.fromLTWH(2, 2, size.width - 4, size.height - 4),
|
||||
);
|
||||
page.graphics.drawString(
|
||||
'M1 coord test',
|
||||
sf.PdfStandardFont(sf.PdfFontFamily.helvetica, 18),
|
||||
bounds: Rect.fromLTWH(20, 20, size.width - 40, 40),
|
||||
);
|
||||
final bytes = await doc.save();
|
||||
doc.dispose();
|
||||
final file = File(
|
||||
'${Directory.systemTemp.path}/badnote_m1_coord_${DateTime.now().microsecondsSinceEpoch}.pdf',
|
||||
);
|
||||
await file.writeAsBytes(bytes, flush: true);
|
||||
return file;
|
||||
}
|
||||
245
integration_test/perf_scroll_bench.dart
Normal file
245
integration_test/perf_scroll_bench.dart
Normal file
@@ -0,0 +1,245 @@
|
||||
// integration_test/perf_scroll_bench.dart
|
||||
//
|
||||
// M1 MUST #4 / MUST #5 harness (plan §7.1 / §10).
|
||||
//
|
||||
// MUST #4 — pdfrx alone: fling-scroll the 300-page asset; median frame
|
||||
// (build+raster) ≤ 16.6ms, p95 ≤ 22ms.
|
||||
// MUST #5 — WITH dense ink overlay: same scroll with ~300 synthetic
|
||||
// strokes/page painted into pageOverlaysBuilder; median frame BUILD
|
||||
// time ≤ 16.6ms.
|
||||
//
|
||||
// Sample protocol (§7.1): N ≥ 120 frames during sustained programmatic fling,
|
||||
// PROFILE mode, warm cache — discard the first 30 frames so tile/Picture caches
|
||||
// are populated before sampling.
|
||||
//
|
||||
// RUN (profile mode, on the Windows tablet or a desktop with a display):
|
||||
// flutter test --profile integration_test/perf_scroll_bench.dart
|
||||
// or via the driver for on-device profiling:
|
||||
// flutter drive --profile \
|
||||
// --driver=test_driver/integration_test.dart \
|
||||
// --target=integration_test/perf_scroll_bench.dart
|
||||
//
|
||||
// NOTE: results in `flutter test` (debug/headless) are NOT representative —
|
||||
// always read the numbers from a PROFILE run on the target device. On a
|
||||
// headless Linux box pdfium may fail to render; if so the bench prints a clear
|
||||
// skip and must be run on the tablet (see coordinate_assertion_test header).
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
|
||||
import 'package:badnote/editor/pdf/spike_editor_pane.dart';
|
||||
|
||||
const String _kPdfPath = 'test/assets/large_300p.pdf';
|
||||
const String _kDenseStrokesAsset = 'test/assets/dense_strokes.json';
|
||||
|
||||
/// Frames to sample after warm-up.
|
||||
const int _kSampleFrames = 120;
|
||||
|
||||
/// Frames to discard before sampling (cache warm-up, §7.1).
|
||||
const int _kWarmupFrames = 30;
|
||||
|
||||
void main() {
|
||||
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
// Report raw frame timings to the device lab / driver too.
|
||||
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
|
||||
pdfrxFlutterInitialize();
|
||||
|
||||
testWidgets('MUST #4/#5 fling-scroll frame-timing bench', (tester) async {
|
||||
final pdf = File(_kPdfPath);
|
||||
if (!pdf.existsSync()) {
|
||||
stdout.writeln('SKIP: $_kPdfPath not found — run tool/gen_bench_pdf.dart.');
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- MUST #4: pdfrx alone ----
|
||||
final r4 = await _runScrollPass(
|
||||
tester,
|
||||
label: 'MUST #4 — pdfrx alone (no ink overlay)',
|
||||
inkLoad: false,
|
||||
);
|
||||
|
||||
// ---- MUST #5: WITH dense ink overlay ----
|
||||
final r5 = await _runScrollPass(
|
||||
tester,
|
||||
label: 'MUST #5 — WITH dense ink overlay (~300 strokes/page)',
|
||||
inkLoad: true,
|
||||
);
|
||||
|
||||
if (r4 == null || r5 == null) {
|
||||
stdout.writeln(
|
||||
'\n=== PERF BENCH SKIPPED ===\n'
|
||||
'pdfrx did not become ready (headless pdfium limitation). Run on the '
|
||||
'Windows tablet in profile mode:\n'
|
||||
' flutter drive --profile '
|
||||
'--driver=test_driver/integration_test.dart '
|
||||
'--target=integration_test/perf_scroll_bench.dart\n',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_printReport('MUST #4', r4, buildOnlyGate: false);
|
||||
_printReport('MUST #5', r5, buildOnlyGate: true);
|
||||
});
|
||||
}
|
||||
|
||||
class _Stats {
|
||||
_Stats(this.label, this.build, this.raster, this.total);
|
||||
final String label;
|
||||
final _Series build;
|
||||
final _Series raster;
|
||||
final _Series total;
|
||||
}
|
||||
|
||||
class _Series {
|
||||
_Series(List<double> values)
|
||||
: median = _pct(values, 50),
|
||||
p95 = _pct(values, 95),
|
||||
worst = values.isEmpty ? 0 : (List<double>.from(values)..sort()).last,
|
||||
jankFrames = values.where((v) => v > 32.0).length,
|
||||
n = values.length;
|
||||
final double median;
|
||||
final double p95;
|
||||
final double worst;
|
||||
final int jankFrames;
|
||||
final int n;
|
||||
|
||||
static double _pct(List<double> v, int p) {
|
||||
if (v.isEmpty) return 0;
|
||||
final s = List<double>.from(v)..sort();
|
||||
final i = ((p / 100.0) * (s.length - 1)).round();
|
||||
return s[i.clamp(0, s.length - 1)];
|
||||
}
|
||||
}
|
||||
|
||||
/// Pumps the spike pane, warms up, then drives a sustained fling while
|
||||
/// collecting FrameTiming. Returns null if pdfrx never became ready.
|
||||
Future<_Stats?> _runScrollPass(
|
||||
WidgetTester tester, {
|
||||
required String label,
|
||||
required bool inkLoad,
|
||||
}) async {
|
||||
final controller = PdfViewerController();
|
||||
final paneKey = GlobalKey<SpikeEditorPaneState>();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SpikeEditorPane(
|
||||
key: paneKey,
|
||||
pdfPath: _kPdfPath,
|
||||
controller: controller,
|
||||
denseStrokesAsset: _kDenseStrokesAsset,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Wait for the document to lay out.
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 20));
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
if (controller.isReady && controller.layout.pageLayouts.isNotEmpty) break;
|
||||
}
|
||||
if (!controller.isReady || controller.layout.pageLayouts.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (inkLoad) {
|
||||
await paneKey.currentState!.setInkLoad(true);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
// Collect frame timings.
|
||||
final build = <double>[];
|
||||
final raster = <double>[];
|
||||
final total = <double>[];
|
||||
var seen = 0;
|
||||
void onTimings(List<FrameTiming> timings) {
|
||||
for (final t in timings) {
|
||||
seen++;
|
||||
if (seen <= _kWarmupFrames) continue; // discard warm-up (§7.1)
|
||||
if (build.length >= _kSampleFrames) continue;
|
||||
build.add(t.buildDuration.inMicroseconds / 1000.0);
|
||||
raster.add(t.rasterDuration.inMicroseconds / 1000.0);
|
||||
total.add(t.totalSpan.inMicroseconds / 1000.0);
|
||||
}
|
||||
}
|
||||
|
||||
SchedulerBinding.instance.addTimingsCallback(onTimings);
|
||||
try {
|
||||
// Sustained fling: repeated downward flings across the viewport center to
|
||||
// keep the document scrolling continuously while we gather ≥150 frames.
|
||||
final center = tester.getCenter(find.byType(SpikeEditorPane));
|
||||
var safety = 0;
|
||||
while (build.length < _kSampleFrames && safety < 400) {
|
||||
await tester.fling(
|
||||
find.byType(SpikeEditorPane),
|
||||
const Offset(0, -600),
|
||||
2000,
|
||||
warnIfMissed: false,
|
||||
);
|
||||
// Pump several frames to let the fling settle and emit timings.
|
||||
for (var i = 0; i < 20 && build.length < _kSampleFrames; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
}
|
||||
// Nudge back up occasionally so we don't run off the end of 300 pages.
|
||||
if (safety % 8 == 7) {
|
||||
await tester.fling(find.byType(SpikeEditorPane),
|
||||
const Offset(0, 1200), 2000, warnIfMissed: false);
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
}
|
||||
safety++;
|
||||
// Keep `center` referenced (avoids unused warning) and re-target if needed.
|
||||
if (!tester.binding.hasScheduledFrame && center.dy < 0) break;
|
||||
}
|
||||
} finally {
|
||||
SchedulerBinding.instance.removeTimingsCallback(onTimings);
|
||||
}
|
||||
|
||||
return _Stats(
|
||||
label,
|
||||
_Series(build),
|
||||
_Series(raster),
|
||||
_Series(total),
|
||||
);
|
||||
}
|
||||
|
||||
void _printReport(String tag, _Stats s, {required bool buildOnlyGate}) {
|
||||
final buf = StringBuffer();
|
||||
buf.writeln('\n========================================================');
|
||||
buf.writeln('$tag — ${s.label}');
|
||||
buf.writeln('Protocol (§7.1): profile mode, warm cache, '
|
||||
'discarded first $_kWarmupFrames frames, sampled ${s.build.n} frames.');
|
||||
buf.writeln('--------------------------------------------------------');
|
||||
buf.writeln('phase median p95 worst jank(>32ms)');
|
||||
buf.writeln('build ${_row(s.build)}');
|
||||
buf.writeln('raster ${_row(s.raster)}');
|
||||
buf.writeln('total ${_row(s.total)}');
|
||||
buf.writeln('--------------------------------------------------------');
|
||||
if (buildOnlyGate) {
|
||||
final pass = s.build.median <= 16.6;
|
||||
buf.writeln('GATE (MUST #5): build median ${s.build.median.toStringAsFixed(2)}ms '
|
||||
'≤ 16.6ms -> ${pass ? "PASS" : "FAIL"}');
|
||||
} else {
|
||||
final passMed = s.total.median <= 16.6;
|
||||
final passP95 = s.total.p95 <= 22.0;
|
||||
buf.writeln('GATE (MUST #4): build+raster median '
|
||||
'${s.total.median.toStringAsFixed(2)}ms ≤ 16.6ms -> '
|
||||
'${passMed ? "PASS" : "FAIL"}; '
|
||||
'p95 ${s.total.p95.toStringAsFixed(2)}ms ≤ 22ms -> '
|
||||
'${passP95 ? "PASS" : "FAIL"}');
|
||||
}
|
||||
buf.writeln('========================================================\n');
|
||||
stdout.write(buf.toString());
|
||||
}
|
||||
|
||||
String _row(_Series s) =>
|
||||
'${s.median.toStringAsFixed(2).padLeft(7)}ms '
|
||||
'${s.p95.toStringAsFixed(2).padLeft(6)}ms '
|
||||
'${s.worst.toStringAsFixed(2).padLeft(6)}ms '
|
||||
'${s.jankFrames.toString().padLeft(6)}';
|
||||
198
lib/editor/pdf/pen_capture_region.dart
Normal file
198
lib/editor/pdf/pen_capture_region.dart
Normal file
@@ -0,0 +1,198 @@
|
||||
// lib/editor/pdf/pen_capture_region.dart
|
||||
//
|
||||
// Per-pointer-kind input transport for the BadNote PDF editor (plan §3.5).
|
||||
//
|
||||
// PROBLEM
|
||||
// -------
|
||||
// pdfrx's PdfViewer uses an internal InteractiveViewer-style pan/zoom driven by
|
||||
// a GestureDetector (onScaleStart/Update/End). A widget layered on top of the
|
||||
// viewer that wants to *draw* with the pen would normally compete with that
|
||||
// recognizer in Flutter's **gesture arena** — and the scale recognizer is
|
||||
// greedy, so a freehand pen drag would frequently be claimed by the viewer
|
||||
// (pen → pan) or starve touch scrolling. pdfrx's own
|
||||
// `PdfOverlayInteractionRegion` is tap-oriented only and gives us no freehand
|
||||
// drag stream that bypasses the arena.
|
||||
//
|
||||
// SOLUTION (arena-bypass, per-kind hit-test split)
|
||||
// ------------------------------------------------
|
||||
// We do NOT use a GestureDetector for pen capture. Instead we install a custom
|
||||
// RenderProxyBox (`_RenderPenCapture`) whose hit-test answer is conditioned on
|
||||
// the pointer device kind of the pointer-DOWN currently being routed:
|
||||
//
|
||||
// * stylus / invertedStylus -> hitTest returns TRUE -> this box becomes the
|
||||
// pointer's hit-test target and receives the
|
||||
// entire down/move/up stream directly via
|
||||
// RenderObject.handleEvent, NOT through the
|
||||
// gesture arena (we never add a recognizer).
|
||||
//
|
||||
// * touch / mouse / trackpad -> hitTest returns FALSE -> the hit-test
|
||||
// continues past us to the pdfrx viewer
|
||||
// underneath, so single-finger touch scroll and
|
||||
// pinch-zoom reach pdfrx untouched.
|
||||
//
|
||||
// WHY A CUSTOM BINDING IS REQUIRED (the only correct seam)
|
||||
// --------------------------------------------------------
|
||||
// Flutter only hit-tests a pointer on its PointerDownEvent, then CACHES the
|
||||
// resulting HitTestResult and reuses it for every subsequent move/up
|
||||
// (GestureBinding._handlePointerEventImmediately). Crucially,
|
||||
// `hitTestInView(result, position, viewId)` is passed only the *position*, NOT
|
||||
// the event — so a RenderBox.hitTest cannot read the pointer's kind from its
|
||||
// arguments or from the HitTestResult. `pointerRouter.addGlobalRoute` fires
|
||||
// during dispatchEvent, which is AFTER hit-test, so it is too late to influence
|
||||
// the DOWN routing.
|
||||
//
|
||||
// The single reliable, fully-public seam is `GestureBinding.handlePointerEvent`
|
||||
// (virtual), which is called with the live event immediately before the
|
||||
// synchronous hit-test of that same event. [PenCaptureBinding] overrides it to
|
||||
// stash the in-flight pointer kind into [PenCaptureBinding.currentPointerKind]
|
||||
// before delegating to super; `_RenderPenCapture.hitTest` reads that field. The
|
||||
// app must install [PenCaptureBinding] in main() (see spike_main.dart). If a
|
||||
// non-PenCapture binding is in use, [currentPointerKind] stays null and the
|
||||
// region defaults to NOT capturing — so touch scrolling is never accidentally
|
||||
// stolen.
|
||||
//
|
||||
// The `captureEnabled` flag gates capture by editor mode (plan §6.1: Browse
|
||||
// never captures pen; Draw/Type do). When disabled the box is hit-test
|
||||
// transparent for all kinds.
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Pointer kinds this region captures (plan §3.5 / §6.2: pen only; mouse is
|
||||
/// never captured for drawing — pen required).
|
||||
const Set<PointerDeviceKind> kPenCaptureKinds = {
|
||||
PointerDeviceKind.stylus,
|
||||
PointerDeviceKind.invertedStylus,
|
||||
};
|
||||
|
||||
/// A [WidgetsFlutterBinding] that records the device kind of the pointer event
|
||||
/// currently being routed, so that kind-gated hit-testing
|
||||
/// ([PenCaptureRegion]) can consult it during the synchronous DOWN hit-test.
|
||||
///
|
||||
/// Install in main():
|
||||
/// ```dart
|
||||
/// void main() {
|
||||
/// PenCaptureBinding.ensureInitialized();
|
||||
/// pdfrxFlutterInitialize();
|
||||
/// runApp(const SpikeApp());
|
||||
/// }
|
||||
/// ```
|
||||
class PenCaptureBinding extends WidgetsFlutterBinding {
|
||||
/// The device kind of the pointer event currently being handled by
|
||||
/// [handlePointerEvent], valid for the duration of the synchronous hit-test
|
||||
/// the framework performs for that event. Null when no binding override is
|
||||
/// active or between events.
|
||||
static PointerDeviceKind? currentPointerKind;
|
||||
|
||||
/// Ensures a [PenCaptureBinding] is the active binding and returns it.
|
||||
static WidgetsBinding ensureInitialized() {
|
||||
PenCaptureBinding();
|
||||
return WidgetsBinding.instance;
|
||||
}
|
||||
|
||||
@override
|
||||
void handlePointerEvent(PointerEvent event) {
|
||||
// Set the kind BEFORE super performs the synchronous hit-test for a DOWN.
|
||||
currentPointerKind = event.kind;
|
||||
try {
|
||||
super.handlePointerEvent(event);
|
||||
} finally {
|
||||
// Leave currentPointerKind set to the last event's kind; it is only read
|
||||
// transiently during hit-test (which happens inside super for DOWNs).
|
||||
// We do not null it here because moves reuse the cached hit path and never
|
||||
// re-hit-test, so staleness between events is harmless.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A transparent capture region that forwards the full pointer stream for
|
||||
/// stylus/invertedStylus pointers to [onPenEvent] while letting touch and mouse
|
||||
/// pointers fall through to whatever is painted underneath (typically a pdfrx
|
||||
/// `PdfViewer`).
|
||||
///
|
||||
/// See the file header for the arena-bypass rationale and binding requirement.
|
||||
class PenCaptureRegion extends SingleChildRenderObjectWidget {
|
||||
const PenCaptureRegion({
|
||||
super.key,
|
||||
required this.onPenEvent,
|
||||
this.captureEnabled = true,
|
||||
required Widget child,
|
||||
}) : super(child: child);
|
||||
|
||||
/// Called with every [PointerEvent] (down/move/up/cancel) of a captured pen
|
||||
/// pointer, delivered raw and in order without arena arbitration.
|
||||
final void Function(PointerEvent event) onPenEvent;
|
||||
|
||||
/// When false the region is hit-test transparent for all pointer kinds, so
|
||||
/// even pen events fall through to the viewer underneath (e.g. Browse mode).
|
||||
final bool captureEnabled;
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) {
|
||||
return _RenderPenCapture(
|
||||
onPenEvent: onPenEvent,
|
||||
captureEnabled: captureEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateRenderObject(BuildContext context, RenderObject renderObject) {
|
||||
(renderObject as _RenderPenCapture)
|
||||
..onPenEvent = onPenEvent
|
||||
..captureEnabled = captureEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
/// RenderProxyBox that conditionally participates in hit-testing based on the
|
||||
/// in-flight pointer's device kind (via [PenCaptureBinding.currentPointerKind])
|
||||
/// and forwards captured pen events.
|
||||
class _RenderPenCapture extends RenderProxyBox {
|
||||
_RenderPenCapture({
|
||||
required void Function(PointerEvent) onPenEvent,
|
||||
required bool captureEnabled,
|
||||
}) : _onPenEvent = onPenEvent,
|
||||
_captureEnabled = captureEnabled;
|
||||
|
||||
void Function(PointerEvent) _onPenEvent;
|
||||
set onPenEvent(void Function(PointerEvent) value) => _onPenEvent = value;
|
||||
|
||||
bool _captureEnabled;
|
||||
set captureEnabled(bool value) => _captureEnabled = value;
|
||||
|
||||
/// Returns true only for pen kinds while capture is enabled. Returning false
|
||||
/// continues the hit-test to the pdfrx viewer underneath, which is how
|
||||
/// touch/mouse reach it for scroll/zoom.
|
||||
///
|
||||
/// We override [hitTest] directly (rather than relying on
|
||||
/// [hitTestChildren]+[hitTestSelf]) so the kind check is the single decision
|
||||
/// point and covers the entire box area, regardless of the translucent paint
|
||||
/// child.
|
||||
@override
|
||||
bool hitTest(BoxHitTestResult result, {required Offset position}) {
|
||||
if (!_captureEnabled) return false;
|
||||
if (!_shouldCaptureCurrentPointer()) return false;
|
||||
if (!size.contains(position)) return false;
|
||||
result.add(BoxHitTestEntry(this, position));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Mirrors the predicate for any caller routing through the default
|
||||
/// RenderBox.hitTest dispatch (hitTestChildren then hitTestSelf).
|
||||
@override
|
||||
bool hitTestSelf(Offset position) =>
|
||||
_captureEnabled && _shouldCaptureCurrentPointer();
|
||||
|
||||
bool _shouldCaptureCurrentPointer() {
|
||||
final kind = PenCaptureBinding.currentPointerKind;
|
||||
if (kind == null) return false; // No kind-aware binding → never steal touch.
|
||||
return kPenCaptureKinds.contains(kind);
|
||||
}
|
||||
|
||||
@override
|
||||
void handleEvent(PointerEvent event, HitTestEntry entry) {
|
||||
if (!_captureEnabled) return;
|
||||
if (!kPenCaptureKinds.contains(event.kind)) return;
|
||||
_onPenEvent(event);
|
||||
}
|
||||
}
|
||||
187
lib/editor/pdf/spike_app.dart
Normal file
187
lib/editor/pdf/spike_app.dart
Normal file
@@ -0,0 +1,187 @@
|
||||
// lib/editor/pdf/spike_app.dart
|
||||
//
|
||||
// THROWAWAY M1 spike app shell (plan §10). Wraps [SpikeEditorPane] with an
|
||||
// on-screen frame-timing HUD (median build & raster ms over the last ~120
|
||||
// frames) and an ink-load toggle, so MUST #4/#5 are observable on-device when
|
||||
// launched via `flutter run -t lib/editor/pdf/spike_main.dart` on the tablet.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
|
||||
import 'spike_editor_pane.dart';
|
||||
|
||||
class SpikeApp extends StatelessWidget {
|
||||
const SpikeApp({super.key, required this.pdfPath, this.denseStrokesAsset});
|
||||
|
||||
final String pdfPath;
|
||||
final String? denseStrokesAsset;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'BadNote M1 Spike',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.indigo),
|
||||
home: SpikeHome(
|
||||
pdfPath: pdfPath,
|
||||
denseStrokesAsset: denseStrokesAsset,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SpikeHome extends StatefulWidget {
|
||||
const SpikeHome({super.key, required this.pdfPath, this.denseStrokesAsset});
|
||||
|
||||
final String pdfPath;
|
||||
final String? denseStrokesAsset;
|
||||
|
||||
@override
|
||||
State<SpikeHome> createState() => _SpikeHomeState();
|
||||
}
|
||||
|
||||
class _SpikeHomeState extends State<SpikeHome> {
|
||||
final GlobalKey<SpikeEditorPaneState> _paneKey =
|
||||
GlobalKey<SpikeEditorPaneState>();
|
||||
final PdfViewerController _controller = PdfViewerController();
|
||||
bool _inkLoad = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
SpikeEditorPane(
|
||||
key: _paneKey,
|
||||
controller: _controller,
|
||||
pdfPath: widget.pdfPath,
|
||||
denseStrokesAsset: widget.denseStrokesAsset,
|
||||
),
|
||||
const Positioned(top: 8, left: 8, child: FrameTimingHud()),
|
||||
],
|
||||
),
|
||||
floatingActionButton: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
FloatingActionButton.extended(
|
||||
heroTag: 'inkload',
|
||||
onPressed: () async {
|
||||
final next = !_inkLoad;
|
||||
await _paneKey.currentState?.setInkLoad(next);
|
||||
setState(() => _inkLoad = next);
|
||||
},
|
||||
label: Text(_inkLoad ? 'Ink load: ON' : 'Ink load: OFF'),
|
||||
icon: const Icon(Icons.brush),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// On-screen median build/raster frame-time HUD, driven by
|
||||
/// [SchedulerBinding.addTimingsCallback]. Shows the median of the last
|
||||
/// [_window] frames for both the build (`buildDuration`) and raster
|
||||
/// (`rasterDuration`) phases — the two halves of the 16.6ms budget tracked by
|
||||
/// MUST #4/#5.
|
||||
class FrameTimingHud extends StatefulWidget {
|
||||
const FrameTimingHud({super.key});
|
||||
|
||||
@override
|
||||
State<FrameTimingHud> createState() => _FrameTimingHudState();
|
||||
}
|
||||
|
||||
class _FrameTimingHudState extends State<FrameTimingHud> {
|
||||
static const int _window = 120;
|
||||
final List<double> _build = <double>[];
|
||||
final List<double> _raster = <double>[];
|
||||
double _medBuild = 0;
|
||||
double _medRaster = 0;
|
||||
double _p95Build = 0;
|
||||
double _p95Raster = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
SchedulerBinding.instance.addTimingsCallback(_onTimings);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
SchedulerBinding.instance.removeTimingsCallback(_onTimings);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onTimings(List<FrameTiming> timings) {
|
||||
for (final t in timings) {
|
||||
_build.add(t.buildDuration.inMicroseconds / 1000.0);
|
||||
_raster.add(t.rasterDuration.inMicroseconds / 1000.0);
|
||||
}
|
||||
while (_build.length > _window) {
|
||||
_build.removeAt(0);
|
||||
}
|
||||
while (_raster.length > _window) {
|
||||
_raster.removeAt(0);
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_medBuild = _percentile(_build, 50);
|
||||
_medRaster = _percentile(_raster, 50);
|
||||
_p95Build = _percentile(_build, 95);
|
||||
_p95Raster = _percentile(_raster, 95);
|
||||
});
|
||||
}
|
||||
|
||||
static double _percentile(List<double> values, int p) {
|
||||
if (values.isEmpty) return 0;
|
||||
final sorted = List<double>.from(values)..sort();
|
||||
final idx = ((p / 100.0) * (sorted.length - 1)).round();
|
||||
return sorted[idx.clamp(0, sorted.length - 1)];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Color budget(double ms) => ms <= 16.6
|
||||
? Colors.greenAccent
|
||||
: (ms <= 22 ? Colors.amberAccent : Colors.redAccent);
|
||||
return IgnorePointer(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.65),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: DefaultTextStyle(
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: Colors.white,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('frames: ${_build.length}/$_window'),
|
||||
Text.rich(TextSpan(children: [
|
||||
const TextSpan(text: 'build med '),
|
||||
TextSpan(
|
||||
text: '${_medBuild.toStringAsFixed(1)}ms',
|
||||
style: TextStyle(color: budget(_medBuild))),
|
||||
TextSpan(text: ' p95 ${_p95Build.toStringAsFixed(1)}ms'),
|
||||
])),
|
||||
Text.rich(TextSpan(children: [
|
||||
const TextSpan(text: 'raster med '),
|
||||
TextSpan(
|
||||
text: '${_medRaster.toStringAsFixed(1)}ms',
|
||||
style: TextStyle(color: budget(_medRaster))),
|
||||
TextSpan(text: ' p95 ${_p95Raster.toStringAsFixed(1)}ms'),
|
||||
])),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
344
lib/editor/pdf/spike_editor_pane.dart
Normal file
344
lib/editor/pdf/spike_editor_pane.dart
Normal file
@@ -0,0 +1,344 @@
|
||||
// lib/editor/pdf/spike_editor_pane.dart
|
||||
//
|
||||
// THROWAWAY M1 spike widget (plan §10 / MUST #2, #4, #5). Hosts a pdfrx
|
||||
// PdfViewer.file and exercises the three things the M1 gate must prove:
|
||||
//
|
||||
// 1. Coordinate correctness (MUST #2): a `pageOverlaysBuilder` paints a
|
||||
// diagnostic crosshair at normalized (0.5, 0.5) using
|
||||
// `canvas.scale(size.width, size.height)`, with the CustomPaint sized to
|
||||
// `pageRect.size` (plan §2.1). This dot MUST sit at the visual page center
|
||||
// at every zoom level. `coordinate_assertion_test.dart` asserts this.
|
||||
//
|
||||
// 2. Pen/touch arbitration (MUST #3): a `viewerOverlayBuilder` wraps a
|
||||
// `PenCaptureRegion` so pen events draw a live viewer-level stroke while
|
||||
// touch scrolls and pinch zooms — same overlay, no mode switch.
|
||||
//
|
||||
// 3. Ink-overlay build cost (MUST #5): a toggle injects ~N synthetic strokes
|
||||
// per page (from dense_strokes.json) into the page overlay so the perf
|
||||
// bench can measure BUILD time with a non-trivial ui.Picture per page.
|
||||
//
|
||||
// This file is NOT production code and is excluded from the real editor.
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
|
||||
import 'pen_capture_region.dart';
|
||||
|
||||
/// Normalized page-space point the diagnostic marker is painted at. The M1
|
||||
/// coordinate assertion checks this maps to the page-center pixel at all zooms.
|
||||
const Offset kMarkerNormalized = Offset(0.5, 0.5);
|
||||
|
||||
/// A single captured pen sample in normalized page space, tagged with its page.
|
||||
class _PenSample {
|
||||
const _PenSample(this.pageIndex, this.normalized);
|
||||
final int pageIndex;
|
||||
final Offset normalized;
|
||||
}
|
||||
|
||||
/// Spike editor pane. Provide a [pdfPath] to a local PDF (e.g.
|
||||
/// test/assets/large_300p.pdf). [denseStrokesAsset] is a filesystem PATH to the
|
||||
/// synthetic ink load (MUST #5); if null the ink-load toggle is inert.
|
||||
class SpikeEditorPane extends StatefulWidget {
|
||||
const SpikeEditorPane({
|
||||
super.key,
|
||||
required this.pdfPath,
|
||||
this.denseStrokesAsset,
|
||||
this.strokesPerPage = 300,
|
||||
this.strokeCountKey = '2000',
|
||||
this.onViewerReady,
|
||||
this.controller,
|
||||
});
|
||||
|
||||
final String pdfPath;
|
||||
final String? denseStrokesAsset;
|
||||
final int strokesPerPage;
|
||||
|
||||
/// Which top-level array in dense_strokes.json to draw from ("2000"/"5000").
|
||||
final String strokeCountKey;
|
||||
|
||||
/// Forwarded from pdfrx once the document is laid out and interactive.
|
||||
final void Function(PdfDocument document, PdfViewerController controller)?
|
||||
onViewerReady;
|
||||
|
||||
/// Optional externally-owned controller (tests drive zoom through this).
|
||||
final PdfViewerController? controller;
|
||||
|
||||
@override
|
||||
State<SpikeEditorPane> createState() => SpikeEditorPaneState();
|
||||
}
|
||||
|
||||
class SpikeEditorPaneState extends State<SpikeEditorPane> {
|
||||
late final PdfViewerController _controller =
|
||||
widget.controller ?? PdfViewerController();
|
||||
|
||||
/// Live pen strokes captured via PenCaptureRegion (viewer-level overlay).
|
||||
final List<List<_PenSample>> _penStrokes = <List<_PenSample>>[];
|
||||
List<_PenSample>? _activeStroke;
|
||||
|
||||
/// Synthetic strokes for the ink-load gate, lazily loaded. Each entry is a
|
||||
/// list of normalized polylines (one stroke = list of points).
|
||||
List<List<Offset>>? _syntheticStrokes;
|
||||
bool _inkLoadEnabled = false;
|
||||
bool _loadingSynthetic = false;
|
||||
|
||||
bool get inkLoadEnabled => _inkLoadEnabled;
|
||||
|
||||
/// Toggle the dense synthetic-ink overlay (MUST #5). Loads the asset on first
|
||||
/// enable. Public so the perf bench can drive it programmatically.
|
||||
Future<void> setInkLoad(bool enabled) async {
|
||||
if (enabled && _syntheticStrokes == null) {
|
||||
await _loadSyntheticStrokes();
|
||||
}
|
||||
if (mounted) setState(() => _inkLoadEnabled = enabled);
|
||||
}
|
||||
|
||||
Future<void> _loadSyntheticStrokes() async {
|
||||
final asset = widget.denseStrokesAsset;
|
||||
if (asset == null || _loadingSynthetic) return;
|
||||
_loadingSynthetic = true;
|
||||
try {
|
||||
// [asset] is a filesystem path (e.g. test/assets/dense_strokes.json),
|
||||
// not a bundled rootBundle key — regenerate via tool/gen_dense_strokes.dart.
|
||||
final raw = await File(asset).readAsString();
|
||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
||||
final strokesJson =
|
||||
(decoded[widget.strokeCountKey] as List<dynamic>? ?? const []);
|
||||
final result = <List<Offset>>[];
|
||||
for (final s in strokesJson) {
|
||||
final points = (s as Map<String, dynamic>)['points'] as List<dynamic>;
|
||||
final poly = <Offset>[];
|
||||
for (final p in points) {
|
||||
final pt = p as Map<String, dynamic>;
|
||||
poly.add(Offset(
|
||||
(pt['x'] as num).toDouble(),
|
||||
(pt['y'] as num).toDouble(),
|
||||
));
|
||||
}
|
||||
if (poly.length >= 2) result.add(poly);
|
||||
}
|
||||
_syntheticStrokes = result;
|
||||
} finally {
|
||||
_loadingSynthetic = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Pen capture (viewer-level) ---------------------------------------
|
||||
|
||||
void _onPenEvent(PointerEvent event) {
|
||||
// Convert global → document → which page + normalized page coords.
|
||||
final doc = _controller.globalToDocument(event.position);
|
||||
if (doc == null) return;
|
||||
final hit = _documentToPage(doc);
|
||||
if (hit == null) return;
|
||||
|
||||
if (event is PointerDownEvent) {
|
||||
_activeStroke = <_PenSample>[hit];
|
||||
_penStrokes.add(_activeStroke!);
|
||||
setState(() {});
|
||||
} else if (event is PointerMoveEvent) {
|
||||
_activeStroke?.add(hit);
|
||||
setState(() {});
|
||||
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
|
||||
_activeStroke = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a document-space point to (pageIndex, normalized-in-page) using the
|
||||
/// controller's page layout rects (document coordinates). Returns null if the
|
||||
/// point is outside every page box.
|
||||
_PenSample? _documentToPage(Offset doc) {
|
||||
if (!_controller.isReady) return null;
|
||||
final rects = _controller.layout.pageLayouts;
|
||||
for (var i = 0; i < rects.length; i++) {
|
||||
final r = rects[i];
|
||||
if (r.contains(doc)) {
|
||||
final nx = ((doc.dx - r.left) / r.width).clamp(0.0, 1.0);
|
||||
final ny = ((doc.dy - r.top) / r.height).clamp(0.0, 1.0);
|
||||
return _PenSample(i, Offset(nx, ny));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
PdfViewer.file(
|
||||
widget.pdfPath,
|
||||
controller: _controller,
|
||||
params: PdfViewerParams(
|
||||
onViewerReady: widget.onViewerReady,
|
||||
// (1) Per-page overlay: diagnostic center marker + optional synthetic
|
||||
// ink. CustomPaint is sized to pageRect.size so canvas.scale maps
|
||||
// normalized [0,1] → zoomed pixels (plan §2.1).
|
||||
pageOverlaysBuilder: (context, pageRectInViewer, page) {
|
||||
final pageIndex = page.pageNumber - 1;
|
||||
return [
|
||||
SizedBox.fromSize(
|
||||
size: pageRectInViewer.size,
|
||||
child: CustomPaint(
|
||||
painter: _SpikeInkPainter(
|
||||
synthetic:
|
||||
_inkLoadEnabled ? _strokesForPage(pageIndex) : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
// (2) Viewer-level overlay: pen capture + live pen rendering. Touch
|
||||
// falls through to pdfrx for scroll/zoom (per-kind hit-test split).
|
||||
viewerOverlayBuilder: (context, size, handleLinkTap) {
|
||||
return [
|
||||
Positioned.fill(
|
||||
child: PenCaptureRegion(
|
||||
onPenEvent: _onPenEvent,
|
||||
child: IgnorePointer(
|
||||
child: CustomPaint(
|
||||
size: size,
|
||||
painter: _LivePenPainter(
|
||||
strokes: _penStrokes,
|
||||
controller: _controller,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Deterministic per-page slice of the synthetic stroke pool so each page
|
||||
/// shows ~[widget.strokesPerPage] strokes without loading 300× the data.
|
||||
List<List<Offset>> _strokesForPage(int pageIndex) {
|
||||
final pool = _syntheticStrokes;
|
||||
if (pool == null || pool.isEmpty) return const [];
|
||||
final n = widget.strokesPerPage.clamp(0, pool.length);
|
||||
final start = (pageIndex * n) % pool.length;
|
||||
final out = <List<Offset>>[];
|
||||
for (var i = 0; i < n; i++) {
|
||||
out.add(pool[(start + i) % pool.length]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Note: PdfViewerController is not a Listenable/ChangeNotifier we own a
|
||||
// lifecycle for; pdfrx attaches/detaches it via the PdfViewer. No dispose().
|
||||
}
|
||||
|
||||
/// Paints the diagnostic center marker (always) plus synthetic ink (when the
|
||||
/// MUST #5 load is enabled), in normalized [0,1] page space scaled to the
|
||||
/// CustomPaint size (== zoomed page box). This is what the coordinate assertion
|
||||
/// inspects.
|
||||
class _SpikeInkPainter extends CustomPainter {
|
||||
_SpikeInkPainter({this.synthetic});
|
||||
|
||||
final List<List<Offset>>? synthetic;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
canvas.save();
|
||||
// Map normalized [0,1] → zoomed pixels (plan §2.1).
|
||||
canvas.scale(size.width, size.height);
|
||||
|
||||
// Synthetic ink load (MUST #5): a non-trivial set of polylines per page.
|
||||
final syn = synthetic;
|
||||
if (syn != null && syn.isNotEmpty) {
|
||||
final inkPaint = Paint()
|
||||
..color = const Color(0x5500AAFF)
|
||||
..style = PaintingStyle.stroke
|
||||
// Stroke width is in normalized units post-scale; keep it page-relative
|
||||
// and hairline-ish so 300 strokes are visible but cheap.
|
||||
..strokeWidth = 0.002
|
||||
..strokeCap = StrokeCap.round;
|
||||
for (final poly in syn) {
|
||||
if (poly.length < 2) continue;
|
||||
final path = Path()..moveTo(poly.first.dx, poly.first.dy);
|
||||
for (var i = 1; i < poly.length; i++) {
|
||||
path.lineTo(poly[i].dx, poly[i].dy);
|
||||
}
|
||||
canvas.drawPath(path, inkPaint);
|
||||
}
|
||||
}
|
||||
|
||||
canvas.restore();
|
||||
|
||||
// Diagnostic crosshair at normalized (0.5,0.5) — drawn in PIXEL space (after
|
||||
// restore) so its line thickness is constant on screen and its CENTER is at
|
||||
// exactly size.width*0.5, size.height*0.5. The coordinate assertion checks
|
||||
// this pixel.
|
||||
final center = Offset(
|
||||
size.width * kMarkerNormalized.dx,
|
||||
size.height * kMarkerNormalized.dy,
|
||||
);
|
||||
final markerPaint = Paint()
|
||||
..color = const Color(0xFFFF0066)
|
||||
..strokeWidth = 2.0
|
||||
..style = PaintingStyle.stroke;
|
||||
const arm = 16.0;
|
||||
canvas.drawLine(
|
||||
center.translate(-arm, 0), center.translate(arm, 0), markerPaint);
|
||||
canvas.drawLine(
|
||||
center.translate(0, -arm), center.translate(0, arm), markerPaint);
|
||||
canvas.drawCircle(center, 3.0, Paint()..color = const Color(0xFFFF0066));
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _SpikeInkPainter oldDelegate) =>
|
||||
oldDelegate.synthetic != synthetic;
|
||||
}
|
||||
|
||||
/// Paints live pen strokes captured by the PenCaptureRegion. Strokes are stored
|
||||
/// in normalized page space, so for each sample we re-project page→document→
|
||||
/// local each paint via the controller (keeps strokes glued to pages under
|
||||
/// scroll/zoom — the §2.1 property, exercised at the viewer level here).
|
||||
class _LivePenPainter extends CustomPainter {
|
||||
_LivePenPainter({required this.strokes, required this.controller})
|
||||
: super(repaint: controller);
|
||||
|
||||
final List<List<_PenSample>> strokes;
|
||||
final PdfViewerController controller;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (!controller.isReady) return;
|
||||
final rects = controller.layout.pageLayouts;
|
||||
final paint = Paint()
|
||||
..color = const Color(0xFF1565C0)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 3.0
|
||||
..strokeCap = StrokeCap.round
|
||||
..strokeJoin = StrokeJoin.round;
|
||||
|
||||
for (final stroke in strokes) {
|
||||
Path? path;
|
||||
for (final s in stroke) {
|
||||
if (s.pageIndex >= rects.length) continue;
|
||||
final r = rects[s.pageIndex];
|
||||
// normalized page → document
|
||||
final docPt = Offset(
|
||||
r.left + s.normalized.dx * r.width,
|
||||
r.top + s.normalized.dy * r.height,
|
||||
);
|
||||
// document → local (viewer) coords
|
||||
final local = controller.documentToLocal(docPt);
|
||||
if (path == null) {
|
||||
path = Path()..moveTo(local.dx, local.dy);
|
||||
} else {
|
||||
path.lineTo(local.dx, local.dy);
|
||||
}
|
||||
}
|
||||
if (path != null) canvas.drawPath(path, paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _LivePenPainter oldDelegate) => true;
|
||||
}
|
||||
34
lib/editor/pdf/spike_launcher.dart
Normal file
34
lib/editor/pdf/spike_launcher.dart
Normal file
@@ -0,0 +1,34 @@
|
||||
// lib/editor/pdf/spike_launcher.dart
|
||||
//
|
||||
// THROWAWAY M1 entry: lets the user open the pdfrx pen/perf spike from the
|
||||
// running app (so the CI-built Windows package can exercise MUST #3/#4/#5 on a
|
||||
// real Surface Pen with the user's OWN large PDFs). Remove together with the
|
||||
// rest of lib/editor/pdf/spike_* once M1 is signed off.
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'spike_editor_pane.dart';
|
||||
|
||||
/// Opens a file picker for a PDF, then pushes the spike pane on it.
|
||||
/// Pass the user's own large PDF to get a realistic 60fps / pen test.
|
||||
Future<void> openM1Spike(BuildContext context) async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['pdf'],
|
||||
);
|
||||
final path = result?.files.single.path;
|
||||
if (path == null) return;
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => Scaffold(
|
||||
appBar: AppBar(title: const Text('M1 Spike — pen / scroll / zoom')),
|
||||
// denseStrokesAsset is null here: the bundled synthetic-stroke load is
|
||||
// only for the perf bench. For manual on-device testing, draw real ink
|
||||
// and scroll a real large PDF while watching the frame-time HUD.
|
||||
body: SpikeEditorPane(pdfPath: path),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
62
lib/editor/pdf/spike_main.dart
Normal file
62
lib/editor/pdf/spike_main.dart
Normal file
@@ -0,0 +1,62 @@
|
||||
// lib/editor/pdf/spike_main.dart
|
||||
//
|
||||
// Standalone entry point for the THROWAWAY M1 pdfrx spike (plan §10).
|
||||
//
|
||||
// Launch on the Windows tablet (or any desktop with a display):
|
||||
// flutter run -t lib/editor/pdf/spike_main.dart
|
||||
//
|
||||
// It opens test/assets/large_300p.pdf in [SpikeEditorPane] with the
|
||||
// frame-timing HUD and ink-load toggle, so the M1 perf/pen gates are
|
||||
// observable on-device.
|
||||
//
|
||||
// IMPORTANT: pen capture requires the kind-aware [PenCaptureBinding] (installed
|
||||
// below before pdfrx init). pdfrx itself is initialized via
|
||||
// pdfrxFlutterInitialize() — confirmed from pdfrx 2.4.4 example/pdf_combine.
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
|
||||
import 'pen_capture_region.dart';
|
||||
import 'spike_app.dart';
|
||||
|
||||
/// Default benchmark asset (300-page PDF generated by tool/gen_bench_pdf.dart).
|
||||
const String _kDefaultPdfRelPath = 'test/assets/large_300p.pdf';
|
||||
|
||||
/// Filesystem path for the synthetic ink load (regenerate via
|
||||
/// tool/gen_dense_strokes.dart; not bundled — read from disk at the project root).
|
||||
const String _kDenseStrokesAsset = 'test/assets/dense_strokes.json';
|
||||
|
||||
void main(List<String> args) {
|
||||
// Kind-aware binding MUST be installed before runApp so PenCaptureRegion can
|
||||
// gate hit-testing by pointer kind (see pen_capture_region.dart header).
|
||||
PenCaptureBinding.ensureInitialized();
|
||||
// pdfrx native engine init (pdfrx 2.4.4 example pattern).
|
||||
pdfrxFlutterInitialize();
|
||||
|
||||
// Allow overriding the PDF path as the first CLI arg (otherwise the default
|
||||
// 300-page bench asset relative to the project root / cwd).
|
||||
final pdfPath = args.isNotEmpty ? args.first : _resolvePdfPath();
|
||||
|
||||
runApp(
|
||||
SpikeApp(
|
||||
pdfPath: pdfPath,
|
||||
denseStrokesAsset: _kDenseStrokesAsset,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Resolve the bench PDF path. `flutter run` sets cwd to the project root, so
|
||||
/// the relative asset path works on desktop; we also try a couple of fallbacks.
|
||||
String _resolvePdfPath() {
|
||||
final candidates = <String>[
|
||||
_kDefaultPdfRelPath,
|
||||
'${Directory.current.path}/$_kDefaultPdfRelPath',
|
||||
];
|
||||
for (final c in candidates) {
|
||||
if (File(c).existsSync()) return c;
|
||||
}
|
||||
// Return the primary path anyway; pdfrx will surface a clear load error.
|
||||
return _kDefaultPdfRelPath;
|
||||
}
|
||||
@@ -1,14 +1,22 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'editor/pdf/pen_capture_region.dart';
|
||||
import 'providers/settings_provider.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
import 'services/database_service.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
// Kind-aware binding (extends WidgetsFlutterBinding) must be the active
|
||||
// binding before runApp so the M1 spike's PenCaptureRegion can gate
|
||||
// hit-testing by pointer kind. Safe for the rest of the app: with no pen
|
||||
// region mounted it behaves exactly like the default binding.
|
||||
PenCaptureBinding.ensureInitialized();
|
||||
// pdfrx native engine init (required before any PdfViewer is built).
|
||||
pdfrxFlutterInitialize();
|
||||
|
||||
// Ensure DB is ready before the app starts so providers can use it eagerly
|
||||
await DatabaseService.getInstance();
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../models/note.dart';
|
||||
import '../providers/document_provider.dart';
|
||||
import '../providers/note_provider.dart';
|
||||
import '../providers/ocr_provider.dart';
|
||||
import '../editor/pdf/spike_launcher.dart';
|
||||
import '../services/pdf_service.dart';
|
||||
import '../services/pptx_service.dart';
|
||||
import 'note_editor_screen.dart';
|
||||
@@ -67,6 +68,12 @@ class HomeScreen extends ConsumerWidget {
|
||||
).push(MaterialPageRoute(builder: (_) => const SearchScreen()));
|
||||
},
|
||||
),
|
||||
// THROWAWAY M1 spike entry (remove with lib/editor/pdf/spike_*).
|
||||
IconButton(
|
||||
icon: const Icon(Icons.science_outlined),
|
||||
tooltip: 'M1 Spike (pen/scroll/zoom test)',
|
||||
onPressed: () => openM1Spike(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
|
||||
105
pubspec.lock
105
pubspec.lock
@@ -25,6 +25,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.4"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: archive
|
||||
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.9"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -318,6 +326,11 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
flutter_driver:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -384,6 +397,11 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
fuchsia_remote_debug_protocol:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -440,6 +458,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
image:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image
|
||||
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.8.0"
|
||||
image_picker:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -504,6 +530,11 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2"
|
||||
integration_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
intl:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -712,6 +743,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
pdfium_dart:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pdfium_dart
|
||||
sha256: "86e95c66b09f3245b95c4924f2edce8d4f7c9786876e5c5ee8e36b104a94bbb0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.5"
|
||||
pdfium_flutter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pdfium_flutter
|
||||
sha256: "420ba8e7673b54da387ceeeb18a72c8bc6e4452128dbb391dca900c748f9e9ba"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2"
|
||||
pdfrx:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: pdfrx
|
||||
sha256: e0ca318004c3f32144db8e74fa612abb80ebe79004677293af74ed9af119f47f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.4"
|
||||
pdfrx_engine:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pdfrx_engine
|
||||
sha256: "89865e158ced818690ab207a13a34a65803cd67ee1bec9f5f87838eb5a79600b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.3"
|
||||
perfect_freehand:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -752,6 +815,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.2"
|
||||
posix:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: posix
|
||||
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.5.0"
|
||||
process:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: process
|
||||
sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.5"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -808,6 +887,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.4"
|
||||
rxdart:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: rxdart
|
||||
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.28.0"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1005,6 +1092,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
sync_http:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sync_http
|
||||
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.1"
|
||||
syncfusion_flutter_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1229,6 +1324,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
webdriver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webdriver
|
||||
sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1271,4 +1374,4 @@ packages:
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.10.8 <4.0.0"
|
||||
flutter: ">=3.38.4"
|
||||
flutter: ">=3.41.0"
|
||||
|
||||
@@ -50,6 +50,7 @@ dependencies:
|
||||
|
||||
# Embedded ONNX runtime (local OCR recognition backend)
|
||||
flutter_onnxruntime: ^1.8.0
|
||||
pdfrx: ^2.4.4
|
||||
|
||||
# Pin sqlite3 to the exact version whose native binaries are vendored under
|
||||
# vendor/sqlite3/ (see hooks block below). Without this, pub re-resolves to the
|
||||
@@ -61,6 +62,9 @@ dependency_overrides:
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
# M1 spike perf/coordinate gates (integration_test harness).
|
||||
integration_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
# Code Generation
|
||||
|
||||
16
test_driver/integration_test.dart
Normal file
16
test_driver/integration_test.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
// test_driver/integration_test.dart
|
||||
//
|
||||
// Driver entry point for running the M1 integration_test gates on a real device
|
||||
// (Windows tablet) in profile mode, e.g.:
|
||||
//
|
||||
// flutter drive --profile \
|
||||
// --driver=test_driver/integration_test.dart \
|
||||
// --target=integration_test/perf_scroll_bench.dart
|
||||
//
|
||||
// flutter drive \
|
||||
// --driver=test_driver/integration_test.dart \
|
||||
// --target=integration_test/coordinate_assertion_test.dart
|
||||
|
||||
import 'package:integration_test/integration_test_driver.dart';
|
||||
|
||||
Future<void> main() => integrationDriver();
|
||||
239
tool/gen_bench_pdf.dart
Normal file
239
tool/gen_bench_pdf.dart
Normal file
@@ -0,0 +1,239 @@
|
||||
// tool/gen_bench_pdf.dart
|
||||
//
|
||||
// Generates a benchmark PDF at test/assets/large_300p.pdf using
|
||||
// package:syncfusion_flutter_pdf.
|
||||
//
|
||||
// syncfusion_flutter_pdf imports dart:ui which is only available inside the
|
||||
// Flutter SDK runtime, so this file CANNOT be run via plain `dart run`.
|
||||
//
|
||||
// USAGE:
|
||||
// flutter test tool/gen_bench_pdf.dart
|
||||
// flutter test tool/gen_bench_pdf.dart --dart-define=PAGE_COUNT=50
|
||||
//
|
||||
// The script is structured as a flutter_test file (one `test(...)` block) so
|
||||
// that `flutter test` invokes it with the full Flutter engine (dart:ui present).
|
||||
// It is NOT a real unit test — it is a code-generation tool that happens to
|
||||
// need the Flutter runtime. The test "passes" as long as the file is written
|
||||
// successfully.
|
||||
//
|
||||
// Each page contains:
|
||||
// - A bold title (page number heading)
|
||||
// - Two paragraphs of body text
|
||||
// - A ruled grid of lines (10x10)
|
||||
// - A filled rectangle and an outlined ellipse
|
||||
// This gives a realistic (non-blank) render load for pdfrx frame-timing tests.
|
||||
|
||||
// ignore_for_file: avoid_print
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'dart:ui' show Offset, Rect, Size;
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:syncfusion_flutter_pdf/pdf.dart';
|
||||
|
||||
void main() {
|
||||
// Read PAGE_COUNT from --dart-define (default 300).
|
||||
const int pageCount = int.fromEnvironment('PAGE_COUNT', defaultValue: 300);
|
||||
|
||||
test('generate test/assets/large_300p.pdf ($pageCount pages)', () {
|
||||
final outputPath = _resolveOutputPath();
|
||||
final outFile = File(outputPath);
|
||||
outFile.parent.createSync(recursive: true);
|
||||
|
||||
final pdf = PdfDocument();
|
||||
|
||||
// Reusable fonts and brushes (created once, shared across pages).
|
||||
final titleFont = PdfStandardFont(PdfFontFamily.helvetica, 18,
|
||||
style: PdfFontStyle.bold);
|
||||
final bodyFont = PdfStandardFont(PdfFontFamily.helvetica, 10);
|
||||
final smallFont = PdfStandardFont(PdfFontFamily.helvetica, 8);
|
||||
|
||||
final blackBrush = PdfSolidBrush(PdfColor(0, 0, 0));
|
||||
final darkBlueBrush = PdfSolidBrush(PdfColor(10, 30, 80));
|
||||
final lightGrayBrush = PdfSolidBrush(PdfColor(220, 220, 220));
|
||||
final accentBrush = PdfSolidBrush(PdfColor(60, 100, 200));
|
||||
|
||||
final gridPen = PdfPen(PdfColor(180, 180, 180), width: 0.3);
|
||||
final borderPen = PdfPen(PdfColor(0, 0, 0), width: 1.0);
|
||||
final accentPen = PdfPen(PdfColor(60, 100, 200), width: 1.5);
|
||||
|
||||
final rng = Random(42); // deterministic
|
||||
|
||||
for (int i = 1; i <= pageCount; i++) {
|
||||
final page = pdf.pages.add();
|
||||
final g = page.graphics;
|
||||
final w = page.getClientSize().width;
|
||||
final h = page.getClientSize().height;
|
||||
|
||||
// ── Title ──────────────────────────────────────────────────────────────
|
||||
g.drawString(
|
||||
'BadNote Benchmark — Page $i of $pageCount',
|
||||
titleFont,
|
||||
brush: darkBlueBrush,
|
||||
bounds: Rect.fromLTWH(36, 30, w - 72, 28),
|
||||
);
|
||||
|
||||
// Horizontal rule under title
|
||||
g.drawLine(
|
||||
PdfPen(PdfColor(60, 100, 200), width: 1.0),
|
||||
Offset(36, 62),
|
||||
Offset(w - 36, 62),
|
||||
);
|
||||
|
||||
// ── Body text (two paragraphs) ────────────────────────────────────────
|
||||
final paragraph1 =
|
||||
'This page is part of a synthetic $pageCount-page benchmark PDF '
|
||||
'generated by BadNote\'s tool/gen_bench_pdf.dart. Each page carries '
|
||||
'non-trivial content (text, vector shapes, a line grid) to simulate '
|
||||
'realistic rendering load for pdfrx frame-timing measurements. '
|
||||
'Page index: $i. Seed value: ${rng.nextInt(99999)}.';
|
||||
|
||||
final paragraph2 =
|
||||
'Performance target (MUST #4, §10/M1): pdfrx fling-scroll over '
|
||||
'$pageCount pages in profile mode must stay at ≤ 16.6 ms median '
|
||||
'frame time (build + raster) and ≤ 22 ms at p95, measured over '
|
||||
'N ≥ 120 frames per §7.1 of the BadNote Phase 1 plan. If this gate '
|
||||
'fails the backend choice is invalidated. Fill: ${_lorem(rng, 60)}.';
|
||||
|
||||
g.drawString(
|
||||
paragraph1,
|
||||
bodyFont,
|
||||
brush: blackBrush,
|
||||
bounds: Rect.fromLTWH(36, 72, w - 72, 80),
|
||||
format: PdfStringFormat(lineSpacing: 4),
|
||||
);
|
||||
|
||||
g.drawString(
|
||||
paragraph2,
|
||||
bodyFont,
|
||||
brush: blackBrush,
|
||||
bounds: Rect.fromLTWH(36, 158, w - 72, 80),
|
||||
format: PdfStringFormat(lineSpacing: 4),
|
||||
);
|
||||
|
||||
// ── 10×10 ruled grid ──────────────────────────────────────────────────
|
||||
const gridLeft = 36.0;
|
||||
const gridTop = 260.0;
|
||||
final gridWidth = w - 72;
|
||||
const gridHeight = 220.0;
|
||||
const cols = 10;
|
||||
const rows = 10;
|
||||
final cellW = gridWidth / cols;
|
||||
const cellH = gridHeight / rows;
|
||||
|
||||
for (int col = 0; col <= cols; col++) {
|
||||
final x = gridLeft + col * cellW;
|
||||
g.drawLine(
|
||||
gridPen, Offset(x, gridTop), Offset(x, gridTop + gridHeight));
|
||||
}
|
||||
for (int row = 0; row <= rows; row++) {
|
||||
const y = gridTop;
|
||||
g.drawLine(gridPen, Offset(gridLeft, y + row * cellH),
|
||||
Offset(gridLeft + gridWidth, y + row * cellH));
|
||||
}
|
||||
|
||||
for (int row = 0; row < rows; row++) {
|
||||
for (int col = 0; col < cols; col++) {
|
||||
if ((row + col) % 3 == 0) {
|
||||
g.drawRectangle(
|
||||
brush: lightGrayBrush,
|
||||
bounds: Rect.fromLTWH(
|
||||
gridLeft + col * cellW + 0.5,
|
||||
gridTop + row * cellH + 0.5,
|
||||
cellW - 1,
|
||||
cellH - 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
g.drawRectangle(
|
||||
pen: borderPen,
|
||||
bounds: Rect.fromLTWH(gridLeft, gridTop, gridWidth, gridHeight),
|
||||
);
|
||||
|
||||
for (int row = 0; row < rows; row++) {
|
||||
g.drawString(
|
||||
'R${row + 1}',
|
||||
smallFont,
|
||||
brush: blackBrush,
|
||||
bounds: Rect.fromLTWH(
|
||||
gridLeft + 2,
|
||||
gridTop + row * cellH + 2,
|
||||
cellW - 4,
|
||||
cellH - 4,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Accent shapes ──────────────────────────────────────────────────────
|
||||
const shapeTop = gridTop + gridHeight + 18;
|
||||
final rectW = 60.0 + (i % 8) * 10.0;
|
||||
|
||||
g.drawRectangle(
|
||||
pen: accentPen,
|
||||
brush: accentBrush,
|
||||
bounds: Rect.fromLTWH(36, shapeTop, rectW, 24),
|
||||
);
|
||||
g.drawString(
|
||||
'Page $i',
|
||||
smallFont,
|
||||
brush: PdfSolidBrush(PdfColor(255, 255, 255)),
|
||||
bounds: Rect.fromLTWH(40, shapeTop + 6, rectW - 8, 14),
|
||||
);
|
||||
|
||||
g.drawEllipse(
|
||||
Rect.fromLTWH(36 + rectW + 16, shapeTop, 80, 24),
|
||||
pen: accentPen,
|
||||
);
|
||||
|
||||
// ── Footer ────────────────────────────────────────────────────────────
|
||||
g.drawString(
|
||||
'BadNote bench PDF • page $i/$pageCount • tool/gen_bench_pdf.dart',
|
||||
smallFont,
|
||||
brush: PdfSolidBrush(PdfColor(140, 140, 140)),
|
||||
bounds: Rect.fromLTWH(36, h - 28, w - 72, 18),
|
||||
format: PdfStringFormat(alignment: PdfTextAlignment.center),
|
||||
);
|
||||
}
|
||||
|
||||
final bytes = pdf.saveSync();
|
||||
pdf.dispose();
|
||||
|
||||
outFile.writeAsBytesSync(bytes);
|
||||
|
||||
final sizeKb = (outFile.lengthSync() / 1024).toStringAsFixed(1);
|
||||
print('Generated: $outputPath');
|
||||
print('Pages: $pageCount');
|
||||
print('Size: ${sizeKb} KB (${outFile.lengthSync()} bytes)');
|
||||
|
||||
expect(outFile.existsSync(), isTrue);
|
||||
expect(outFile.lengthSync(), greaterThan(1024),
|
||||
reason: 'PDF must be at least 1 KB');
|
||||
}, timeout: const Timeout(Duration(minutes: 5)));
|
||||
}
|
||||
|
||||
/// Resolves test/assets/large_300p.pdf relative to this script's location.
|
||||
/// tool/gen_bench_pdf.dart → project root → test/assets/large_300p.pdf
|
||||
String _resolveOutputPath() {
|
||||
// When run via `flutter test`, the CWD is the project root.
|
||||
return 'test/assets/large_300p.pdf';
|
||||
}
|
||||
|
||||
/// Generates a deterministic Lorem-Ipsum-style filler of roughly [words] words.
|
||||
String _lorem(Random rng, int words) {
|
||||
const vocab = [
|
||||
'lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur',
|
||||
'adipiscing', 'elit', 'sed', 'eiusmod', 'tempor', 'incididunt',
|
||||
'labore', 'dolore', 'magna', 'aliqua', 'enim', 'minim', 'veniam',
|
||||
'quis', 'nostrud', 'exercitation', 'ullamco', 'laboris', 'nisi',
|
||||
'aliquip', 'commodo', 'consequat', 'duis', 'aute', 'irure',
|
||||
'reprehenderit', 'voluptate', 'velit', 'esse', 'cillum', 'fugiat',
|
||||
'nulla', 'pariatur', 'excepteur', 'sint', 'occaecat', 'cupidatat',
|
||||
'proident', 'culpa', 'officia', 'deserunt', 'mollit', 'anim',
|
||||
];
|
||||
return List.generate(words, (_) => vocab[rng.nextInt(vocab.length)])
|
||||
.join(' ');
|
||||
}
|
||||
220
tool/gen_dense_strokes.dart
Normal file
220
tool/gen_dense_strokes.dart
Normal file
@@ -0,0 +1,220 @@
|
||||
// tool/gen_dense_strokes.dart
|
||||
//
|
||||
// Generates synthetic ink-stroke datasets as JSON matching InkStroke.toJson()
|
||||
// (from lib/models/ink_stroke.dart + lib/models/ink_point.dart) exactly.
|
||||
//
|
||||
// Output: test/assets/dense_strokes.json
|
||||
// Format:
|
||||
// {
|
||||
// "2000": [ ...2000 InkStroke objects... ],
|
||||
// "5000": [ ...5000 InkStroke objects... ]
|
||||
// }
|
||||
//
|
||||
// Each stroke:
|
||||
// - 8–20 InkPoint objects
|
||||
// - x/y ∈ [0,1] (normalized page space, matching InkStroke coordinate model)
|
||||
// - pressure ∈ [0.2, 1.0]
|
||||
// - tilt ∈ [0.0, 30.0] degrees
|
||||
// - pointerDeviceKind: "stylus" (surface pen benchmark)
|
||||
// - tool: "pen"
|
||||
// - color: varied from a palette of realistic ink colors
|
||||
// - strokeWidth: 1.0–4.0
|
||||
//
|
||||
// Usage:
|
||||
// dart run tool/gen_dense_strokes.dart # 2000 + 5000 (defaults)
|
||||
// dart run tool/gen_dense_strokes.dart 500 1000 # custom counts
|
||||
//
|
||||
// The counts are also the JSON keys (converted to strings).
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
void main(List<String> args) {
|
||||
final counts = args.isNotEmpty
|
||||
? args.map(int.parse).toList()
|
||||
: [2000, 5000];
|
||||
|
||||
final outputPath = _resolveOutputPath();
|
||||
File(outputPath).parent.createSync(recursive: true);
|
||||
|
||||
final rng = Random(12345); // deterministic seed for reproducibility
|
||||
final Map<String, dynamic> result = {};
|
||||
|
||||
for (final count in counts) {
|
||||
final strokes = List.generate(count, (i) => _generateStroke(rng, i));
|
||||
result['$count'] = strokes;
|
||||
print('Generated $count strokes');
|
||||
}
|
||||
|
||||
final jsonStr = const JsonEncoder.withIndent(null).convert(result);
|
||||
File(outputPath).writeAsStringSync(jsonStr);
|
||||
|
||||
final sizeKb = (File(outputPath).lengthSync() / 1024).toStringAsFixed(1);
|
||||
print('Output: $outputPath');
|
||||
print('Size: ${sizeKb} KB');
|
||||
for (final count in counts) {
|
||||
print(' "$count": ${(result[count.toString()] as List).length} strokes');
|
||||
}
|
||||
|
||||
// ── Inline round-trip sanity check ─────────────────────────────────────
|
||||
// Verify that the first stroke in the first dataset round-trips through the
|
||||
// InkStroke JSON shape without data loss (field names, enum values, types).
|
||||
_verifyRoundTrip(result[counts.first.toString()]);
|
||||
}
|
||||
|
||||
/// Generates one InkStroke as a plain Map matching InkStroke.toJson().
|
||||
///
|
||||
/// Field names and enum string values are taken directly from the generated
|
||||
/// code in:
|
||||
/// lib/models/ink_stroke.g.dart (_$$InkStrokeImplToJson)
|
||||
/// lib/models/ink_point.g.dart (_$$InkPointImplToJson)
|
||||
///
|
||||
/// InkStroke fields:
|
||||
/// id, points, tool, color, strokeWidth, createdAt, filled,
|
||||
/// textContent, fontSize
|
||||
///
|
||||
/// InkPoint fields:
|
||||
/// x, y, pressure, tilt, timestamp, pointerDeviceKind
|
||||
Map<String, dynamic> _generateStroke(Random rng, int index) {
|
||||
// Pick a random color from a set of realistic ink tones.
|
||||
// Stored as ARGB int (0xFF......) matching @Default(0xFF000000).
|
||||
final color = _pickColor(rng);
|
||||
|
||||
final strokeWidth = 1.0 + rng.nextDouble() * 3.0; // [1.0, 4.0]
|
||||
final pointCount = 8 + rng.nextInt(13); // [8, 20]
|
||||
|
||||
// Start position — random page location
|
||||
double x = 0.05 + rng.nextDouble() * 0.90; // [0.05, 0.95]
|
||||
double y = 0.05 + rng.nextDouble() * 0.90;
|
||||
|
||||
// Simulate a realistic hand-drawn stroke: incremental movement with
|
||||
// small steps (realistic velocity on a ~A4 page at ~1000 DPI effective).
|
||||
final points = <Map<String, dynamic>>[];
|
||||
int timestamp = DateTime.now().millisecondsSinceEpoch - (5000 - index * 2);
|
||||
|
||||
for (int p = 0; p < pointCount; p++) {
|
||||
// Step in a semi-consistent direction with jitter
|
||||
final angle = rng.nextDouble() * 2 * pi;
|
||||
final step = 0.005 + rng.nextDouble() * 0.015; // [0.005, 0.02] page-units
|
||||
x = (x + cos(angle) * step).clamp(0.0, 1.0);
|
||||
y = (y + sin(angle) * step).clamp(0.0, 1.0);
|
||||
|
||||
// Pressure ramps up then down (pen-press profile)
|
||||
final t = p / (pointCount - 1);
|
||||
final basePressure = sin(t * pi); // 0→1→0 over the stroke
|
||||
final pressure = (0.2 + basePressure * 0.8 + (rng.nextDouble() - 0.5) * 0.1)
|
||||
.clamp(0.2, 1.0);
|
||||
|
||||
final tilt = rng.nextDouble() * 30.0; // [0, 30] degrees
|
||||
|
||||
timestamp += 8 + rng.nextInt(8); // ~8–16 ms between points (120 Hz stylus)
|
||||
|
||||
points.add({
|
||||
'x': _round6(x),
|
||||
'y': _round6(y),
|
||||
'pressure': _round6(pressure),
|
||||
'tilt': _round6(tilt),
|
||||
'timestamp': timestamp,
|
||||
// enum string from _$InputDeviceKindEnumMap in ink_point.g.dart
|
||||
'pointerDeviceKind': 'stylus',
|
||||
});
|
||||
}
|
||||
|
||||
// createdAt as ISO-8601 string (DateTime.toIso8601String() format)
|
||||
final createdAt = DateTime.fromMillisecondsSinceEpoch(timestamp - pointCount * 12)
|
||||
.toIso8601String();
|
||||
|
||||
return {
|
||||
'id': 'bench_${index.toString().padLeft(6, '0')}',
|
||||
'points': points,
|
||||
// enum string from _$PenToolEnumMap in ink_stroke.g.dart
|
||||
'tool': 'pen',
|
||||
'color': color,
|
||||
'strokeWidth': _round6(strokeWidth),
|
||||
'createdAt': createdAt,
|
||||
'filled': false,
|
||||
'textContent': null,
|
||||
'fontSize': 14.0,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns one of several realistic ink colors as an ARGB int.
|
||||
/// These match the range of values that @Default(0xFF000000) int color stores.
|
||||
int _pickColor(Random rng) {
|
||||
// Palette: black, dark-blue, dark-red, dark-green, dark-purple, charcoal
|
||||
const palette = [
|
||||
0xFF000000, // black
|
||||
0xFF0A1E50, // dark navy
|
||||
0xFF800020, // dark red
|
||||
0xFF1A4D1A, // dark green
|
||||
0xFF3D0066, // dark purple
|
||||
0xFF1C1C1C, // charcoal
|
||||
0xFF002B5C, // midnight blue
|
||||
0xFF4B0000, // deep crimson
|
||||
];
|
||||
return palette[rng.nextInt(palette.length)];
|
||||
}
|
||||
|
||||
/// Rounds a double to 6 decimal places to keep JSON compact and exact.
|
||||
double _round6(double v) => double.parse(v.toStringAsFixed(6));
|
||||
|
||||
/// Resolves test/assets/dense_strokes.json relative to this script.
|
||||
String _resolveOutputPath() {
|
||||
final scriptUri = Platform.script;
|
||||
final toolDir = File.fromUri(scriptUri).parent;
|
||||
final projectRoot = toolDir.parent;
|
||||
return '${projectRoot.path}/test/assets/dense_strokes.json';
|
||||
}
|
||||
|
||||
/// Minimal round-trip verification that confirms the JSON shape produced
|
||||
/// here matches InkStroke.fromJson() expectations.
|
||||
///
|
||||
/// We cannot call actual Dart model classes (they import Flutter packages),
|
||||
/// so we do a structural check: re-parse the JSON and assert that every
|
||||
/// required field survives the round-trip with the correct type.
|
||||
void _verifyRoundTrip(dynamic dataset) {
|
||||
final strokes = dataset as List<dynamic>;
|
||||
assert(strokes.isNotEmpty, 'Dataset must not be empty');
|
||||
|
||||
final raw = strokes.first as Map<String, dynamic>;
|
||||
|
||||
// Re-encode → decode to simulate fromJson parsing.
|
||||
final encoded = jsonEncode(raw);
|
||||
final decoded = jsonDecode(encoded) as Map<String, dynamic>;
|
||||
|
||||
// Assert required InkStroke fields exist with correct types.
|
||||
void check(String field, Type type) {
|
||||
final val = decoded[field];
|
||||
assert(
|
||||
val == null || val.runtimeType.toString().contains(type.toString()) || val is num || val is String || val is bool || val is List,
|
||||
'Field "$field" missing or wrong type: ${val.runtimeType}',
|
||||
);
|
||||
}
|
||||
|
||||
assert(decoded['id'] is String, 'id must be String');
|
||||
assert(decoded['points'] is List, 'points must be List');
|
||||
assert(decoded['tool'] == 'pen', 'tool enum must be "pen"');
|
||||
assert(decoded['color'] is int || decoded['color'] is num, 'color must be int/num');
|
||||
assert(decoded['strokeWidth'] is double || decoded['strokeWidth'] is num,
|
||||
'strokeWidth must be num');
|
||||
assert(decoded['createdAt'] is String, 'createdAt must be String (ISO-8601)');
|
||||
assert(decoded['filled'] is bool, 'filled must be bool');
|
||||
assert(decoded['fontSize'] is double || decoded['fontSize'] is num,
|
||||
'fontSize must be num');
|
||||
|
||||
final points = decoded['points'] as List<dynamic>;
|
||||
assert(points.isNotEmpty, 'stroke must have at least one point');
|
||||
final p0 = points.first as Map<String, dynamic>;
|
||||
|
||||
assert(p0['x'] is num, 'InkPoint.x must be num');
|
||||
assert(p0['y'] is num, 'InkPoint.y must be num');
|
||||
assert(p0['pressure'] is num, 'InkPoint.pressure must be num');
|
||||
assert(p0['tilt'] is num, 'InkPoint.tilt must be num');
|
||||
assert(p0['timestamp'] is int || p0['timestamp'] is num,
|
||||
'InkPoint.timestamp must be int/num');
|
||||
assert(p0['pointerDeviceKind'] == 'stylus',
|
||||
'pointerDeviceKind must be "stylus"');
|
||||
|
||||
print('Round-trip check: PASS (all required fields present with correct types)');
|
||||
}
|
||||
39
tool/test.sh
Executable file
39
tool/test.sh
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# tool/test.sh — flutter test wrapper with sqlite3 workaround.
|
||||
#
|
||||
# WHY THIS EXISTS:
|
||||
# BadNote vendors sqlite3 native binaries under vendor/sqlite3/ (selected via
|
||||
# the pubspec.yaml `hooks.user_defines.sqlite3.source: test-sqlite3` block).
|
||||
# On Linux the vendored file is `vendor/sqlite3/libsqlite3.x64.linux.so`.
|
||||
# Without pointing the dynamic linker at it, `flutter test` either falls back
|
||||
# to a system sqlite3 (wrong version / missing) or tries to download one at
|
||||
# build time (blocked behind the GFW on this machine).
|
||||
#
|
||||
# Setting LD_LIBRARY_PATH to the vendor dir tells the linker to prefer the
|
||||
# vendored shared library. The Flutter toolchain here (3.41.4 / Dart 3.10.8)
|
||||
# does NOT forward proxy env vars to build hooks, so LD_LIBRARY_PATH is the
|
||||
# reliable workaround for local Linux development.
|
||||
#
|
||||
# On Windows CI the vendored sqlite3.x64.windows.dll is picked up
|
||||
# automatically by the native-asset build — no wrapper needed there.
|
||||
#
|
||||
# USAGE:
|
||||
# tool/test.sh # run all tests
|
||||
# tool/test.sh test/foo_test.dart # run a specific test file
|
||||
# tool/test.sh --coverage # pass any flutter test flags
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
VENDOR_SQLITE="${PROJECT_ROOT}/vendor/sqlite3"
|
||||
|
||||
if [ ! -d "${VENDOR_SQLITE}" ]; then
|
||||
echo "WARNING: vendor/sqlite3/ not found at ${VENDOR_SQLITE}" >&2
|
||||
echo " Proceeding without LD_LIBRARY_PATH override." >&2
|
||||
else
|
||||
export LD_LIBRARY_PATH="${VENDOR_SQLITE}${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}"
|
||||
fi
|
||||
|
||||
exec flutter test "$@"
|
||||
Reference in New Issue
Block a user