This commit is contained in:
2026-02-21 17:24:59 +09:00
parent aba99a8996
commit 4f8e81e39a
16 changed files with 714 additions and 26 deletions
Generated
+6 -6
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": {
+4 -4
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
View File
@@ -0,0 +1 @@
export 'package:kanimaji/widget.dart' show Kanimaji;
+212
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 number;
final Point position;
KanjiStrokeNumber(this.number, this.position);
@override
String toString() =>
'KanjiStrokeNumber(number: $number, 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,
);
}
}
+92 -1
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;
}
}
View File
+3 -3
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;
+344 -4
View File
@@ -1,11 +1,351 @@
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);
}
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;
// TODO: add support for drawing stroke numbers
// final bool showStrokeNumbers;
// final Color strokeNumberColor;
// final double strokeNumberFontSize;
// final String strokeNumberFontFamily;
// TODO: add support for drawing stipled cross
// 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(0xFFEEEEEE),
this.strokeWidth = 3.0,
this.backgroundColor = Colors.transparent,
this.showBrush = true,
this.brushColor = Colors.red,
this.brushRadius = 4.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;
// // 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<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);
// 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<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 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;
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;
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);
}
}
}
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;
}
}
+1
View File
@@ -15,3 +15,4 @@ dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
path: ^1.9.1
+43
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');
}
});
}
}
+1 -1
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', () {
+3 -3
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", () {
+2 -2
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
+2 -2
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 {