From 819eecd81107cf769e73c80fbce26e5648dc4c12 Mon Sep 17 00:00:00 2001 From: h7x4 Date: Sat, 21 Feb 2026 17:24:59 +0900 Subject: [PATCH] WIP --- 1.svg | 119 +++++ 2.svg | 133 ++++++ flake.lock | 12 +- index.html | 252 +++++++++++ lib/animator.dart | 8 +- lib/kanjivg_parser.dart | 212 +++++++++ lib/{ => primitives}/bezier.dart | 0 lib/{svg => primitives}/path.dart | 2 +- lib/{ => primitives}/point.dart | 0 lib/svg_exporter.dart | 0 lib/{svg/parser.dart => svg_parser.dart} | 6 +- lib/widget.dart | 534 ++++++++++++++++++++++- 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 +- 18 files changed, 1307 insertions(+), 31 deletions(-) create mode 100644 1.svg create mode 100644 2.svg create mode 100644 index.html create mode 100644 lib/kanjivg_parser.dart rename lib/{ => primitives}/bezier.dart (100%) rename lib/{svg => primitives}/path.dart (99%) rename lib/{ => primitives}/point.dart (100%) create mode 100644 lib/svg_exporter.dart rename lib/{svg/parser.dart => svg_parser.dart} (98%) create mode 100644 test/kanjivg_parser_test.dart diff --git a/1.svg b/1.svg new file mode 100644 index 0000000..a1cb0a1 --- /dev/null +++ b/1.svg @@ -0,0 +1,119 @@ + + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/2.svg b/2.svg new file mode 100644 index 0000000..1ea4042 --- /dev/null +++ b/2.svg @@ -0,0 +1,133 @@ + + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file 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/index.html b/index.html new file mode 100644 index 0000000..2c0af1f --- /dev/null +++ b/index.html @@ -0,0 +1,252 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/lib/animator.dart b/lib/animator.dart index 59da897..42592d8 100644 --- a/lib/animator.dart +++ b/lib/animator.dart @@ -1,12 +1,12 @@ 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'; +import 'svg_parser.dart'; +import 'primitives/point.dart'; +import 'primitives/bezier.dart' as bezier; + double _computePathLength(String path) => parsePath(path).size(error: 1e-8).toDouble(); diff --git a/lib/kanjivg_parser.dart b/lib/kanjivg_parser.dart new file mode 100644 index 0000000..aca59a1 --- /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 number; + final Point position; + + KanjiStrokeNumber(this.number, this.position); + + @override + String toString() => + 'KanjiStrokeNumber(number: $number, 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 99% rename from lib/svg/path.dart rename to lib/primitives/path.dart index 8aea693..d20df74 100644 --- a/lib/svg/path.dart +++ b/lib/primitives/path.dart @@ -8,7 +8,7 @@ import 'dart:math' show sqrt, sin, cos, acos, log, pi; 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; 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_exporter.dart b/lib/svg_exporter.dart new file mode 100644 index 0000000..e69de29 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..e4b7761 100644 --- a/lib/widget.dart +++ b/lib/widget.dart @@ -1,11 +1,527 @@ -import 'package:flutter/material.dart'; +// // A Flutter widget that renders an animated single-kanji stroke drawing using +// // the project's SVG path parser. This widget loads the Kanji SVG from +// // `assets/kanjivg/kanji/.svg` (the same assets used by the exporter), +// // parses the path `d` attributes, converts them into `ui.Path` objects and +// // animates the stroke draw using an AnimationController. +// // +// // Notes: +// // - This implementation uses the repository's SVG path parser (`parsePath`) +// // and the custom `Path` representation in `lib/svg/path.dart`. We convert +// // those into Flutter `ui.Path` by sampling points along the sampled SVG path. +// // - The SVGs in KanjiVG typically use a 109x109 viewBox. We detect the +// // `viewBox` attribute (if present) and scale paths to fit the widget size. +// // +// // Public API (Kanimaji widget parameters): +// // - `kanji` (required): the single character Kanji to display. +// // - `size`: target width/height of the widget (square). +// // - `strokeColor`, `strokeUnfilledColor`, `strokeWidth`. +// // - `backgroundColor`. +// // - `speed`: multiplier to speed up (>1) or slow down (<1) the animation. +// // - `loop`: whether to repeat the animation. +// // - `showBrush`: whether to show a small circular "brush" tip following the stroke. +// // - `durationOverride`: optionally override the computed animation duration. +// // +// // Example: +// // Kanimaji( +// // kanji: '水', +// // size: 240, +// // strokeColor: Colors.black, +// // strokeUnfilledColor: Colors.grey.shade300, +// // strokeWidth: 3.0, +// // backgroundColor: Colors.white, +// // speed: 1.0, +// // loop: true, +// // ); -class Kanimaji extends StatelessWidget { - final String kanji; - const Kanimaji({super.key, required this.kanji}); - @override - Widget build(BuildContext context) { - return Container(); - } -} +// import 'dart:math' as math; +// import 'dart:ui' as ui; +// import 'package:flutter/material.dart'; +// import 'package:flutter/services.dart' show rootBundle; +// import 'package:xml/xml.dart'; + +// import 'svg/parser.dart' show parsePath; +// import 'svg/path.dart' as svgpath; +// import 'point.dart' as kpt; +// import 'animator.dart' show strokeLengthToDuration; + +// class Kanimaji extends StatefulWidget { +// final String kanji; +// final double size; +// final Color strokeColor; +// final Color strokeUnfilledColor; +// final double strokeWidth; +// final Color backgroundColor; +// final double speed; +// final bool loop; +// final bool showBrush; +// final Duration? durationOverride; + +// const Kanimaji({ +// super.key, +// required this.kanji, +// this.size = 150, +// this.strokeColor = Colors.black, +// this.strokeUnfilledColor = const Color(0xFFEEEEEE), +// this.strokeWidth = 3.0, +// this.backgroundColor = Colors.transparent, +// this.speed = 1.0, +// this.loop = true, +// this.showBrush = true, +// this.durationOverride, +// }); + +// @override +// State createState() => _KanimajiState(); +// } + +// class _KanimajiState extends State with SingleTickerProviderStateMixin { +// bool _loaded = false; +// String? _error; +// late AnimationController _controller; + +// // Converted UI paths and per-path lengths +// List _paths = []; +// List _pathLengths = []; +// // per-stroke durations (in seconds) computed using animator.strokeLengthToDuration +// List _pathDurations = []; + +// // viewBox width/height from svg for scaling +// double _viewBoxWidth = 109.0; +// double _viewBoxHeight = 109.0; + +// // total length summation (for drawing metrics) +// double _totalLength = 0.0; + +// @override +// void initState() { +// super.initState(); +// // Dummy controller; real duration will be set after loading +// _controller = AnimationController(vsync: this); +// // Add a single listener once to avoid duplicates when reconfiguring the controller. +// _controller.addListener(_onTick); +// _loadAndParseSvg(); +// } + +// @override +// void didUpdateWidget(covariant Kanimaji oldWidget) { +// super.didUpdateWidget(oldWidget); +// // If kanji changed, re-load +// if (oldWidget.kanji != widget.kanji) { +// _loadAndParseSvg(); +// } else if (oldWidget.speed != widget.speed || +// oldWidget.durationOverride != widget.durationOverride || +// oldWidget.loop != widget.loop) { +// // Update controller if animation parameters changed +// _configureController(); +// } +// } + +// void _onTick() { +// if (mounted) setState(() {}); +// } + +// @override +// void dispose() { +// _controller.removeListener(_onTick); +// _controller.dispose(); +// super.dispose(); +// } + +// String _codepointHex(String s) { +// // Support multi-code-unit characters by taking the first Unicode rune +// final runes = s.runes.toList(); +// if (runes.isEmpty) return '00000'; +// return runes[0].toRadixString(16).padLeft(5, '0'); +// } + +// Future _loadAndParseSvg() async { +// setState(() { +// _loaded = false; +// _error = null; +// _paths = []; +// _pathLengths = []; +// _pathDurations = []; +// _totalLength = 0.0; +// }); + +// final hex = _codepointHex(widget.kanji); +// final assetPath = 'assets/kanjivg/kanji/$hex.svg'; + +// try { +// final svgString = await rootBundle.loadString(assetPath); + +// final doc = XmlDocument.parse(svgString); + +// // parse viewBox: "minX minY width height" +// final viewBoxAttr = doc.rootElement.getAttribute('viewBox'); +// if (viewBoxAttr != null) { +// final parts = viewBoxAttr.split(RegExp(r'[\s,]+')).where((p) => p.isNotEmpty).toList(); +// if (parts.length >= 4) { +// final w = double.tryParse(parts[2]); +// final h = double.tryParse(parts[3]); +// if (w != null && h != null && w > 0 && h > 0) { +// _viewBoxWidth = w; +// _viewBoxHeight = h; +// } +// } +// } else { +// // fallback to width/height attributes if present (may include "px") +// final widthAttr = doc.rootElement.getAttribute('width'); +// final heightAttr = doc.rootElement.getAttribute('height'); +// if (widthAttr != null && heightAttr != null) { +// final w = double.tryParse(widthAttr.replaceAll('px', '').trim()); +// final h = double.tryParse(heightAttr.replaceAll('px', '').trim()); +// if (w != null && h != null && w > 0 && h > 0) { +// _viewBoxWidth = w; +// _viewBoxHeight = h; +// } +// } +// } + +// // Find all elements inside the SVG. KanjiVG groups strokes in . +// final pathElements = doc.findAllElements('path', namespace: doc.rootElement.name.namespaceUri).toList(); + +// if (pathElements.isEmpty) { +// throw Exception('No elements found in $assetPath'); +// } + +// final uiPaths = []; +// final lengths = []; + +// for (final p in pathElements) { +// final d = p.getAttribute('d'); +// if (d == null || d.trim().isEmpty) continue; + +// // Parse the SVG path using existing parser +// final svgParsed = parsePath(d); // returns svgpath.Path (project type) + +// // Convert to ui.Path using direct segment conversion (no sampling) +// final uiPath = _svgPathToUiPath(svgParsed); +// if (uiPath == null) continue; + +// // Compute its drawing length using PathMetrics (for extractPath) +// final pm = uiPath.computeMetrics(); +// double pathLen = 0.0; +// for (final metric in pm) { +// pathLen += metric.length; +// } +// // If the path metric yields zero (rare), skip +// if (pathLen <= 0) continue; + +// // Compute logical stroke length using the parser (precise geometry) and convert to duration +// final double logicalLen = svgParsed.size(error: 1e-8).toDouble(); +// final double durationSec = strokeLengthToDuration(logicalLen); + +// uiPaths.add(uiPath); +// lengths.add(pathLen); +// _pathDurations.add(durationSec); +// _totalLength += pathLen; +// } + +// if (uiPaths.isEmpty) { +// throw Exception('No drawable paths found in $assetPath'); +// } + +// setState(() { +// _paths = uiPaths; +// _pathLengths = lengths; +// }); + +// _configureController(); + +// setState(() { +// _loaded = true; +// }); +// } catch (e) { +// setState(() { +// _error = 'Error loading/ parsing $assetPath: $e'; +// _loaded = true; // mark loaded so we can show error +// }); +// } +// } + +// // Convert a parsed SVG path (project Path) into a Flutter ui.Path without sampling. +// // Uses the concrete segment types (Move, Line, CubicBezier, QuadraticBezier, Arc, Close) +// // and maps them to the corresponding ui.Path methods for native, accurate drawing. +// ui.Path? _svgPathToUiPath(svgpath.Path svgP) { +// try { +// final ui.Path p = ui.Path(); +// bool started = false; + +// for (final seg in svgP) { +// if (seg is svgpath.Move) { +// p.moveTo(seg.start.x.toDouble(), seg.start.y.toDouble()); +// started = true; +// } else if (seg is svgpath.Line) { +// if (!started) { +// p.moveTo(seg.start.x.toDouble(), seg.start.y.toDouble()); +// started = true; +// } +// p.lineTo(seg.end.x.toDouble(), seg.end.y.toDouble()); +// } else if (seg is svgpath.Close) { +// p.close(); +// } else if (seg is svgpath.CubicBezier) { +// if (!started) { +// p.moveTo(seg.start.x.toDouble(), seg.start.y.toDouble()); +// started = true; +// } +// p.cubicTo( +// seg.control1.x.toDouble(), +// seg.control1.y.toDouble(), +// seg.control2.x.toDouble(), +// seg.control2.y.toDouble(), +// seg.end.x.toDouble(), +// seg.end.y.toDouble(), +// ); +// } else if (seg is svgpath.QuadraticBezier) { +// if (!started) { +// p.moveTo(seg.start.x.toDouble(), seg.start.y.toDouble()); +// started = true; +// } +// p.quadraticBezierTo( +// seg.control.x.toDouble(), +// seg.control.y.toDouble(), +// seg.end.x.toDouble(), +// seg.end.y.toDouble(), +// ); +// } else if (seg is svgpath.Arc) { +// if (!started) { +// p.moveTo(seg.start.x.toDouble(), seg.start.y.toDouble()); +// started = true; +// } +// // Map SVG Arc to Flutter arcToPoint. rotation is in degrees in svg; arcToPoint expects radians. +// final radius = Radius.elliptical(seg.radius.x.toDouble(), seg.radius.y.toDouble()); +// p.arcToPoint( +// Offset(seg.end.x.toDouble(), seg.end.y.toDouble()), +// radius: radius, +// rotation: seg.rotation.toDouble() * (math.pi / 180.0), +// largeArc: seg.arc, +// clockwise: seg.sweep, +// ); +// } else { +// // Unknown segment fallback: try to move to end +// try { +// p.moveTo(seg.end.x.toDouble(), seg.end.y.toDouble()); +// started = true; +// } catch (_) {} +// } +// } + +// return p; +// } catch (e) { +// return null; +// } +// } + +// void _configureController() { +// // Use per-stroke durations computed from the parsed geometry (strokeLengthToDuration) +// // Sum durations and scale by widget.speed. If durationOverride is provided use it. +// final double totalSec = _pathDurations.fold(0.0, (a, b) => a + b); +// final double scaledTotalSec = (widget.speed <= 0) ? totalSec : (totalSec / widget.speed); +// final Duration duration = widget.durationOverride ?? +// Duration(milliseconds: (math.max(0.001, scaledTotalSec) * 1000.0).round()); + +// final bool shouldRepeat = widget.loop; + +// _controller.stop(); +// _controller.duration = duration; +// if (shouldRepeat) { +// _controller.repeat(); +// } else { +// _controller.forward(from: 0); +// } +// // Repaint handled by the single listener added in initState. +// } + +// @override +// Widget build(BuildContext context) { +// if (!_loaded) { +// return SizedBox( +// width: widget.size, +// height: widget.size, +// child: Center(child: CircularProgressIndicator()), +// ); +// } +// if (_error != null) { +// return SizedBox( +// width: widget.size, +// height: widget.size, +// child: Container( +// color: widget.backgroundColor, +// alignment: Alignment.center, +// child: Text( +// 'Error', +// style: TextStyle(color: Colors.red, fontSize: math.max(10, widget.size * 0.08)), +// ), +// ), +// ); +// } + +// return SizedBox( +// width: widget.size, +// height: widget.size, +// child: CustomPaint( +// painter: _KanimajiPainter( +// paths: _paths, +// pathLengths: _pathLengths, +// pathDurations: _pathDurations, +// progress: _controller.value, +// strokeColor: widget.strokeColor, +// strokeUnfilledColor: widget.strokeUnfilledColor, +// strokeWidth: widget.strokeWidth, +// backgroundColor: widget.backgroundColor, +// viewBoxWidth: _viewBoxWidth, +// viewBoxHeight: _viewBoxHeight, +// showBrush: widget.showBrush, +// ), +// ), +// ); +// } +// } + +// class _KanimajiPainter extends CustomPainter { +// final List paths; +// final List pathLengths; +// final List pathDurations; // per-stroke durations in seconds +// final double progress; // 0..1 +// final Color strokeColor; +// final Color strokeUnfilledColor; +// final double strokeWidth; +// final Color backgroundColor; +// final double viewBoxWidth; +// final double viewBoxHeight; +// final bool showBrush; + +// _KanimajiPainter({ +// required this.paths, +// required this.pathLengths, +// required this.pathDurations, +// required this.progress, +// required this.strokeColor, +// required this.strokeUnfilledColor, +// required this.strokeWidth, +// required this.backgroundColor, +// required this.viewBoxWidth, +// required this.viewBoxHeight, +// required this.showBrush, +// }); + +// @override +// void paint(Canvas canvas, Size size) { +// // Paint background +// final bgPaint = Paint()..color = backgroundColor; +// canvas.drawRect(Offset.zero & size, bgPaint); + +// if (paths.isEmpty) return; + +// // compute scale to fit viewBox into size, preserving aspect ratio +// final double sx = size.width / viewBoxWidth; +// final double sy = size.height / viewBoxHeight; +// final double scale = math.min(sx, sy); + +// // compute translation to center the drawing +// final double dx = (size.width - viewBoxWidth * scale) / 2.0; +// final double dy = (size.height - viewBoxHeight * scale) / 2.0; + +// canvas.save(); +// canvas.translate(dx, dy); +// canvas.scale(scale, scale); + +// // paints +// final Paint unfilledPaint = Paint() +// ..style = PaintingStyle.stroke +// ..strokeCap = StrokeCap.round +// ..strokeJoin = StrokeJoin.round +// ..strokeWidth = strokeWidth / scale // scale stroke width so it looks consistent +// ..color = strokeUnfilledColor; + +// final Paint filledPaint = Paint() +// ..style = PaintingStyle.stroke +// ..strokeCap = StrokeCap.round +// ..strokeJoin = StrokeJoin.round +// ..strokeWidth = strokeWidth / scale +// ..color = strokeColor; + +// // 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); + +// double elapsedTime = 0.0; // seconds accumulated so far + +// for (int i = 0; i < paths.length; i++) { +// final ui.Path path = paths[i]; +// final double len = pathLengths[i]; +// final double dur = (i < pathDurations.length) ? pathDurations[i] : 0.0; +// final double startFraction = totalTime == 0 ? 0.0 : (elapsedTime / totalTime); +// final double endFraction = totalTime == 0 ? 1.0 : ((elapsedTime + dur) / totalTime); + +// // Draw background (unfilled) fully for this stroke +// canvas.drawPath(path, unfilledPaint); + +// // Determine coverage for this stroke based on global progress (time-based) +// double coverage = 0.0; +// if (p >= endFraction) { +// coverage = 1.0; +// } else if (p <= startFraction || dur <= 0.0) { +// coverage = 0.0; +// } else { +// coverage = (p - startFraction) / (endFraction - startFraction); +// } + +// if (coverage > 0.0) { +// // extract subpath up to coverage * len (length-based extraction for drawing) +// final metrics = path.computeMetrics().toList(); +// final ui.Path drawn = ui.Path(); + +// double remain = coverage * len; +// for (final metric in metrics) { +// if (remain <= 0) break; +// final take = math.min(metric.length, remain); +// final sub = metric.extractPath(0.0, take); +// drawn.addPath(sub, Offset.zero); +// remain -= take; +// } + +// // draw filled portion +// canvas.drawPath(drawn, filledPaint); + +// // brush tip +// if (showBrush && coverage > 0.0 && coverage < 1.0) { +// // find the tangent at the very end of the drawn part +// final metrics2 = path.computeMetrics().toList(); +// double rem = coverage * len; +// for (final metric in metrics2) { +// if (rem <= 0) break; +// if (rem <= metric.length + 1e-9) { +// final tangent = metric.getTangentForOffset(rem.clamp(0.0, metric.length)); +// if (tangent != null) { +// final Offset pos = tangent.position; +// final double brushRadius = (strokeWidth * 0.9) / scale; +// final Paint brushPaint = Paint()..color = strokeColor; +// canvas.drawCircle(pos, brushRadius, brushPaint); +// } +// break; +// } else { +// rem -= metric.length; +// } +// } +// } +// } + +// elapsedTime += dur; +// } + +// canvas.restore(); +// } + +// @override +// bool shouldRepaint(covariant _KanimajiPainter oldDelegate) { +// return oldDelegate.progress != progress || +// oldDelegate.paths != paths || +// oldDelegate.strokeColor != strokeColor || +// oldDelegate.strokeUnfilledColor != strokeUnfilledColor || +// oldDelegate.strokeWidth != strokeWidth || +// oldDelegate.backgroundColor != backgroundColor; +// } +// } 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 {