diff --git a/1.svg b/1.svg
new file mode 100644
index 0000000..a1cb0a1
--- /dev/null
+++ b/1.svg
@@ -0,0 +1,119 @@
+
+
+
+
+]>
+
\ No newline at end of file
diff --git a/2.svg b/2.svg
new file mode 100644
index 0000000..1ea4042
--- /dev/null
+++ b/2.svg
@@ -0,0 +1,133 @@
+
+
+
+
+]>
+
\ No newline at end of file
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/index.html b/index.html
new file mode 100644
index 0000000..2c0af1f
--- /dev/null
+++ b/index.html
@@ -0,0 +1,252 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
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..b08b082
--- /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 number;
+ final Point position;
+
+ KanjiStrokeNumber(this.number, this.position);
+
+ @override
+ String toString() =>
+ 'KanjiStrokeNumber(number: $number, 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..62d2652 100644
--- a/lib/widget.dart
+++ b/lib/widget.dart
@@ -1,11 +1,348 @@
-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);
+}
+
+class Kanimaji extends StatefulWidget {
final String kanji;
- const Kanimaji({super.key, required this.kanji});
+
+ // TODO: add support for drawing stipled cross
+
+ final bool loop;
+
+ final bool showBrush;
+ final Color brushColor;
+ final double brushRadius;
+
+ final Color strokeColor;
+ final Color strokeUnfilledColor;
+ final double strokeWidth;
+ final Color backgroundColor;
+
+ final FutureOr Function(String kanji) kanjiDataProvider;
+
+ // TODO: add support for configuring a kanji data provider
+
+ // final double speed;
+ // final bool loop;
+ // final Duration? durationOverride;
+
+ const Kanimaji({
+ super.key,
+ required this.kanji,
+ this.loop = true,
+ this.strokeColor = Colors.black,
+ this.strokeUnfilledColor = const Color(0xFFEEEEEE),
+ this.strokeWidth = 3.0,
+ this.backgroundColor = Colors.transparent,
+ this.showBrush = true,
+ this.brushColor = Colors.red,
+ this.brushRadius = 4.0,
+ this.kanjiDataProvider = _defaultKanjiDataProvider,
+ });
+
+ @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;
+
+ // // total length summation (for drawing metrics)
+ // double _totalLength = 0.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 kanji changed, re-load
+ // if (oldWidget.kanji != widget.kanji) {
+ // _loadAndParseSvg();
+ // } else if (oldWidget.speed != widget.speed ||
+ // oldWidget.durationOverride != widget.durationOverride ||
+ // oldWidget.loop != widget.loop) {
+ // // Update controller if animation parameters changed
+ // _configureController();
+ // }
+ // }
+
+ 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);
+ // final double scaledTotalSec = (widget.speed <= 0) ? totalSec : (totalSec / widget.speed);
+ // final Duration duration = widget.durationOverride ??
+ // Duration(milliseconds: (math.max(0.001, scaledTotalSec) * 1000.0).round());
+
+ _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,
+ 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,
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _KanimajiPainter extends CustomPainter {
+ final double progress; // 0..1
+
+ final List paths;
+ final List pathLengths;
+ final List pathDurations;
+
+ 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;
+
+ _KanimajiPainter({
+ required this.progress,
+
+ required this.paths,
+ required this.pathLengths,
+ required this.pathDurations,
+
+ 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,
+ });
+
+ @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;
+
+ canvas.save();
+ canvas.translate(dx, dy);
+ canvas.scale(scale, scale);
+
+ final Paint unfilledPaint = Paint()
+ ..style = PaintingStyle.stroke
+ ..strokeCap = StrokeCap.round
+ ..strokeJoin = StrokeJoin.round
+ ..strokeWidth =
+ strokeWidth /
+ scale // scale stroke width so it looks consistent
+ ..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.stroke
+ ..strokeCap = StrokeCap.round
+ ..strokeJoin = StrokeJoin.round
+ ..strokeWidth = brushRadius / scale
+ ..color = brushColor;
+
+ // // 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);
+
+ double elapsedTime = 0.0; // seconds accumulated so far
+
+ for (int i = 0; i < paths.length; i++) {
+ final ui.Path path = paths[i];
+ final double len = pathLengths[i];
+ final double dur = (i < pathDurations.length) ? pathDurations[i] : 0.0;
+ final double startFraction = totalTime == 0
+ ? 0.0
+ : (elapsedTime / totalTime);
+ final double endFraction = totalTime == 0
+ ? 1.0
+ : ((elapsedTime + dur) / totalTime);
+
+ // Draw background (unfilled) fully for this stroke
+ canvas.drawPath(path, unfilledPaint);
+
+ // Determine coverage for this stroke based on global progress (time-based)
+ double coverage = 0.0;
+ if (p >= endFraction) {
+ coverage = 1.0;
+ } else if (p <= startFraction || dur <= 0.0) {
+ coverage = 0.0;
+ } else {
+ coverage = (p - startFraction) / (endFraction - startFraction);
+ }
+
+ if (coverage > 0.0) {
+ // extract subpath up to coverage * len (length-based extraction for drawing)
+ final metrics = path.computeMetrics().toList();
+ final ui.Path drawn = ui.Path();
+
+ double remain = coverage * len;
+ for (final metric in metrics) {
+ if (remain <= 0) break;
+ final take = math.min(metric.length, remain);
+ final sub = metric.extractPath(0.0, take);
+ drawn.addPath(sub, Offset.zero);
+ remain -= take;
+ }
+
+ // draw filled portion
+ canvas.drawPath(drawn, filledPaint);
+
+ // brush tip
+ if (showBrush && coverage > 0.0 && coverage < 1.0) {
+ // find the tangent at the very end of the drawn part
+ final lastMetric = metrics.lastWhere(
+ (m) => m.length >= coverage * len,
+ );
+ final tangent = lastMetric.getTangentForOffset(coverage * len);
+ if (tangent != null) {
+ canvas.drawCircle(
+ tangent.position,
+ brushRadius / scale,
+ brushPaint,
+ );
+ }
+ }
+ }
+
+ elapsedTime += dur;
+ }
+
+ canvas.restore();
+ }
+
+ @override
+ bool shouldRepaint(covariant _KanimajiPainter oldDelegate) {
+ return oldDelegate.progress != progress ||
+ oldDelegate.paths != paths ||
+ oldDelegate.strokeColor != strokeColor ||
+ oldDelegate.strokeUnfilledColor != strokeUnfilledColor ||
+ oldDelegate.strokeWidth != strokeWidth ||
+ oldDelegate.backgroundColor != backgroundColor ||
+ oldDelegate.showBrush != showBrush ||
+ oldDelegate.brushColor != brushColor ||
+ oldDelegate.brushRadius != brushRadius;
}
}
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 {