From 89652c401fe6d89348d537bddb52a851d1dbfed5 Mon Sep 17 00:00:00 2001 From: h7x4 Date: Sat, 21 Feb 2026 17:24:59 +0900 Subject: [PATCH] Restructure project, create main widget and remove SVG generator --- flake.lock | 12 +- lib/animator.dart | 715 ----------------------- lib/kanimaji.dart | 2 + lib/kanjivg_parser.dart | 212 +++++++ lib/{ => primitives}/bezier.dart | 0 lib/{svg => primitives}/path.dart | 93 ++- lib/{ => primitives}/point.dart | 0 lib/{svg/parser.dart => svg_parser.dart} | 6 +- lib/widget.dart | 544 ++++++++++++++++- pubspec.yaml | 1 + test/kanjivg_parser_test.dart | 43 ++ test/svg/generation_test.dart | 2 +- test/svg/parser_test.dart | 6 +- test/svg/path_test.dart | 4 +- test/svg/tokenizer_test.dart | 4 +- 15 files changed, 907 insertions(+), 737 deletions(-) delete mode 100644 lib/animator.dart create mode 100644 lib/kanimaji.dart create mode 100644 lib/kanjivg_parser.dart rename lib/{ => primitives}/bezier.dart (100%) rename lib/{svg => primitives}/path.dart (87%) rename lib/{ => primitives}/point.dart (100%) rename lib/{svg/parser.dart => svg_parser.dart} (98%) create mode 100644 test/kanjivg_parser_test.dart diff --git a/flake.lock b/flake.lock index ab901b6..e5e7f24 100644 --- a/flake.lock +++ b/flake.lock @@ -3,11 +3,11 @@ "kanjivg": { "flake": false, "locked": { - "lastModified": 1754131552, - "narHash": "sha256-1h3nT1f1QqXme4rU3HMmEREn74sAASyZ8qzjZ0tPi4I=", + "lastModified": 1770109946, + "narHash": "sha256-zgkyLJwEJe6YABUNrL27BBrDSWVNcAUk7K6P1mcVHxQ=", "owner": "KanjiVG", "repo": "kanjivg", - "rev": "0d08020e69611552a0fbe13d49365d6196431b96", + "rev": "2fe6daaba502ee6735f888e5fb4a7d30639a18ee", "type": "github" }, "original": { @@ -18,11 +18,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1754214453, - "narHash": "sha256-Q/I2xJn/j1wpkGhWkQnm20nShYnG7TI99foDBpXm1SY=", + "lastModified": 1771369470, + "narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "5b09dc45f24cf32316283e62aec81ffee3c3e376", + "rev": "0182a361324364ae3f436a63005877674cf45efb", "type": "github" }, "original": { diff --git a/lib/animator.dart b/lib/animator.dart deleted file mode 100644 index 59da897..0000000 --- a/lib/animator.dart +++ /dev/null @@ -1,715 +0,0 @@ -import 'dart:io'; -import 'dart:math' show min, sqrt, pow; - -import 'svg/parser.dart'; -import 'point.dart'; - -import 'bezier.dart' as bezier; -import 'package:xml/xml.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 = 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.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]!; -} - -// 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); - } - } -} - -String basename(String path) { - final int lastSlash = path.lastIndexOf(Platform.pathSeparator); - if (lastSlash == -1) { - return path; - } - return path.substring(lastSlash + 1); -} - -/// Note: setting any color to transparent will result in a much bigger -/// filesize for GIFs. -void createAnimation({ - required String inputFile, - String? outputFile, - TimingFunction timingFunction = TimingFunction.easeInOut, - double strokeBorderWidth = 4.5, - double strokeUnfilledWidth = 3, - double strokeFilledWidth = 3.1, - bool showBrush = true, - bool showBrushFrontBorder = true, - double brushWidth = 5.5, - double brushBorderWidth = 7, - double waitAfter = 1.5, - String strokeBorderColor = '#666', - String strokeUnfilledColor = '#EEE', - String strokeFillingColor = '#F00', - String strokeFilledColor = '#000', - String brushColor = '#F00', - String brushBorderColor = '#666', -}) { - print('processing $inputFile'); - final String filenameNoext = inputFile.replaceAll(RegExp(r'\.[^\.]+$'), ''); - outputFile ??= '${filenameNoext}_anim.svg'; - final String baseid = basename(filenameNoext); - - // load xml - final XmlDocument doc = XmlDocument.parse(File(inputFile).readAsStringSync()); - - // for xlink namespace introduction - doc.rootElement.setAttribute('xmlns:xlink', namespaces['xlink']); - doc.rootElement.setAttribute('xlink:used', ''); - - clearPreviousElements(doc); - - /// 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, - ); - } - - // 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; - - final Map staticCss = {}; - late String animatedCss; - - /// collect the ids of animating elements - final List 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 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; - } - } - - 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); - } - - 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; - - 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; - } - } - - 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; - } - } - - 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; - } - } - - 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); - - 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'); - } - - 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) { - // createAnimation('assets/kanjivg/kanji/0f9b1.svg'); - - const kanji = '実例'; - final fileList = []; - for (int k = 0; k < kanji.length; k++) { - createAnimation( - inputFile: - 'assets/kanjivg/kanji/${kanji.codeUnits[k].toRadixString(16).padLeft(5, '0')}.svg', - outputFile: '${k + 1}.svg', - ); - fileList.add('${k + 1}.svg'); - } - - File('index.html').writeAsStringSync( - '${fileList.map((e) => File(e).readAsStringSync().replaceAll(']>', '')).join('\n')}', - ); - // createAnimation( - // inputFile: 'assets/kanjivg/kanji/060c5.svg', - // outputFile: 'test.svg', - // ); -} diff --git a/lib/kanimaji.dart b/lib/kanimaji.dart new file mode 100644 index 0000000..48c5eb1 --- /dev/null +++ b/lib/kanimaji.dart @@ -0,0 +1,2 @@ +export 'package:kanimaji/widget.dart' show Kanimaji, TimingFunction; +export 'package:kanimaji/kanjivg_parser.dart'; diff --git a/lib/kanjivg_parser.dart b/lib/kanjivg_parser.dart new file mode 100644 index 0000000..af7e153 --- /dev/null +++ b/lib/kanjivg_parser.dart @@ -0,0 +1,212 @@ +import 'package:xml/xml.dart'; + +import 'svg_parser.dart'; +import 'primitives/path.dart'; +import 'primitives/point.dart'; + +/// Enum set in the kvg:position attribute, used by `` 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 \ element in the KanjiVG SVG files. +class KanjiPathGroupTreeNode { + final String id; + final List 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 `` element in the StrokeNumber's group in the KanjiVG SVG files +class KanjiStrokeNumber { + final int num; + final Point position; + + KanjiStrokeNumber(this.num, this.position); + + @override + String toString() => + 'KanjiStrokeNumber(number: $num, position: $position)'; +} + +/// Contents of a `` element in the KanjiVG SVG files +class KanjiVGPath { + final String id; + final String type; + final Path svgPath; + + KanjiVGPath({required this.id, required this.type, required this.svgPath}); + + @override + String toString() => 'KanjiVGPath(id: $id, type: $type, svgPath: $svgPath)'; +} + +/// Representation of the entire KanjiVG SVG file for a single kanji character +class KanjiVGKanji { + final String character; + final List paths; + final List strokeNumbers; + final KanjiPathGroupTreeNode? pathGroups; + + KanjiVGKanji({ + required this.character, + required this.paths, + required this.strokeNumbers, + this.pathGroups, + }); +} + +/// Small wrapper returned by the parser. It contains the parsed paths and stroke +/// numbers and the kanji character string. +class KanjiVGItem { + final String character; + final List paths; + final List strokeNumbers; + + KanjiVGItem({ + required this.character, + required this.paths, + required this.strokeNumbers, + }); + + /// Helper method to get attributes that may be in the kvg namespace or as `kvg:` prefixed attributes. + static String? _kvgAttr(XmlElement el, String name) { + final fromNs = el.getAttribute( + name, + namespace: 'http://kanjivg.tagaini.net', + ); + if (fromNs != null) return fromNs; + return el.getAttribute('kvg:$name'); + } + + /// Parse the path data from the provided XML document and return a list of [Path]s. + static List _parsePaths(XmlDocument doc) { + final List paths = []; + for (final p in doc.findAllElements('path')) { + final d = p.getAttribute('d'); + if (d == null || d.trim().isEmpty) { + continue; + } + final svgPath = parsePath(d); + final id = p.getAttribute('id') ?? ''; + final type = _kvgAttr(p, 'type') ?? p.getAttribute('kvg:type') ?? ''; + paths.add(KanjiVGPath(id: id, type: type, svgPath: svgPath)); + } + return paths; + } + + /// Parse the stroke number group from the provided XML document and return a list of [KanjiStrokeNumber]s. + static List _parseStrokeNumbers(XmlDocument doc) { + final List strokeNumbers = []; + final strokeNumberGroup = doc.findAllElements('g').firstWhere((g) { + final id = g.getAttribute('id') ?? ''; + return RegExp(r'^kvg:StrokeNumbers_').hasMatch(id); + }, orElse: () => XmlElement(XmlName(''))); + + if (strokeNumberGroup.name.local != '') { + for (final t in strokeNumberGroup.findAllElements('text')) { + final rawText = t.innerText.trim(); + if (rawText.isEmpty) continue; + final numVal = int.tryParse(rawText); + if (numVal == null) continue; + + final transform = t.getAttribute('transform') ?? ''; + final numbers = transform + .replaceAll('matrix(1 0 0 1', '') + .replaceAll(')', '') + .trim() + .split(' ') + .map(num.parse) + .toList(); + + assert( + numbers.length >= 2, + 'Expected at least 2 numbers in transform for stroke number position', + ); + + Point pos = Point( + numbers[numbers.length - 2], + numbers[numbers.length - 1], + ); + + strokeNumbers.add(KanjiStrokeNumber(numVal, pos)); + } + } + + return strokeNumbers; + } + + /// Parse the provided KanjiVG SVG content and return a [KanjiVGItem]. + factory KanjiVGItem.parseFromXml(String xmlContent) { + final XmlDocument doc = XmlDocument.parse(xmlContent); + + XmlElement strokePathsGroup = doc + .findElements('svg') + .first + .findElements('g') + .first; + XmlElement kanjiGroup = strokePathsGroup.findElements('g').first; + final character = _kvgAttr(kanjiGroup, 'element') ?? ''; + final paths = _parsePaths(doc); + final strokeNumbers = _parseStrokeNumbers(doc); + + return KanjiVGItem( + character: character, + paths: paths, + strokeNumbers: strokeNumbers, + ); + } +} diff --git a/lib/bezier.dart b/lib/primitives/bezier.dart similarity index 100% rename from lib/bezier.dart rename to lib/primitives/bezier.dart diff --git a/lib/svg/path.dart b/lib/primitives/path.dart similarity index 87% rename from lib/svg/path.dart rename to lib/primitives/path.dart index 8aea693..a36fee4 100644 --- a/lib/svg/path.dart +++ b/lib/primitives/path.dart @@ -5,10 +5,11 @@ 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 'point.dart'; num radians(num n) => n * pi / 180; num degrees(num n) => n * 180 / pi; @@ -609,4 +610,94 @@ class Path extends ListBase { 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; + } } diff --git a/lib/point.dart b/lib/primitives/point.dart similarity index 100% rename from lib/point.dart rename to lib/primitives/point.dart diff --git a/lib/svg/parser.dart b/lib/svg_parser.dart similarity index 98% rename from lib/svg/parser.dart rename to lib/svg_parser.dart index c0609a9..743f6f3 100644 --- a/lib/svg/parser.dart +++ b/lib/svg_parser.dart @@ -3,8 +3,8 @@ /// See https://pypi.org/project/svg.path/ for the original implementation. library; -import '../point.dart'; -import 'path.dart'; +import 'primitives/point.dart'; +import 'primitives/path.dart'; const _commands = { 'M', @@ -32,7 +32,7 @@ const _commands = { // const _uppercaseCommands = {'M', 'Z', 'L', 'H', 'V', 'C', 'S', 'Q', 'T', 'A'}; final _commandPattern = RegExp("(?=[${_commands.join('')}])"); -final _floatPattern = RegExp(r"^[-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?"); +final _floatPattern = RegExp(r"^[-+]?(?:[0-9]+\.?[0-9]*|[0-9]*\.?[0-9]+)(?:[eE][-+]?[0-9]+)?"); class ParserResult { final T value; diff --git a/lib/widget.dart b/lib/widget.dart index 88e1c3d..6b08d28 100644 --- a/lib/widget.dart +++ b/lib/widget.dart @@ -1,11 +1,547 @@ -import 'package:flutter/material.dart'; +import 'dart:async'; +import 'dart:ui' as ui; +import 'dart:math' as math; -class Kanimaji extends StatelessWidget { +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:kanimaji/kanjivg_parser.dart'; +import 'package:kanimaji/primitives/point.dart'; +import 'package:kanimaji/primitives/bezier.dart' as bezier; + +Future _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]!; +} + +/// A widget that animates the stroke order of a given kanji character using KanjiVG data. +class Kanimaji extends StatefulWidget { final String kanji; - const Kanimaji({super.key, required this.kanji}); + final FutureOr 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 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.strokeColor = Colors.black, + this.strokeUnfilledColor = const Color(0xA0DDDDDD), + this.strokeWidth = 3.0, + this.backgroundColor = Colors.transparent, + + this.showBrush = true, + this.brushColor = Colors.red, + this.brushRadius = 4.0, + + this.showStrokeNumbers = 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 createState() => _KanimajiState(); +} + +class _KanimajiState extends State + with SingleTickerProviderStateMixin { + KanjiVGItem? _kanjiData; + String? _error; + late AnimationController _controller; + + List 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 get _pathDurations => + _pathLengths.map((len) => _strokeLengthToDuration(len)).toList(); + + static const double _viewBoxWidth = 109.0; + static const double _viewBoxHeight = 109.0; + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this); + _controller.addListener(_onTick); + _loadAndParseSvg().then((_) => _configureController()); + } + + @override + void didUpdateWidget(covariant Kanimaji oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.kanji != widget.kanji) { + _loadAndParseSvg().then((_) => _configureController()); + } else if (oldWidget.loop != widget.loop || + oldWidget.strokeColor != widget.strokeColor || + oldWidget.strokeUnfilledColor != widget.strokeUnfilledColor || + oldWidget.strokeWidth != widget.strokeWidth || + oldWidget.backgroundColor != widget.backgroundColor || + oldWidget.showBrush != widget.showBrush || + oldWidget.brushColor != widget.brushColor || + oldWidget.brushRadius != widget.brushRadius || + oldWidget.showStrokeNumbers != widget.showStrokeNumbers || + oldWidget.strokeNumberColor != widget.strokeNumberColor || + oldWidget.currentStrokeNumberColor != widget.currentStrokeNumberColor || + oldWidget.strokeNumberFontSize != widget.strokeNumberFontSize || + oldWidget.strokeNumberFontFamily != widget.strokeNumberFontFamily || + oldWidget.showCross != widget.showCross || + oldWidget.crossColor != widget.crossColor || + oldWidget.crossStrokeWidth != widget.crossStrokeWidth || + oldWidget.crossStipleLength != widget.crossStipleLength || + oldWidget.crossStipleGap != widget.crossStipleGap) { + setState(() {}); + } + } + + void _onTick() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + _controller.removeListener(_onTick); + _controller.dispose(); + super.dispose(); + } + + Future _loadAndParseSvg() async { + try { + final data = await widget.kanjiDataProvider(widget.kanji); + setState(() { + _kanjiData = data; + _error = null; + }); + } catch (e) { + setState(() { + _kanjiData = null; + _error = 'Error loading/parsing kanji data: $e'; + }); + } + } + + void _configureController() { + if (_kanjiData == null) return; + final double totalSec = _pathDurations.fold(0.0, (a, b) => a + b); + + _controller.stop(); + _controller.duration = Duration(milliseconds: (totalSec * 1000.0).round()); + if (widget.loop) { + _controller.repeat(); + } else { + _controller.forward(from: 0); + } + } @override Widget build(BuildContext context) { - return Container(); + if (_kanjiData == null && _error == null) { + return SizedBox.expand(child: Center(child: CircularProgressIndicator())); + } + + if (_error != null) { + return SizedBox.expand(child: ErrorWidget(_error!)); + } + + return FittedBox( + child: SizedBox( + width: _viewBoxWidth, + height: _viewBoxHeight, + child: CustomPaint( + painter: _KanimajiPainter( + progress: _controller.value, + + paths: _kanjiData!.paths.map((p) => p.svgPath.toUiPath()).toList(), + pathLengths: _pathLengths, + pathDurations: _pathDurations, + strokeNumbers: _kanjiData!.strokeNumbers, + + viewBoxWidth: _viewBoxWidth, + viewBoxHeight: _viewBoxHeight, + + timingFunction: widget.timingFunction, + + strokeColor: widget.strokeColor, + 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 paths; + final List pathLengths; + final List pathDurations; + + // TODO: don't recalculate these all the time, compute once and cache + List get absolutePathDurations { + final List absolute = []; + double sum = 0.0; + for (final dur in pathDurations) { + absolute.add(sum); + sum += dur; + } + return absolute; + } + + final List strokeNumbers; + + final double viewBoxWidth; + final double viewBoxHeight; + + final TimingFunction timingFunction; + + final Color strokeColor; + final Color strokeUnfilledColor; + final double strokeWidth; + final Color backgroundColor; + + final bool showBrush; + final Color brushColor; + final double brushRadius; + + final bool showStrokeNumbers; + final Color strokeNumberColor; + final Color currentStrokeNumberColor; + final double strokeNumberFontSize; + final String? strokeNumberFontFamily; + + final bool showCross; + final Color crossColor; + final double crossStrokeWidth; + final double crossStipleLength; + final double crossStipleGap; + + _KanimajiPainter({ + required this.progress, + + required this.paths, + required this.pathLengths, + required this.pathDurations, + required this.strokeNumbers, + + required this.viewBoxWidth, + required this.viewBoxHeight, + + required this.timingFunction, + + required this.strokeColor, + required this.strokeUnfilledColor, + required this.strokeWidth, + required this.backgroundColor, + + required this.showBrush, + required this.brushColor, + required this.brushRadius, + + required this.showStrokeNumbers, + required this.strokeNumberColor, + required this.currentStrokeNumberColor, + required this.strokeNumberFontSize, + required this.strokeNumberFontFamily, + + required this.showCross, + required this.crossColor, + required this.crossStrokeWidth, + required this.crossStipleLength, + required this.crossStipleGap, + }); + + @override + void paint(Canvas canvas, Size size) { + final bgPaint = Paint()..color = backgroundColor; + canvas.drawRect(Offset.zero & size, bgPaint); + + if (paths.isEmpty) return; + + final double sx = size.width / viewBoxWidth; + final double sy = size.height / viewBoxHeight; + final double scale = math.min(sx, sy); + + final double dx = (size.width - viewBoxWidth * scale) / 2.0; + final double dy = (size.height - viewBoxHeight * scale) / 2.0; + + final Paint unfilledPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..strokeWidth = strokeWidth / scale + ..color = strokeUnfilledColor; + + final Paint filledPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..strokeWidth = strokeWidth / scale + ..color = strokeColor; + + final Paint brushPaint = Paint() + ..style = PaintingStyle.fill + ..color = brushColor; + + final Paint crossPaint = Paint() + ..style = PaintingStyle.stroke + ..color = crossColor + ..strokeWidth = crossStrokeWidth; + + // Draw cross if enabled + if (showCross) { + // Draw vertical stipled line + for ( + double y = 0; + y < size.height; + y += crossStipleLength + crossStipleGap + ) { + canvas.drawLine( + Offset(size.width / 2, y), + Offset(size.width / 2, math.min(y + crossStipleLength, size.height)), + crossPaint, + ); + } + // Draw horizontal stipled line + for ( + double x = 0; + x < size.width; + x += crossStipleLength + crossStipleGap + ) { + canvas.drawLine( + Offset(x, size.height / 2), + Offset(math.min(x + crossStipleLength, size.width), size.height / 2), + crossPaint, + ); + } + } + + // Draw all unfilled paths + for (final path in paths) { + canvas.drawPath(path, unfilledPaint); + } + + canvas.save(); + canvas.translate(dx, dy); + canvas.scale(scale, scale); + + // total animation time in seconds computed from durations + final double totalTime = pathDurations.isEmpty + ? 1.0 + : pathDurations.fold(0.0, (a, b) => a + b); + final double p = progress.clamp(0.0, 1.0); + + final int currentlyDrawingIndex = absolutePathDurations.lastIndexWhere( + (t) => t <= p * totalTime, + ); + + if (currentlyDrawingIndex == -1) { + for (final path in paths) { + canvas.drawPath(path, filledPaint); + } + canvas.restore(); + return; + } + + // Draw all completed strokes fully filled + for (int i = 0; i < currentlyDrawingIndex; i++) { + canvas.drawPath(paths[i], filledPaint); + } + + // Draw the currently drawing stroke with partial coverage + if (currentlyDrawingIndex >= 0 && currentlyDrawingIndex < paths.length) { + final ui.Path path = paths[currentlyDrawingIndex]; + final double len = pathLengths[currentlyDrawingIndex]; + final double dur = pathDurations[currentlyDrawingIndex]; + + final relativeElapsedTime = + p * totalTime - + (currentlyDrawingIndex > 0 + ? absolutePathDurations[currentlyDrawingIndex] + : 0.0); + + final double strokeProgress = timingFunction.func( + (relativeElapsedTime / dur).clamp(0.0, 1.0), + ); + final ui.PathMetrics metrics = path.computeMetrics(); + final ui.PathMetric metric = metrics.first; + final double drawLength = len * strokeProgress; + final ui.Path partialPath = metric.extractPath(0, drawLength); + canvas.drawPath(partialPath, filledPaint); + + if (showBrush) { + final ui.Tangent? tangent = metric.getTangentForOffset(drawLength); + if (tangent != null) { + canvas.drawCircle(tangent.position, brushRadius / scale, brushPaint); + } + } + } + + // Draw stroke numbers + if (showStrokeNumbers) { + final textPainter = TextPainter( + textAlign: TextAlign.center, + textDirection: TextDirection.ltr, + ); + + for (final sn in strokeNumbers) { + final bool isCurrent = + sn.num == + (currentlyDrawingIndex + 1).clamp(1, strokeNumbers.length); + final textSpan = TextSpan( + text: sn.num.toString(), + style: TextStyle( + color: isCurrent ? currentStrokeNumberColor : strokeNumberColor, + fontSize: strokeNumberFontSize / scale, + fontFamily: strokeNumberFontFamily, + ), + ); + textPainter.text = textSpan; + textPainter.layout(); + + final Offset pos = Offset( + sn.position.x.toDouble(), + sn.position.y.toDouble(), + ); + final Offset centeredPos = + pos - Offset(textPainter.width / 2, textPainter.height / 2); + textPainter.paint(canvas, centeredPos); + } + } + + canvas.restore(); + } + + @override + bool shouldRepaint(covariant _KanimajiPainter oldDelegate) { + return oldDelegate.progress != progress || + oldDelegate.paths != paths || + oldDelegate.strokeNumbers != strokeNumbers || + oldDelegate.strokeColor != strokeColor || + oldDelegate.strokeUnfilledColor != strokeUnfilledColor || + oldDelegate.strokeWidth != strokeWidth || + oldDelegate.backgroundColor != backgroundColor || + oldDelegate.showBrush != showBrush || + oldDelegate.brushColor != brushColor || + oldDelegate.brushRadius != brushRadius || + oldDelegate.showStrokeNumbers != showStrokeNumbers || + oldDelegate.strokeNumberColor != strokeNumberColor || + oldDelegate.currentStrokeNumberColor != currentStrokeNumberColor || + oldDelegate.strokeNumberFontSize != strokeNumberFontSize || + oldDelegate.strokeNumberFontFamily != strokeNumberFontFamily || + oldDelegate.showCross != showCross || + oldDelegate.crossColor != crossColor || + oldDelegate.crossStrokeWidth != crossStrokeWidth || + oldDelegate.crossStipleLength != crossStipleLength || + oldDelegate.crossStipleGap != crossStipleGap; } } diff --git a/pubspec.yaml b/pubspec.yaml index 6d81533..c9116ed 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,3 +15,4 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 + path: ^1.9.1 diff --git a/test/kanjivg_parser_test.dart b/test/kanjivg_parser_test.dart new file mode 100644 index 0000000..567e194 --- /dev/null +++ b/test/kanjivg_parser_test.dart @@ -0,0 +1,43 @@ +import 'dart:io'; +import 'package:path/path.dart' as path; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:kanimaji/kanjivg_parser.dart'; + +void main() { + final kanjivgDirStr = Platform.environment['KANJIVG_PATH']; + if (kanjivgDirStr == null) { + throw Exception('KANJIVG_PATH environment variable not set'); + } + final kanjivgDir = Directory(kanjivgDirStr); + final kanjivgFiles = kanjivgDir + .listSync(recursive: true) + .whereType() + .where((f) => f.path.endsWith('.svg')) + .toList(); + + for (final file in kanjivgFiles) { + test('Test parsing KanjiVG file ${path.basename(file.path)}', () async { + final content = await file.readAsString(); + try { + final item = KanjiVGItem.parseFromXml(content); + + expect(item, isNotNull, reason: 'Failed to parse ${file.path}'); + + expect( + item.strokeNumbers, + isNotEmpty, + reason: 'No stroke numbers found in ${file.path}', + ); + + expect( + item.strokeNumbers.length, + item.paths.length, + reason: 'Mismatch between stroke numbers and paths in ${file.path}', + ); + } catch (e) { + fail('Error parsing ${file.path}: $e'); + } + }); + } +} diff --git a/test/svg/generation_test.dart b/test/svg/generation_test.dart index 525d6e0..5762e1e 100644 --- a/test/svg/generation_test.dart +++ b/test/svg/generation_test.dart @@ -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', () { diff --git a/test/svg/parser_test.dart b/test/svg/parser_test.dart index 7411ff3..3faacce 100644 --- a/test/svg/parser_test.dart +++ b/test/svg/parser_test.dart @@ -1,7 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:kanimaji/point.dart'; -import 'package:kanimaji/svg/parser.dart'; -import 'package:kanimaji/svg/path.dart'; +import 'package:kanimaji/primitives/path.dart'; +import 'package:kanimaji/primitives/point.dart'; +import 'package:kanimaji/svg_parser.dart'; void main() { group("Examples from the SVG spec", () { diff --git a/test/svg/path_test.dart b/test/svg/path_test.dart index 42603c6..d0ff037 100644 --- a/test/svg/path_test.dart +++ b/test/svg/path_test.dart @@ -2,8 +2,8 @@ import 'dart:math' show sqrt, pi; import 'package:flutter_test/flutter_test.dart'; -import 'package:kanimaji/point.dart'; -import 'package:kanimaji/svg/path.dart'; +import 'package:kanimaji/primitives/point.dart'; +import 'package:kanimaji/primitives/path.dart'; // from ..path import CubicBezier, QuadraticBezier, Line, Arc, Move, Close, Path diff --git a/test/svg/tokenizer_test.dart b/test/svg/tokenizer_test.dart index 24f20f2..a0f632b 100644 --- a/test/svg/tokenizer_test.dart +++ b/test/svg/tokenizer_test.dart @@ -2,8 +2,8 @@ // from svg.path import parser import 'package:flutter_test/flutter_test.dart'; -import 'package:kanimaji/point.dart'; -import 'package:kanimaji/svg/parser.dart' +import 'package:kanimaji/primitives/point.dart'; +import 'package:kanimaji/svg_parser.dart' show Command, Token, commandifyPath, parsePath, tokenizePath; class TokenizerTest {