Compare commits
1 Commits
main
...
restructur
| Author | SHA1 | Date | |
|---|---|---|---|
|
87e23e794c
|
12
flake.lock
generated
12
flake.lock
generated
@@ -3,11 +3,11 @@
|
|||||||
"kanjivg": {
|
"kanjivg": {
|
||||||
"flake": false,
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1754131552,
|
"lastModified": 1770109946,
|
||||||
"narHash": "sha256-1h3nT1f1QqXme4rU3HMmEREn74sAASyZ8qzjZ0tPi4I=",
|
"narHash": "sha256-zgkyLJwEJe6YABUNrL27BBrDSWVNcAUk7K6P1mcVHxQ=",
|
||||||
"owner": "KanjiVG",
|
"owner": "KanjiVG",
|
||||||
"repo": "kanjivg",
|
"repo": "kanjivg",
|
||||||
"rev": "0d08020e69611552a0fbe13d49365d6196431b96",
|
"rev": "2fe6daaba502ee6735f888e5fb4a7d30639a18ee",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -18,11 +18,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1754214453,
|
"lastModified": 1771369470,
|
||||||
"narHash": "sha256-Q/I2xJn/j1wpkGhWkQnm20nShYnG7TI99foDBpXm1SY=",
|
"narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "5b09dc45f24cf32316283e62aec81ffee3c3e376",
|
"rev": "0182a361324364ae3f436a63005877674cf45efb",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math' show min, sqrt, pow;
|
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 'package:xml/xml.dart';
|
||||||
|
|
||||||
|
import 'svg_parser.dart';
|
||||||
|
import 'primitives/point.dart';
|
||||||
|
import 'primitives/bezier.dart' as bezier;
|
||||||
|
|
||||||
double _computePathLength(String path) =>
|
double _computePathLength(String path) =>
|
||||||
parsePath(path).size(error: 1e-8).toDouble();
|
parsePath(path).size(error: 1e-8).toDouble();
|
||||||
|
|
||||||
|
|||||||
1
lib/kanimaji.dart
Normal file
1
lib/kanimaji.dart
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export 'package:kanimaji/widget.dart' show Kanimaji;
|
||||||
212
lib/kanjivg_parser.dart
Normal file
212
lib/kanjivg_parser.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,10 +5,11 @@ library;
|
|||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'dart:math' show sqrt, sin, cos, acos, log, pi;
|
import 'dart:math' show sqrt, sin, cos, acos, log, pi;
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:bisection/extension.dart';
|
import 'package:bisection/extension.dart';
|
||||||
|
|
||||||
import '../point.dart';
|
import 'point.dart';
|
||||||
|
|
||||||
num radians(num n) => n * pi / 180;
|
num radians(num n) => n * pi / 180;
|
||||||
num degrees(num n) => n * 180 / pi;
|
num degrees(num n) => n * 180 / pi;
|
||||||
@@ -609,4 +610,94 @@ class Path extends ListBase<SvgPath> {
|
|||||||
|
|
||||||
return parts.join(" ").toUpperCase();
|
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
0
lib/svg_exporter.dart
Normal file
@@ -3,8 +3,8 @@
|
|||||||
/// See https://pypi.org/project/svg.path/ for the original implementation.
|
/// See https://pypi.org/project/svg.path/ for the original implementation.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import '../point.dart';
|
import 'primitives/point.dart';
|
||||||
import 'path.dart';
|
import 'primitives/path.dart';
|
||||||
|
|
||||||
const _commands = {
|
const _commands = {
|
||||||
'M',
|
'M',
|
||||||
@@ -32,7 +32,7 @@ const _commands = {
|
|||||||
// const _uppercaseCommands = {'M', 'Z', 'L', 'H', 'V', 'C', 'S', 'Q', 'T', 'A'};
|
// const _uppercaseCommands = {'M', 'Z', 'L', 'H', 'V', 'C', 'S', 'Q', 'T', 'A'};
|
||||||
|
|
||||||
final _commandPattern = RegExp("(?=[${_commands.join('')}])");
|
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> {
|
class ParserResult<T> {
|
||||||
final T value;
|
final T value;
|
||||||
481
lib/widget.dart
481
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<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;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,3 +15,4 @@ dev_dependencies:
|
|||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
|
path: ^1.9.1
|
||||||
|
|||||||
43
test/kanjivg_parser_test.dart
Normal file
43
test/kanjivg_parser_test.dart
Normal 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,5 +1,5 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:kanimaji/svg/parser.dart';
|
import 'package:kanimaji/svg_parser.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
test('Test generating SVG path strings', () {
|
test('Test generating SVG path strings', () {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:kanimaji/point.dart';
|
import 'package:kanimaji/primitives/path.dart';
|
||||||
import 'package:kanimaji/svg/parser.dart';
|
import 'package:kanimaji/primitives/point.dart';
|
||||||
import 'package:kanimaji/svg/path.dart';
|
import 'package:kanimaji/svg_parser.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group("Examples from the SVG spec", () {
|
group("Examples from the SVG spec", () {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
import 'dart:math' show sqrt, pi;
|
import 'dart:math' show sqrt, pi;
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:kanimaji/point.dart';
|
import 'package:kanimaji/primitives/point.dart';
|
||||||
import 'package:kanimaji/svg/path.dart';
|
import 'package:kanimaji/primitives/path.dart';
|
||||||
|
|
||||||
// from ..path import CubicBezier, QuadraticBezier, Line, Arc, Move, Close, Path
|
// from ..path import CubicBezier, QuadraticBezier, Line, Arc, Move, Close, Path
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
// from svg.path import parser
|
// from svg.path import parser
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:kanimaji/point.dart';
|
import 'package:kanimaji/primitives/point.dart';
|
||||||
import 'package:kanimaji/svg/parser.dart'
|
import 'package:kanimaji/svg_parser.dart'
|
||||||
show Command, Token, commandifyPath, parsePath, tokenizePath;
|
show Command, Token, commandifyPath, parsePath, tokenizePath;
|
||||||
|
|
||||||
class TokenizerTest {
|
class TokenizerTest {
|
||||||
|
|||||||
Reference in New Issue
Block a user