diff --git a/lib/editor/canvas/ink_painters.dart b/lib/editor/canvas/ink_painters.dart index 12c6605..3330938 100644 --- a/lib/editor/canvas/ink_painters.dart +++ b/lib/editor/canvas/ink_painters.dart @@ -65,6 +65,34 @@ Path buildStrokePath( return path; } +/// Mean point pressure (`pressure ?? 0.5`) of a [PenStroke], for the per-stroke +/// opacity resolution (spec §3/§4 tie ballpoint/pencil opacity to pressure). +double _avgPressure(PenStroke stroke) { + if (stroke.points.isEmpty) return 0.5; + var sum = 0.0; + for (final p in stroke.points) { + sum += p.pressure ?? 0.5; + } + return sum / stroke.points.length; +} + +/// THE single fill [Paint] for a committed/live stroke, with the brush's +/// resolved opacity (multiplied into the color's alpha) and blend mode applied +/// — closes TODO(brush-opacity). Shared by [StaticInkPainter]/[LiveInkPainter] +/// and the PDF overlay painter so both render paths composite identically. +Paint paintForStroke(PenStroke stroke) { + final resolved = resolveStrokePaint( + stroke.brush, + stroke.color, + pressureAvg: _avgPressure(stroke), + ); + return Paint() + ..color = resolved.color + ..blendMode = resolved.blendMode + ..style = PaintingStyle.fill + ..isAntiAlias = true; +} + /// Paints all committed strokes for the page. Repaints only when the stroke /// list identity or page size changes (kept behind a RepaintBoundary). class StaticInkPainter extends CustomPainter { @@ -86,13 +114,9 @@ class StaticInkPainter extends CustomPainter { final path = buildStrokePath(stroke, pageSize, isComplete: true, thinning: thinning); if (path.getBounds().isEmpty) continue; - canvas.drawPath( - path, - Paint() - ..color = Color(stroke.color) - ..style = PaintingStyle.fill - ..isAntiAlias = true, - ); + // Single drawPath per stroke ⇒ a highlighter's own self-overlap never + // darkens; cross-stroke overlap darkens via BlendMode.multiply (marker). + canvas.drawPath(path, paintForStroke(stroke)); } } @@ -268,13 +292,7 @@ class LiveInkPainter extends CustomPainter { final path = buildStrokePath(s, pageSize, isComplete: false, thinning: thinning); if (path.getBounds().isEmpty) return; - canvas.drawPath( - path, - Paint() - ..color = Color(s.color) - ..style = PaintingStyle.fill - ..isAntiAlias = true, - ); + canvas.drawPath(path, paintForStroke(s)); } @override diff --git a/lib/editor/canvas/pen_editor_screen.dart b/lib/editor/canvas/pen_editor_screen.dart index 13187e7..4458719 100644 --- a/lib/editor/canvas/pen_editor_screen.dart +++ b/lib/editor/canvas/pen_editor_screen.dart @@ -50,7 +50,7 @@ import '../persistence/sidecar_repository.dart'; import '../ui/pen_settings_page.dart'; import '../ui/thumbnail_grid.dart'; import 'editor_tool.dart'; -import 'ink_painters.dart' show buildStrokePath; +import 'ink_painters.dart' show buildStrokePath, paintForStroke; import 'input_diagnostics.dart'; import 'pen_palette_widgets.dart'; import 'pen_stroke.dart'; @@ -1689,13 +1689,10 @@ class _PageOverlayPainter extends CustomPainter { final path = buildStrokePath(stroke, size, isComplete: true, thinning: thinning); if (path.getBounds().isEmpty) continue; - canvas.drawPath( - path, - Paint() - ..color = Color(stroke.color) - ..style = PaintingStyle.fill - ..isAntiAlias = true, - ); + // Single drawPath per stroke ⇒ highlighter self-overlap never darkens; + // cross-stroke overlap darkens via BlendMode.multiply (closes + // TODO(brush-opacity); shared resolver with the PenCanvas painters). + canvas.drawPath(path, paintForStroke(stroke)); } // 3. Live stroke — read from the notifier at paint time, only for this page. @@ -1704,13 +1701,7 @@ class _PageOverlayPainter extends CustomPainter { final path = buildStrokePath(live.stroke, size, isComplete: false, thinning: thinning); if (!path.getBounds().isEmpty) { - canvas.drawPath( - path, - Paint() - ..color = Color(live.stroke.color) - ..style = PaintingStyle.fill - ..isAntiAlias = true, - ); + canvas.drawPath(path, paintForStroke(live.stroke)); } } diff --git a/lib/editor/engine/brush.dart b/lib/editor/engine/brush.dart index bfe7857..03e8ca0 100644 --- a/lib/editor/engine/brush.dart +++ b/lib/editor/engine/brush.dart @@ -18,19 +18,21 @@ // (per-brush perfect_freehand option tables). The numbers below are lifted from // that spec verbatim. +import 'dart:ui' show Color, BlendMode; + /// The four selectable brushes. Extensible: add a kind here + a preset in /// [kBrushPresets]. The eraser is NOT a brush — it stays a separate tool. enum BrushKind { /// Strong pressure→width (rnote Pow2 / quadratic), soft taper, solid ink. fountainPen, - /// Near-constant thin width; pressure carries opacity in a later increment. + /// Near-constant thin width; pressure carries OPACITY (the ballpoint "tell"). ballpoint, /// Broad, flat width, translucent, square (uncapped) ends. highlighter, - /// Moderate width + (later) opacity from pressure (rnote Sqrt / √p), scratchy. + /// Moderate width + opacity from pressure (rnote Sqrt / √p), scratchy. pencil, } @@ -41,9 +43,10 @@ enum BrushKind { /// path reads the perfect_freehand geometry fields ([pfThinning], [pfStreamline], /// [pfSmoothing], [simulatePressure]) plus the cap/taper flags. /// -/// [opacity] / [blendMultiply] are carried NOW for the later compositing -/// increment but are NOT yet applied to rendered geometry — see -/// TODO(brush-opacity) at the render sites. +/// [opacity] / [blendMultiply] drive the painters' compositing via +/// [resolveStrokePaint] (closes TODO(brush-opacity)): opacity is multiplied +/// into the stroke color's alpha (pressure-tied for ballpoint/pencil — see +/// [resolveStrokeOpacity]) and [blendMultiply] selects [BlendMode.multiply]. class BrushProfile { const BrushProfile({ required this.kind, @@ -101,12 +104,14 @@ class BrushProfile { /// Whether the ends taper to a point (fountain pen) (spec §4). final bool taper; - /// Per-stroke opacity hint in [0,1]. CARRIED NOW, applied in a later - /// increment — see TODO(brush-opacity). `1.0` = solid. + /// Per-stroke opacity in [0,1]; `1.0` = solid. For fountain pen / highlighter + /// this flat value is used; ballpoint/pencil derive opacity from pressure + /// instead (spec §3/§4) — see [resolveStrokeOpacity]. Applied by the painters + /// via [resolveStrokePaint] (multiplied into the stroke color's alpha). final double opacity; - /// Whether the brush should composite with `BlendMode.multiply` (highlighter - /// build-up / marker feel). CARRIED NOW, applied later — TODO(brush-opacity). + /// Whether the brush composites with [BlendMode.multiply] (highlighter + /// build-up / marker feel). Applied by [resolveStrokePaint]. final bool blendMultiply; } @@ -136,9 +141,9 @@ const Map kBrushPresets = { blendMultiply: false, ), // Ballpoint — spec §4: size~2.2, thinning 0.15, smoothing 0.5, - // streamline 0.55, near-constant width, linear pressure (gamma 1.0). Pressure - // → opacity is deferred (TODO(brush-opacity)); opacity hint carried at 1.0 - // until then so geometry-only ballpoint is solid (not invisible). + // streamline 0.55, near-constant width, linear pressure (gamma 1.0). The + // "tell" is pressure → OPACITY (0.55 + 0.45·pressureAvg, resolved per-stroke + // in resolveStrokeOpacity); the flat opacity field below is the solid cap. BrushKind.ballpoint: BrushProfile( kind: BrushKind.ballpoint, baseWidthFraction: 0.0022, @@ -155,9 +160,10 @@ const Map kBrushPresets = { ), // Highlighter — spec §4: size~22, thinning 0.0 (constant width), // smoothing 0.4, streamline 0.5, square (uncapped) ends, translucent + - // multiply build-up. opacity 0.35 / blendMultiply true carried now; the - // existing highlighter ships a 0x80 (50%) color alpha at capture, so the - // 0.35 hint is NOT yet applied — see TODO(brush-opacity). + // multiply build-up. opacity 0.35 / blendMultiply true are APPLIED via + // resolveStrokePaint: the 0.35 is multiplied INTO the color's existing alpha + // (the capture path ships a 0x80 / 50% translucent color), and the stroke + // composites with BlendMode.multiply (cross-stroke overlap darkens = marker). BrushKind.highlighter: BrushProfile( kind: BrushKind.highlighter, baseWidthFraction: 0.02, @@ -173,9 +179,9 @@ const Map kBrushPresets = { blendMultiply: true, ), // Pencil — spec §4: size~3, thinning 0.5, smoothing 0.5, streamline 0.4, - // pressure pre-warped to √p (Sqrt = pressureGamma 0.5). Pressure→opacity and - // paper grain are deferred (TODO(brush-opacity) / TODO(brush-texture)); - // opacity hint 0.9 carried now, not yet applied. + // pressure pre-warped to √p (Sqrt = pressureGamma 0.5). Pressure → OPACITY + // (0.35 + 0.55·pressureAvg, resolveStrokeOpacity) makes it lighter/scratchy; + // the 0.9 field is the solid cap. TODO(brush-texture): paper grain deferred. BrushKind.pencil: BrushProfile( kind: BrushKind.pencil, baseWidthFraction: 0.003, @@ -194,3 +200,80 @@ const Map kBrushPresets = { /// Resolve the [BrushProfile] for [kind] (always present; const map). BrushProfile brushProfileFor(BrushKind kind) => kBrushPresets[kind]!; + +// ---- Compositing (opacity + blend) — closes TODO(brush-opacity) ------------- +// +// perfect_freehand produces a single closed fill polygon per stroke; the +// painters then fill it with ONE Paint. These helpers resolve that Paint's +// alpha + blend mode from the stroke's [BrushProfile] so the four brushes feel +// distinct (the ballpoint/highlighter/pencil "soul"), while geometry stays in +// the freehand path. Both render paths (PenCanvas + the PDF +// `_PageOverlayPainter`) call [resolveStrokePaint] so they can never diverge. + +/// Resolve the EFFECTIVE per-stroke opacity in [0,1] for [profile], given the +/// stroke's AVERAGE pressure [pressureAvg] (already gamma-pre-warped at +/// capture, but for opacity we want the raw feel of "how hard you pressed", so +/// callers pass the mean of each point's `pressure ?? 0.5`). +/// +/// PER-STROKE (not per-segment): one alpha for the whole stroke this increment. +/// The spec (§3/§4) ties ballpoint/pencil opacity to pressure; fountain pen and +/// highlighter use the profile's flat [BrushProfile.opacity]. Per-point opacity +/// (splitting into pressure-banded sub-strokes — spec §4) is deferred. +double resolveStrokeOpacity(BrushProfile profile, {double pressureAvg = 0.5}) { + final p = pressureAvg.clamp(0.0, 1.0); + switch (profile.kind) { + // Spec §4: ballpoint "tell" is pressure → opacity (near-constant width). + case BrushKind.ballpoint: + return (0.55 + 0.45 * p).clamp(0.0, 1.0); + // Spec §4: pencil darkens with pressure (firm, quick-darkening √p feel). + case BrushKind.pencil: + return (0.35 + 0.55 * p).clamp(0.0, 1.0); + // Fountain pen (solid 1.0) + highlighter (flat 0.35) use the profile value. + case BrushKind.fountainPen: + case BrushKind.highlighter: + return profile.opacity.clamp(0.0, 1.0); + } +} + +/// Multiply [opacity] (0..1) into [argb]'s existing alpha channel and return the +/// new ARGB int. Keeps any alpha the capture path already baked in (e.g. the +/// highlighter's 0x80 translucent capture) so this composes WITHOUT +/// double-counting — the profile opacity scales whatever alpha the color has. +int applyOpacityToArgb(int argb, double opacity) { + final baseAlpha = (argb >> 24) & 0xFF; + final scaled = (baseAlpha * opacity.clamp(0.0, 1.0)).round().clamp(0, 255); + return (scaled << 24) | (argb & 0x00FFFFFF); +} + +/// The fully-resolved fill [Color] + [BlendMode] for one stroke, so every +/// painter can configure its `Paint` identically. [argb] is the stroke's stored +/// color; [pressureAvg] is the mean point pressure (`pressure ?? 0.5`). +/// +/// - [color]: stroke color with `profile`-resolved opacity multiplied into its +/// alpha (pressure-tied for ballpoint/pencil; flat for fountain/highlighter). +/// - [blendMode]: [BlendMode.multiply] for the highlighter (marker build-up: +/// cross-stroke overlap darkens), [BlendMode.srcOver] otherwise. The stroke +/// is still drawn ONCE per render (single fill polygon) so its OWN self- +/// overlap never darkens — that single-draw invariant lives in the painters. +class ResolvedStrokePaint { + const ResolvedStrokePaint({required this.color, required this.blendMode}); + + final Color color; + final BlendMode blendMode; +} + +/// Resolve the paint config for a stroke drawn with [kind]. See +/// [ResolvedStrokePaint]. TODO(brush-texture): pencil paper-grain texture is +/// still deferred — opacity is enough for this increment. +ResolvedStrokePaint resolveStrokePaint( + BrushKind kind, + int argb, { + double pressureAvg = 0.5, +}) { + final profile = brushProfileFor(kind); + final opacity = resolveStrokeOpacity(profile, pressureAvg: pressureAvg); + return ResolvedStrokePaint( + color: Color(applyOpacityToArgb(argb, opacity)), + blendMode: profile.blendMultiply ? BlendMode.multiply : BlendMode.srcOver, + ); +} diff --git a/lib/editor/engine/stroke_geometry.dart b/lib/editor/engine/stroke_geometry.dart index 848d878..6caab01 100644 --- a/lib/editor/engine/stroke_geometry.dart +++ b/lib/editor/engine/stroke_geometry.dart @@ -88,10 +88,9 @@ List freehandOutlinePoints({ /// Build perfect_freehand [pf.StrokeOptions] from a [BrushProfile] (spec §4). /// -/// TODO(brush-opacity): [BrushProfile.opacity] / [BrushProfile.blendMultiply] -/// are NOT consumed here — geometry only this increment. The caller still paints -/// fill color (with its own alpha) and BlendMode.srcOver; per-stroke opacity / -/// BlendMode.multiply for ballpoint/pencil/highlighter lands later. +/// Geometry only: [BrushProfile.opacity] / [BrushProfile.blendMultiply] are +/// consumed by the painters' [paintForEditorStroke] / `paintForStroke` (via +/// [resolveStrokePaint]), NOT here — this stays a pure outline recipe. pf.StrokeOptions _optionsFromBrush( BrushProfile brush, { required double size, @@ -171,3 +170,36 @@ Path buildStrokeOutline( path.close(); return path; } + +/// Mean point pressure (`pressure ?? 0.5`) of an [EditorStroke], for the +/// per-stroke opacity resolution (spec §3/§4 tie ballpoint/pencil opacity to +/// pressure). +double _avgPressure(EditorStroke stroke) { + if (stroke.points.isEmpty) return 0.5; + var sum = 0.0; + for (final p in stroke.points) { + sum += p.pressure ?? 0.5; + } + return sum / stroke.points.length; +} + +/// THE single fill [Paint] for an [EditorStroke], with the brush's resolved +/// opacity (multiplied into the color's alpha) and blend mode applied — closes +/// TODO(brush-opacity). Shared by the committed [Picture] and live painters so +/// the EditorStroke render path composites exactly like the PenStroke one. +/// +/// The stroke is drawn as ONE fill polygon, so a highlighter's own self-overlap +/// never darkens; cross-stroke overlap darkens via [BlendMode.multiply] +/// (marker build-up). TODO(brush-texture): pencil paper grain still deferred. +Paint paintForEditorStroke(EditorStroke stroke) { + final resolved = resolveStrokePaint( + stroke.brush, + stroke.color, + pressureAvg: _avgPressure(stroke), + ); + return Paint() + ..color = resolved.color + ..blendMode = resolved.blendMode + ..style = PaintingStyle.fill + ..isAntiAlias = true; +} diff --git a/lib/editor/render/live_ink_painter.dart b/lib/editor/render/live_ink_painter.dart index 61c8ba2..bef267e 100644 --- a/lib/editor/render/live_ink_painter.dart +++ b/lib/editor/render/live_ink_painter.dart @@ -39,13 +39,7 @@ class LiveInkPainter extends CustomPainter { isComplete: false, thinning: thinning); if (path.getBounds().isEmpty) return; - canvas.drawPath( - path, - Paint() - ..color = Color(stroke.color) - ..style = PaintingStyle.fill - ..isAntiAlias = true, - ); + canvas.drawPath(path, paintForEditorStroke(stroke)); } @override diff --git a/lib/editor/render/static_ink_painter.dart b/lib/editor/render/static_ink_painter.dart index 17e8341..aaaa6c8 100644 --- a/lib/editor/render/static_ink_painter.dart +++ b/lib/editor/render/static_ink_painter.dart @@ -58,13 +58,10 @@ class StaticInkPainter extends CustomPainter { final path = buildStrokeOutline(stroke, pageSize, isComplete: true, thinning: thinning); if (path.getBounds().isEmpty) continue; - rec.drawPath( - path, - Paint() - ..color = Color(stroke.color) - ..style = PaintingStyle.fill - ..isAntiAlias = true, - ); + // One drawPath per stroke ⇒ highlighter self-overlap never darkens; + // cross-stroke overlap darkens via BlendMode.multiply (closes + // TODO(brush-opacity); shared resolver with the live + PenCanvas paths). + rec.drawPath(path, paintForEditorStroke(stroke)); } return recorder.endRecording(); }); diff --git a/test/brush_opacity_test.dart b/test/brush_opacity_test.dart new file mode 100644 index 0000000..f151899 --- /dev/null +++ b/test/brush_opacity_test.dart @@ -0,0 +1,169 @@ +// test/brush_opacity_test.dart +// +// Pins brush OPACITY + BLEND compositing (closes TODO(brush-opacity)) at the +// geometry/paint-config level (NOT pixel snapshots), per the spec +// (docs/research/pen-brush-spec.md §3/§4): +// (a) resolveStrokeOpacity: fountain solid (1.0), highlighter flat (<1), +// ballpoint = 0.55 + 0.45·pressureAvg, pencil = 0.35 + 0.55·pressureAvg; +// (b) the resolved Paint's color alpha reflects profile.opacity multiplied +// into the stroke color's existing alpha (and pressure-tied for +// ballpoint/pencil); +// (c) highlighter composites with BlendMode.multiply; others BlendMode.srcOver; +// (d) BOTH render paths' shared paint helpers agree (PenStroke path: +// paintForStroke; EditorStroke path: paintForEditorStroke). + +import 'dart:ui' show BlendMode; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:badnote/editor/canvas/ink_painters.dart' show paintForStroke; +import 'package:badnote/editor/canvas/pen_stroke.dart'; +import 'package:badnote/editor/engine/brush.dart'; +import 'package:badnote/editor/engine/stroke_geometry.dart' + show paintForEditorStroke; +import 'package:badnote/editor/engine/stroke_model.dart'; + +int _alpha(int argb) => (argb >> 24) & 0xFF; + +PenStroke _pen(BrushKind brush, int color, List pressures) => PenStroke( + points: [ + for (final p in pressures) PenPoint(0.1, 0.1, p), + ], + color: color, + width: 0.006, + kind: brush == BrushKind.highlighter + ? PenStrokeKind.highlighter + : PenStrokeKind.pen, + brush: brush, + ); + +EditorStroke _editor(BrushKind brush, int color, List pressures) => + EditorStroke.create( + points: [ + for (final p in pressures) EditorPoint(x: 0.1, y: 0.1, pressure: p), + ], + color: color, + tool: brush == BrushKind.highlighter + ? EditorTool.highlighter + : EditorTool.pen, + brush: brush, + ); + +void main() { + group('(a) resolveStrokeOpacity per brush (spec §3/§4)', () { + test('fountain pen is solid (1.0) regardless of pressure', () { + final b = brushProfileFor(BrushKind.fountainPen); + expect(resolveStrokeOpacity(b, pressureAvg: 0.0), 1.0); + expect(resolveStrokeOpacity(b, pressureAvg: 1.0), 1.0); + }); + + test('highlighter uses its flat profile opacity (0.35), not pressure', () { + final b = brushProfileFor(BrushKind.highlighter); + expect(resolveStrokeOpacity(b, pressureAvg: 0.0), b.opacity); + expect(resolveStrokeOpacity(b, pressureAvg: 1.0), b.opacity); + expect(b.opacity, lessThan(1.0)); + }); + + test('ballpoint opacity = 0.55 + 0.45·pressureAvg (the "tell")', () { + final b = brushProfileFor(BrushKind.ballpoint); + expect(resolveStrokeOpacity(b, pressureAvg: 0.0), closeTo(0.55, 1e-9)); + expect(resolveStrokeOpacity(b, pressureAvg: 1.0), closeTo(1.0, 1e-9)); + expect(resolveStrokeOpacity(b, pressureAvg: 0.5), closeTo(0.775, 1e-9)); + // Pressure visibly modulates opacity (low < high). + expect(resolveStrokeOpacity(b, pressureAvg: 0.2), + lessThan(resolveStrokeOpacity(b, pressureAvg: 0.9))); + }); + + test('pencil opacity = 0.35 + 0.55·pressureAvg (lighter/translucent)', () { + final b = brushProfileFor(BrushKind.pencil); + expect(resolveStrokeOpacity(b, pressureAvg: 0.0), closeTo(0.35, 1e-9)); + expect(resolveStrokeOpacity(b, pressureAvg: 1.0), closeTo(0.90, 1e-9)); + expect(resolveStrokeOpacity(b, pressureAvg: 0.5), closeTo(0.625, 1e-9)); + // Pencil at any pressure is lighter than a solid fountain pen. + expect(resolveStrokeOpacity(b, pressureAvg: 1.0), lessThan(1.0)); + }); + }); + + group('(b) opacity multiplies into the color alpha', () { + test('applyOpacityToArgb scales the existing alpha (keeps RGB)', () { + // Opaque black at 0.5 → half alpha; RGB untouched. + expect(applyOpacityToArgb(0xFF000000, 0.5), 0x80000000); + // Already-translucent (0x80) highlighter color at 0.35 ⇒ composes + // (no double-counting bug): 0x80 * 0.35 ≈ 45 (0x2D), not 0x80. + final hi = applyOpacityToArgb(0x80FFEB3B, 0.35); + expect(_alpha(hi), (0x80 * 0.35).round()); + expect(hi & 0x00FFFFFF, 0x00FFEB3B); // RGB preserved + }); + + test('fountain pen keeps full opacity in the resolved paint', () { + final p = paintForStroke(_pen(BrushKind.fountainPen, 0xFF112233, [1.0])); + expect(p.color.toARGB32(), 0xFF112233); // unchanged + expect(p.blendMode, BlendMode.srcOver); + }); + + test('ballpoint resolved alpha tracks pressureAvg', () { + final soft = paintForStroke(_pen(BrushKind.ballpoint, 0xFF000000, [0.0])); + final hard = paintForStroke(_pen(BrushKind.ballpoint, 0xFF000000, [1.0])); + // 0.55·255 ≈ 140; 1.0·255 = 255. + expect(_alpha(soft.color.toARGB32()), (0xFF * 0.55).round()); + expect(_alpha(hard.color.toARGB32()), 0xFF); + expect(_alpha(soft.color.toARGB32()), + lessThan(_alpha(hard.color.toARGB32()))); + }); + + test('pencil resolved alpha is lighter and tracks pressureAvg', () { + final soft = paintForStroke(_pen(BrushKind.pencil, 0xFF000000, [0.0])); + final hard = paintForStroke(_pen(BrushKind.pencil, 0xFF000000, [1.0])); + expect(_alpha(soft.color.toARGB32()), (0xFF * 0.35).round()); + expect(_alpha(hard.color.toARGB32()), (0xFF * 0.90).round()); + // Pencil always lighter than fully-opaque fountain pen. + expect(_alpha(hard.color.toARGB32()), lessThan(0xFF)); + }); + + test('ballpoint differs from fountain pen via opacity at same pressure', () { + final ball = paintForStroke(_pen(BrushKind.ballpoint, 0xFF000000, [0.3])); + final fount = + paintForStroke(_pen(BrushKind.fountainPen, 0xFF000000, [0.3])); + expect(_alpha(ball.color.toARGB32()), + lessThan(_alpha(fount.color.toARGB32()))); + }); + }); + + group('(c) highlighter uses BlendMode.multiply, others srcOver', () { + test('highlighter ⇒ multiply', () { + final p = + paintForStroke(_pen(BrushKind.highlighter, 0x80FFEB3B, [0.5, 0.5])); + expect(p.blendMode, BlendMode.multiply); + // 0.35 multiplied into the 0x80 capture alpha. + expect(_alpha(p.color.toARGB32()), (0x80 * 0.35).round()); + }); + + test('fountain/ballpoint/pencil ⇒ srcOver', () { + for (final k in const [ + BrushKind.fountainPen, + BrushKind.ballpoint, + BrushKind.pencil, + ]) { + expect(paintForStroke(_pen(k, 0xFF000000, [0.5])).blendMode, + BlendMode.srcOver, + reason: '$k should not multiply'); + } + }); + }); + + group('(d) both render paths composite identically', () { + test('EditorStroke path matches PenStroke path (alpha + blend)', () { + for (final k in BrushKind.values) { + final color = k == BrushKind.highlighter ? 0x80FFEB3B : 0xFF102030; + const pressures = [0.2, 0.8]; + final penPaint = paintForStroke(_pen(k, color, pressures)); + final editorPaint = paintForEditorStroke(_editor(k, color, pressures)); + expect(editorPaint.color.toARGB32(), penPaint.color.toARGB32(), + reason: '$k color mismatch across render paths'); + expect(editorPaint.blendMode, penPaint.blendMode, + reason: '$k blend mismatch across render paths'); + } + }); + }); +}