31 Commits

Author SHA1 Message Date
oysteikt 716a0bab16 flake.lock: bump
Test / test (push) Successful in 1m16s
2026-06-10 11:50:57 +09:00
oysteikt 04c13fd5a0 Revert xml package upgrade from 7.0.1 -> 6.6.1
Test / test (push) Successful in 3m37s
2026-06-10 09:31:51 +09:00
oysteikt c4634a8889 kanimaji: merge paths and stroke numbers
Test / test (push) Successful in 2m24s
2026-06-09 18:57:09 +09:00
oysteikt a5a4a3726f flake.lock: bump, pubspec.yaml: bump
Test / test (push) Successful in 5m35s
2026-06-02 02:37:26 +09:00
oysteikt c447257af3 flake.lock: bump
Test / test (push) Successful in 2m29s
2026-03-26 22:19:35 +09:00
oysteikt 26c2cab239 Add support for delay between strokes and loops
Test / test (push) Successful in 1m21s
2026-02-26 13:59:47 +09:00
oysteikt b96ac7378d flake.lock: bump
Test / test (push) Successful in 3m40s
2026-02-25 16:28:35 +09:00
oysteikt f75836fcbe tests/parser_test: disable expected failure test
Test / test (push) Successful in 2m49s
2026-02-25 12:42:02 +09:00
oysteikt 4c201507a8 .gitea/workflows/test: run on push on all branches
Test / test (push) Failing after 1m22s
2026-02-24 20:42:10 +09:00
oysteikt b44ea6d321 Format, fix an edgecase
Test / test (push) Failing after 2m3s
2026-02-24 16:05:03 +09:00
oysteikt 60f6465c24 Add color option for currently drawing stroke 2026-02-24 16:04:26 +09:00
oysteikt 89652c401f Restructure project, create main widget and remove SVG generator
Test / test (push) Failing after 6m24s
2026-02-22 18:34:00 +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 1944 additions and 1703 deletions
+1
View File
@@ -0,0 +1 @@
use flake
+29
View File
@@ -0,0 +1,29 @@
name: "Test"
on:
push:
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": 1778620714,
"narHash": "sha256-LwNcY5A6XPGI+DASZfmP7OeYe8IFesShhSrE7Go2ux8=",
"owner": "KanjiVG",
"repo": "kanjivg",
"rev": "1957802840a6f059d1e27dcb5755722955cc7dbb",
"type": "github"
},
"original": {
"owner": "KanjiVG",
"repo": "kanjivg",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1780749050,
"narHash": "sha256-3av0pIjlOWQ6rDbNOmpUSvbNnJkGORQKKjb4LtCZsIY=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a799d3e3886da994fa307f817a6bc705ae538eeb",
"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
'';
});
};
}
+2 -7
View File
@@ -1,7 +1,2 @@
library kanimaji;
/// A Calculator.
class Calculator {
/// Returns [value] plus 1.
int addOne(int value) => value + 1;
}
export 'package:kanimaji/widget.dart' show Kanimaji, TimingFunction;
export 'package:kanimaji/kanjivg_parser.dart';
-778
View File
@@ -1,778 +0,0 @@
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';
double _computePathLength(String path) =>
parsePath(path).size(error: 1e-8).toDouble();
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 whitespaceAmounts = [
for (final line in withoutEmptyLines)
line.split('').takeWhile((c) => c == ' ').length
];
final whitespaceToRemove = whitespaceAmounts.reduce(min);
return withoutEmptyLines
.map((l) => l.replaceRange(0, whitespaceToRemove, ''))
.join('\n');
}
}
class JsAnimationElement {
final XmlDocumentFragment bg;
final XmlDocumentFragment anim;
final XmlDocumentFragment? brush;
final XmlDocumentFragment? brushBorder;
/// the time set (as default) for each animation
final num time;
const JsAnimationElement({
required this.bg,
required this.anim,
required this.time,
this.brush,
this.brushBorder,
});
}
// ease, ease-in, etc:
// https://developer.mozilla.org/en-US/docs/Web/CSS/timing-function#ease
const pt1 = Point(0, 0);
const easeCt1 = Point(0.25, 0.1);
const easeCt2 = Point(0.25, 1.0);
const easeInCt1 = Point(0.42, 0.0);
const easeInCt2 = Point(1.0, 1.0);
const easeInOutCt1 = Point(0.42, 0.0);
const easeInOutCt2 = Point(0.58, 1.0);
const easeOutCt1 = Point(0.0, 0.0);
const easeOutCt2 = Point(0.58, 1.0);
const pt2 = Point(1, 1);
// class {
// }
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]!;
String get name => {
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'
};
// etree.register_namespace("xlink","http://www.w3.org/1999/xlink")
// final parser = etree.XMLParser(remove_blank_text=true);
// gif settings
// const DELETE_TEMPORARY_FILES = false;
const GIF_SIZE = 150;
const GIF_FRAME_DURATION = 0.04;
const GIF_BACKGROUND_COLOR = '#ddf';
// set to true to allow transparent background, much bigger file!
// const GIF_ALLOW_TRANSPARENT = false;
// edit here to decide what will be generated
const GENERATE_SVG = true;
const GENERATE_JS_SVG = true;
const GENERATE_GIF = true;
/// sqrt, ie a stroke 4 times the length is drawn
/// at twice the speed, in twice the time.
double strokeLengthToDuration(double length) => sqrt(length) / 8;
/// global time rescale, let's make animation a bit
/// faster when there are many strokes.
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 ??
[]) {
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 ??
[]) {
if (RegExp(r'-Kanimaji$').hasMatch(g.getAttribute('id') ?? '')) {
g.parent!.children.remove(g);
}
}
}
XmlDocument fetchXML(String inputFile) {
//------------------------------
// FETCH DATA FILE
//------------------------------
print('processing $inputFile');
// load xml
final XmlDocument doc = XmlDocument.parse(File(inputFile).readAsStringSync());
// for xlink namespace introduction
doc.rootElement.setAttribute('xmlns:xlink', namespaces['xlink']);
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({
required String id,
required String color,
required double width,
}) {
final builder = XmlBuilder();
builder.element(
'g',
attributes: {
'id': 'kvg:$baseid-$id-Kanimaji',
'style': 'fill:none;'
'stroke:$color;'
'stroke-width:$width;'
'stroke-linecap:round;'
'stroke-linejoin:round;',
},
isSelfClosing: false,
);
return builder.buildFragment();
}
final bgGroup = pathCopyGroup(
id: 'bg',
color: strokeUnfilledColor,
width: strokeUnfilledWidth,
);
final animGroup = pathCopyGroup(
id: 'anim',
color: strokeFilledColor,
width: strokeFilledWidth,
);
late final XmlDocumentFragment brushGroup;
late final XmlDocumentFragment brushBrdGroup;
if (showBrush) {
brushGroup = pathCopyGroup(
id: 'brush',
color: brushColor,
width: brushWidth,
);
brushBrdGroup = pathCopyGroup(
id: 'brush-brd',
color: brushBorderColor,
width: brushBorderWidth,
);
}
//------------------------------
// 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 ??
[]) {
if (RegExp(r'^kvg:StrokeNumbers_').hasMatch(g.getAttribute('id') ?? '')) {
continue;
}
for (final p in g.findAllElements('path', namespace: namespaces['n'])) {
final pathlen = _computePathLength(p.getAttribute('d')!);
final duration = strokeLengthToDuration(pathlen);
totlen += pathlen;
tottime += duration;
}
}
double animationTime = timeRescale(tottime); // math.pow(3 * tottime, 2.0/3)
tottime += waitAfter * tottime / animationTime;
final double actualAnimationTime = animationTime;
animationTime += waitAfter;
//------------------------------
// START ADDING CSS
//------------------------------
final Map<int, String> staticCss = {};
late String animatedCss;
/// collect the ids of animating elements
final List<JsAnimationElement> jsAnimationElements = [];
String jsAnimatedCss = '';
const String cssHeader =
'\n/* CSS automatically generated by kanimaji.py, do not edit! */\n';
if (GENERATE_SVG) animatedCss = cssHeader;
if (GENERATE_JS_SVG) {
jsAnimatedCss += cssHeader +
'''
.backward {\n
animation-direction: reverse !important;\n
}
''';
}
late final int lastFrameIndex;
late final double lastFrameDelay;
if (GENERATE_GIF) {
// final static_css = {};
lastFrameIndex = actualAnimationTime ~/ GIF_FRAME_DURATION + 1;
for (int i = 0; i < lastFrameIndex + 1; i++) {
staticCss[i] = cssHeader;
}
lastFrameDelay = animationTime - lastFrameIndex * GIF_FRAME_DURATION;
}
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 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 = '''
#${groupid.replaceAll(':', '\\3a ')} {
display: none;
}
'''
.dedented;
if (GENERATE_SVG) animatedCss += rule;
if (GENERATE_JS_SVG) jsAnimatedCss += rule;
if (GENERATE_GIF) {
for (final k in staticCss.keys) {
staticCss[k] = staticCss[k]! + rule;
}
}
continue;
}
final gidcss = groupid.replaceAll(':', '\\3a ');
final rule = '''
#$gidcss {
stroke-width: ${strokeBorderWidth.toStringAsFixed(1)}px !important;
stroke: $strokeBorderColor !important;
}
'''
.dedented;
if (GENERATE_SVG) animatedCss += rule;
if (GENERATE_JS_SVG) jsAnimatedCss += rule;
if (GENERATE_GIF) {
for (final k in staticCss.keys) {
staticCss[k] = staticCss[k]! + rule;
}
}
//------------------------------
// 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 ');
XmlDocumentFragment addHref(String suffix, XmlDocumentFragment parent) {
final builder = XmlBuilder();
builder.element(
'use',
attributes: {'id': '$pathid-$suffix', 'xlink:href': '#$pathid'},
);
final ref = builder.buildFragment();
parent.firstElementChild!.children.add(ref);
return ref;
}
final String bgPathidcss = '$pathidcss-bg';
final String animPathidcss = '$pathidcss-anim';
final String brushPathidcss = '$pathidcss-brush';
final String brushBorderPathidcss = '$pathidcss-brush-brd';
final bgGroupElement = addHref('bg', bgGroup);
final animGroupElement = addHref('anim', animGroup);
XmlDocumentFragment? brushGroupElement;
XmlDocumentFragment? brushBorderGroupElement;
if (showBrush) {
brushGroupElement = addHref('brush', brushGroup);
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);
final relativeDuration =
duration * tottime / animationTime; // unscaled time
if (GENERATE_JS_SVG) {
jsAnimationElements.add(
JsAnimationElement(
bg: bgGroupElement,
anim: animGroupElement,
brush: brushGroupElement,
brushBorder: brushBorderGroupElement,
time: relativeDuration,
),
);
}
final newelapsedlen = elapsedlen + pathlen;
final newelapsedtime = elapsedtime + duration;
final animStart = elapsedtime / tottime * 100;
final animEnd = newelapsedtime / tottime * 100;
//------------------------------
// GENERATE SVG SPECIFIC ANIMATION CSS
//------------------------------
if (GENERATE_SVG) {
// animation stroke progression
animatedCss += '''
@keyframes strike-$pathname {
0% { stroke-dashoffset: ${pathlen.toStringAsFixed(3)}; }
${animStart.toStringAsFixed(3)}% { stroke-dashoffset: ${pathlen.toStringAsFixed(3)}; }
${animEnd.toStringAsFixed(3)}% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: 0; }
}
@keyframes showhide-$pathname {
${animStart.toStringAsFixed(3)}% { visibility: hidden; }
${animEnd.toStringAsFixed(3)}% { stroke: $strokeFillingColor; }
}
#$animPathidcss {
stroke-dasharray: ${pathlen.toStringAsFixed(3)} ${pathlen.toStringAsFixed(3)};
stroke-dashoffset: 0;
animation: strike-$pathname ${animationTime.toStringAsFixed(3)}s ${timingFunction.name} infinite,
showhide-$pathname ${animationTime.toStringAsFixed(3)}s step-start infinite;
}
'''
.dedented;
if (showBrush) {
// brush element visibility
animatedCss += '''
@keyframes showhide-brush-$pathname {
${animStart.toStringAsFixed(3)}% { visibility: hidden; }
${animEnd.toStringAsFixed(3)}% { visibility: visible; }
100% { visibility: hidden; }
}
#$brushPathidcss, #$brushBorderPathidcss {
stroke-dasharray: 0 ${pathlen.toStringAsFixed(3)};
animation: strike-$pathname ${animationTime.toStringAsFixed(3)}s ${timingFunction.name} infinite,
showhide-brush-$pathname ${animationTime.toStringAsFixed(3)}s step-start infinite;
}
'''
.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 += '''
#$brushPathidcss, #$brushBorderPathidcss, #$bgPathidcss {
visibility: hidden;
}
'''
.dedented;
}
// hide stroke after current element
const afterCurrent = '[class *= "current"]';
jsAnimatedCss += '''
$afterCurrent ~ #$animPathidcss {
visibility: hidden;
}
$afterCurrent ~ #$bgPathidcss, #$bgPathidcss.animate {
visibility: visible;
}
@keyframes strike-$pathname {
0% { stroke-dashoffset: ${pathlen.toStringAsFixed(3)}; }
100% { stroke-dashoffset: 0; }
}
#$animPathidcss.animate {
stroke: $strokeFillingColor;
stroke-dasharray: ${pathlen.toStringAsFixed(3)} ${pathlen.toStringAsFixed(3)};
visibility: visible;
animation: strike-$pathname ${relativeDuration.toStringAsFixed(3)}s ${timingFunction.name} forwards 1;
}
'''
.dedented;
if (showBrush) {
jsAnimatedCss += '''
@keyframes strike-brush-$pathname {
0% { stroke-dashoffset: ${pathlen.toStringAsFixed(3)}; }
100% { stroke-dashoffset: 0.4; }
}
#$brushPathidcss.animate.brush, #$brushBorderPathidcss.animate.brush {
stroke-dasharray: 0 ${pathlen.toStringAsFixed(3)};
visibility: visible;
animation: strike-brush-$pathname ${relativeDuration.toStringAsFixed(3)}s ${timingFunction.name} forwards 1;
}
'''
.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';
String rule = '';
// animation
if (reltime < elapsedtime) {
// just hide everything
rule += "#$animPathidcss";
if (showBrush) {
rule += ", #$brushPathidcss, #$brushBorderPathidcss";
}
staticCss[k] = staticCss[k]! +
'''
%$rule {
visibility: hidden;
}
'''
.dedented;
} else if (reltime > newelapsedtime) {
// just hide the brush, and bg
rule += "#$bgPathidcss";
if (showBrush) {
rule += ", #$brushPathidcss, #$brushBorderPathidcss";
}
staticCss[k] = staticCss[k]! +
'''
$rule {
visibility: hidden;
}
'''
.dedented;
} else {
final intervalprop =
((reltime - elapsedtime) / (newelapsedtime - elapsedtime));
final progression = timingFunction.func(intervalprop);
staticCss[k] = staticCss[k]! +
'''
#$animPathidcss {
stroke-dasharray: ${pathlen.toStringAsFixed(3)} ${(pathlen + 0.002).toStringAsFixed(3)};
stroke-dashoffset: ${(pathlen * (1 - progression) + 0.0015).toStringAsFixed(4)};
stroke: $strokeFillingColor;
}
'''
.dedented;
if (showBrush) {
staticCss[k] = staticCss[k]! +
'''
#$brushPathidcss, #$brushBorderPathidcss {
stroke-dasharray: 0.001 ${(pathlen + 0.002).toStringAsFixed(3)};
stroke-dashoffset: ${(pathlen * (1 - progression) + 0.0015).toStringAsFixed(4)};
}
'''
.dedented;
}
}
}
}
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);
addGroup(bgGroup);
if (showBrush && showBrushFrontBorder)
addGroup(brushBrdGroup);
addGroup(animGroup);
if (showBrush)
addGroup(brushGroup);
//------------------------------
// PRODUCE SVG
//------------------------------
if (GENERATE_SVG) {
print(animatedCss);
final builder = XmlBuilder();
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 = [];
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()!);
// // create json file
// svgexport_datafile = filename_noext_ascii+"_export_data.json"
// with open(svgexport_datafile,'w') as f:
// f.write(json.dumps(svgexport_data))
// print 'created instructions %s' % svgexport_datafile
// // run svgexport
// cmdline = 'svgexport %s' % shescape(svgexport_datafile)
// print cmdline
// if os.system(cmdline) != 0:
// exit('Error running external command')
// if DELETE_TEMPORARY_FILES:
// os.remove(svgexport_datafile)
// for f in svgframefiles:
// os.remove(f)
// // generate GIF
// giffile_tmp1 = filename_noext + '_anim_tmp1.gif'
// giffile_tmp2 = filename_noext + '_anim_tmp2.gif'
// giffile = filename_noext + '_anim.gif'
// escpngframefiles = ' '.join(shescape(f) for f in pngframefiles[0:-1])
// if GIF_BACKGROUND_COLOR == 'transparent':
// bgopts = '-dispose previous'
// else:
// bgopts = "-background '%s' -alpha remove" % GIF_BACKGROUND_COLOR
// cmdline = ("convert -delay %d %s -delay %d %s "+
// "%s -layers OptimizePlus %s") % (
// int(GIF_FRAME_DURATION*100),
// escpngframefiles,
// int(last_frame_delay*100),
// shescape(pngframefiles[-1]),
// bgopts,
// shescape(giffile_tmp1))
// print(cmdline);
// if os.system(cmdline) != 0:
// exit('Error running external command')
// if DELETE_TEMPORARY_FILES:
// for f in pngframefiles:
// os.remove(f)
// print 'cleaned up.'
// cmdline = ("convert %s \\( -clone 0--1 -background none "+
// "+append -quantize transparent -colors 63 "+
// "-unique-colors -write mpr:cmap +delete \\) "+
// "-map mpr:cmap %s") % (
// shescape(giffile_tmp1),
// shescape(giffile_tmp2))
// print cmdline
// if os.system(cmdline) != 0:
// exit('Error running external command')
// if DELETE_TEMPORARY_FILES:
// os.remove(giffile_tmp1)
// cmdline = ("gifsicle -O3 %s -o %s") % (
// shescape(giffile_tmp2),
// shescape(giffile))
// print cmdline
// if os.system(cmdline) != 0:
// exit('Error running external command')
// if DELETE_TEMPORARY_FILES:
// os.remove(giffile_tmp2)
}
// if (GENERATE_JS_SVG) {
// final f0insert = [bg_g, anim_g];
// if (SHOW_BRUSH) f0insert += [brush_g, brush_brd_g];
// for g in f0insert:
// el = E.a()
// el.set("data-stroke","0")
// g.insert(0, el)
// for i in range(0, len(js_anim_els)):
// els = js_anim_els[i]
// for k in els:
// els[k].set("data-stroke",str(i+1))
// els["anim"].set("data-duration", str(js_anim_time[i]))
// doc.getroot().set('data-num-strokes', str(len(js_anim_els)))
// style = E.style(js_animated_css, id="style-Kanimaji")
// doc.getroot().insert(0, style)
// svgfile = filename_noext + '_js_anim.svg'
// doc.write(svgfile, pretty_print=True)
// doc.getroot().remove(style)
// print('written $svgfile');
// }
}
View File
-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() {
// }
+193
View File
@@ -0,0 +1,193 @@
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 `<path>` element in the KanjiVG SVG files
class KanjiVGPath {
final int strokeNumber;
final String type;
final Path svgPath;
final Point strokeNumberLabelPosition;
KanjiVGPath({
required this.strokeNumber,
required this.type,
required this.svgPath,
required this.strokeNumberLabelPosition,
});
@override
String toString() => 'KanjiVGPath(type: $type, svgPath: $svgPath)';
}
/// 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;
const KanjiVGItem(this.character, this.paths);
/// 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.
static List<(int, String, Path)> _parsePaths(XmlDocument doc) {
final List<(int, String, Path)> 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 = int.parse(p.getAttribute('id')!.split('-').last.substring(1));
final type = _kvgAttr(p, 'type') ?? p.getAttribute('kvg:type') ?? '';
paths.add((id, type, svgPath));
}
return paths;
}
/// Parse the stroke number group from the provided XML document.
static Map<int, Point> _parseStrokeNumbersPositions(XmlDocument doc) {
final Map<int, Point> 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[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 strokeNumberPositions = _parseStrokeNumbersPositions(doc);
final result = [
for (final p in paths)
KanjiVGPath(
strokeNumber: p.$1,
type: p.$2,
svgPath: p.$3,
strokeNumberLabelPosition: strokeNumberPositions[p.$1]!,
),
];
return KanjiVGItem(character, result);
}
}
@@ -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) =>
+18 -9
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,15 @@ 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 +184,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;
@@ -249,8 +255,10 @@ Path parsePath(String pathdef) {
segments.add(Move(to: currentPos));
startPos = currentPos;
} else if (command == "Z") {
// TODO Throw error if not available:
segments.add(Close(start: currentPos, end: startPos!));
if (startPos == null) {
throw InvalidPathError("Path closed without a starting position.");
}
segments.add(Close(start: currentPos, end: startPos));
currentPos = startPos;
} else if (command == "L") {
Point pos = token.args[0] as Point;
@@ -356,7 +364,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 {
+649
View File
@@ -0,0 +1,649 @@
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/kanjivg_parser.dart';
import 'package:kanimaji/primitives/point.dart';
import 'package:kanimaji/primitives/bezier.dart' as bezier;
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);
}
enum TimingFunction { linear, ease, easeIn, easeInOut, easeOut }
// ease, ease-in, etc:
// https://developer.mozilla.org/en-US/docs/Web/CSS/timing-function#ease
const pt1 = Point(0, 0);
const easeCt1 = Point(0.25, 0.1);
const easeCt2 = Point(0.25, 1.0);
const easeInCt1 = Point(0.42, 0.0);
const easeInCt2 = Point(1.0, 1.0);
const easeInOutCt1 = Point(0.42, 0.0);
const easeInOutCt2 = Point(0.58, 1.0);
const easeOutCt1 = Point(0.0, 0.0);
const easeOutCt2 = Point(0.58, 1.0);
const pt2 = Point(1, 1);
extension Funcs on TimingFunction {
double Function(double) get func => {
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]!;
}
// TODO: fall back to just drawing the character as text if it does not exist in the kanjivg dataset
/// 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 TimingFunction timingFunction;
// final Cubic animationCurve;
// final double speed;
// Brush parameters
final bool showBrush;
final Color brushColor;
final double brushRadius;
// Stroke parameters
final Color strokeColor;
final Color currentStrokeColor;
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.timingFunction = TimingFunction.ease,
this.delayBetweenLoops = const Duration(seconds: 1),
this.delayBetweenStrokes = const Duration(milliseconds: 100),
this.strokeColor = Colors.black,
this.currentStrokeColor = Colors.red,
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 = false,
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() ??
[];
/// sqrt, ie a stroke 4 times the length is drawn
/// at twice the speed, in twice the time.
static double _strokeLengthToDuration(double length) => math.sqrt(length) / 8;
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.timingFunction != widget.timingFunction ||
oldWidget.delayBetweenLoops != widget.delayBetweenLoops ||
oldWidget.delayBetweenStrokes != widget.delayBetweenStrokes ||
oldWidget.strokeColor != widget.strokeColor ||
oldWidget.currentStrokeColor != widget.currentStrokeColor ||
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 int totalSec =
(_pathDurations.fold(0.0, (a, b) => a + b) * 1000).round() +
widget.delayBetweenStrokes.inMilliseconds *
(_pathDurations.length - 1) +
widget.delayBetweenLoops.inMilliseconds;
_controller.stop();
_controller.duration = Duration(milliseconds: totalSec);
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(
progress: _controller.value,
paths: _kanjiData!.paths.map((p) => p.svgPath.toUiPath()).toList(),
pathLengths: _pathLengths,
pathDurations: _pathDurations,
strokeNumbers: {
for (final p in _kanjiData!.paths)
p.strokeNumber: p.strokeNumberLabelPosition,
},
viewBoxWidth: _viewBoxWidth,
viewBoxHeight: _viewBoxHeight,
timingFunction: widget.timingFunction,
delayBetweenStrokes: widget.delayBetweenStrokes,
delayBetweenLoops: widget.delayBetweenLoops,
strokeColor: widget.strokeColor,
currentStrokeColor: widget.currentStrokeColor,
strokeUnfilledColor: widget.strokeUnfilledColor,
strokeWidth: widget.strokeWidth,
backgroundColor: widget.backgroundColor,
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;
final Map<int, Point> strokeNumbers;
final double viewBoxWidth;
final double viewBoxHeight;
final TimingFunction timingFunction;
final Duration delayBetweenStrokes;
final Duration delayBetweenLoops;
final Color strokeColor;
final Color currentStrokeColor;
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;
const _KanimajiPainter({
required this.progress,
required this.paths,
required this.pathLengths,
required this.pathDurations,
required this.strokeNumbers,
required this.viewBoxWidth,
required this.viewBoxHeight,
required this.timingFunction,
required this.delayBetweenStrokes,
required this.delayBetweenLoops,
required this.strokeColor,
required this.currentStrokeColor,
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,
});
int get elapsedTimeMilliseconds {
// TODO: only calculate the total time once
final int totalTimeMilliseconds =
(pathDurations.fold(0.0, (a, b) => a + b) * 1000).round() +
delayBetweenStrokes.inMilliseconds * (pathDurations.length - 1) +
delayBetweenLoops.inMilliseconds;
final double p = progress.clamp(0.0, 1.0);
return (p * totalTimeMilliseconds).round();
}
// TODO: cache the value of the previous paint iteration, to avoid having to recalculate the entire stroke index and progress on every frame
// fall back to recalculating if it does not step forward.
// The index of the currently drawing stroke if it is being drawn.
// Returns null if any of the following is true:
// - We are in the delay after a stroke
// - We are in the delay between loops
int? get _currentStrokeIndex {
int currentTime = 0;
for (int i = 0; i < pathDurations.length; i++) {
final int strokeTime = (pathDurations[i] * 1000).round();
if (elapsedTimeMilliseconds < currentTime + strokeTime) {
return i;
} else if (elapsedTimeMilliseconds <
currentTime + strokeTime + delayBetweenStrokes.inMilliseconds) {
return null;
}
currentTime += strokeTime;
currentTime += delayBetweenStrokes.inMilliseconds;
}
return null;
}
// TODO: optimize by caching the last stroke index and progress, and only recalculating if the elapsed time has moved past the next expected threshold (either the end of the current stroke or the end of the current delay)
/// The index of the last fully drawn stroke. Returns -1 if no stroke has been fully drawn yet.
int get _lastStrokeIndex {
int currentTime = 0;
for (int i = 0; i < pathDurations.length; i++) {
final int strokeTime = (pathDurations[i] * 1000).round();
if (elapsedTimeMilliseconds < currentTime + strokeTime) {
return i - 1;
}
currentTime += strokeTime;
currentTime += delayBetweenStrokes.inMilliseconds;
}
return pathDurations.length - 1;
}
/// The progress of the currently drawing stroke (0.0..1.0). Returns null if we are in a delay.
double? get _currentStrokeProgress {
final int? currentStrokeIndex = _currentStrokeIndex;
if (currentStrokeIndex == null) return null;
int currentTime = 0;
for (int i = 0; i < currentStrokeIndex; i++) {
final int strokeTime = (pathDurations[i] * 1000).round();
currentTime += strokeTime;
currentTime += delayBetweenStrokes.inMilliseconds;
}
final int strokeTime = (pathDurations[currentStrokeIndex] * 1000).round();
final int elapsedInCurrentStroke = elapsedTimeMilliseconds - currentTime;
return (elapsedInCurrentStroke / strokeTime).clamp(0.0, 1.0);
}
/// Draw the static parts of the canvas that do not change with each frame, such as the background, cross, and unfilled paths.
void _drawBaseCanvas(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 crossPaint = Paint()
..style = PaintingStyle.stroke
..color = crossColor
..strokeWidth = crossStrokeWidth;
final Paint unfilledPaint = Paint()
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..strokeWidth = strokeWidth / scale
..color = strokeUnfilledColor;
// 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);
}
// Draw stroke numbers if enabled
if (showStrokeNumbers) {
final textPainter = TextPainter(
textAlign: TextAlign.center,
textDirection: TextDirection.ltr,
);
for (final sn in strokeNumbers.entries) {
final textSpan = TextSpan(
text: sn.key.toString(),
style: TextStyle(
color: strokeNumberColor,
fontSize: strokeNumberFontSize / scale,
fontFamily: strokeNumberFontFamily,
),
);
textPainter.text = textSpan;
textPainter.layout();
final Offset pos = Offset(sn.value.x.toDouble(), sn.value.y.toDouble());
final Offset centeredPos =
pos - Offset(textPainter.width / 2, textPainter.height / 2);
textPainter.paint(canvas, centeredPos);
}
}
// canvas.save();
canvas.translate(dx, dy);
canvas.scale(scale, scale);
}
@override
void paint(Canvas canvas, Size size) {
// TODO: see if we can optimize by storing the base canvas once and restoring it on each frame instead of redrawing it every time
_drawBaseCanvas(canvas, size);
final double sx = size.width / viewBoxWidth;
final double sy = size.height / viewBoxHeight;
final double scale = math.min(sx, sy);
final Paint filledPaint = Paint()
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..strokeWidth = strokeWidth / scale
..color = strokeColor;
final Paint currentStrokePaint = Paint()
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..strokeWidth = strokeWidth / scale
..color = currentStrokeColor;
final Paint brushPaint = Paint()
..style = PaintingStyle.fill
..color = brushColor;
final int currentlyDrawingIndex = _currentStrokeIndex ?? -1;
final int lastStrokeIndex = _lastStrokeIndex;
// Draw all completed strokes fully filled
for (int i = 0; i < lastStrokeIndex + 1; i++) {
canvas.drawPath(paths[i], filledPaint);
}
// Draw the currently drawing stroke with partial coverage
if (currentlyDrawingIndex >= 0) {
final ui.Path path = paths[currentlyDrawingIndex];
final double len = pathLengths[currentlyDrawingIndex];
final double strokeProgress = timingFunction.func(
_currentStrokeProgress!,
);
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, currentStrokePaint);
if (showBrush) {
final ui.Tangent? tangent = metric.getTangentForOffset(drawLength);
if (tangent != null) {
canvas.drawCircle(tangent.position, brushRadius / scale, brushPaint);
}
}
}
// Color over the stroke number of the currently drawing stroke if enabled
if (showStrokeNumbers && currentlyDrawingIndex != -1) {
final textPainter = TextPainter(
textAlign: TextAlign.center,
textDirection: TextDirection.ltr,
);
final sn = strokeNumbers[currentlyDrawingIndex]!;
final textSpan = TextSpan(
text: currentlyDrawingIndex.toString(),
style: TextStyle(
color: currentStrokeNumberColor,
fontSize: strokeNumberFontSize / scale,
fontFamily: strokeNumberFontFamily,
),
);
textPainter.text = textSpan;
textPainter.layout();
final Offset pos = Offset(sn.x.toDouble(), sn.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.timingFunction != timingFunction ||
oldDelegate.delayBetweenStrokes != delayBetweenStrokes ||
oldDelegate.delayBetweenLoops != delayBetweenLoops ||
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.0.0 < 7.0.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
+31
View File
@@ -0,0 +1,31 @@
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}');
} 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', () {
+53 -45
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,21 +386,23 @@ 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(
'Errors',
() => expect(
parsePath("M 100 100 L 200 200 Z 100 200"),
throwsA(const TypeMatcher<InvalidPathError>()),
),
);
// NOTE: The parser seems to just ignore the arguments to Z, so this doesn't throw an error.
// This is probably not completely spec compliant, but it's good enough for now.
// test(
// 'Errors',
// () => expect(
// parsePath("M 100 100 L 200 200 Z 100 200"),
// throwsA(const TypeMatcher<InvalidPathError>()),
// ),
// );
test(
"Nonpath: It's possible in SVG to create paths that has zero length, "
@@ -405,15 +411,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);
}
});
}
}