From 1c692a688991c491dac4ef75047d63f7fec60c1d Mon Sep 17 00:00:00 2001 From: h7x4 Date: Mon, 31 Jan 2022 18:10:19 +0100 Subject: [PATCH] Initial commit --- .gitignore | 29 ++ .gitmodules | 3 + .metadata | 10 + CHANGELOG.md | 3 + LICENSE | 1 + README.md | 40 ++ analysis_options.yaml | 4 + assets/kanjivg | 1 + lib/kanimaji.dart | 7 + lib/kanimaji/animate_kanji.dart | 581 +++++++++++++++++++++++++++ lib/kanimaji/bezier_cubic.dart | 84 ++++ lib/kanimaji/settings.dart | 56 +++ lib/svg/parser.dart | 410 +++++++++++++++++++ lib/svg/path.dart | 682 ++++++++++++++++++++++++++++++++ pubspec.yaml | 56 +++ test/kanimaji_test.dart | 12 + 16 files changed, 1979 insertions(+) create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 .metadata create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 analysis_options.yaml create mode 160000 assets/kanjivg create mode 100644 lib/kanimaji.dart create mode 100644 lib/kanimaji/animate_kanji.dart create mode 100644 lib/kanimaji/bezier_cubic.dart create mode 100644 lib/kanimaji/settings.dart create mode 100644 lib/svg/parser.dart create mode 100644 lib/svg/path.dart create mode 100644 pubspec.yaml create mode 100644 test/kanimaji_test.dart diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9be145f --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.packages +build/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..105884a --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "assets/kanjivg"] + path = assets/kanjivg + url = git@github.com:KanjiVG/kanjivg.git diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..af84dae --- /dev/null +++ b/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 77d935af4db863f6abd0b9c31c7e6df2a13de57b + channel: stable + +project_type: package diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..41cc7d8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* TODO: Describe initial release. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ba75c69 --- /dev/null +++ b/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b44ac25 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ + + +TODO: Put a short description of the package here that helps potential users +know whether this package might be useful for them. + +## Features + +TODO: List what your package can do. Maybe include images, gifs, or videos. + +## Getting started + +TODO: List prerequisites and provide or point to information on how to +start using the package. + +## Usage + +TODO: Include short and useful examples for package users. Add longer examples +to `/example` folder. + +```dart +const like = 'sample'; +``` + +## Additional information + +The [svg library used](lib/svg) is mostly a rewrite of pythons [svg.path][svg.path]. +This is what kanimaji originally used for animation, and even thought there's a lot of svg path parsers in dart, I found none that was able to calculate the length of the path. If you do find one, please let me know! + +[svg.path]: https://pypi.org/project/svg.path/ \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..a5744c1 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/assets/kanjivg b/assets/kanjivg new file mode 160000 index 0000000..e1d9925 --- /dev/null +++ b/assets/kanjivg @@ -0,0 +1 @@ +Subproject commit e1d99250c5477796c1d08bc3e032566c5be3538a diff --git a/lib/kanimaji.dart b/lib/kanimaji.dart new file mode 100644 index 0000000..a5c46c0 --- /dev/null +++ b/lib/kanimaji.dart @@ -0,0 +1,7 @@ +library kanimaji; + +/// A Calculator. +class Calculator { + /// Returns [value] plus 1. + int addOne(int value) => value + 1; +} diff --git a/lib/kanimaji/animate_kanji.dart b/lib/kanimaji/animate_kanji.dart new file mode 100644 index 0000000..523cc8b --- /dev/null +++ b/lib/kanimaji/animate_kanji.dart @@ -0,0 +1,581 @@ +/// ignore_for_file: non_constant_identifier_names, avoid_print, unused_local_variable, dead_code, constant_identifier_names + +import 'dart:io'; +import 'dart:math' show min; + +import '../svg/parser.dart'; + +import 'bezier_cubic.dart' as bezier_cubic; +import 'settings.dart'; +import 'package:xml/xml.dart'; +import 'package:path/path.dart'; + +double computePathLength(String path) => + parse_path(path).size(error: 1e-8).toDouble(); + +String shescape(path) => "'${path.replace(RegExp(r"(?=['\\\\])"), "\\\\")}'"; + +String dedent(String s) { + final withoutEmptyLines = + s.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'); +} + +// ease, ease-in, etc: +// https://developer.mozilla.org/en-US/docs/Web/CSS/timing-function#ease +const pt1 = bezier_cubic.Point(0, 0); +const easeCt1 = bezier_cubic.Point(0.25, 0.1); +const easeCt2 = bezier_cubic.Point(0.25, 1.0); +const easeInCt1 = bezier_cubic.Point(0.42, 0.0); +const easeInCt2 = bezier_cubic.Point(1.0, 1.0); +const easeInOutCt1 = bezier_cubic.Point(0.42, 0.0); +const easeInOutCt2 = bezier_cubic.Point(0.58, 1.0); +const easeOutCt1 = bezier_cubic.Point(0.0, 0.0); +const easeOutCt2 = bezier_cubic.Point(0.58, 1.0); +const pt2 = bezier_cubic.Point(1, 1); + +double linear(x) => x; +double ease(x) => bezier_cubic.value(pt1, easeCt1, easeCt2, pt2, x); +double easeIn(x) => bezier_cubic.value(pt1, easeInCt1, easeInCt2, pt2, x); +double easeInOut(x) => + bezier_cubic.value(pt1, easeInOutCt1, easeInOutCt2, pt2, x); +double easeOut(x) => bezier_cubic.value(pt1, easeOutCt1, easeOutCt2, pt2, x); + +const Map timingFunctions = { + 'linear': linear, + 'ease': ease, + 'ease-in': easeIn, + 'ease-in-out': easeInOut, + 'ease-out': easeOut +}; + +final myTimingFunction = timingFunctions[TIMING_FUNCTION]!; + +// 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); + +void createAnimation(String filename) { + print('processing $filename'); + final String filenameNoext = filename.replaceAll(RegExp(r'\.[^\.]+$'), ''); + final String baseid = basename(filenameNoext); + + // load xml + final XmlDocument doc = XmlDocument.parse(File(filename).readAsStringSync()); + + // for xlink namespace introduction + doc.rootElement.setAttribute('xmlns:xlink', namespaces['xlink']); + doc.rootElement.setAttribute('xlink:used', ''); + + // clear all extra elements this program may have previously added + 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); + } + } + + // 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: STOKE_UNFILLED_COLOR, + width: STOKE_UNFILLED_WIDTH, + ); + final animGroup = pathCopyGroup( + id: 'anim', + color: STOKE_FILLED_COLOR, + width: STOKE_FILLED_WIDTH, + ); + + late final XmlDocumentFragment brushGroup; + late final XmlDocumentFragment brushBrdGroup; + if (SHOW_BRUSH) { + brushGroup = pathCopyGroup( + id: 'brush', + color: BRUSH_COLOR, + width: BRUSH_WIDTH, + ); + brushBrdGroup = pathCopyGroup( + id: 'brush-brd', + color: BRUSH_BORDER_COLOR, + width: BRUSH_BORDER_WIDTH, + ); + } + + // 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 = stroke_length_to_duration(pathlen); + totlen += pathlen; + tottime += duration; + } + } + + double animationTime = time_rescale(tottime); // math.pow(3 * tottime, 2.0/3) + tottime += WAIT_AFTER * tottime / animationTime; + final double actualAnimationTime = animationTime; + animationTime += WAIT_AFTER; + + final Map staticCss = {}; + late String animatedCss; + final jsAnimationElements = []; // collect the ids of animating elements + final jsAnimationTimes = []; // the time set (as default) for each animation + + 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 + } + '''; + } + if (GENERATE_GIF) { + // final static_css = {}; + final last_frame_index = actualAnimationTime ~/ GIF_FRAME_DURATION + 1; + for (int i = 0; i < last_frame_index + 1; i++) { + staticCss[i] = cssHeader; + } + final last_frame_delay = + animationTime - last_frame_index * GIF_FRAME_DURATION; + } + double elapsedlen = 0; + double elapsedtime = 0; + + + // 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 = dedent(''' + #${groupid.replaceAll(':', '\\3a ')} { + display: none; + } + '''); + 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 = dedent(''' + #$gidcss { + stroke-width: ${STOKE_BORDER_WIDTH.toStringAsFixed(1)}px !important; + stroke: $STOKE_BORDER_COLOR !important; + } + '''); + + 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; + } + } + + for (final p in g.findAllElements("path", namespace: namespaces['n'])) { + final pathid = p.getAttribute('id') as String; + final pathidcss = pathid.replaceAll(':', '\\3a '); + + if (GENERATE_JS_SVG) jsAnimationElements.add({}); + + void addHref(String suffix, XmlDocumentFragment element) { + final builder = XmlBuilder(); + builder.element( + 'use', + attributes: {'id': '$pathid-$suffix', 'xlink:href': '#$pathid'}, + ); + final ref = builder.buildFragment(); + element.firstElementChild!.children.add(ref); + if (GENERATE_JS_SVG) jsAnimationElements.last[suffix] = ref; + } + + final String bgPathidcss = '$pathidcss-bg'; + final String animPathidcss = '$pathidcss-anim'; + final String brushPathidcss = '$pathidcss-brush'; + final String brushBrdPathidcss = '$pathidcss-brush-brd'; + + addHref('bg', bgGroup); + addHref('anim', animGroup); + + if (SHOW_BRUSH) { + addHref('brush', brushGroup); + addHref('brush-brd', brushBrdGroup); + } + + final pathname = pathid.replaceAll(RegExp(r'^kvg:'), ''); + final pathlen = computePathLength(p.getAttribute('d') as String); + final duration = stroke_length_to_duration(pathlen); + final relduration = duration * tottime / animationTime; // unscaled time + + if (GENERATE_JS_SVG) { + jsAnimationTimes.add(relduration); + } + + final newelapsedlen = elapsedlen + pathlen; + final newelapsedtime = elapsedtime + duration; + final animStart = elapsedtime / tottime * 100; + final animEnd = newelapsedtime / tottime * 100; + + if (GENERATE_SVG) { + // animation stroke progression + animatedCss += dedent(''' + @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: $STOKE_FILLING_COLOR; } + } + #$animPathidcss { + stroke-dasharray: ${pathlen.toStringAsFixed(3)} ${pathlen.toStringAsFixed(3)}; + stroke-dashoffset: 0; + animation: strike-$pathname ${animationTime.toStringAsFixed(3)}s $TIMING_FUNCTION infinite, + showhide-$pathname ${animationTime.toStringAsFixed(3)}s step-start infinite; + } + '''); + + if (SHOW_BRUSH) { + // brush element visibility + animatedCss += dedent(''' + @keyframes showhide-brush-$pathname { + ${animStart.toStringAsFixed(3)}% { visibility: hidden; } + ${animEnd.toStringAsFixed(3)}% { visibility: visible; } + 100% { visibility: hidden; } + } + #$brushPathidcss, #$brushBrdPathidcss { + stroke-dasharray: 0 ${pathlen.toStringAsFixed(3)}; + animation: strike-$pathname ${animationTime.toStringAsFixed(3)}s $TIMING_FUNCTION infinite, + showhide-brush-$pathname ${animationTime.toStringAsFixed(3)}s step-start infinite; + } + '''); + } + } + + if (GENERATE_JS_SVG) { + jsAnimatedCss += '\n/* stroke $pathid */'; + + // brush and background hidden by default + if (SHOW_BRUSH) { + jsAnimatedCss += dedent(''' + #$brushPathidcss, #$brushBrdPathidcss, #$bgPathidcss { + visibility: hidden; + } + '''); + } + + // hide stroke after current element + const after_curr = '[class *= "current"]'; + jsAnimatedCss += dedent(''' + $after_curr ~ #$animPathidcss { + visibility: hidden; + } + $after_curr ~ #$bgPathidcss, #$bgPathidcss.animate { + visibility: visible; + } + @keyframes strike-$pathname { + 0% { stroke-dashoffset: ${pathlen.toStringAsFixed(3)}; } + 100% { stroke-dashoffset: 0; } + } + #$animPathidcss.animate { + stroke: $STOKE_FILLING_COLOR; + stroke-dasharray: ${pathlen.toStringAsFixed(3)} ${pathlen.toStringAsFixed(3)}; + visibility: visible; + animation: strike-$pathname ${relduration.toStringAsFixed(3)}s $TIMING_FUNCTION forwards 1; + } + '''); + if (SHOW_BRUSH) { + jsAnimatedCss += dedent(''' + @keyframes strike-brush-$pathname { + 0% { stroke-dashoffset: ${pathlen.toStringAsFixed(3)}; } + 100% { stroke-dashoffset: 0.4; } + } + #$brushPathidcss.animate.brush, #$brushBrdPathidcss.animate.brush { + stroke-dasharray: 0 ${pathlen.toStringAsFixed(3)}; + visibility: visible; + animation: strike-brush-$pathname ${relduration.toStringAsFixed(3)}s $TIMING_FUNCTION forwards 1; + } + '''); + } + } + + 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 (SHOW_BRUSH) { + rule += ", #$brushPathidcss, #$brushBrdPathidcss"; + } + + staticCss[k] = staticCss[k]! + + dedent(''' + %$rule { + visibility: hidden; + } + '''); + } else if (reltime > newelapsedtime) { + // just hide the brush, and bg + rule += "#$bgPathidcss"; + + if (SHOW_BRUSH) { + rule += ", #$brushPathidcss, #$brushBrdPathidcss"; + } + + staticCss[k] = staticCss[k]! + + dedent(''' + $rule { + visibility: hidden; + } + '''); + } else { + final intervalprop = + ((reltime - elapsedtime) / (newelapsedtime - elapsedtime)); + final progression = myTimingFunction(intervalprop); + + staticCss[k] = staticCss[k]! + + dedent(''' + #$animPathidcss { + stroke-dasharray: ${pathlen.toStringAsFixed(3)} ${(pathlen + 0.002).toStringAsFixed(3)}; + stroke-dashoffset: ${(pathlen * (1 - progression) + 0.0015).toStringAsFixed(4)}; + stroke: $STOKE_FILLING_COLOR; + } + '''); + if (SHOW_BRUSH) { + staticCss[k] = staticCss[k]! + + dedent(''' + #$brushPathidcss, #$brushBrdPathidcss { + stroke-dasharray: 0.001 ${(pathlen + 0.002).toStringAsFixed(3)}; + stroke-dashoffset: ${(pathlen * (1 - progression) + 0.0015).toStringAsFixed(4)}; + } + '''); + } + } + } + } + + elapsedlen = newelapsedlen; + elapsedtime = newelapsedtime; + } + } + + void addGroup(XmlDocumentFragment g) => + doc.root.firstElementChild?.children.add(g); + + // insert groups + if (SHOW_BRUSH && !SHOW_BRUSH_FRONT_BORDER) addGroup(brushBrdGroup); + addGroup(bgGroup); + if (SHOW_BRUSH && SHOW_BRUSH_FRONT_BORDER) addGroup(brushBrdGroup); + addGroup(animGroup); + if (SHOW_BRUSH) addGroup(brushGroup); + + if (GENERATE_SVG) { + print(animatedCss); + final builder = XmlBuilder(); + final style = (builder + ..element( + 'style', + attributes: {'id': "style-Kanimaji", 'type': 'text/css'}, + nest: animatedCss, + )) + .buildFragment(); + doc.root.firstElementChild!.children.insert(0, style); + final svgfile = '${filenameNoext}_anim.svg'; + File(svgfile).writeAsStringSync(doc.toXmlString(pretty: true)); + doc.root.children.removeAt(0); + print('written $svgfile'); + } + + if (GENERATE_GIF) { + // svgframefiles = [] + // pngframefiles = [] + // svgexport_data = [] + // for k in static_css: + // svgframefile = filename_noext_ascii + ("_frame%04d.svg"%k) + // pngframefile = filename_noext_ascii + ("_frame%04d.png"%k) + // svgframefiles.append(svgframefile) + // pngframefiles.append(pngframefile) + // svgexport_data.append({"input": [abspath(svgframefile)], + // "output": [[abspath(pngframefile), + // "%d:%d"% (GIF_SIZE, GIF_SIZE)]]}) + + // style = E.style(static_css[k], id="style-Kanimaji") + // doc.getroot().insert(0, style) + // doc.write(svgframefile, pretty_print=True) + // doc.getroot().remove(style) + // print 'written %s' % svgframefile + + // // create json file + // svgexport_datafile = filename_noext_ascii+"_export_data.json" + // 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'); + // } +} + +void main(List args) { + if (!timingFunctions.keys.contains(TIMING_FUNCTION)) { + throw 'Sorry, invalid timing function "$TIMING_FUNCTION"'; + } + // createAnimation('assets/kanjivg/kanji/0f9b1.svg'); + createAnimation('assets/kanjivg/kanji/04f5c.svg'); +} diff --git a/lib/kanimaji/bezier_cubic.dart b/lib/kanimaji/bezier_cubic.dart new file mode 100644 index 0000000..aba7c2a --- /dev/null +++ b/lib/kanimaji/bezier_cubic.dart @@ -0,0 +1,84 @@ + +import 'dart:math' as math; + +class Point { + final double x; + final double y; + + const Point(this.x, this.y); + + @override + String toString() => '($x,$y)'; +} + +double thrt(double x) => + x > 0 ? math.pow(x, 1.0 / 3).toDouble() : -math.pow(-x, 1.0 / 3).toDouble(); + +double sqrt(double x) => x > 0 ? math.sqrt(x) : 0; + +double sq(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) { + // var C = Cubic, a,b,c,d,p,q,lambda,sqlambda,tmp,addcoef,t,qb,qc,norm,angle,fact; + final double a = pt1.x - 3 * ct1.x + 3 * ct2.x - pt2.x; + final double b = 3 * ct1.x - 6 * ct2.x + 3 * pt2.x; + final double c = 3 * ct2.x - 3 * pt2.x; + final double d = pt2.x - x; + + if (a.abs() < 0.000000001) { // quadratic + if (b.abs() < 0.000000001) return -d / c; // linear + + final qb = c / b; + final qc = d / b; + final tmp = sqrt(sq(qb) - 4 * qc); + return (-qb + ((qb > 0 || qc < 0) ? tmp : -tmp)) / 2; + } + + final p = -sq(b) / (3 * sq(a)) + c / a; + final q = 2 * cb(b / (3 * a)) - b * c / (3 * sq(a)) + d / a; + final addcoef = -b / (3 * a); + + final lmbd = sq(q) / 4 + cb(p) / 27; + if (lmbd >= 0) { // real + final sqlambda = sqrt(lmbd); + final tmp = thrt(-q / 2 + (q < 0 ? sqlambda : -sqlambda)); + return tmp - p / (3 * tmp) + addcoef; + } + + final norm = sqrt(sq(q) / 4 - lmbd); + if (norm < 0.0000000001) return addcoef; + + final angle = math.acos(-q / (2 * norm)) / 3; + final fact = 2 * thrt(norm); + double t = double.infinity; + for (final i in [-1, 0, 1]) { + final tmp = fact * math.cos(angle + i * math.pi * 2 / 3) + addcoef; + if (tmp >= -0.000000001 && tmp < t) t = tmp; + } + + return t; +} + +double value(Point pt1, Point ct1, Point ct2, Point pt2, double x) { + final t = time(pt1, ct1, ct2, pt2, x); + return cb(t) * pt1.y + + 3 * sq(t) * (1 - t) * ct1.y + + 3 * t * sq(1 - t) * ct2.y + + cb(1 - t) * pt2.y; +} + +// if __name__ == "__main__": +// pt1 = pt(0,0) +// ct1 = pt(0.25, 0.1) +// ct2 = pt(0.25, 1.0) +// pt2 = pt(1,1) + +// part = 100 +// with open('ease.txt', 'w') as f: +// 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)) \ No newline at end of file diff --git a/lib/kanimaji/settings.dart b/lib/kanimaji/settings.dart new file mode 100644 index 0000000..098fcee --- /dev/null +++ b/lib/kanimaji/settings.dart @@ -0,0 +1,56 @@ +import 'dart:math'; + +// *_BORDER_WIDTH is the width INCLUDING the border. +const STOKE_BORDER_WIDTH = 4.5; +const STOKE_BORDER_COLOR = "#666"; +const STOKE_UNFILLED_COLOR = "#eee"; +const double STOKE_UNFILLED_WIDTH = 3; +const STOKE_FILLING_COLOR = "#f00"; +const STOKE_FILLED_COLOR = "#000"; +const double STOKE_FILLED_WIDTH = 3.1; + +// brush settings +const SHOW_BRUSH = true; +const SHOW_BRUSH_FRONT_BORDER = true; +const BRUSH_COLOR = "#f00"; +const double BRUSH_WIDTH = 5.5; +const BRUSH_BORDER_COLOR = "#666"; +const double BRUSH_BORDER_WIDTH = 7; + +const WAIT_AFTER = 1.5; + +// 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 stroke_length_to_duration(double length) => sqrt(length) / 8; + +// global time rescale, let's make animation a bit +// faster when there are many strokes. +double time_rescale(interval) => pow(2 * interval, 2.0 / 3).toDouble(); + +// Possibilities are linear, ease, ease-in, ease-in-out, ease-out, see +// https://developer.mozilla.org/en-US/docs/Web/CSS/timing-function +// for more info. +const TIMING_FUNCTION = "ease-in-out"; + +// +// colorful debug settings +// +// STOKE_BORDER_COLOR = "#00f" +// STOKE_UNFILLED_COLOR = "#ff0" +// STOKE_FILLING_COLOR = "#f00" +// STOKE_FILLED_COLOR = "#000" +// BRUSH_COLOR = "#0ff" +// BRUSH_BORDER_COLOR = "#0f0" \ No newline at end of file diff --git a/lib/svg/parser.dart b/lib/svg/parser.dart new file mode 100644 index 0000000..dc8a7b9 --- /dev/null +++ b/lib/svg/parser.dart @@ -0,0 +1,410 @@ +/// SVG Path specification parser +/// +import 'path.dart' + show Arc, Close, CubicBezier, Line, Move, Path, Point, QuadraticBezier; + +const COMMANDS = { + 'M', + 'm', + 'Z', + 'z', + 'L', + 'l', + 'H', + 'h', + 'V', + 'v', + 'C', + 'c', + 'S', + 's', + 'Q', + 'q', + 'T', + 't', + 'A', + 'a' +}; +const UPPERCASE = {'M', 'Z', 'L', 'H', 'V', 'C', 'S', 'Q', 'T', 'A'}; + +final COMMAND_RE = RegExp("(?=[${COMMANDS.join('')}])"); +final FLOAT_RE = RegExp(r"^[-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?"); + +class ParserResult { + final T value; + final String remaining; + + const ParserResult({required this.value, required this.remaining}); +} + +class InvalidPathError implements Exception { + final String msg; + const InvalidPathError(this.msg); + + @override + String toString() => 'InvalidPathError: $msg'; +} + +// The argument sequences from the grammar, made sane. +// u: Non-negative number +// s: Signed number or coordinate +// c: coordinate-pair, which is two coordinates/numbers, separated by whitespace +// f: A one character flag, doesn't need whitespace, 1 or 0 +const ARGUMENT_SEQUENCE = { + "M": "c", + "Z": "", + "L": "c", + "H": "s", + "V": "s", + "C": "ccc", + "S": "cc", + "Q": "cc", + "T": "c", + "A": "uusffc", +}; + +/// Strips whitespace and commas +String strip_array(String arg_array) { + // EBNF wsp:(#x20 | #x9 | #xD | #xA) + comma: 0x2C + while (arg_array.isNotEmpty && ' \t\n\r,'.contains(arg_array[0])) { + arg_array = arg_array.substring(1); + } + return arg_array; +} + +ParserResult pop_number(String arg_array) { + final res = FLOAT_RE.firstMatch(arg_array); + if (res == null) { + throw InvalidPathError("Expected a number, got '$arg_array'."); + } + + final number = double.parse(res.group(0)!); + final start = res.start; + final end = res.end; + arg_array = arg_array.substring(0, start) + arg_array.substring(end); + arg_array = strip_array(arg_array); + + return ParserResult(value: number, remaining: arg_array); +} + +ParserResult pop_unsigned_number(arg_array) { + final number = pop_number(arg_array); + if (number.value < 0) { + throw InvalidPathError("Expected a non-negative number, got '$number'."); + } + return number; +} + +ParserResult pop_coordinate_pair(arg_array) { + final x = pop_number(arg_array); + final y = pop_number(x.remaining); + return ParserResult(value: Point(x.value, y.value), remaining: y.remaining); +} + +ParserResult pop_flag(String arg_array) { + final flag = arg_array[0]; + arg_array = arg_array.substring(1); + arg_array = strip_array(arg_array); + if (flag == '0') return ParserResult(value: false, remaining: arg_array); + if (flag == '1') return ParserResult(value: true, remaining: arg_array); + + throw InvalidPathError("Expected either 1 or 0, got '$flag'"); +} + +const FIELD_POPPERS = { + "u": pop_unsigned_number, + "s": pop_number, + "c": pop_coordinate_pair, + "f": pop_flag, +}; + +class Command { + final String command; + final String args; + + const Command({required this.command, required this.args}); + + @override + String toString() => 'Command: $command $args'; +} + +// Splits path into commands and arguments +List _commandify_path(String pathdef) { + List tokens = []; + List token = []; + for (String c in pathdef.split(COMMAND_RE)) { + String x = c[0]; + String? y = (c.length > 1) ? c.substring(1).trim() : null; + if (!COMMANDS.contains(x)) { + throw InvalidPathError("Path does not start with a command: $pathdef"); + } + if (token.isNotEmpty) { + tokens.add(Command(command: token[0], args: token[1])); + // yield token; + } + if (x == "z" || x == "Z") { + // The end command takes no arguments, so add a blank one + token.addAll([x, ""]); + } else { + // token = [x, x.substring(1).trim()]; + token = [x]; + } + + if (y != null) { + token.add(y); + } + } + tokens.add(Command(command: token[0], args: token[1])); + // yield token; + return tokens; +} + +class Token { + final String command; + final List args; + + const Token({required this.command, required this.args}); + + @override + String toString() => 'Token: $command ($args)'; +} + +List _tokenize_path(String pathdef) { + List tokens = []; + for (final token in _commandify_path(pathdef)) { + // _commandify_path(pathdef).forEach((List token) { + String command = token.command; + String args = token.args; + + // Shortcut this for the close command, that doesn't have arguments: + if (command == "z" || command == "Z") { + tokens.add(Token(command: command, args: [])); + continue; + } + + // For the rest of the commands, we parse the arguments and + // yield one command per full set of arguments + final String arg_sequence = ARGUMENT_SEQUENCE[command.toUpperCase()]!; + String arguments = args; + while (arguments.isNotEmpty) { + final List command_arguments = []; + for (final arg in arg_sequence.split('')) { + try { + final result = FIELD_POPPERS[arg]!.call(arguments); + arguments = result.remaining; + command_arguments.add(result.value); + } on InvalidPathError { + throw InvalidPathError("Invalid path element $command $args"); + } + } + + tokens.add(Token(command: command, args: command_arguments)); + // yield (command,) + tuple(command_arguments) + + // Implicit Moveto commands should be treated as Lineto commands. + if (command == "m") { + command = "l"; + } else if (command == "M") { + command = "L"; + } + } + } + return tokens; +} + +Path parse_path(String pathdef) { + final segments = Path(); + Point? start_pos; + String? last_command; + Point current_pos = Point.zero; + + for (final token in _tokenize_path(pathdef)) { + final command = token.command.toUpperCase(); + final absolute = token.command.toUpperCase() == token.command; + if (command == "M") { + final pos = token.args[0] as Point; + if (absolute) { + current_pos = pos; + } else { + current_pos += pos; + } + segments.add(Move(to: current_pos)); + start_pos = current_pos; + } else if (command == "Z") { + // TODO Throw error if not available: + segments.add(Close(start: current_pos, end: start_pos!)); + current_pos = start_pos; + } else if (command == "L") { + Point pos = token.args[0] as Point; + if (!absolute) { + pos += current_pos; + } + segments.add(Line(start: current_pos, end: pos)); + current_pos = pos; + } else if (command == "H") { + double hpos = token.args[0] as double; + if (!absolute) { + hpos += current_pos.x; + } + final pos = Point(hpos, current_pos.y); + segments.add(Line(start: current_pos, end: pos)); + current_pos = pos; + } else if (command == "V") { + double vpos = token.args[0] as double; + if (!absolute) { + vpos += current_pos.y; + } + final pos = Point(current_pos.x, vpos); + segments.add(Line(start: current_pos, end: pos)); + current_pos = pos; + } else if (command == "C") { + Point control1 = token.args[0] as Point; + Point control2 = token.args[1] as Point; + Point end = token.args[2] as Point; + + if (!absolute) { + control1 += current_pos; + control2 += current_pos; + end += current_pos; + } + + segments.add( + CubicBezier( + start: current_pos, + control1: control1, + control2: control2, + end: end, + ), + ); + current_pos = end; + } else if (command == "S") { + // Smooth curve. First control point is the "reflection" of + // the second control point in the previous path. + Point control2 = token.args[0] as Point; + Point end = token.args[1] as Point; + + if (!absolute) { + control2 += current_pos; + end += current_pos; + } + + late final Point control1; + + if (last_command == 'C' || last_command == 'S') { + // The first control point is assumed to be the reflection of + // the second control point on the previous command relative + // to the current point. + control1 = + current_pos + current_pos - (segments.last as CubicBezier).control2; + } else { + // If there is no previous command or if the previous command + // was not an C, c, S or s, assume the first control point is + // coincident with the current point. + control1 = current_pos; + } + segments.add( + CubicBezier( + start: current_pos, + control1: control1, + control2: control2, + end: end), + ); + current_pos = end; + } else if (command == "Q") { + Point control = token.args[0] as Point; + Point end = token.args[1] as Point; + + if (!absolute) { + control += current_pos; + end += current_pos; + } + + segments.add( + QuadraticBezier(start: current_pos, control: control, end: end), + ); + current_pos = end; + } else if (command == "T") { + // Smooth curve. Control point is the "reflection" of + // the second control point in the previous path. + Point end = token.args[0] as Point; + + if (!absolute) { + end += current_pos; + } + + late final Point control; + if (last_command == "Q" || last_command == 'T') { + // The control point is assumed to be the reflection of + // the control point on the previous command relative + // to the current point. + control = current_pos + + current_pos - + (segments.last as QuadraticBezier).control; + } else { + // If there is no previous command or if the previous command + // was not an Q, q, T or t, assume the first control point is + // coincident with the current point. + control = current_pos; + } + + segments.add( + QuadraticBezier(start: current_pos, control: control, end: end), + ); + current_pos = end; + } else if (command == "A") { + // For some reason I implemented the Arc with a complex radius. + // That doesn't really make much sense, but... *shrugs* + final radius = Point(token.args[0] as double, token.args[1] as double); + final rotation = token.args[2] as double; + final arc = token.args[3] as bool; + final sweep = token.args[4] as bool; + Point end = token.args[5] as Point; + + if (!absolute) { + end += current_pos; + } + + segments.add( + Arc( + start: current_pos, + radius: radius, + rotation: rotation, + arc: arc, + sweep: sweep, + end: end, + ), + ); + current_pos = end; + } + + // Finish up the loop in preparation for next command + last_command = command; + } + + return segments; +} + +void main(List args) { + // print(_commandify_path('M 10 10 C 20 20, 40 20, 50 10')); +// print(_tokenize_path('M 10 10 C 20 20, 40 20, 50 10')); +// print(_tokenize_path('M 10 80 Q 52.5 10, 95 80 T 180 80')); +// print(_tokenize_path(""" +// M 10 315 +// L 110 215 +// A 30 50 0 0 1 162.55 162.45 +// L 172.55 152.45 +// A 30 50 -45 0 1 215.1 109.9 +// L 315 10 +// """)); + + print(parse_path('M 10 10 C 20 20, 40 20, 50 10')); + print(parse_path('M 10 80 Q 52.5 10, 95 80 T 180 80')); + print(parse_path(""" +M 10 315 + L 110 215 + A 30 50 0 0 1 162.55 162.45 + L 172.55 152.45 + A 30 50 -45 0 1 215.1 109.9 + L 315 10 + """)); +} \ No newline at end of file diff --git a/lib/svg/path.dart b/lib/svg/path.dart new file mode 100644 index 0000000..31239e5 --- /dev/null +++ b/lib/svg/path.dart @@ -0,0 +1,682 @@ +import 'dart:collection'; +import 'dart:math' as math; +import 'dart:math' show sqrt, sin, cos, acos, log, pi; +import 'package:bisect/bisect.dart'; + +// try: +// from collections.abc import MutableSequence +// except ImportError: +// from collections import MutableSequence + +// 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. + +double radians(num n) => n * pi / 180; +double degrees(num n) => n * 180 / pi; + +class Point { + final num x; + final num y; + + const Point(this.x, this.y); + const Point.from({this.x = 0, this.y = 0}); + static const zero = Point(0, 0); + + operator +(covariant Point p) => Point(x + p.x, y + p.y); + operator -(covariant Point p) => Point(x - p.x, y - p.y); + operator *(covariant Point p) => Point(x * p.x, y * p.y); + operator /(covariant Point p) => Point(x / p.x, y / p.y); + + Point addX(num n) => Point(x + n, y); + Point addY(num n) => Point(x, y + n); + Point add(num n) => Point(x + n, y + n); + + Point subtractX(num n) => Point(x - n, y); + Point subtractY(num n) => Point(x, y - n); + Point subtractXY(num n) => Point(x - n, y - n); + + Point xSubtract(num n) => Point(n - x, y); + Point ySubtract(num n) => Point(x, n - y); + Point xySubtract(num n) => Point(n - x, n - y); + + Point timesX(num n) => Point(x * n, y); + Point timesY(num n) => Point(x, y * n); + Point times(num n) => Point(x * n, y * n); + + Point dividesX(num n) => Point(x / n, y); + Point dividesY(num n) => Point(x, y / n); + Point divides(num n) => Point(x / n, y / n); + + Point pow(int n) => Point(math.pow(x, n), math.pow(y, n)); + double abs() => math.sqrt(x * x + y * y); + + @override + String toString() => '($x,$y)'; +} + +const defaultMinDepth = 5; +const defaultError = 1e-12; + +/// Recursively approximates the length by straight lines +double segmentLength({ + required SvgPath curve, + required num start, + required num end, + required Point startPoint, + required Point endPoint, + required double error, + required int minDepth, + required double depth, +}) { + num mid = (start + end) / 2; + Point midPoint = curve.point(mid); + double length = (endPoint - startPoint).abs(); + double firstHalf = (midPoint - startPoint).abs(); + double secondHalf = (endPoint - midPoint).abs(); + + double length2 = firstHalf + secondHalf; + if ((length2 - length > error) || (depth < minDepth)) { + // Calculate the length of each segment: + depth += 1; + return segmentLength( + curve: curve, + start: start, + end: mid, + startPoint: startPoint, + endPoint: midPoint, + error: error, + minDepth: minDepth, + depth: depth, + ) + + segmentLength( + curve: curve, + start: mid, + end: end, + startPoint: midPoint, + endPoint: endPoint, + error: error, + minDepth: minDepth, + depth: depth, + ); + } + // This is accurate enough. + return length2; +} + +abstract class SvgPath { + final Point start; + final Point end; + + const SvgPath({ + required this.start, + required this.end, + }); + + /// Calculate the x,y position at a certain position of the path + Point point(num pos); + + /// Calculate the length of the path up to a certain position + double size({double error = defaultError, int minDepth = defaultMinDepth}); +} + +abstract class Bezier extends SvgPath { + const Bezier({ + required Point start, + required Point end, + }) : super(start: start, end: end); + + /// Checks if this segment would be a smooth segment following the previous + bool isSmoothFrom(Object? previous); +} + +/// A straight line +/// The base for Line() and Close(). +class Linear extends SvgPath { + const Linear({ + required Point start, + required Point end, + }) : super(start: start, end: end); + + // def __ne__(self, other): + // if not isinstance(other, Line): + // return NotImplemented + // return not self == other + + @override + Point point(num pos) => start + (end - start).times(pos); + + @override + double size({double error = defaultError, int minDepth = defaultMinDepth}) { + final distance = end - start; + return sqrt(distance.x * distance.x + distance.y * distance.y); + } +} + +class Line extends Linear { + const Line({ + required Point start, + required Point end, + }) : super(start: start, end: end); + + @override + String toString() { + return "Line(start=$start, end=$end)"; + } + // @override + // operator ==(covariant Line other) => start == other.start && end == other.end; +} + +class CubicBezier extends Bezier { + final Point control1; + final Point control2; + + const CubicBezier({ + required Point start, + required this.control1, + required this.control2, + required Point end, + }) : super(start: start, end: end); + + @override + String toString() => "CubicBezier(start=$start, control1=$control1, " + "control2=$control2, end=$end)"; + + // @override + // operator ==(covariant CubicBezier other) => + // start == other.start && + // and end == other.end && + // and control1 == other.control1 && + // and control2 == other.control2; + + // def __ne__(self, other): + // if not isinstance(other, CubicBezier): + // return NotImplemented + // return not self == other + + @override + bool isSmoothFrom(Object? previous) => previous is CubicBezier + ? start == previous.end && + control1 - start == previous.end - previous.control2 + : control1 == start; + + @override + Point point(num pos) => + start.times(math.pow(1 - pos, 3)) + + control1.times(math.pow(1 - pos, 2) * 3 * pos) + + control2.times(math.pow(pos, 2) * 3 * (1 - pos)) + + end.times(math.pow(pos, 3)); + + @override + double size({double error = defaultError, int minDepth = defaultMinDepth}) { + 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); + } +} + +class QuadraticBezier extends Bezier { + final Point control; + + const QuadraticBezier({ + required Point start, + required Point end, + required this.control, + }) : super( + start: start, + end: end, + ); + + @override + String toString() => + "QuadraticBezier(start=$start, control=$control, end=$end)"; + + // def __eq__(self, other): + // if not isinstance(other, QuadraticBezier): + // return NotImplemented + // return ( + // self.start == other.start + // and self.end == other.end + // and self.control == other.control + // ) + + // def __ne__(self, other): + // if not isinstance(other, QuadraticBezier): + // return NotImplemented + // return not self == other + + @override + bool isSmoothFrom(Object? previous) => previous is QuadraticBezier + ? start == previous.end && + (control - start) == (previous.end - previous.control) + : control == start; + + @override + Point point(num pos) => + start.times(math.pow(1 - pos, 2)) + + control.times(pos * (1 - pos) * 2) + + end.times(math.pow(pos, 2)); + + @override + double size({double error = defaultError, int minDepth = defaultMinDepth}) { + final Point a = start - control.times(2) + end; + final Point b = (control - start).times(2); + final num aDotB = a.x * b.x + a.y * b.y; + + late final double s; + if (a.abs() < 1e-12) { + s = b.abs(); + } else if ((aDotB + a.abs() * b.abs()).abs() < 1e-12) { + final k = b.abs() / a.abs(); + s = (k >= 2) ? b.abs() - a.abs() : a.abs() * ((k * k) / 2 - k + 1); + } else { + // For an explanation of this case, see + // http://www.malczak.info/blog/quadratic-bezier-curve-length/ + final num A = 4 * (a.x * a.x + a.y * a.y); + final num B = 4 * (a.x * b.x + a.y * b.y); + final num C = b.x * b.x + b.y * b.y; + + final double sabc = 2 * sqrt(A + B + C); + final double a2 = sqrt(A); + final double a32 = 2 * A * a2; + final double c2 = 2 * sqrt(C); + final double bA = B / a2; + + s = (a32 * sabc + + a2 * B * (sabc - c2) + + (4 * C * A - (B * B)) * log((2 * a2 + bA + sabc) / (bA + c2))) / + (4 * a32); + } + return s; + } +} + +/// radius is complex, rotation is in degrees, +/// large and sweep are 1 or 0 (True/False also work) +class Arc extends SvgPath { + final Point radius; + final double rotation; + final bool arc; + final bool sweep; + late final num radiusScale; + late final Point center; + late final num theta; + late final num delta; + + Arc({ + required Point start, + required Point end, + required this.radius, + required this.rotation, + required this.arc, + required this.sweep, + }) : super(start: start, end: end) { + _parameterize(); + } + + @override + String toString() => "Arc(start=$start, radius=$radius, rotation=$rotation, " + "arc=$arc, sweep=$sweep, end=$end)"; + + // def __eq__(self, other): + // if not isinstance(other, Arc): + // return NotImplemented + // return ( + // self.start == other.start + // and self.end == other.end + // and self.radius == other.radius + // and self.rotation == other.rotation + // and self.arc == other.arc + // and self.sweep == other.sweep + // ) + + // def __ne__(self, other): + // if not isinstance(other, Arc): + // return NotImplemented + // return not self == other + + void _parameterize() { + // Conversion from endpoint to center parameterization + // http://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes + + // This is equivalent of omitting the segment, so do nothing + if (start == end) return; + + // This should be treated as a straight line + if (radius.x == 0 || radius.y == 0) return; + + final cosr = cos(radians(rotation)); + final sinr = sin(radians(rotation)); + final dx = (start.x - end.x) / 2; + final dy = (start.y - end.y) / 2; + final x1prim = cosr * dx + sinr * dy; + final x1primSq = x1prim * x1prim; + final y1prim = -sinr * dx + cosr * dy; + final y1primSq = y1prim * y1prim; + + num rx = radius.x; + num rxSq = rx * rx; + num ry = radius.y; + num rySq = ry * ry; + + // Correct out of range radii + num radiusScale = (x1primSq / rxSq) + (y1primSq / rySq); + if (radiusScale > 1) { + radiusScale = sqrt(radiusScale); + rx *= radiusScale; + ry *= radiusScale; + rxSq = rx * rx; + rySq = ry * ry; + this.radiusScale = radiusScale; + } else { + // SVG spec only scales UP + this.radiusScale = 1; + } + + final t1 = rxSq * y1primSq; + final t2 = rySq * x1primSq; + double c = sqrt(((rxSq * rySq - t1 - t2) / (t1 + t2)).abs()); + + if (arc == sweep) { + c = -c; + } + final cxprim = c * rx * y1prim / ry; + final cyprim = -c * ry * x1prim / rx; + + center = Point( + (cosr * cxprim - sinr * cyprim) + ((start.x + end.x) / 2), + (sinr * cxprim + cosr * cyprim) + ((start.y + end.y) / 2), + ); + + final ux = (x1prim - cxprim) / rx; + final uy = (y1prim - cyprim) / ry; + final vx = (-x1prim - cxprim) / rx; + final vy = (-y1prim - cyprim) / ry; + num n = sqrt(ux * ux + uy * uy); + num p = ux; + theta = (((uy < 0) ? -1 : 1) * degrees(acos(p / n))) % 360; + + n = sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy)); + p = ux * vx + uy * vy; + num d = p / n; + // In certain cases the above calculation can through inaccuracies + // become just slightly out of range, f ex -1.0000000000000002. + if (d > 1.0) { + d = 1.0; + } else if (d < -1.0) { + d = -1.0; + } + delta = ((((ux * vy - uy * vx) < 0) ? -1 : 1) * degrees(acos(d))) % 360; + if (!sweep) delta -= 360; + } + + @override + Point point(num pos) { + // This is equivalent of omitting the segment + if (start == end) return start; + + // This should be treated as a straight line + if (this.radius.x == 0 || this.radius.y == 0) { + return start + (end - start) * pos; + } + + final angle = radians(theta + pos * delta); + final cosr = cos(radians(rotation)); + final sinr = sin(radians(rotation)); + final radius = this.radius.times(radiusScale); + + final x = + cosr * cos(angle) * radius.x - sinr * sin(angle) * radius.y + center.x; + + final y = + sinr * cos(angle) * radius.x + cosr * sin(angle) * radius.y + center.y; + + return Point(x, y); + } + + /// The length of an elliptical arc segment requires numerical + /// integration, and in that case it's simpler to just do a geometric + /// approximation, as for cubic bezier curves. + @override + double size({double error = defaultError, minDepth = defaultMinDepth}) { + // This is equivalent of omitting the segment + if (start == end) return 0; + + // This should be treated as a straight line + if (radius.x == 0 || radius.y == 0) { + final distance = end - start; + return sqrt(distance.x * distance.x + distance.y * distance.y); + } + + if (radius.x == radius.y) { + // It's a circle, which simplifies this a LOT. + final radius = this.radius.x * radiusScale; + return radians(radius * delta).abs(); + } + + 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); + } +} + +// Represents move commands. Does nothing, but is there to handle +// paths that consist of only move commands, which is valid, but pointless. +class Move extends SvgPath { + const Move({required Point to}) : super(start: to, end: to); + + @override + String toString() => "Move(to=$start)"; +// def __eq__(self, other): +// if not isinstance(other, Move): +// return NotImplemented +// return self.start == other.start + +// def __ne__(self, other): +// if not isinstance(other, Move): +// return NotImplemented +// return not self == other + + @override + Point point(num pos) => start; + + @override + double size({double error = defaultError, int minDepth = defaultMinDepth}) => 0; +} + +// Represents the closepath command +class Close extends Linear { + const Close({ + required Point start, + required Point end, + }) : super(start: start, end: end); + + // def __eq__(self, other): + // if not isinstance(other, Close): + // return NotImplemented + // return self.start == other.start and self.end == other.end + + @override + String toString() => "Close(start=$start, end=$end)"; +} + +/// A Path is a sequence of path segments +class Path extends ListBase { + late final List segments; + List? _memoizedLengths; + num? _memoizedLength; + final List _fractions = []; + + Path() { + segments = []; + } + + @override + SvgPath operator [](int index) => segments[index]; + + @override + void operator []=(int index, SvgPath value) { + segments[index] = value; + _memoizedLength = null; + } + + @override + int get length => segments.length; + + @override + set length(int newLength) => segments.length = newLength; + + @override + String toString() => + 'Path(${[for (final s in segments) s.toString()].join(", ")})'; + + void _calcLengths({double error = defaultError, int minDepth = defaultMinDepth}) { + if (_memoizedLength != null) return; + + final lengths = [ + for (final s in segments) s.size(error: error, minDepth: minDepth) + ]; + _memoizedLength = lengths.reduce((a, b) => a + b); + if (_memoizedLength == 0) { + _memoizedLengths = lengths; + } else { + _memoizedLengths = [for (final l in lengths) l / _memoizedLength!]; + } + + // Calculate the fractional distance for each segment to use in point() + num fraction = 0; + for (final l in _memoizedLengths!) { + fraction += l; + _fractions.add(fraction); + } + } + + Point point({required num pos, double error = defaultError}) { + // Shortcuts + if (pos == 0.0) { + return segments[0].point(pos); + } + if (pos == 1.0) { + return segments.last.point(pos); + } + + _calcLengths(error: error); + + // Fix for paths of length 0 (i.e. points) + if (length == 0) { + return segments[0].point(0.0); + } + + // Find which segment the point we search for is located on: + late final num segmentPos; + int i = _fractions.bisect(pos); + if (i == 0) { + segmentPos = pos / _fractions[0]; + } else { + segmentPos = + (pos - _fractions[i - 1]) / (_fractions[i] - _fractions[i - 1]); + } + return segments[i].point(segmentPos); + } + + num size({error = defaultError, minDepth = defaultMinDepth}) { + _calcLengths(error: error, minDepth: minDepth); + return _memoizedLength!; + } + + String d() { + Point? currentPos; + final parts = []; + SvgPath? previousSegment; + final end = last.end; + + String formatNumber(num n) => n.toString(); + String coord(Point p) => '${formatNumber(p.x)},${formatNumber(p.y)}'; + + for (final segment in this) { + final start = segment.start; + // If the start of this segment does not coincide with the end of + // the last segment or if this segment is actually the close point + // of a closed path, then we should start a new subpath here. + if (segment is Close) { + parts.add("Z"); + } else if (segment is Move || + (currentPos != start) || + (start == end && previousSegment is! Move)) { + parts.add("M ${coord(start)}"); + } + + if (segment is Line) { + parts.add("L ${coord(segment.end)}"); + } else if (segment is CubicBezier) { + if (segment.isSmoothFrom(previousSegment)) { + parts.add("S ${coord(segment.control2)} ${coord(segment.end)}"); + } else { + parts.add( + "C ${coord(segment.control1)} ${coord(segment.control2)} ${coord(segment.end)}", + ); + } + } else if (segment is QuadraticBezier) { + if (segment.isSmoothFrom(previousSegment)) { + parts.add("T ${coord(segment.end)}"); + } else { + parts.add("Q ${coord(segment.control)} ${coord(segment.end)}"); + } + } else if (segment is Arc) { + parts.add( + "A ${coord(segment.radius)} ${formatNumber(segment.rotation)} " + "${(segment.arc ? 1 : 0).toDouble},${(segment.sweep ? 1 : 0).toDouble} ${coord(end)}", + ); + } + + currentPos = segment.end; + previousSegment = segment; + } + + return parts.join(" "); + } + +// def __delitem__(self, index): +// del self._segments[index] +// self._length = None + +// def reverse(self): +// # Reversing the order of a path would require reversing each element +// # as well. That's not implemented. +// raise NotImplementedError + +// def __len__(self): +// return len(self._segments) + +// def __eq__(self, other): + +// if not isinstance(other, Path): +// return NotImplemented +// if len(self) != len(other): +// return False +// for s, o in zip(self._segments, other._segments): +// if not s == o: +// return False +// return True + +// def __ne__(self, other): +// if not isinstance(other, Path): +// return NotImplemented +// return not self == other + +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..ad664ad --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,56 @@ +name: kanimaji +description: A new Flutter package project. +version: 0.0.1 +homepage: + +environment: + sdk: ">=2.15.1 <3.0.0" + flutter: ">=1.17.0" + +dependencies: + bisect: ^1.0.2 + flutter: + sdk: flutter + xml: ^5.3.1 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^1.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + + # To add assets to your package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/assets-and-images/#from-packages + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware. + + # To add custom fonts to your package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/custom-fonts/#from-packages diff --git a/test/kanimaji_test.dart b/test/kanimaji_test.dart new file mode 100644 index 0000000..7061791 --- /dev/null +++ b/test/kanimaji_test.dart @@ -0,0 +1,12 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:kanimaji/kanimaji.dart'; + +void main() { + test('adds one to input values', () { + final calculator = Calculator(); + expect(calculator.addOne(2), 3); + expect(calculator.addOne(-7), -6); + expect(calculator.addOne(0), 1); + }); +}