Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
779f119992
|
|||
| e03a34905e | |||
| 62c488740e | |||
| 062f06196c | |||
| 97e886d7ea | |||
| aaad8b1db6 | |||
| 4151df40b5 | |||
| 1ab1f067b5 |
@@ -1,29 +0,0 @@
|
||||
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
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "assets/kanjivg"]
|
||||
path = assets/kanjivg
|
||||
url = git@github.com:KanjiVG/kanjivg.git
|
||||
@@ -1,21 +1 @@
|
||||
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.
|
||||
TODO: Add your license here.
|
||||
|
||||
@@ -1,17 +1,34 @@
|
||||
|
||||
# Kanimaji
|
||||
|
||||
Kanji stroke order animations for flutter.
|
||||
Add animated kanji strokes to your app!
|
||||
|
||||
> [!WARNING]
|
||||
> This library is still not finished, take the contents and promises of this README with a grain of salt.
|
||||
## Features
|
||||
|
||||
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 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.
|
||||
|
||||
You can configure the animation's speed, curve, colors, and more.
|
||||
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.
|
||||
|
||||
## Usage
|
||||
|
||||
TODO: Include short and useful examples for package users. Add longer examples
|
||||
to `/example` folder.
|
||||
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/
|
||||
Submodule
+1
Submodule assets/kanjivg added at e1d99250c5
Generated
-43
@@ -1,43 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
{
|
||||
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
|
||||
'';
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -8,11 +8,10 @@ class Point {
|
||||
const Point.from({this.x = 0, this.y = 0});
|
||||
static const zero = Point(0, 0);
|
||||
|
||||
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);
|
||||
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);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
+7
-2
@@ -1,2 +1,7 @@
|
||||
export 'package:kanimaji/widget.dart' show Kanimaji, TimingFunction;
|
||||
export 'package:kanimaji/kanjivg_parser.dart';
|
||||
library kanimaji;
|
||||
|
||||
/// A Calculator.
|
||||
class Calculator {
|
||||
/// Returns [value] plus 1.
|
||||
int addOne(int value) => value + 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,778 @@
|
||||
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');
|
||||
// }
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'point.dart';
|
||||
|
||||
import '../common/point.dart';
|
||||
|
||||
// class Point {
|
||||
// final double x;
|
||||
@@ -17,9 +19,9 @@ double thrt(double x) =>
|
||||
|
||||
double sqrt(double x) => x > 0 ? math.sqrt(x) : 0;
|
||||
|
||||
num sq(num x) => x * x;
|
||||
double sq(x) => x * x;
|
||||
|
||||
num cb(num x) => x * x * x;
|
||||
double cb(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) {
|
||||
@@ -29,8 +31,7 @@ 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;
|
||||
@@ -44,8 +45,7 @@ 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))
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
import 'package:kanimaji/kanimaji/options.dart';
|
||||
|
||||
Future<void> createGif(String kanji, KanimajiOptions options) async {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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';
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// String calculate_css() {
|
||||
|
||||
// }
|
||||
@@ -1,193 +0,0 @@
|
||||
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,10 +1,7 @@
|
||||
/// SVG Path specification parser
|
||||
///
|
||||
/// See https://pypi.org/project/svg.path/ for the original implementation.
|
||||
library;
|
||||
|
||||
import 'primitives/point.dart';
|
||||
import 'primitives/path.dart';
|
||||
import '../common/point.dart';
|
||||
import 'path.dart';
|
||||
|
||||
const _commands = {
|
||||
'M',
|
||||
@@ -26,15 +23,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]*|[0-9]*\.?[0-9]+)(?:[eE][-+]?[0-9]+)?",
|
||||
);
|
||||
final _floatPattern = RegExp(r"^[-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?");
|
||||
|
||||
class ParserResult<T> {
|
||||
final T value;
|
||||
@@ -184,9 +179,8 @@ 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;
|
||||
@@ -255,10 +249,8 @@ Path parsePath(String pathdef) {
|
||||
segments.add(Move(to: currentPos));
|
||||
startPos = currentPos;
|
||||
} else if (command == "Z") {
|
||||
if (startPos == null) {
|
||||
throw InvalidPathError("Path closed without a starting position.");
|
||||
}
|
||||
segments.add(Close(start: currentPos, end: startPos));
|
||||
// TODO Throw error if not available:
|
||||
segments.add(Close(start: currentPos, end: startPos!));
|
||||
currentPos = startPos;
|
||||
} else if (command == "L") {
|
||||
Point pos = token.args[0] as Point;
|
||||
@@ -364,8 +356,7 @@ 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 {
|
||||
@@ -1,15 +1,13 @@
|
||||
/// 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 'point.dart';
|
||||
import '../common/point.dart';
|
||||
|
||||
num radians(num n) => n * pi / 180;
|
||||
num degrees(num n) => n * 180 / pi;
|
||||
@@ -71,7 +69,10 @@ 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) =>
|
||||
@@ -88,7 +89,10 @@ abstract class SvgPath {
|
||||
}
|
||||
|
||||
abstract class Bezier extends SvgPath {
|
||||
const Bezier({required super.start, required super.end});
|
||||
const Bezier({
|
||||
required Point start,
|
||||
required Point end,
|
||||
}) : super(start: start, end: end);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is Bezier && super == other;
|
||||
@@ -103,7 +107,10 @@ abstract class Bezier extends SvgPath {
|
||||
/// A straight line
|
||||
/// The base for Line() and Close().
|
||||
class Linear extends SvgPath {
|
||||
const Linear({required super.start, required super.end});
|
||||
const Linear({
|
||||
required Point start,
|
||||
required Point end,
|
||||
}) : super(start: start, end: end);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is Linear && super == other;
|
||||
@@ -122,7 +129,10 @@ class Linear extends SvgPath {
|
||||
}
|
||||
|
||||
class Line extends Linear {
|
||||
const Line({required super.start, required super.end});
|
||||
const Line({
|
||||
required Point start,
|
||||
required Point end,
|
||||
}) : super(start: start, end: end);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is Line && super == other;
|
||||
@@ -141,11 +151,11 @@ class CubicBezier extends Bezier {
|
||||
final Point control2;
|
||||
|
||||
const CubicBezier({
|
||||
required super.start,
|
||||
required Point start,
|
||||
required this.control1,
|
||||
required this.control2,
|
||||
required super.end,
|
||||
});
|
||||
required Point end,
|
||||
}) : super(start: start, end: end);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
@@ -158,14 +168,13 @@ 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
|
||||
@@ -196,10 +205,10 @@ class QuadraticBezier extends Bezier {
|
||||
final Point control;
|
||||
|
||||
const QuadraticBezier({
|
||||
required super.start,
|
||||
required super.end,
|
||||
required Point start,
|
||||
required Point end,
|
||||
required this.control,
|
||||
});
|
||||
}) : super(start: start, end: end);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
@@ -215,7 +224,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
|
||||
@@ -249,8 +258,7 @@ 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);
|
||||
@@ -272,13 +280,13 @@ class Arc extends SvgPath {
|
||||
// late num delta;
|
||||
|
||||
const Arc({
|
||||
required super.start,
|
||||
required super.end,
|
||||
required Point start,
|
||||
required Point end,
|
||||
required this.radius,
|
||||
required this.rotation,
|
||||
required this.arc,
|
||||
required this.sweep,
|
||||
});
|
||||
}) : super(start: start, end: end);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
@@ -298,10 +306,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));
|
||||
@@ -329,17 +337,16 @@ 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;
|
||||
@@ -358,8 +365,7 @@ 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
|
||||
@@ -369,7 +375,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).times(pos);
|
||||
return start + (end - start) * pos;
|
||||
}
|
||||
|
||||
final angle = radians(theta + pos * delta);
|
||||
@@ -409,15 +415,14 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,7 +449,10 @@ class Move extends SvgPath {
|
||||
|
||||
/// Represents the closepath command
|
||||
class Close extends Linear {
|
||||
const Close({required super.start, required super.end});
|
||||
const Close({
|
||||
required Point start,
|
||||
required Point end,
|
||||
}) : super(start: start, end: end);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is Close && super == other;
|
||||
@@ -494,14 +502,12 @@ 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) {
|
||||
@@ -546,7 +552,7 @@ class Path extends ListBase<SvgPath> {
|
||||
return segments[i]!.point(segmentPos);
|
||||
}
|
||||
|
||||
num size({double error = defaultError, int minDepth = defaultMinDepth}) {
|
||||
num size({error = defaultError, minDepth = defaultMinDepth}) {
|
||||
_calcLengths(error: error, minDepth: minDepth);
|
||||
return _memoizedLength!;
|
||||
}
|
||||
@@ -573,35 +579,27 @@ class Path extends ListBase<SvgPath> {
|
||||
parts.add("M ${coord(segment.start)}");
|
||||
}
|
||||
|
||||
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 _:
|
||||
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 {
|
||||
parts.add(
|
||||
"A ${coord(segment.radius)} ${formatNumber(segment.rotation)} "
|
||||
"${segment.arc ? 1 : 0},${segment.sweep ? 1 : 0} ${coord(segment.end)}",
|
||||
"C ${coord(segment.control1)} ${coord(segment.control2)} ${coord(segment.end)}",
|
||||
);
|
||||
break;
|
||||
}
|
||||
} 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)}",
|
||||
);
|
||||
}
|
||||
|
||||
currentPos = segment.end;
|
||||
@@ -610,94 +608,4 @@ 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;
|
||||
}
|
||||
}
|
||||
-649
@@ -1,649 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
+49
-5
@@ -1,18 +1,62 @@
|
||||
name: kanimaji
|
||||
description: Kanji stroke animations.
|
||||
description: A new Flutter package project.
|
||||
version: 0.0.1
|
||||
homepage:
|
||||
|
||||
executables:
|
||||
kanimaji: lib/kanimaji/animate_kanji
|
||||
|
||||
environment:
|
||||
sdk: "^3.8.0"
|
||||
sdk: ">=2.12.0 <3.0.0"
|
||||
flutter: ">=1.17.0"
|
||||
|
||||
dependencies:
|
||||
bisection: ^0.4.3+1
|
||||
flutter:
|
||||
sdk: flutter
|
||||
xml: '>=6.0.0 < 7.0.0'
|
||||
flutter_svg: ^1.0.3
|
||||
gifencoder: ^1.0.0
|
||||
image: ^3.1.3
|
||||
xml: ^5.3.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
path: ^1.9.1
|
||||
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
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
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,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', () {
|
||||
|
||||
+45
-53
@@ -1,22 +1,21 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kanimaji/primitives/path.dart';
|
||||
import 'package:kanimaji/primitives/point.dart';
|
||||
import 'package:kanimaji/svg_parser.dart';
|
||||
import 'package:kanimaji/common/point.dart';
|
||||
import 'package:kanimaji/svg/parser.dart';
|
||||
import 'package:kanimaji/svg/path.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(
|
||||
@@ -161,11 +160,10 @@ 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),
|
||||
@@ -349,10 +347,9 @@ 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)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
@@ -364,10 +361,9 @@ 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)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
@@ -386,23 +382,21 @@ 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))
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
// 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(
|
||||
'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, "
|
||||
@@ -411,17 +405,15 @@ 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"));
|
||||
});
|
||||
}
|
||||
|
||||
+629
-620
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,8 @@
|
||||
// from svg.path import parser
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kanimaji/primitives/point.dart';
|
||||
import 'package:kanimaji/svg_parser.dart'
|
||||
show Command, Token, commandifyPath, parsePath, tokenizePath;
|
||||
import 'package:kanimaji/common/point.dart';
|
||||
import 'package:kanimaji/svg/parser.dart' show Command, Token, commandifyPath, parsePath, tokenizePath;
|
||||
|
||||
class TokenizerTest {
|
||||
final String pathdef;
|
||||
@@ -31,7 +30,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(
|
||||
@@ -43,22 +42,17 @@ 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: ""),
|
||||
],
|
||||
@@ -67,36 +61,26 @@ 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: []),
|
||||
],
|
||||
@@ -136,4 +120,4 @@ void main() {
|
||||
parsePath(tokenizerTest.pathdef);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user