20 Commits

Author SHA1 Message Date
oysteikt 87e23e794c WIP 2026-02-22 18:19:30 +09:00
oysteikt aba99a8996 .gitea/workflows: update actions/checkout: v4 -> v6
Test / test (push) Failing after 2m6s
2025-12-08 18:54:46 +09:00
oysteikt 8ae8d4ebd1 .gitea/workflows: run on debian-latest 2025-12-08 18:54:38 +09:00
oysteikt 039c7e2693 lib/svg/path: swap out large if-else for switch
Test / test (push) Failing after 2m30s
2025-08-04 23:05:58 +02:00
oysteikt 417db6ab0c workflow/test: init
Test / test (push) Failing after 1m19s
2025-08-04 22:40:32 +02:00
oysteikt 00ec3c43fc flake.nix: use nativeBuildInputs instead of buildInputs 2025-08-04 22:40:31 +02:00
oysteikt 1130ae2974 flake.nix: add kanjivg as input, remove assets/kanjivg git submodule 2025-08-04 22:40:31 +02:00
oysteikt 0623bbc50a lib: reorganize file structure 2025-08-04 22:40:31 +02:00
oysteikt c83821fa38 treewide: dart format + analyze + some other fixes 2025-08-04 21:39:31 +02:00
oysteikt 9f9757d710 README: update 2025-08-04 21:39:31 +02:00
oysteikt 5cdd383e86 pubspec.yaml: bump package versions 2025-08-04 21:29:06 +02:00
oysteikt e7f1e3563b LICENSE: init 2025-08-04 21:29:05 +02:00
oysteikt c02009f601 flake.nix: init 2025-08-04 21:29:05 +02:00
oysteikt 2338e34a19 Update tests 2025-08-04 21:29:04 +02:00
oysteikt ee92a46b11 Make Arc const 2025-08-04 21:29:04 +02:00
oysteikt f983a3adf1 update file names 2025-08-04 21:29:04 +02:00
oysteikt b1732b71f1 Add some tests and fix several bugs 2025-08-04 21:29:03 +02:00
oysteikt 4c98d488f8 Misc changes 2025-08-04 21:29:03 +02:00
oysteikt e5ed44c24b Several changes 2025-08-04 21:29:03 +02:00
oysteikt 1c692a6889 Initial commit 2025-08-04 21:29:02 +02:00
28 changed files with 1960 additions and 1141 deletions
+1
View File
@@ -0,0 +1 @@
use flake
+33
View File
@@ -0,0 +1,33 @@
name: "Test"
on:
push:
branches:
- main
pull_request:
jobs:
test:
runs-on: debian-latest
steps:
- uses: actions/checkout@v6
- name: apt-get install dependencies
run: |
apt-get update
apt-get install -y git jq
- run: git config --global --add safe.directory '*'
- run: git clone --depth=1 --single-branch --branch=master https://git.pvv.ntnu.no/mugiten/kanjivg.git kanjivg
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: 'stable'
- run: flutter pub get
- run: flutter test
env:
KANJIVG_PATH: ${{ github.workspace }}/kanjivg
-3
View File
@@ -1,3 +0,0 @@
[submodule "assets/kanjivg"]
path = assets/kanjivg
url = git@github.com:KanjiVG/kanjivg.git
+21 -1
View File
@@ -1 +1,21 @@
TODO: Add your license here.
MIT License
Copyright (c) 2025 h7x4 <h7x4@nani.wtf>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+7 -24
View File
@@ -1,34 +1,17 @@
# Kanimaji
Add animated kanji strokes to your app!
Kanji stroke order animations for flutter.
## Features
> [!WARNING]
> This library is still not finished, take the contents and promises of this README with a grain of salt.
This library is a port of [Kanimaji][kanimaji], a library for animating kanji.
It provides a way to convert stroke data from [KanjiVG][kanjivg] into kanji animations.
This library is a port of [Kanimaji][kanimaji], a library for animating kanji strokes.
It uses the stroke data from the [KanjiVG][kanjivg] project, and renders them as either SVG animations or flutter animations.
This library ports this ability into flutter, and lets you choose speed, colors, and formats, in the form of a `Kanimaji` widget and a SVG/GIF generating function.
## Getting started
Start by adding the project to your pubspec.yaml.
You can configure the animation's speed, curve, colors, and more.
## Usage
TODO: Include short and useful examples for package users. Add longer examples
to `/example` folder.
```dart
const like = 'sample';
```
## Additional information
The [svg library used](lib/svg) is mostly a rewrite of pythons [svg.path][svg.path].
This is what kanimaji originally used for animation, and even thought there's a lot of svg path parsers in dart, I found none that was able to calculate the length of the path. If you do find one, please let me know!
Also, do note that most of the comments in the project is brought over from the python projects.
I've tried to adjust and remove some of them to make them more useful, but they shouldn't be trusted if there's doubt.
[svg.path]: https://pypi.org/project/svg.path/
to `/example` folder.
Submodule assets/kanjivg deleted from e1d99250c5
Generated
+43
View File
@@ -0,0 +1,43 @@
{
"nodes": {
"kanjivg": {
"flake": false,
"locked": {
"lastModified": 1770109946,
"narHash": "sha256-zgkyLJwEJe6YABUNrL27BBrDSWVNcAUk7K6P1mcVHxQ=",
"owner": "KanjiVG",
"repo": "kanjivg",
"rev": "2fe6daaba502ee6735f888e5fb4a7d30639a18ee",
"type": "github"
},
"original": {
"owner": "KanjiVG",
"repo": "kanjivg",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1771369470,
"narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "0182a361324364ae3f436a63005877674cf45efb",
"type": "github"
},
"original": {
"id": "nixpkgs",
"ref": "nixos-unstable",
"type": "indirect"
}
},
"root": {
"inputs": {
"kanjivg": "kanjivg",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+57
View File
@@ -0,0 +1,57 @@
{
description = "Flutter package for rendering kanji stroke animations";
inputs = {
nixpkgs.url = "nixpkgs/nixos-unstable";
kanjivg = {
url = "github:KanjiVG/kanjivg";
flake = false;
};
};
outputs = {
self,
nixpkgs,
kanjivg
}: let
inherit (nixpkgs) lib;
systems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forAllSystems = f: lib.genAttrs systems (system: f system nixpkgs.legacyPackages.${system});
in {
devShells = forAllSystems (system: pkgs: {
default = pkgs.mkShell {
nativeBuildInputs = with pkgs; [
flutter
dart
];
env.KANJIVG_PATH = "${kanjivg}/kanji";
};
});
packages = let
src = builtins.filterSource (path: type: let
baseName = baseNameOf (toString path);
in !(lib.any (b: b) [
(!(lib.cleanSourceFilter path type))
(baseName == "nix" && type == "directory")
(baseName == ".envrc" && type == "regular")
(baseName == "flake.lock" && type == "regular")
(baseName == "flake.nix" && type == "regular")
])) ./.;
in forAllSystems (system: pkgs: {
# default = self.packages.${system}.kanjimaji;
filteredSource = pkgs.runCommandLocal "filtered-source" { } ''
ln -s ${src} $out
'';
});
};
}
@@ -1,31 +1,24 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' show min, sqrt, pow;
import 'dart:typed_data';
import 'package:flutter_svg/flutter_svg.dart' as svg;
import 'package:image/image.dart' as image;
import '../svg/parser.dart';
import '../common/point.dart';
import 'bezier_cubic.dart' as bezier_cubic;
import 'package:xml/xml.dart';
import 'package:path/path.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();
String _shescape(String path) =>
"'${path.replaceAll(RegExp(r"(?=['\\\\])"), "\\\\")}'";
// String _shescape(String path) =>
// "'${path.replaceAll(RegExp(r"(?=['\\\\])"), "\\\\")}'";
extension _Dedent on String {
String get dedented {
final withoutEmptyLines =
this.split('\n').where((l) => l.isNotEmpty).toList();
final withoutEmptyLines = split('\n').where((l) => l.isNotEmpty).toList();
final whitespaceAmounts = [
for (final line in withoutEmptyLines)
line.split('').takeWhile((c) => c == ' ').length
line.split('').takeWhile((c) => c == ' ').length,
];
final whitespaceToRemove = whitespaceAmounts.reduce(min);
return withoutEmptyLines
@@ -68,40 +61,34 @@ const pt2 = Point(1, 1);
// class {
// }
enum TimingFunction {
linear,
ease,
easeIn,
easeInOut,
easeOut,
}
enum TimingFunction { linear, ease, easeIn, easeInOut, easeOut }
extension Funcs on TimingFunction {
double Function(double) get func => {
TimingFunction.linear: (double x) => x,
TimingFunction.ease: (double x) =>
bezier_cubic.value(pt1, easeCt1, easeCt2, pt2, x),
TimingFunction.easeIn: (double x) =>
bezier_cubic.value(pt1, easeInCt1, easeInCt2, pt2, x),
TimingFunction.easeInOut: (double x) =>
bezier_cubic.value(pt1, easeInOutCt1, easeInOutCt2, pt2, x),
TimingFunction.easeOut: (double x) =>
bezier_cubic.value(pt1, easeOutCt1, easeOutCt2, pt2, x),
}[this]!;
TimingFunction.linear: (double x) => x,
TimingFunction.ease: (double x) =>
bezier.value(pt1, easeCt1, easeCt2, pt2, x),
TimingFunction.easeIn: (double x) =>
bezier.value(pt1, easeInCt1, easeInCt2, pt2, x),
TimingFunction.easeInOut: (double x) =>
bezier.value(pt1, easeInOutCt1, easeInOutCt2, pt2, x),
TimingFunction.easeOut: (double x) =>
bezier.value(pt1, easeOutCt1, easeOutCt2, pt2, x),
}[this]!;
String get name => {
TimingFunction.linear: 'linear',
TimingFunction.ease: 'ease',
TimingFunction.easeIn: 'ease-in',
TimingFunction.easeInOut: 'ease-in-out',
TimingFunction.easeOut: 'ease-out',
}[this]!;
TimingFunction.linear: 'linear',
TimingFunction.ease: 'ease',
TimingFunction.easeIn: 'ease-in',
TimingFunction.easeInOut: 'ease-in-out',
TimingFunction.easeOut: 'ease-out',
}[this]!;
}
// we will need this to deal with svg
const namespaces = {
'n': 'http://www.w3.org/2000/svg',
'xlink': 'http://www.w3.org/1999/xlink'
'xlink': 'http://www.w3.org/1999/xlink',
};
// etree.register_namespace("xlink","http://www.w3.org/1999/xlink")
// final parser = etree.XMLParser(remove_blank_text=true);
@@ -129,33 +116,61 @@ double timeRescale(interval) => pow(2 * interval, 2.0 / 3).toDouble();
/// clear all extra elements this program may have previously added
void clearPreviousElements(XmlDocument doc) {
for (final XmlNode el in doc
.getElement('svg', namespace: namespaces['n'])
?.getElement('style', namespace: namespaces['n'])
?.children ??
[]) {
for (final XmlNode el
in doc
.getElement('svg', namespace: namespaces['n'])
?.getElement('style', namespace: namespaces['n'])
?.children ??
[]) {
if (RegExp(r'-Kanimaji$').hasMatch(el.getAttribute('id') ?? '')) {
el.parent!.children.remove(el);
}
}
for (final XmlNode g in doc
.getElement('svg', namespace: namespaces['n'])
?.getElement('g', namespace: namespaces['n'])
?.children ??
[]) {
for (final XmlNode g
in doc
.getElement('svg', namespace: namespaces['n'])
?.getElement('g', namespace: namespaces['n'])
?.children ??
[]) {
if (RegExp(r'-Kanimaji$').hasMatch(g.getAttribute('id') ?? '')) {
g.parent!.children.remove(g);
}
}
}
XmlDocument fetchXML(String inputFile) {
//------------------------------
// FETCH DATA FILE
//------------------------------
String basename(String path) {
final int lastSlash = path.lastIndexOf(Platform.pathSeparator);
if (lastSlash == -1) {
return path;
}
return path.substring(lastSlash + 1);
}
/// Note: setting any color to transparent will result in a much bigger
/// filesize for GIFs.
void createAnimation({
required String inputFile,
String? outputFile,
TimingFunction timingFunction = TimingFunction.easeInOut,
double strokeBorderWidth = 4.5,
double strokeUnfilledWidth = 3,
double strokeFilledWidth = 3.1,
bool showBrush = true,
bool showBrushFrontBorder = true,
double brushWidth = 5.5,
double brushBorderWidth = 7,
double waitAfter = 1.5,
String strokeBorderColor = '#666',
String strokeUnfilledColor = '#EEE',
String strokeFillingColor = '#F00',
String strokeFilledColor = '#000',
String brushColor = '#F00',
String brushBorderColor = '#666',
}) {
print('processing $inputFile');
final String filenameNoext = inputFile.replaceAll(RegExp(r'\.[^\.]+$'), '');
outputFile ??= '${filenameNoext}_anim.svg';
final String baseid = basename(filenameNoext);
// load xml
final XmlDocument doc = XmlDocument.parse(File(inputFile).readAsStringSync());
@@ -165,44 +180,6 @@ XmlDocument fetchXML(String inputFile) {
doc.rootElement.setAttribute('xlink:used', '');
clearPreviousElements(doc);
return doc;
}
// void generateCssForPath(XmlNode p, void Function<String> addCss) {
// }
/// Note: setting any color to transparent will result in a much bigger
/// filesize for GIFs.
FutureOr<void> createAnimation({
required String kanji,
String? outputFile,
TimingFunction timingFunction = TimingFunction.easeInOut,
double strokeBorderWidth = 4.5,
double strokeUnfilledWidth = 3,
double strokeFilledWidth = 3.1,
bool showBrush = true,
// bool showNumbers = false,
bool showBrushFrontBorder = true,
double brushWidth = 5.5,
double brushBorderWidth = 7,
double waitAfter = 1.5,
int gifSize = 500,
String strokeBorderColor = '#666',
String strokeUnfilledColor = '#EEE',
String strokeFillingColor = '#F00',
String strokeFilledColor = '#000',
String brushColor = '#F00',
String brushBorderColor = '#666',
}) async {
final inputFile = 'assets/kanjivg/kanji/${kanji.codeUnitAt(0).toRadixString(16).padLeft(5, '0')}.svg';
final String filenameNoext = inputFile.replaceAll(RegExp(r'\.[^\.]+$'), '');
outputFile ??= '${filenameNoext}_anim.svg';
final String baseid = basename(filenameNoext);
final doc = fetchXML(inputFile);
//------------------------------
// CREATE SVG PATH GROUPS
//------------------------------
/// create groups with a copies (references actually) of the paths
XmlDocumentFragment pathCopyGroup({
@@ -215,7 +192,8 @@ FutureOr<void> createAnimation({
'g',
attributes: {
'id': 'kvg:$baseid-$id-Kanimaji',
'style': 'fill:none;'
'style':
'fill:none;'
'stroke:$color;'
'stroke-width:$width;'
'stroke-linecap:round;'
@@ -252,20 +230,17 @@ FutureOr<void> createAnimation({
);
}
//------------------------------
// CALCULATE STROKE TIMES
//------------------------------
// compute total length and time, at first
double totlen = 0;
double tottime = 0;
// for (final g in doc.xpath("/n:svg/n:g", namespaces=namespaces) {
for (final XmlNode g in doc
.getElement('svg', namespace: namespaces['n'])
?.getElement('g', namespace: namespaces['n'])
?.children ??
[]) {
for (final XmlNode g
in doc
.getElement('svg', namespace: namespaces['n'])
?.getElement('g', namespace: namespaces['n'])
?.children ??
[]) {
if (RegExp(r'^kvg:StrokeNumbers_').hasMatch(g.getAttribute('id') ?? '')) {
continue;
}
@@ -283,10 +258,6 @@ FutureOr<void> createAnimation({
final double actualAnimationTime = animationTime;
animationTime += waitAfter;
//------------------------------
// START ADDING CSS
//------------------------------
final Map<int, String> staticCss = {};
late String animatedCss;
@@ -299,9 +270,7 @@ FutureOr<void> createAnimation({
'\n/* CSS automatically generated by kanimaji.py, do not edit! */\n';
if (GENERATE_SVG) animatedCss = cssHeader;
if (GENERATE_JS_SVG) {
jsAnimatedCss += cssHeader +
'''
.backward {\n
jsAnimatedCss += '''$cssHeader .backward {\n
animation-direction: reverse !important;\n
}
''';
@@ -319,23 +288,21 @@ FutureOr<void> createAnimation({
double elapsedlen = 0;
double elapsedtime = 0;
//------------------------------
// ADD CSS FOR STROKE STYLE
//------------------------------
// add css elements for all strokes
for (final XmlNode g in doc
.getElement('svg', namespace: namespaces['n'])!
.findElements('g', namespace: namespaces['n'])) {
for (final XmlNode g
in doc
.getElement('svg', namespace: namespaces['n'])!
.findElements('g', namespace: namespaces['n'])) {
// for (final g in doc.xpath("/n:svg/n:g", namespaces=namespaces)){
final groupid = g.getAttribute('id') ?? '';
if (RegExp(r'^kvg:StrokeNumbers_').hasMatch(groupid)) {
final String rule = '''
final String rule =
'''
#${groupid.replaceAll(':', '\\3a ')} {
display: none;
}
'''
.dedented;
.dedented;
if (GENERATE_SVG) animatedCss += rule;
if (GENERATE_JS_SVG) jsAnimatedCss += rule;
if (GENERATE_GIF) {
@@ -347,13 +314,14 @@ FutureOr<void> createAnimation({
}
final gidcss = groupid.replaceAll(':', '\\3a ');
final rule = '''
final rule =
'''
#$gidcss {
stroke-width: ${strokeBorderWidth.toStringAsFixed(1)}px !important;
stroke: $strokeBorderColor !important;
}
'''
.dedented;
.dedented;
if (GENERATE_SVG) animatedCss += rule;
if (GENERATE_JS_SVG) jsAnimatedCss += rule;
@@ -363,12 +331,7 @@ FutureOr<void> createAnimation({
}
}
//------------------------------
// ADD CSS FOR HREFS
//------------------------------
for (final p in g.findAllElements("path", namespace: namespaces['n'])) {
final pathid = p.getAttribute('id') as String;
final pathidcss = pathid.replaceAll(':', '\\3a ');
@@ -397,10 +360,6 @@ FutureOr<void> createAnimation({
brushBorderGroupElement = addHref('brush-brd', brushBrdGroup);
}
//------------------------------
// CALCULATE RELATIVE TIMING
//------------------------------
final pathname = pathid.replaceAll(RegExp(r'^kvg:'), '');
final pathlen = _computePathLength(p.getAttribute('d') as String);
final duration = strokeLengthToDuration(pathlen);
@@ -424,13 +383,10 @@ FutureOr<void> createAnimation({
final animStart = elapsedtime / tottime * 100;
final animEnd = newelapsedtime / tottime * 100;
//------------------------------
// GENERATE SVG SPECIFIC ANIMATION CSS
//------------------------------
if (GENERATE_SVG) {
// animation stroke progression
animatedCss += '''
animatedCss +=
'''
@keyframes strike-$pathname {
0% { stroke-dashoffset: ${pathlen.toStringAsFixed(3)}; }
${animStart.toStringAsFixed(3)}% { stroke-dashoffset: ${pathlen.toStringAsFixed(3)}; }
@@ -448,11 +404,12 @@ FutureOr<void> createAnimation({
showhide-$pathname ${animationTime.toStringAsFixed(3)}s step-start infinite;
}
'''
.dedented;
.dedented;
if (showBrush) {
// brush element visibility
animatedCss += '''
animatedCss +=
'''
@keyframes showhide-brush-$pathname {
${animStart.toStringAsFixed(3)}% { visibility: hidden; }
${animEnd.toStringAsFixed(3)}% { visibility: visible; }
@@ -464,30 +421,28 @@ FutureOr<void> createAnimation({
showhide-brush-$pathname ${animationTime.toStringAsFixed(3)}s step-start infinite;
}
'''
.dedented;
.dedented;
}
}
//------------------------------
// GENERATE JS SVG SPECIFIC ANIMATION CSS
//------------------------------
if (GENERATE_JS_SVG) {
jsAnimatedCss += '\n/* stroke $pathid */\n';
// brush and background hidden by default
if (showBrush) {
jsAnimatedCss += '''
jsAnimatedCss +=
'''
#$brushPathidcss, #$brushBorderPathidcss, #$bgPathidcss {
visibility: hidden;
}
'''
.dedented;
.dedented;
}
// hide stroke after current element
const afterCurrent = '[class *= "current"]';
jsAnimatedCss += '''
jsAnimatedCss +=
'''
$afterCurrent ~ #$animPathidcss {
visibility: hidden;
}
@@ -505,9 +460,10 @@ FutureOr<void> createAnimation({
animation: strike-$pathname ${relativeDuration.toStringAsFixed(3)}s ${timingFunction.name} forwards 1;
}
'''
.dedented;
.dedented;
if (showBrush) {
jsAnimatedCss += '''
jsAnimatedCss +=
'''
@keyframes strike-brush-$pathname {
0% { stroke-dashoffset: ${pathlen.toStringAsFixed(3)}; }
100% { stroke-dashoffset: 0.4; }
@@ -518,20 +474,16 @@ FutureOr<void> createAnimation({
animation: strike-brush-$pathname ${relativeDuration.toStringAsFixed(3)}s ${timingFunction.name} forwards 1;
}
'''
.dedented;
.dedented;
}
}
//------------------------------
// GENERATE GIF SPECIFIC STATIC FRAME CSS
//------------------------------
if (GENERATE_GIF) {
for (final k in staticCss.keys) {
final time = k * GIF_FRAME_DURATION;
final reltime = time * tottime / animationTime; // unscaled time
staticCss[k] = staticCss[k]! + '\n/* stroke $pathid */\n';
staticCss[k] = '${staticCss[k]!}\n/* stroke $pathid */\n';
String rule = '';
@@ -544,7 +496,8 @@ FutureOr<void> createAnimation({
rule += ", #$brushPathidcss, #$brushBorderPathidcss";
}
staticCss[k] = staticCss[k]! +
staticCss[k] =
staticCss[k]! +
'''
%$rule {
visibility: hidden;
@@ -559,7 +512,8 @@ FutureOr<void> createAnimation({
rule += ", #$brushPathidcss, #$brushBorderPathidcss";
}
staticCss[k] = staticCss[k]! +
staticCss[k] =
staticCss[k]! +
'''
$rule {
visibility: hidden;
@@ -571,7 +525,8 @@ FutureOr<void> createAnimation({
((reltime - elapsedtime) / (newelapsedtime - elapsedtime));
final progression = timingFunction.func(intervalprop);
staticCss[k] = staticCss[k]! +
staticCss[k] =
staticCss[k]! +
'''
#$animPathidcss {
stroke-dasharray: ${pathlen.toStringAsFixed(3)} ${(pathlen + 0.002).toStringAsFixed(3)};
@@ -581,7 +536,8 @@ FutureOr<void> createAnimation({
'''
.dedented;
if (showBrush) {
staticCss[k] = staticCss[k]! +
staticCss[k] =
staticCss[k]! +
'''
#$brushPathidcss, #$brushBorderPathidcss {
stroke-dasharray: 0.001 ${(pathlen + 0.002).toStringAsFixed(3)};
@@ -597,94 +553,52 @@ FutureOr<void> createAnimation({
elapsedlen = newelapsedlen;
elapsedtime = newelapsedtime;
}
// for (final p in g.findAllElements("path", namespace: namespaces['n'])) {
// generateCssForPath(p);
// }
}
//------------------------------
// ADD CSS TO XML-OBJECT INSTANCE
//------------------------------
void addGroup(XmlDocumentFragment g) =>
doc.root.firstElementChild?.children.add(g);
// insert groups
if (showBrush && !showBrushFrontBorder)
addGroup(brushBrdGroup);
if (showBrush && !showBrushFrontBorder) addGroup(brushBrdGroup);
addGroup(bgGroup);
if (showBrush && showBrushFrontBorder)
addGroup(brushBrdGroup);
if (showBrush && showBrushFrontBorder) addGroup(brushBrdGroup);
addGroup(animGroup);
if (showBrush)
addGroup(brushGroup);
//------------------------------
// PRODUCE SVG
//------------------------------
if (showBrush) addGroup(brushGroup);
if (GENERATE_SVG) {
print(animatedCss);
final builder = XmlBuilder();
final style = (builder
..element(
'style',
attributes: {'id': "style-Kanimaji", 'type': 'text/css'},
nest: animatedCss,
))
.buildFragment();
final style =
(builder..element(
'style',
attributes: {'id': "style-Kanimaji", 'type': 'text/css'},
nest: animatedCss,
))
.buildFragment();
doc.root.firstElementChild!.children.insert(0, style);
File(outputFile).writeAsStringSync(doc.toXmlString(pretty: true));
doc.root.children.removeAt(0);
print('written $outputFile');
}
//------------------------------
// PRODUCE GIF
//------------------------------
if (GENERATE_GIF) {
// var svgframefiles = [];
// var pngframefiles = [];
// var svgexport_data = [];
// svgframefiles = []
// pngframefiles = []
// svgexport_data = []
// for k in static_css:
// svgframefile = filename_noext_ascii + ("_frame%04d.svg"%k)
// pngframefile = filename_noext_ascii + ("_frame%04d.png"%k)
// svgframefiles.append(svgframefile)
// pngframefiles.append(pngframefile)
// svgexport_data.append({"input": [abspath(svgframefile)],
// "output": [[abspath(pngframefile),
// "%d:%d"% (GIF_SIZE, GIF_SIZE)]]})
final encoder = image.GifEncoder(dither: 0 as DitherKernel);
for (var k in staticCss.keys) {
final style = XmlBuilder()..element(
'style',
attributes: {'id': 'style-Kanimaji'},
);
doc.children.insert(0, style.buildFragment()..innerXml = staticCss[k]!);
final svg.DrawableRoot svgRoot = await svg.svg.fromSvgString(doc.outerXml, doc.outerXml);
final picture = await svgRoot.toPicture().toImage(gifSize, gifSize);
final ByteData? bytes = await picture.toByteData();
final intBytes = bytes!.buffer.asInt32List().toList();
image.Image frame = image.Image.fromBytes(gifSize, gifSize, intBytes);
encoder.addFrame(frame);
doc.children.removeAt(0);
// svgframefile = filename_noext_ascii + ("_frame%04d.svg"%k);
// pngframefile = filename_noext_ascii + ("_frame%04d.png"%k);
// svgframefiles.append(svgframefile)
// pngframefiles.append(pngframefile)
// svgexport_data.append({"input": [svgframefile.abs()],
// "output": [[abspath(pngframefile),
// "%d:%d"% (GIF_SIZE, GIF_SIZE)]]})
// style = E.style(staticCss[k], id="style-Kanimaji")
// doc.getroot().insert(0, style)
// doc.write(svgframefile, pretty_print=True)
// doc.getroot().remove(style)
// print 'written %s' % svgframefile
}
// encoder.finish();
File(outputFile).writeAsBytesSync(encoder.finish()!);
// style = E.style(static_css[k], id="style-Kanimaji")
// doc.getroot().insert(0, style)
// doc.write(svgframefile, pretty_print=True)
// doc.getroot().remove(style)
// print 'written %s' % svgframefile
// // create json file
// svgexport_datafile = filename_noext_ascii+"_export_data.json"
@@ -775,4 +689,27 @@ FutureOr<void> createAnimation({
// doc.getroot().remove(style)
// print('written $svgfile');
// }
}
}
void main(List<String> args) {
// createAnimation('assets/kanjivg/kanji/0f9b1.svg');
const kanji = '実例';
final fileList = [];
for (int k = 0; k < kanji.length; k++) {
createAnimation(
inputFile:
'assets/kanjivg/kanji/${kanji.codeUnits[k].toRadixString(16).padLeft(5, '0')}.svg',
outputFile: '${k + 1}.svg',
);
fileList.add('${k + 1}.svg');
}
File('index.html').writeAsStringSync(
'<html>${fileList.map((e) => File(e).readAsStringSync().replaceAll(']>', '')).join('\n')}</html>',
);
// createAnimation(
// inputFile: 'assets/kanjivg/kanji/060c5.svg',
// outputFile: 'test.svg',
// );
}
+1 -7
View File
@@ -1,7 +1 @@
library kanimaji;
/// A Calculator.
class Calculator {
/// Returns [value] plus 1.
int addOne(int value) => value + 1;
}
export 'package:kanimaji/widget.dart' show Kanimaji;
-6
View File
@@ -1,6 +0,0 @@
import 'package:kanimaji/kanimaji/options.dart';
Future<void> createGif(String kanji, KanimajiOptions options) async {
}
View File
-24
View File
@@ -1,24 +0,0 @@
import 'dart:core';
import 'animate_kanji.dart';
class KanimajiOptions {
// String inputFile;
// String? outputFile;
TimingFunction timingFunction = TimingFunction.easeInOut;
double strokeBorderWidth = 4.5;
double strokeUnfilledWidth = 3;
double strokeFilledWidth = 3.1;
bool showBrush = true;
bool showBrushFrontBorder = true;
double brushWidth = 5.5;
double brushBorderWidth = 7;
double waitAfter = 1.5;
int gifSize = 500;
String strokeBorderColor = '#666';
String strokeUnfilledColor = '#EEE';
String strokeFillingColor = '#F00';
String strokeFilledColor = '#000';
String brushColor = '#F00';
String brushBorderColor = '#666';
}
-3
View File
@@ -1,3 +0,0 @@
// String calculate_css() {
// }
+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 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,
);
}
}
@@ -1,8 +1,6 @@
import 'dart:math' as math;
import '../common/point.dart';
import 'point.dart';
// class Point {
// final double x;
@@ -19,9 +17,9 @@ double thrt(double x) =>
double sqrt(double x) => x > 0 ? math.sqrt(x) : 0;
double sq(x) => x * x;
num sq(num x) => x * x;
double cb(x) => x * x * x;
num cb(num x) => x * x * x;
/// x(t) = t^3 T + 3t^2(1-t) U + 3t(1-t)^2 V + (1-t)^3 W
double time(Point pt1, Point ct1, Point ct2, Point pt2, double x) {
@@ -31,7 +29,8 @@ double time(Point pt1, Point ct1, Point ct2, Point pt2, double x) {
final num c = 3 * ct2.x - 3 * pt2.x;
final num d = pt2.x - x;
if (a.abs() < 0.000000001) { // quadratic
if (a.abs() < 0.000000001) {
// quadratic
if (b.abs() < 0.000000001) return -d / c; // linear
final qb = c / b;
@@ -45,7 +44,8 @@ double time(Point pt1, Point ct1, Point ct2, Point pt2, double x) {
final addcoef = -b / (3 * a);
final lmbd = sq(q) / 4 + cb(p) / 27;
if (lmbd >= 0) { // real
if (lmbd >= 0) {
// real
final sqlambda = sqrt(lmbd);
final tmp = thrt(-q / 2 + (q < 0 ? sqlambda : -sqlambda));
return tmp - p / (3 * tmp) + addcoef;
@@ -84,4 +84,4 @@ double value(Point pt1, Point ct1, Point ct2, Point pt2, double x) {
// for i in range(0,part+1,1):
// x = float(i) / part
// y = value(pt1, ct1, ct2, pt2, x)
// f.write("%f %f\n" % (x,y))
// f.write("%f %f\n" % (x,y))
+166 -74
View File
@@ -1,13 +1,15 @@
/// This file contains classes for the different types of SVG path segments as
/// well as a Path object that contains a sequence of path segments.
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 '../common/point.dart';
import 'point.dart';
num radians(num n) => n * pi / 180;
num degrees(num n) => n * 180 / pi;
@@ -69,10 +71,7 @@ abstract class SvgPath {
final Point start;
final Point end;
const SvgPath({
required this.start,
required this.end,
});
const SvgPath({required this.start, required this.end});
@override
bool operator ==(Object other) =>
@@ -89,10 +88,7 @@ abstract class SvgPath {
}
abstract class Bezier extends SvgPath {
const Bezier({
required Point start,
required Point end,
}) : super(start: start, end: end);
const Bezier({required super.start, required super.end});
@override
bool operator ==(Object other) => other is Bezier && super == other;
@@ -107,10 +103,7 @@ abstract class Bezier extends SvgPath {
/// A straight line
/// The base for Line() and Close().
class Linear extends SvgPath {
const Linear({
required Point start,
required Point end,
}) : super(start: start, end: end);
const Linear({required super.start, required super.end});
@override
bool operator ==(Object other) => other is Linear && super == other;
@@ -129,10 +122,7 @@ class Linear extends SvgPath {
}
class Line extends Linear {
const Line({
required Point start,
required Point end,
}) : super(start: start, end: end);
const Line({required super.start, required super.end});
@override
bool operator ==(Object other) => other is Line && super == other;
@@ -151,11 +141,11 @@ class CubicBezier extends Bezier {
final Point control2;
const CubicBezier({
required Point start,
required super.start,
required this.control1,
required this.control2,
required Point end,
}) : super(start: start, end: end);
required super.end,
});
@override
bool operator ==(Object other) =>
@@ -168,13 +158,14 @@ class CubicBezier extends Bezier {
int get hashCode => super.hashCode ^ control1.hashCode ^ control2.hashCode;
@override
String toString() => "CubicBezier(start=$start, control1=$control1, "
String toString() =>
"CubicBezier(start=$start, control1=$control1, "
"control2=$control2, end=$end)";
@override
bool isSmoothFrom(Object? previous) => previous is CubicBezier
? start == previous.end &&
control1 - start == previous.end - previous.control2
control1 - start == previous.end - previous.control2
: control1 == start;
@override
@@ -205,10 +196,10 @@ class QuadraticBezier extends Bezier {
final Point control;
const QuadraticBezier({
required Point start,
required Point end,
required super.start,
required super.end,
required this.control,
}) : super(start: start, end: end);
});
@override
bool operator ==(Object other) =>
@@ -224,7 +215,7 @@ class QuadraticBezier extends Bezier {
@override
bool isSmoothFrom(Object? previous) => previous is QuadraticBezier
? start == previous.end &&
(control - start) == (previous.end - previous.control)
(control - start) == (previous.end - previous.control)
: control == start;
@override
@@ -258,7 +249,8 @@ class QuadraticBezier extends Bezier {
final num c2 = 2 * sqrt(C);
final num bA = B / a2;
s = (a32 * sabc +
s =
(a32 * sabc +
a2 * B * (sabc - c2) +
(4 * C * A - (B * B)) * log((2 * a2 + bA + sabc) / (bA + c2))) /
(4 * a32);
@@ -280,13 +272,13 @@ class Arc extends SvgPath {
// late num delta;
const Arc({
required Point start,
required Point end,
required super.start,
required super.end,
required this.radius,
required this.rotation,
required this.arc,
required this.sweep,
}) : super(start: start, end: end);
});
@override
bool operator ==(Object other) =>
@@ -306,10 +298,10 @@ class Arc extends SvgPath {
sweep.hashCode;
@override
String toString() => 'Arc(start=$start, radius=$radius, rotation=$rotation, '
String toString() =>
'Arc(start=$start, radius=$radius, rotation=$rotation, '
'arc=$arc, sweep=$sweep, end=$end)';
// Conversion from endpoint to center parameterization
// http://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes
num get _cosr => cos(radians(rotation));
@@ -337,16 +329,17 @@ class Arc extends SvgPath {
num get _cyprim => -c * _ry * _x1prim / _rx;
num get radiusScale {
final rs = (_x1primSq / (radius.x * radius.x)) +
final rs =
(_x1primSq / (radius.x * radius.x)) +
(_y1primSq / (radius.y * radius.y));
return rs > 1 ? sqrt(rs) : 1;
}
Point get center => Point(
(_cosr * _cxprim - _sinr * _cyprim) + ((start.x + end.x) / 2),
(_sinr * _cxprim + _cosr * _cyprim) + ((start.y + end.y) / 2),
);
(_cosr * _cxprim - _sinr * _cyprim) + ((start.x + end.x) / 2),
(_sinr * _cxprim + _cosr * _cyprim) + ((start.y + end.y) / 2),
);
num get theta {
final num n = sqrt(_ux * _ux + _uy * _uy);
final num p = _ux;
@@ -365,7 +358,8 @@ class Arc extends SvgPath {
d = -1.0;
}
return ((((_ux * _vy - _uy * _vx) < 0) ? -1 : 1) * degrees(acos(d))) % 360 - (!sweep ? 360 : 0);
return ((((_ux * _vy - _uy * _vx) < 0) ? -1 : 1) * degrees(acos(d))) % 360 -
(!sweep ? 360 : 0);
}
@override
@@ -375,7 +369,7 @@ class Arc extends SvgPath {
// This should be treated as a straight line
if (this.radius.x == 0 || this.radius.y == 0) {
return start + (end - start) * pos;
return start + (end - start).times(pos);
}
final angle = radians(theta + pos * delta);
@@ -415,14 +409,15 @@ class Arc extends SvgPath {
final startPoint = point(0);
final endPoint = point(1);
return segmentLength(
curve: this,
start: 0,
end: 1,
startPoint: startPoint,
endPoint: endPoint,
error: error,
minDepth: minDepth,
depth: 0);
curve: this,
start: 0,
end: 1,
startPoint: startPoint,
endPoint: endPoint,
error: error,
minDepth: minDepth,
depth: 0,
);
}
}
@@ -449,10 +444,7 @@ class Move extends SvgPath {
/// Represents the closepath command
class Close extends Linear {
const Close({
required Point start,
required Point end,
}) : super(start: start, end: end);
const Close({required super.start, required super.end});
@override
bool operator ==(Object other) => other is Close && super == other;
@@ -502,12 +494,14 @@ class Path extends ListBase<SvgPath> {
String toString() =>
'Path(${[for (final s in segments) s.toString()].join(", ")})';
void _calcLengths(
{num error = defaultError, int minDepth = defaultMinDepth}) {
void _calcLengths({
num error = defaultError,
int minDepth = defaultMinDepth,
}) {
if (_memoizedLength != null) return;
final lengths = [
for (final s in segments) s!.size(error: error, minDepth: minDepth)
for (final s in segments) s!.size(error: error, minDepth: minDepth),
];
_memoizedLength = lengths.reduce((a, b) => a + b);
if (_memoizedLength == 0) {
@@ -552,7 +546,7 @@ class Path extends ListBase<SvgPath> {
return segments[i]!.point(segmentPos);
}
num size({error = defaultError, minDepth = defaultMinDepth}) {
num size({double error = defaultError, int minDepth = defaultMinDepth}) {
_calcLengths(error: error, minDepth: minDepth);
return _memoizedLength!;
}
@@ -579,27 +573,35 @@ class Path extends ListBase<SvgPath> {
parts.add("M ${coord(segment.start)}");
}
if (segment is Line) {
parts.add("L ${coord(segment.end)}");
} else if (segment is CubicBezier) {
if (segment.isSmoothFrom(previousSegment)) {
parts.add("S ${coord(segment.control2)} ${coord(segment.end)}");
} else {
switch (segment) {
case Line _:
parts.add("L ${coord(segment.end)}");
break;
case CubicBezier _:
if (segment.isSmoothFrom(previousSegment)) {
parts.add("S ${coord(segment.control2)} ${coord(segment.end)}");
} else {
parts.add(
"C ${coord(segment.control1)} ${coord(segment.control2)} ${coord(segment.end)}",
);
}
break;
case QuadraticBezier _:
if (segment.isSmoothFrom(previousSegment)) {
parts.add("T ${coord(segment.end)}");
} else {
parts.add("Q ${coord(segment.control)} ${coord(segment.end)}");
}
break;
case Arc _:
parts.add(
"C ${coord(segment.control1)} ${coord(segment.control2)} ${coord(segment.end)}",
"A ${coord(segment.radius)} ${formatNumber(segment.rotation)} "
"${segment.arc ? 1 : 0},${segment.sweep ? 1 : 0} ${coord(segment.end)}",
);
}
} else if (segment is QuadraticBezier) {
if (segment.isSmoothFrom(previousSegment)) {
parts.add("T ${coord(segment.end)}");
} else {
parts.add("Q ${coord(segment.control)} ${coord(segment.end)}");
}
} else if (segment is Arc) {
parts.add(
"A ${coord(segment.radius)} ${formatNumber(segment.rotation)} "
"${segment.arc ? 1 : 0},${segment.sweep ? 1 : 0} ${coord(segment.end)}",
);
break;
}
currentPos = segment.end;
@@ -608,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;
}
}
@@ -8,10 +8,11 @@ class Point {
const Point.from({this.x = 0, this.y = 0});
static const zero = Point(0, 0);
operator +(covariant Point p) => Point(x + p.x, y + p.y);
operator -(covariant Point p) => Point(x - p.x, y - p.y);
operator *(covariant Point p) => Point(x * p.x, y * p.y);
operator /(covariant Point p) => Point(x / p.x, y / p.y);
Point operator +(covariant Point p) => Point(x + p.x, y + p.y);
Point operator -(covariant Point p) => Point(x - p.x, y - p.y);
Point operator *(covariant Point p) => Point(x * p.x, y * p.y);
Point operator /(covariant Point p) => Point(x / p.x, y / p.y);
Point operator -() => Point(-x, -y);
@override
bool operator ==(Object other) =>
+12 -7
View File
@@ -1,7 +1,10 @@
/// SVG Path specification parser
///
/// See https://pypi.org/project/svg.path/ for the original implementation.
library;
import '../common/point.dart';
import 'path.dart';
import 'primitives/point.dart';
import 'primitives/path.dart';
const _commands = {
'M',
@@ -23,13 +26,13 @@ const _commands = {
'T',
't',
'A',
'a'
'a',
};
// 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;
@@ -179,8 +182,9 @@ class Token {
other is Token &&
command == other.command &&
args.length == other.args.length &&
![for (int i = 0; i < args.length; i++) args[i] == other.args[i]]
.any((b) => !b);
![
for (int i = 0; i < args.length; i++) args[i] == other.args[i],
].any((b) => !b);
@override
int get hashCode => command.hashCode ^ args.hashCode;
@@ -356,7 +360,8 @@ Path parsePath(String pathdef) {
// The control point is assumed to be the reflection of
// the control point on the previous command relative
// to the current point.
control = currentPos +
control =
currentPos +
currentPos -
(segments.last as QuadraticBezier).control;
} else {
+484
View File
@@ -0,0 +1,484 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'dart:math' as math;
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 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) {
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;
}
}
-18
View File
@@ -1,18 +0,0 @@
import 'package:flutter/material.dart';
extension _Hexcode on Color {
String get hexcode => '#${value.toRadixString(16).padLeft(8, '0')}';
}
class Kanimaji extends StatelessWidget {
final String kanji;
const Kanimaji({
Key? key,
required this.kanji,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container();
}
}
+5 -49
View File
@@ -1,62 +1,18 @@
name: kanimaji
description: A new Flutter package project.
description: Kanji stroke animations.
version: 0.0.1
homepage:
executables:
kanimaji: lib/kanimaji/animate_kanji
environment:
sdk: ">=2.12.0 <3.0.0"
flutter: ">=1.17.0"
sdk: "^3.8.0"
dependencies:
bisection: ^0.4.3+1
flutter:
sdk: flutter
flutter_svg: ^1.0.3
gifencoder: ^1.0.0
image: ^3.1.3
xml: ^5.3.1
xml: ^6.5.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^1.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter.
flutter:
# To add assets to your package, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
#
# For details regarding assets in packages, see
# https://flutter.dev/assets-and-images/#from-packages
#
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware.
# To add custom fonts to your package, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts in packages, see
# https://flutter.dev/custom-fonts/#from-packages
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', () {
+44 -38
View File
@@ -1,21 +1,22 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:kanimaji/common/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", () {
test(
"[Path 1]: MLLz",
() => expect(
parsePath("M 100 100 L 300 100 L 200 300 z"),
Path.fromSegments(const [
Move(to: Point(100, 100)),
Line(start: Point(100, 100), end: Point(300, 100)),
Line(start: Point(300, 100), end: Point(200, 300)),
Close(start: Point(200, 300), end: Point(100, 100)),
]),
));
"[Path 1]: MLLz",
() => expect(
parsePath("M 100 100 L 300 100 L 200 300 z"),
Path.fromSegments(const [
Move(to: Point(100, 100)),
Line(start: Point(100, 100), end: Point(300, 100)),
Line(start: Point(300, 100), end: Point(200, 300)),
Close(start: Point(200, 300), end: Point(100, 100)),
]),
),
);
//
test(
@@ -160,10 +161,11 @@ void main() {
Path.fromSegments(const [
Move(to: Point(600, 800)),
CubicBezier(
start: Point(600, 800),
control1: Point(625, 700),
control2: Point(725, 700),
end: Point(750, 800)),
start: Point(600, 800),
control1: Point(625, 700),
control2: Point(725, 700),
end: Point(750, 800),
),
CubicBezier(
start: Point(750, 800),
control1: Point(775, 900),
@@ -347,9 +349,10 @@ void main() {
Path.fromSegments(const [
Move(to: Point(100, 200)),
QuadraticBezier(
start: Point(100, 200),
control: Point(100, 200),
end: Point(250, 200)),
start: Point(100, 200),
control: Point(100, 200),
end: Point(250, 200),
),
]),
),
);
@@ -361,9 +364,10 @@ void main() {
Path.fromSegments(const [
Move(to: Point(100, 200)),
QuadraticBezier(
start: Point(100, 200),
control: Point(100, 200),
end: Point(250, 200)),
start: Point(100, 200),
control: Point(100, 200),
end: Point(250, 200),
),
]),
),
);
@@ -382,12 +386,12 @@ void main() {
() =>
// It can be e or E, the plus is optional, and a minimum of +/-3.4e38 must be supported.
expect(
parsePath("M-3.4e38 3.4E+38L-3.4E-38,3.4e-38"),
Path.fromSegments(const [
Move(to: Point(-3.4e38, 3.4e38)),
Line(start: Point(-3.4e38, 3.4e38), end: Point(-3.4e-38, 3.4e-38))
]),
),
parsePath("M-3.4e38 3.4E+38L-3.4E-38,3.4e-38"),
Path.fromSegments(const [
Move(to: Point(-3.4e38, 3.4e38)),
Line(start: Point(-3.4e38, 3.4e38), end: Point(-3.4e-38, 3.4e-38)),
]),
),
);
test(
@@ -405,15 +409,17 @@ void main() {
);
test('svg.path library, issue 45', () {
final path = parsePath("m 1672.2372,-54.8161 "
"a 14.5445,14.5445 0 0 0 -11.3152,23.6652 "
"l 27.2573,27.2572 27.2572,-27.2572 "
"a 14.5445,14.5445 0 0 0 -11.3012,-23.634 "
"a 14.5445,14.5445 0 0 0 -11.414,5.4625 "
"l -4.542,4.5420 "
"l -4.5437,-4.5420 "
"a 14.5445,14.5445 0 0 0 -11.3984,-5.4937 "
"z");
final path = parsePath(
"m 1672.2372,-54.8161 "
"a 14.5445,14.5445 0 0 0 -11.3152,23.6652 "
"l 27.2573,27.2572 27.2572,-27.2572 "
"a 14.5445,14.5445 0 0 0 -11.3012,-23.634 "
"a 14.5445,14.5445 0 0 0 -11.414,5.4625 "
"l -4.542,4.5420 "
"l -4.5437,-4.5420 "
"a 14.5445,14.5445 0 0 0 -11.3984,-5.4937 "
"z",
);
expect(path.d(), contains("A 14.5445,14.5445 0 0,0 1672.2372,-54.8161 Z"));
});
}
+620 -629
View File
File diff suppressed because it is too large Load Diff
+35 -19
View File
@@ -2,8 +2,9 @@
// from svg.path import parser
import 'package:flutter_test/flutter_test.dart';
import 'package:kanimaji/common/point.dart';
import 'package:kanimaji/svg/parser.dart' show Command, Token, commandifyPath, parsePath, tokenizePath;
import 'package:kanimaji/primitives/point.dart';
import 'package:kanimaji/svg_parser.dart'
show Command, Token, commandifyPath, parsePath, tokenizePath;
class TokenizerTest {
final String pathdef;
@@ -30,7 +31,7 @@ final List<TokenizerTest> tokenizerTests = [
Token(command: "M", args: [Point(100, 100)]),
Token(command: "L", args: [Point(300, 100)]),
Token(command: "L", args: [Point(200, 300)]),
Token(command: "z", args: [])
Token(command: "z", args: []),
],
),
const TokenizerTest(
@@ -42,17 +43,22 @@ final List<TokenizerTest> tokenizerTests = [
Command(command: "M", args: "5 1"),
Command(command: "v", args: "7.344"),
Command(
command: "A", args: "3.574 3.574 0 003.5 8 3.515 3.515 0 000 11.5"),
command: "A",
args: "3.574 3.574 0 003.5 8 3.515 3.515 0 000 11.5",
),
Command(command: "C", args: "0 13.421 1.579 15 3.5 15"),
Command(command: "A", args: "3.517 3.517 0 007 11.531"),
Command(command: "v", args: "-7.53"),
Command(command: "h", args: "6"),
Command(command: "v", args: "4.343"),
Command(
command: "A", args: "3.574 3.574 0 0011.5 8 3.515 3.515 0 008 11.5"),
command: "A",
args: "3.574 3.574 0 0011.5 8 3.515 3.515 0 008 11.5",
),
Command(
command: "c",
args: "0 1.921 1.579 3.5 3.5 3.5 1.9 0 3.465 -1.546 3.5 -3.437"),
command: "c",
args: "0 1.921 1.579 3.5 3.5 3.5 1.9 0 3.465 -1.546 3.5 -3.437",
),
Command(command: "V", args: "1"),
Command(command: "z", args: ""),
],
@@ -61,26 +67,36 @@ final List<TokenizerTest> tokenizerTests = [
Token(command: "v", args: [7.344]),
Token(command: "A", args: [3.574, 3.574, 0, false, false, Point(3.5, 8)]),
Token(
command: "A", args: [3.515, 3.515, 0, false, false, Point(0, 11.5)]),
command: "A",
args: [3.515, 3.515, 0, false, false, Point(0, 11.5)],
),
Token(
command: "C",
args: [Point(0, 13.421), Point(1.579, 15), Point(3.5, 15)]),
command: "C",
args: [Point(0, 13.421), Point(1.579, 15), Point(3.5, 15)],
),
Token(
command: "A",
args: [3.517, 3.517, 0, false, false, Point(7, 11.531)]),
command: "A",
args: [3.517, 3.517, 0, false, false, Point(7, 11.531)],
),
Token(command: "v", args: [-7.53]),
Token(command: "h", args: [6]),
Token(command: "v", args: [4.343]),
Token(
command: "A", args: [3.574, 3.574, 0, false, false, Point(11.5, 8)]),
command: "A",
args: [3.574, 3.574, 0, false, false, Point(11.5, 8)],
),
Token(
command: "A", args: [3.515, 3.515, 0, false, false, Point(8, 11.5)]),
command: "A",
args: [3.515, 3.515, 0, false, false, Point(8, 11.5)],
),
Token(
command: "c",
args: [Point(0, 1.921), Point(1.579, 3.5), Point(3.5, 3.5)]),
command: "c",
args: [Point(0, 1.921), Point(1.579, 3.5), Point(3.5, 3.5)],
),
Token(
command: "c",
args: [Point(1.9, 0), Point(3.465, -1.546), Point(3.5, -3.437)]),
command: "c",
args: [Point(1.9, 0), Point(3.465, -1.546), Point(3.5, -3.437)],
),
Token(command: "V", args: [1]),
Token(command: "z", args: []),
],
@@ -120,4 +136,4 @@ void main() {
parsePath(tokenizerTest.pathdef);
}
});
}
}