From 87e23e794c77d1c1aaa285ebb72aebe3c5643aea Mon Sep 17 00:00:00 2001 From: h7x4 Date: Sat, 21 Feb 2026 17:24:59 +0900 Subject: [PATCH] WIP --- flake.lock | 12 +- lib/animator.dart | 8 +- lib/kanimaji.dart | 1 + lib/kanjivg_parser.dart | 212 ++++++++++ lib/{ => primitives}/bezier.dart | 0 lib/{svg => primitives}/path.dart | 93 ++++- lib/{ => primitives}/point.dart | 0 lib/svg_exporter.dart | 0 lib/{svg/parser.dart => svg_parser.dart} | 6 +- lib/widget.dart | 481 ++++++++++++++++++++++- pubspec.yaml | 1 + test/kanjivg_parser_test.dart | 43 ++ test/svg/generation_test.dart | 2 +- test/svg/parser_test.dart | 6 +- test/svg/path_test.dart | 4 +- test/svg/tokenizer_test.dart | 4 +- 16 files changed, 847 insertions(+), 26 deletions(-) create mode 100644 lib/kanimaji.dart create mode 100644 lib/kanjivg_parser.dart rename lib/{ => primitives}/bezier.dart (100%) rename lib/{svg => primitives}/path.dart (87%) rename lib/{ => primitives}/point.dart (100%) create mode 100644 lib/svg_exporter.dart rename lib/{svg/parser.dart => svg_parser.dart} (98%) create mode 100644 test/kanjivg_parser_test.dart diff --git a/flake.lock b/flake.lock index ab901b6..e5e7f24 100644 --- a/flake.lock +++ b/flake.lock @@ -3,11 +3,11 @@ "kanjivg": { "flake": false, "locked": { - "lastModified": 1754131552, - "narHash": "sha256-1h3nT1f1QqXme4rU3HMmEREn74sAASyZ8qzjZ0tPi4I=", + "lastModified": 1770109946, + "narHash": "sha256-zgkyLJwEJe6YABUNrL27BBrDSWVNcAUk7K6P1mcVHxQ=", "owner": "KanjiVG", "repo": "kanjivg", - "rev": "0d08020e69611552a0fbe13d49365d6196431b96", + "rev": "2fe6daaba502ee6735f888e5fb4a7d30639a18ee", "type": "github" }, "original": { @@ -18,11 +18,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1754214453, - "narHash": "sha256-Q/I2xJn/j1wpkGhWkQnm20nShYnG7TI99foDBpXm1SY=", + "lastModified": 1771369470, + "narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "5b09dc45f24cf32316283e62aec81ffee3c3e376", + "rev": "0182a361324364ae3f436a63005877674cf45efb", "type": "github" }, "original": { diff --git a/lib/animator.dart b/lib/animator.dart index 59da897..42592d8 100644 --- a/lib/animator.dart +++ b/lib/animator.dart @@ -1,12 +1,12 @@ import 'dart:io'; import 'dart:math' show min, sqrt, pow; -import 'svg/parser.dart'; -import 'point.dart'; - -import 'bezier.dart' as bezier; import 'package:xml/xml.dart'; +import 'svg_parser.dart'; +import 'primitives/point.dart'; +import 'primitives/bezier.dart' as bezier; + double _computePathLength(String path) => parsePath(path).size(error: 1e-8).toDouble(); diff --git a/lib/kanimaji.dart b/lib/kanimaji.dart new file mode 100644 index 0000000..a050b50 --- /dev/null +++ b/lib/kanimaji.dart @@ -0,0 +1 @@ +export 'package:kanimaji/widget.dart' show Kanimaji; diff --git a/lib/kanjivg_parser.dart b/lib/kanjivg_parser.dart new file mode 100644 index 0000000..af7e153 --- /dev/null +++ b/lib/kanjivg_parser.dart @@ -0,0 +1,212 @@ +import 'package:xml/xml.dart'; + +import 'svg_parser.dart'; +import 'primitives/path.dart'; +import 'primitives/point.dart'; + +/// Enum set in the kvg:position attribute, used by `` elements in the KanjiVG SVG files. +enum KanjiPathGroupPosition { + bottom, + kamae, + kamaec, + left, + middle, + nyo, + nyoc, + right, + tare, + tarec, + top; + + factory KanjiPathGroupPosition.fromString(String s) => switch (s) { + 'bottom' => bottom, + 'kamae' => kamae, + 'kamaec' => kamaec, + 'left' => left, + 'middle' => middle, + 'nyo' => nyo, + 'nyoc' => nyoc, + 'right' => right, + 'tare' => tare, + 'tarec' => tarec, + 'top' => top, + _ => throw ArgumentError('Invalid position string: $s'), + }; + + @override + String toString() => switch (this) { + KanjiPathGroupPosition.bottom => 'bottom', + KanjiPathGroupPosition.kamae => 'kamae', + KanjiPathGroupPosition.kamaec => 'kamaec', + KanjiPathGroupPosition.left => 'left', + KanjiPathGroupPosition.middle => 'middle', + KanjiPathGroupPosition.nyo => 'nyo', + KanjiPathGroupPosition.nyoc => 'nyoc', + KanjiPathGroupPosition.right => 'right', + KanjiPathGroupPosition.tare => 'tare', + KanjiPathGroupPosition.tarec => 'tarec', + KanjiPathGroupPosition.top => 'top', + }; +} + +/// Contents of a \ element in the KanjiVG SVG files. +class KanjiPathGroupTreeNode { + final String id; + final List children; + final String? element; + final String? original; + final KanjiPathGroupPosition? position; + final String? radical; + final int? part; + + KanjiPathGroupTreeNode({ + required this.id, + this.children = const [], + this.element, + this.original, + this.position, + this.radical, + this.part, + }); +} + +/// Contents of a `` element in the StrokeNumber's group in the KanjiVG SVG files +class KanjiStrokeNumber { + final int num; + final Point position; + + KanjiStrokeNumber(this.num, this.position); + + @override + String toString() => + 'KanjiStrokeNumber(number: $num, position: $position)'; +} + +/// Contents of a `` element in the KanjiVG SVG files +class KanjiVGPath { + final String id; + final String type; + final Path svgPath; + + KanjiVGPath({required this.id, required this.type, required this.svgPath}); + + @override + String toString() => 'KanjiVGPath(id: $id, type: $type, svgPath: $svgPath)'; +} + +/// Representation of the entire KanjiVG SVG file for a single kanji character +class KanjiVGKanji { + final String character; + final List paths; + final List strokeNumbers; + final KanjiPathGroupTreeNode? pathGroups; + + KanjiVGKanji({ + required this.character, + required this.paths, + required this.strokeNumbers, + this.pathGroups, + }); +} + +/// Small wrapper returned by the parser. It contains the parsed paths and stroke +/// numbers and the kanji character string. +class KanjiVGItem { + final String character; + final List paths; + final List strokeNumbers; + + KanjiVGItem({ + required this.character, + required this.paths, + required this.strokeNumbers, + }); + + /// Helper method to get attributes that may be in the kvg namespace or as `kvg:` prefixed attributes. + static String? _kvgAttr(XmlElement el, String name) { + final fromNs = el.getAttribute( + name, + namespace: 'http://kanjivg.tagaini.net', + ); + if (fromNs != null) return fromNs; + return el.getAttribute('kvg:$name'); + } + + /// Parse the path data from the provided XML document and return a list of [Path]s. + static List _parsePaths(XmlDocument doc) { + final List paths = []; + for (final p in doc.findAllElements('path')) { + final d = p.getAttribute('d'); + if (d == null || d.trim().isEmpty) { + continue; + } + final svgPath = parsePath(d); + final id = p.getAttribute('id') ?? ''; + final type = _kvgAttr(p, 'type') ?? p.getAttribute('kvg:type') ?? ''; + paths.add(KanjiVGPath(id: id, type: type, svgPath: svgPath)); + } + return paths; + } + + /// Parse the stroke number group from the provided XML document and return a list of [KanjiStrokeNumber]s. + static List _parseStrokeNumbers(XmlDocument doc) { + final List strokeNumbers = []; + final strokeNumberGroup = doc.findAllElements('g').firstWhere((g) { + final id = g.getAttribute('id') ?? ''; + return RegExp(r'^kvg:StrokeNumbers_').hasMatch(id); + }, orElse: () => XmlElement(XmlName(''))); + + if (strokeNumberGroup.name.local != '') { + for (final t in strokeNumberGroup.findAllElements('text')) { + final rawText = t.innerText.trim(); + if (rawText.isEmpty) continue; + final numVal = int.tryParse(rawText); + if (numVal == null) continue; + + final transform = t.getAttribute('transform') ?? ''; + final numbers = transform + .replaceAll('matrix(1 0 0 1', '') + .replaceAll(')', '') + .trim() + .split(' ') + .map(num.parse) + .toList(); + + assert( + numbers.length >= 2, + 'Expected at least 2 numbers in transform for stroke number position', + ); + + Point pos = Point( + numbers[numbers.length - 2], + numbers[numbers.length - 1], + ); + + strokeNumbers.add(KanjiStrokeNumber(numVal, pos)); + } + } + + return strokeNumbers; + } + + /// Parse the provided KanjiVG SVG content and return a [KanjiVGItem]. + factory KanjiVGItem.parseFromXml(String xmlContent) { + final XmlDocument doc = XmlDocument.parse(xmlContent); + + XmlElement strokePathsGroup = doc + .findElements('svg') + .first + .findElements('g') + .first; + XmlElement kanjiGroup = strokePathsGroup.findElements('g').first; + final character = _kvgAttr(kanjiGroup, 'element') ?? ''; + final paths = _parsePaths(doc); + final strokeNumbers = _parseStrokeNumbers(doc); + + return KanjiVGItem( + character: character, + paths: paths, + strokeNumbers: strokeNumbers, + ); + } +} diff --git a/lib/bezier.dart b/lib/primitives/bezier.dart similarity index 100% rename from lib/bezier.dart rename to lib/primitives/bezier.dart diff --git a/lib/svg/path.dart b/lib/primitives/path.dart similarity index 87% rename from lib/svg/path.dart rename to lib/primitives/path.dart index 8aea693..a36fee4 100644 --- a/lib/svg/path.dart +++ b/lib/primitives/path.dart @@ -5,10 +5,11 @@ library; import 'dart:collection'; import 'dart:math' as math; import 'dart:math' show sqrt, sin, cos, acos, log, pi; +import 'dart:ui' as ui; import 'package:bisection/extension.dart'; -import '../point.dart'; +import 'point.dart'; num radians(num n) => n * pi / 180; num degrees(num n) => n * 180 / pi; @@ -609,4 +610,94 @@ class Path extends ListBase { return parts.join(" ").toUpperCase(); } + + /// Convert the path into a dart:ui.Path. + ui.Path toUiPath() { + final ui.Path p = ui.Path(); + bool started = false; + + for (final seg in this) { + switch (seg) { + case Move(:final start): + p.moveTo(start.x.toDouble(), start.y.toDouble()); + started = true; + break; + + case Line(:final start, :final end): + if (!started) { + p.moveTo(start.x.toDouble(), start.y.toDouble()); + started = true; + } + p.lineTo(end.x.toDouble(), end.y.toDouble()); + break; + + case Close(): + p.close(); + break; + + case CubicBezier( + :final start, + :final control1, + :final control2, + :final end, + ): + if (!started) { + p.moveTo(start.x.toDouble(), start.y.toDouble()); + started = true; + } + p.cubicTo( + control1.x.toDouble(), + control1.y.toDouble(), + control2.x.toDouble(), + control2.y.toDouble(), + end.x.toDouble(), + end.y.toDouble(), + ); + break; + + case QuadraticBezier(:final start, :final control, :final end): + if (!started) { + p.moveTo(start.x.toDouble(), start.y.toDouble()); + started = true; + } + p.quadraticBezierTo( + control.x.toDouble(), + control.y.toDouble(), + end.x.toDouble(), + end.y.toDouble(), + ); + break; + + case Arc( + :final start, + :final radius, + :final rotation, + :final arc, + :final sweep, + :final end, + ): + if (!started) { + p.moveTo(start.x.toDouble(), start.y.toDouble()); + started = true; + } + // rotation is in degrees in svg; arcToPoint expects radians. + final r = ui.Radius.elliptical( + radius.x.toDouble(), + radius.y.toDouble(), + ); + p.arcToPoint( + ui.Offset(end.x.toDouble(), end.y.toDouble()), + radius: r, + rotation: rotation.toDouble() * (math.pi / 180.0), + largeArc: arc, + clockwise: sweep, + ); + break; + case _: + throw Exception('Unknown segment type: ${seg.runtimeType}'); + } + } + + return p; + } } diff --git a/lib/point.dart b/lib/primitives/point.dart similarity index 100% rename from lib/point.dart rename to lib/primitives/point.dart diff --git a/lib/svg_exporter.dart b/lib/svg_exporter.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/svg/parser.dart b/lib/svg_parser.dart similarity index 98% rename from lib/svg/parser.dart rename to lib/svg_parser.dart index c0609a9..743f6f3 100644 --- a/lib/svg/parser.dart +++ b/lib/svg_parser.dart @@ -3,8 +3,8 @@ /// See https://pypi.org/project/svg.path/ for the original implementation. library; -import '../point.dart'; -import 'path.dart'; +import 'primitives/point.dart'; +import 'primitives/path.dart'; const _commands = { 'M', @@ -32,7 +32,7 @@ const _commands = { // const _uppercaseCommands = {'M', 'Z', 'L', 'H', 'V', 'C', 'S', 'Q', 'T', 'A'}; final _commandPattern = RegExp("(?=[${_commands.join('')}])"); -final _floatPattern = RegExp(r"^[-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?"); +final _floatPattern = RegExp(r"^[-+]?(?:[0-9]+\.?[0-9]*|[0-9]*\.?[0-9]+)(?:[eE][-+]?[0-9]+)?"); class ParserResult { final T value; diff --git a/lib/widget.dart b/lib/widget.dart index 88e1c3d..59e941e 100644 --- a/lib/widget.dart +++ b/lib/widget.dart @@ -1,11 +1,484 @@ -import 'package:flutter/material.dart'; +import 'dart:async'; +import 'dart:ui' as ui; +import 'dart:math' as math; -class Kanimaji extends StatelessWidget { +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:kanimaji/animator.dart'; +import 'package:kanimaji/kanjivg_parser.dart'; + +Future _defaultKanjiDataProvider(String kanji) async { + final hex = kanji.runes.isEmpty + ? '00000' + : kanji.runes.first.toRadixString(16).padLeft(5, '0'); + final assetPath = 'assets/kanjivg/kanji/$hex.svg'; + final svgString = await rootBundle.loadString(assetPath); + return KanjiVGItem.parseFromXml(svgString); +} + +/// A widget that animates the stroke order of a given kanji character using KanjiVG data. +class Kanimaji extends StatefulWidget { final String kanji; - const Kanimaji({super.key, required this.kanji}); + final FutureOr Function(String kanji) kanjiDataProvider; + + // Animation parameters + final bool loop; + // final Duration delayBetweenStrokes; + // final Duration delayBetweenLoops; + // TODO: add support for specifying animation bezier curve + // final Cubic animationCurve; + // final double speed; + + // Brush parameters + final bool showBrush; + final Color brushColor; + final double brushRadius; + + // Stroke parameters + final Color strokeColor; + final Color strokeUnfilledColor; + final double strokeWidth; + final Color backgroundColor; + + // Stroke number parameters + final bool showStrokeNumbers; + final Color strokeNumberColor; + final Color currentStrokeNumberColor; + final double strokeNumberFontSize; + final String? strokeNumberFontFamily; + + // Cross parameters + final bool showCross; + final Color crossColor; + final double crossStrokeWidth; + final double crossStipleLength; + final double crossStipleGap; + + const Kanimaji({ + super.key, + required this.kanji, + this.kanjiDataProvider = _defaultKanjiDataProvider, + this.loop = true, + this.strokeColor = Colors.black, + this.strokeUnfilledColor = const Color(0xA0DDDDDD), + this.strokeWidth = 3.0, + this.backgroundColor = Colors.transparent, + this.showBrush = true, + this.brushColor = Colors.red, + this.brushRadius = 4.0, + this.showStrokeNumbers = true, + this.strokeNumberColor = Colors.grey, + this.currentStrokeNumberColor = Colors.red, + this.strokeNumberFontSize = 4.0, + this.strokeNumberFontFamily, + this.showCross = true, + this.crossColor = const Color(0x40AAAAAA), + this.crossStrokeWidth = 0.8, + this.crossStipleLength = 5.0, + this.crossStipleGap = 3.0, + }); + + @override + State createState() => _KanimajiState(); +} + +class _KanimajiState extends State + with SingleTickerProviderStateMixin { + KanjiVGItem? _kanjiData; + String? _error; + late AnimationController _controller; + + List get _pathLengths => + _kanjiData?.paths + .map((p) => p.svgPath.size(error: 1e-8).toDouble()) + .toList() ?? + []; + + List get _pathDurations => + _pathLengths.map((len) => strokeLengthToDuration(len)).toList(); + + static const double _viewBoxWidth = 109.0; + static const double _viewBoxHeight = 109.0; + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this); + _controller.addListener(_onTick); + _loadAndParseSvg().then((_) => _configureController()); + } + + @override + void didUpdateWidget(covariant Kanimaji oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.kanji != widget.kanji) { + _loadAndParseSvg().then((_) => _configureController()); + } else if (oldWidget.loop != widget.loop || + oldWidget.strokeColor != widget.strokeColor || + oldWidget.strokeUnfilledColor != widget.strokeUnfilledColor || + oldWidget.strokeWidth != widget.strokeWidth || + oldWidget.backgroundColor != widget.backgroundColor || + oldWidget.showBrush != widget.showBrush || + oldWidget.brushColor != widget.brushColor || + oldWidget.brushRadius != widget.brushRadius || + oldWidget.showStrokeNumbers != widget.showStrokeNumbers || + oldWidget.strokeNumberColor != widget.strokeNumberColor || + oldWidget.currentStrokeNumberColor != widget.currentStrokeNumberColor || + oldWidget.strokeNumberFontSize != widget.strokeNumberFontSize || + oldWidget.strokeNumberFontFamily != widget.strokeNumberFontFamily || + oldWidget.showCross != widget.showCross || + oldWidget.crossColor != widget.crossColor || + oldWidget.crossStrokeWidth != widget.crossStrokeWidth || + oldWidget.crossStipleLength != widget.crossStipleLength || + oldWidget.crossStipleGap != widget.crossStipleGap) { + setState(() {}); + } + } + + void _onTick() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + _controller.removeListener(_onTick); + _controller.dispose(); + super.dispose(); + } + + Future _loadAndParseSvg() async { + try { + final data = await widget.kanjiDataProvider(widget.kanji); + setState(() { + _kanjiData = data; + _error = null; + }); + } catch (e) { + setState(() { + _kanjiData = null; + _error = 'Error loading/parsing kanji data: $e'; + }); + } + } + + void _configureController() { + if (_kanjiData == null) return; + final double totalSec = _pathDurations.fold(0.0, (a, b) => a + b); + + _controller.stop(); + _controller.duration = Duration(milliseconds: (totalSec * 1000.0).round()); + if (widget.loop) { + _controller.repeat(); + } else { + _controller.forward(from: 0); + } + } @override Widget build(BuildContext context) { - return Container(); + if (_kanjiData == null && _error == null) { + return SizedBox.expand(child: Center(child: CircularProgressIndicator())); + } + + if (_error != null) { + return SizedBox.expand(child: ErrorWidget(_error!)); + } + + return FittedBox( + child: SizedBox( + width: _viewBoxWidth, + height: _viewBoxHeight, + child: CustomPaint( + painter: _KanimajiPainter( + paths: _kanjiData!.paths.map((p) => p.svgPath.toUiPath()).toList(), + pathLengths: _pathLengths, + pathDurations: _pathDurations, + strokeNumbers: _kanjiData!.strokeNumbers, + progress: _controller.value, + strokeColor: widget.strokeColor, + strokeUnfilledColor: widget.strokeUnfilledColor, + strokeWidth: widget.strokeWidth, + backgroundColor: widget.backgroundColor, + viewBoxWidth: _viewBoxWidth, + viewBoxHeight: _viewBoxHeight, + showBrush: widget.showBrush, + brushColor: widget.brushColor, + brushRadius: widget.brushRadius, + showStrokeNumbers: widget.showStrokeNumbers, + strokeNumberColor: widget.strokeNumberColor, + currentStrokeNumberColor: widget.currentStrokeNumberColor, + strokeNumberFontSize: widget.strokeNumberFontSize, + strokeNumberFontFamily: widget.strokeNumberFontFamily, + showCross: widget.showCross, + crossColor: widget.crossColor, + crossStrokeWidth: widget.crossStrokeWidth, + crossStipleLength: widget.crossStipleLength, + crossStipleGap: widget.crossStipleGap, + ), + ), + ), + ); + } +} + +class _KanimajiPainter extends CustomPainter { + final double progress; // 0..1 + + final List paths; + final List pathLengths; + final List pathDurations; + + // TODO: don't recalculate these all the time, compute once and cache + List get absolutePathDurations { + final List absolute = []; + double sum = 0.0; + for (final dur in pathDurations) { + absolute.add(sum); + sum += dur; + } + return absolute; + } + + final List strokeNumbers; + + final double viewBoxWidth; + final double viewBoxHeight; + + final Color strokeColor; + final Color strokeUnfilledColor; + final double strokeWidth; + final Color backgroundColor; + + final bool showBrush; + final Color brushColor; + final double brushRadius; + + final bool showStrokeNumbers; + final Color strokeNumberColor; + final Color currentStrokeNumberColor; + final double strokeNumberFontSize; + final String? strokeNumberFontFamily; + + final bool showCross; + final Color crossColor; + final double crossStrokeWidth; + final double crossStipleLength; + final double crossStipleGap; + + _KanimajiPainter({ + required this.progress, + + required this.paths, + required this.pathLengths, + required this.pathDurations, + required this.strokeNumbers, + + required this.viewBoxWidth, + required this.viewBoxHeight, + + required this.strokeColor, + required this.strokeUnfilledColor, + required this.strokeWidth, + required this.backgroundColor, + + required this.showBrush, + required this.brushColor, + required this.brushRadius, + + required this.showStrokeNumbers, + required this.strokeNumberColor, + required this.currentStrokeNumberColor, + required this.strokeNumberFontSize, + required this.strokeNumberFontFamily, + + required this.showCross, + required this.crossColor, + required this.crossStrokeWidth, + required this.crossStipleLength, + required this.crossStipleGap, + }); + + @override + void paint(Canvas canvas, Size size) { + final bgPaint = Paint()..color = backgroundColor; + canvas.drawRect(Offset.zero & size, bgPaint); + + if (paths.isEmpty) return; + + final double sx = size.width / viewBoxWidth; + final double sy = size.height / viewBoxHeight; + final double scale = math.min(sx, sy); + + final double dx = (size.width - viewBoxWidth * scale) / 2.0; + final double dy = (size.height - viewBoxHeight * scale) / 2.0; + + final Paint unfilledPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..strokeWidth = strokeWidth / scale + ..color = strokeUnfilledColor; + + final Paint filledPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..strokeWidth = strokeWidth / scale + ..color = strokeColor; + + final Paint brushPaint = Paint() + ..style = PaintingStyle.fill + ..color = brushColor; + + final Paint crossPaint = Paint() + ..style = PaintingStyle.stroke + ..color = crossColor + ..strokeWidth = crossStrokeWidth; + + // Draw cross if enabled + if (showCross) { + // Draw vertical stipled line + for ( + double y = 0; + y < size.height; + y += crossStipleLength + crossStipleGap + ) { + canvas.drawLine( + Offset(size.width / 2, y), + Offset(size.width / 2, math.min(y + crossStipleLength, size.height)), + crossPaint, + ); + } + // Draw horizontal stipled line + for ( + double x = 0; + x < size.width; + x += crossStipleLength + crossStipleGap + ) { + canvas.drawLine( + Offset(x, size.height / 2), + Offset(math.min(x + crossStipleLength, size.width), size.height / 2), + crossPaint, + ); + } + } + + // Draw all unfilled paths + for (final path in paths) { + canvas.drawPath(path, unfilledPaint); + } + + canvas.save(); + canvas.translate(dx, dy); + canvas.scale(scale, scale); + + // total animation time in seconds computed from durations + final double totalTime = pathDurations.isEmpty + ? 1.0 + : pathDurations.fold(0.0, (a, b) => a + b); + final double p = progress.clamp(0.0, 1.0); + + final int currentlyDrawingIndex = absolutePathDurations.lastIndexWhere( + (t) => t <= p * totalTime, + ); + + if (currentlyDrawingIndex == -1) { + for (final path in paths) { + canvas.drawPath(path, filledPaint); + } + canvas.restore(); + return; + } + + // Draw all completed strokes fully filled + for (int i = 0; i < currentlyDrawingIndex; i++) { + canvas.drawPath(paths[i], filledPaint); + } + + // Draw the currently drawing stroke with partial coverage + if (currentlyDrawingIndex >= 0 && currentlyDrawingIndex < paths.length) { + final ui.Path path = paths[currentlyDrawingIndex]; + final double len = pathLengths[currentlyDrawingIndex]; + final double dur = pathDurations[currentlyDrawingIndex]; + + final relativeElapsedTime = + p * totalTime - + (currentlyDrawingIndex > 0 + ? absolutePathDurations[currentlyDrawingIndex] + : 0.0); + + final double strokeProgress = (relativeElapsedTime / dur).clamp(0.0, 1.0); + final ui.PathMetrics metrics = path.computeMetrics(); + final ui.PathMetric metric = metrics.first; + final double drawLength = len * strokeProgress; + final ui.Path partialPath = metric.extractPath(0, drawLength); + canvas.drawPath(partialPath, filledPaint); + + if (showBrush) { + final ui.Tangent? tangent = metric.getTangentForOffset(drawLength); + if (tangent != null) { + canvas.drawCircle(tangent.position, brushRadius / scale, brushPaint); + } + } + } + + // Draw stroke numbers + if (showStrokeNumbers) { + final textPainter = TextPainter( + textAlign: TextAlign.center, + textDirection: TextDirection.ltr, + ); + + for (final sn in strokeNumbers) { + final bool isCurrent = + sn.num == + (currentlyDrawingIndex + 1).clamp(1, strokeNumbers.length); + final textSpan = TextSpan( + text: sn.num.toString(), + style: TextStyle( + color: isCurrent ? currentStrokeNumberColor : strokeNumberColor, + fontSize: strokeNumberFontSize / scale, + fontFamily: strokeNumberFontFamily, + ), + ); + textPainter.text = textSpan; + textPainter.layout(); + + final Offset pos = Offset( + sn.position.x.toDouble(), + sn.position.y.toDouble(), + ); + final Offset centeredPos = + pos - Offset(textPainter.width / 2, textPainter.height / 2); + textPainter.paint(canvas, centeredPos); + } + } + + canvas.restore(); + } + + @override + bool shouldRepaint(covariant _KanimajiPainter oldDelegate) { + return oldDelegate.progress != progress || + oldDelegate.paths != paths || + oldDelegate.strokeNumbers != strokeNumbers || + oldDelegate.strokeColor != strokeColor || + oldDelegate.strokeUnfilledColor != strokeUnfilledColor || + oldDelegate.strokeWidth != strokeWidth || + oldDelegate.backgroundColor != backgroundColor || + oldDelegate.showBrush != showBrush || + oldDelegate.brushColor != brushColor || + oldDelegate.brushRadius != brushRadius || + oldDelegate.showStrokeNumbers != showStrokeNumbers || + oldDelegate.strokeNumberColor != strokeNumberColor || + oldDelegate.currentStrokeNumberColor != currentStrokeNumberColor || + oldDelegate.strokeNumberFontSize != strokeNumberFontSize || + oldDelegate.strokeNumberFontFamily != strokeNumberFontFamily || + oldDelegate.showCross != showCross || + oldDelegate.crossColor != crossColor || + oldDelegate.crossStrokeWidth != crossStrokeWidth || + oldDelegate.crossStipleLength != crossStipleLength || + oldDelegate.crossStipleGap != crossStipleGap; } } diff --git a/pubspec.yaml b/pubspec.yaml index 6d81533..c9116ed 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,3 +15,4 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 + path: ^1.9.1 diff --git a/test/kanjivg_parser_test.dart b/test/kanjivg_parser_test.dart new file mode 100644 index 0000000..567e194 --- /dev/null +++ b/test/kanjivg_parser_test.dart @@ -0,0 +1,43 @@ +import 'dart:io'; +import 'package:path/path.dart' as path; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:kanimaji/kanjivg_parser.dart'; + +void main() { + final kanjivgDirStr = Platform.environment['KANJIVG_PATH']; + if (kanjivgDirStr == null) { + throw Exception('KANJIVG_PATH environment variable not set'); + } + final kanjivgDir = Directory(kanjivgDirStr); + final kanjivgFiles = kanjivgDir + .listSync(recursive: true) + .whereType() + .where((f) => f.path.endsWith('.svg')) + .toList(); + + for (final file in kanjivgFiles) { + test('Test parsing KanjiVG file ${path.basename(file.path)}', () async { + final content = await file.readAsString(); + try { + final item = KanjiVGItem.parseFromXml(content); + + expect(item, isNotNull, reason: 'Failed to parse ${file.path}'); + + expect( + item.strokeNumbers, + isNotEmpty, + reason: 'No stroke numbers found in ${file.path}', + ); + + expect( + item.strokeNumbers.length, + item.paths.length, + reason: 'Mismatch between stroke numbers and paths in ${file.path}', + ); + } catch (e) { + fail('Error parsing ${file.path}: $e'); + } + }); + } +} diff --git a/test/svg/generation_test.dart b/test/svg/generation_test.dart index 525d6e0..5762e1e 100644 --- a/test/svg/generation_test.dart +++ b/test/svg/generation_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:kanimaji/svg/parser.dart'; +import 'package:kanimaji/svg_parser.dart'; void main() { test('Test generating SVG path strings', () { diff --git a/test/svg/parser_test.dart b/test/svg/parser_test.dart index 7411ff3..3faacce 100644 --- a/test/svg/parser_test.dart +++ b/test/svg/parser_test.dart @@ -1,7 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:kanimaji/point.dart'; -import 'package:kanimaji/svg/parser.dart'; -import 'package:kanimaji/svg/path.dart'; +import 'package:kanimaji/primitives/path.dart'; +import 'package:kanimaji/primitives/point.dart'; +import 'package:kanimaji/svg_parser.dart'; void main() { group("Examples from the SVG spec", () { diff --git a/test/svg/path_test.dart b/test/svg/path_test.dart index 42603c6..d0ff037 100644 --- a/test/svg/path_test.dart +++ b/test/svg/path_test.dart @@ -2,8 +2,8 @@ import 'dart:math' show sqrt, pi; import 'package:flutter_test/flutter_test.dart'; -import 'package:kanimaji/point.dart'; -import 'package:kanimaji/svg/path.dart'; +import 'package:kanimaji/primitives/point.dart'; +import 'package:kanimaji/primitives/path.dart'; // from ..path import CubicBezier, QuadraticBezier, Line, Arc, Move, Close, Path diff --git a/test/svg/tokenizer_test.dart b/test/svg/tokenizer_test.dart index 24f20f2..a0f632b 100644 --- a/test/svg/tokenizer_test.dart +++ b/test/svg/tokenizer_test.dart @@ -2,8 +2,8 @@ // from svg.path import parser import 'package:flutter_test/flutter_test.dart'; -import 'package:kanimaji/point.dart'; -import 'package:kanimaji/svg/parser.dart' +import 'package:kanimaji/primitives/point.dart'; +import 'package:kanimaji/svg_parser.dart' show Command, Token, commandifyPath, parsePath, tokenizePath; class TokenizerTest {