This commit is contained in:
2026-02-21 17:24:59 +09:00
parent aba99a8996
commit 87e23e794c
16 changed files with 847 additions and 26 deletions

12
flake.lock generated
View File

@@ -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": {

View File

@@ -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();

1
lib/kanimaji.dart Normal file
View File

@@ -0,0 +1 @@
export 'package:kanimaji/widget.dart' show Kanimaji;

212
lib/kanjivg_parser.dart Normal file
View File

@@ -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 `<g>` 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 \<g> element in the KanjiVG SVG files.
class KanjiPathGroupTreeNode {
final String id;
final List<KanjiPathGroupTreeNode> 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 `<text>` 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 `<path>` 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<Path> paths;
final List<KanjiStrokeNumber> 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<KanjiVGPath> paths;
final List<KanjiStrokeNumber> 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<KanjiVGPath> _parsePaths(XmlDocument doc) {
final List<KanjiVGPath> 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<KanjiStrokeNumber> _parseStrokeNumbers(XmlDocument doc) {
final List<KanjiStrokeNumber> 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,
);
}
}

View File

@@ -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<SvgPath> {
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;
}
}

0
lib/svg_exporter.dart Normal file
View File

View File

@@ -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<T> {
final T value;

View File

@@ -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<KanjiVGItem> _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<KanjiVGItem> 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<Kanimaji> createState() => _KanimajiState();
}
class _KanimajiState extends State<Kanimaji>
with SingleTickerProviderStateMixin {
KanjiVGItem? _kanjiData;
String? _error;
late AnimationController _controller;
List<double> get _pathLengths =>
_kanjiData?.paths
.map((p) => p.svgPath.size(error: 1e-8).toDouble())
.toList() ??
[];
List<double> 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<void> _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<ui.Path> paths;
final List<double> pathLengths;
final List<double> pathDurations;
// TODO: don't recalculate these all the time, compute once and cache
List<double> get absolutePathDurations {
final List<double> absolute = [];
double sum = 0.0;
for (final dur in pathDurations) {
absolute.add(sum);
sum += dur;
}
return absolute;
}
final List<KanjiStrokeNumber> 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;
}
}

View File

@@ -15,3 +15,4 @@ dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
path: ^1.9.1

View File

@@ -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<File>()
.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');
}
});
}
}

View File

@@ -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', () {

View File

@@ -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", () {

View File

@@ -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

View File

@@ -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 {