feat(app): furigana in rich text

Text within a line can carry its reading now (`ruby` on the text's
attributes, `かんじ` on `漢字`), word by word, in lessons, example
sentences, notes and cards alike. Shown, the reading sits above the text,
smaller and centred, the text level with the line around it; while
editing, the text is dotted underneath and the caret moves through it as
text, as with formulas. The format bar's Furigana asks for the reading of
the text selected, starting from the one it has, and takes it away.

An Anki field's `<ruby>` keeps its reading as furigana now, each base its
own, instead of "漢(かん)"; Anki's `漢字[かんじ]` stays as written, as
telling it apart from other brackets is guesswork. The Markdown export
writes furigana as HTML's `<ruby>漢字<rt>かんじ</rt></ruby>`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-09 01:51:28 +02:00
commit 30dde3b8fa
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
15 changed files with 410 additions and 10 deletions

View file

@ -599,6 +599,12 @@
"formatUnderline": "Unterstrichen", "formatUnderline": "Unterstrichen",
"formatSuperscript": "Hochgestellt", "formatSuperscript": "Hochgestellt",
"formatSubscript": "Tiefgestellt", "formatSubscript": "Tiefgestellt",
"formatRuby": "Furigana (eine Lesung über dem Text)",
"rubyTitle": "Furigana",
"rubyReading": "Lesung",
"rubyReadingHint": "In Kana, z. B. かんじ",
"rubySave": "Speichern",
"rubyRemove": "Entfernen",
"vocabularyReading": "Lesung", "vocabularyReading": "Lesung",
"vocabularyReadingHint": "Kana-Lesung", "vocabularyReadingHint": "Kana-Lesung",
"vocabularyMeanings": "Bedeutungen", "vocabularyMeanings": "Bedeutungen",

View file

@ -661,6 +661,12 @@
"formatUnderline": "Underline", "formatUnderline": "Underline",
"formatSuperscript": "Superscript", "formatSuperscript": "Superscript",
"formatSubscript": "Subscript", "formatSubscript": "Subscript",
"formatRuby": "Furigana (a reading above the text)",
"rubyTitle": "Furigana",
"rubyReading": "Reading",
"rubyReadingHint": "In kana, e.g. かんじ",
"rubySave": "Save",
"rubyRemove": "Remove",
"vocabularyReading": "Reading", "vocabularyReading": "Reading",
"vocabularyReadingHint": "Kana reading", "vocabularyReadingHint": "Kana reading",
"vocabularyMeanings": "Meanings", "vocabularyMeanings": "Meanings",

View file

@ -3610,6 +3610,42 @@ abstract class AppLocalizations {
/// **'Subscript'** /// **'Subscript'**
String get formatSubscript; String get formatSubscript;
/// No description provided for @formatRuby.
///
/// In en, this message translates to:
/// **'Furigana (a reading above the text)'**
String get formatRuby;
/// No description provided for @rubyTitle.
///
/// In en, this message translates to:
/// **'Furigana'**
String get rubyTitle;
/// No description provided for @rubyReading.
///
/// In en, this message translates to:
/// **'Reading'**
String get rubyReading;
/// No description provided for @rubyReadingHint.
///
/// In en, this message translates to:
/// **'In kana, e.g. かんじ'**
String get rubyReadingHint;
/// No description provided for @rubySave.
///
/// In en, this message translates to:
/// **'Save'**
String get rubySave;
/// No description provided for @rubyRemove.
///
/// In en, this message translates to:
/// **'Remove'**
String get rubyRemove;
/// No description provided for @vocabularyReading. /// No description provided for @vocabularyReading.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -2147,6 +2147,24 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get formatSubscript => 'Tiefgestellt'; String get formatSubscript => 'Tiefgestellt';
@override
String get formatRuby => 'Furigana (eine Lesung über dem Text)';
@override
String get rubyTitle => 'Furigana';
@override
String get rubyReading => 'Lesung';
@override
String get rubyReadingHint => 'In Kana, z. B. かんじ';
@override
String get rubySave => 'Speichern';
@override
String get rubyRemove => 'Entfernen';
@override @override
String get vocabularyReading => 'Lesung'; String get vocabularyReading => 'Lesung';

View file

@ -2134,6 +2134,24 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get formatSubscript => 'Subscript'; String get formatSubscript => 'Subscript';
@override
String get formatRuby => 'Furigana (a reading above the text)';
@override
String get rubyTitle => 'Furigana';
@override
String get rubyReading => 'Reading';
@override
String get rubyReadingHint => 'In kana, e.g. かんじ';
@override
String get rubySave => 'Save';
@override
String get rubyRemove => 'Remove';
@override @override
String get vocabularyReading => 'Reading'; String get vocabularyReading => 'Reading';

View file

@ -8,6 +8,10 @@
/// A sound: the person's file at `url` (`assetUrl`), played on a tap. /// A sound: the person's file at `url` (`assetUrl`), played on a tap.
const audioBlockType = 'audio'; const audioBlockType = 'audio';
/// On text within a line: its furigana (ruby), the reading of the text it
/// is on (`かんじ` on `漢字`), shown above it.
const rubyKey = 'ruby';
/// On the editor's own `image` block: the id of the PDF a screenshot was /// On the editor's own `image` block: the id of the PDF a screenshot was
/// taken from; missing for any other image. /// taken from; missing for any other image.
const imagePdfKey = 'pdf'; const imagePdfKey = 'pdf';

View file

@ -1,6 +1,7 @@
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:halkyon_learn_flutter/src/core/editor/math_block.dart'; import 'package:halkyon_learn_flutter/src/core/editor/math_block.dart';
import 'package:halkyon_learn_flutter/src/core/editor/ruby.dart';
import 'package:halkyon_learn_flutter/src/core/editor/scripts.dart'; import 'package:halkyon_learn_flutter/src/core/editor/scripts.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
@ -10,8 +11,9 @@ import 'package:material_ui/material_ui.dart';
/// and 100 dp beside it on desktops. /// and 100 dp beside it on desktops.
/// ///
/// Phones get the touch variant, with handles to drag the selection. Both /// Phones get the touch variant, with handles to drag the selection. Both
/// draw superscript and subscript text (`withScripts`), and formulas within /// draw superscript and subscript text (`withScripts`), furigana
/// lines (`withInlineMath`): as their source while [editing], else set. /// (`withRuby`) and formulas within lines (`withInlineMath`): marked or as
/// their source while [editing], else set.
/// The text is the theme's `bodyLarge` unless [textStyle] says otherwise. /// The text is the theme's `bodyLarge` unless [textStyle] says otherwise.
EditorStyle editorStyleOf( EditorStyle editorStyleOf(
BuildContext context, { BuildContext context, {
@ -43,7 +45,10 @@ EditorStyle editorStyleOf(
dragHandleColor: colors.primary, dragHandleColor: colors.primary,
textStyleConfiguration: text, textStyleConfiguration: text,
textSpanDecorator: withInlineMath( textSpanDecorator: withInlineMath(
withScripts(mobileTextSpanDecoratorForAttribute), withRuby(
withScripts(mobileTextSpanDecoratorForAttribute),
editing: editing,
),
editing: editing, editing: editing,
), ),
), ),
@ -53,7 +58,10 @@ EditorStyle editorStyleOf(
selectionColor: selection, selectionColor: selection,
textStyleConfiguration: text, textStyleConfiguration: text,
textSpanDecorator: withInlineMath( textSpanDecorator: withInlineMath(
withScripts(defaultTextSpanDecoratorForAttribute), withRuby(
withScripts(defaultTextSpanDecoratorForAttribute),
editing: editing,
),
editing: editing, editing: editing,
), ),
), ),

View file

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart'; import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart'; import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/editor/ruby.dart';
import 'package:halkyon_learn_flutter/src/core/editor/scripts.dart'; import 'package:halkyon_learn_flutter/src/core/editor/scripts.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
@ -188,6 +189,17 @@ class _FormatBarState() extends State<FormatBar> {
active: _has(subscriptKey), active: _has(subscriptKey),
onPressed: () => toggleScript(editorState, subscriptKey), onPressed: () => toggleScript(editorState, subscriptKey),
), ),
_FormatButton(
icon: LucideIcons.languages,
tooltip: l10n.formatRuby,
active:
editorState.selection != null &&
editorState.getDeltaAttributeValueInSelection<String>(
rubyKey,
) !=
null,
onPressed: () => editRuby(context, editorState),
),
if (widget.onFormula case final onFormula?) if (widget.onFormula case final onFormula?)
_FormatButton( _FormatButton(
icon: LucideIcons.sigma, icon: LucideIcons.sigma,

View file

@ -16,7 +16,8 @@ typedef RichTextMarkdownTexts = ({
}); });
/// [content] as Markdown: CommonMark, with GitHub's tables and task lists, /// [content] as Markdown: CommonMark, with GitHub's tables and task lists,
/// formulas between dollars, and a spoiler as an HTML `<details>`. The /// formulas between dollars, furigana as HTML `<ruby>` and a spoiler as an
/// HTML `<details>`. The
/// person's files (images, sounds) link to where [linkTo] says their asset /// person's files (images, sounds) link to where [linkTo] says their asset
/// is; a YouTube video links to YouTube; a callout is a quote under its /// is; a YouTube video links to YouTube; a callout is a quote under its
/// title. Blocks that mean nothing outside the app (a study button) and /// title. Blocks that mean nothing outside the app (a study button) and
@ -29,6 +30,7 @@ String richContentToMarkdown(
}) { }) {
final document = content.toDocument(); final document = content.toDocument();
_formulasInDollars(document.root); _formulasInDollars(document.root);
_rubiesInHtml(document.root);
return documentToMarkdown( return documentToMarkdown(
document, document,
customParsers: [ customParsers: [
@ -63,6 +65,25 @@ void _formulasInDollars(Node node) {
node.children.forEach(_formulasInDollars); node.children.forEach(_formulasInDollars);
} }
/// Writes every text with furigana under [node] as HTML's ruby, which most
/// Markdown viewers show: `<ruby>漢字<rt>かんじ</rt></ruby>`.
void _rubiesInHtml(Node node) {
if (node.delta case final delta?
when delta.any((op) => op.attributes?[rubyKey] is String)) {
final written = Delta();
for (final op in delta) {
if (op case TextInsert(:final text, :final attributes?)
when attributes[rubyKey] is String) {
written.insert('<ruby>$text<rt>${attributes[rubyKey]}</rt></ruby>');
} else {
written.add(op);
}
}
node.updateAttributes({'delta': written.toJson()});
}
node.children.forEach(_rubiesInHtml);
}
class const _ImageParser( class const _ImageParser(
final String? Function(String assetId) _linkTo, final String? Function(String assetId) _linkTo,
final String _unavailable, final String _unavailable,

View file

@ -0,0 +1,153 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
import 'package:material_ui/material_ui.dart';
// Furigana (ruby): the reading of text within a line, kept on the text's
// attributes in the document as `rubyKey` (content_blocks.dart). Lessons,
// example sentences and notes carry readings this way, word by word.
/// How large a reading is beside the text it reads.
const _rubyScale = 0.5;
/// Gives the text selected in [editorState] a reading, asked for starting
/// from the one it has; an empty one takes it away. Nothing without text
/// selected: a reading belongs to the text it reads.
Future<void> editRuby(BuildContext context, EditorState editorState) async {
final selection = editorState.selection;
if (selection == null || selection.isCollapsed) {
return;
}
final current = editorState.getDeltaAttributeValueInSelection<String>(
rubyKey,
);
final reading = await showDialog<String>(
context: context,
builder: (context) => _ReadingDialog(initial: current ?? ''),
);
editorState.selection = selection;
if (reading == null) {
return;
}
await editorState.formatDelta(selection, {
rubyKey: reading.trim().isEmpty ? null : reading.trim(),
});
}
/// [base], the way marked text is drawn, with readings drawn too: while
/// [editing], the text it reads dotted underneath, as the caret moves
/// through it as text; else the reading set above the text, smaller, the
/// text on the line's baseline.
TextSpanDecoratorForAttribute withRuby(
TextSpanDecoratorForAttribute base, {
required bool editing,
}) => (context, node, index, text, before, after) {
final span = base(context, node, index, text, before, after);
final reading = text.attributes?[rubyKey];
if (reading is! String || reading.isEmpty || span is! TextSpan) {
return span;
}
final style = span.style ?? before.style ?? const TextStyle();
if (editing) {
return TextSpan(
text: span.text,
children: span.children,
style: style.copyWith(
decoration: TextDecoration.underline,
decorationStyle: TextDecorationStyle.dotted,
decorationColor: Theme.of(context).colorScheme.primary,
),
recognizer: span.recognizer,
mouseCursor: span.mouseCursor,
semanticsLabel: span.semanticsLabel,
locale: span.locale,
);
}
return WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: RubyText(text: text.text, reading: reading, style: style),
);
};
/// [text] with its [reading] set above it, smaller and centred, the text in
/// [style] on the baseline of the line around it.
class const RubyText({
required final String text,
required final String reading,
required final TextStyle style,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => Semantics(
label: '$text ($reading)',
excludeSemantics: true,
// Laid out from the bottom: the text comes first, so the box takes its
// baseline, and the line's text stays level with it.
child: Column(
mainAxisSize: MainAxisSize.min,
verticalDirection: VerticalDirection.up,
children: [
Text(text, style: style),
Text(
reading,
style: style.copyWith(
fontSize: (style.fontSize ?? 16) * _rubyScale,
height: 1.1,
),
),
],
),
);
}
/// Asks for a reading, starting from [initial]: completes with it, empty to
/// take the reading away, or with null when cancelled.
class const _ReadingDialog({required final String initial})
extends StatefulWidget {
@override
State<_ReadingDialog> createState() => _ReadingDialogState();
}
class _ReadingDialogState() extends State<_ReadingDialog> {
late final _reading = TextEditingController(text: widget.initial);
@override
void dispose() {
_reading.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return AlertDialog(
title: Text(l10n.rubyTitle),
content: TextField(
controller: _reading,
autofocus: true,
decoration: InputDecoration(
labelText: l10n.rubyReading,
hintText: l10n.rubyReadingHint,
border: const OutlineInputBorder(),
),
onSubmitted: (reading) => Navigator.of(context).pop(reading),
),
actions: [
if (widget.initial.isNotEmpty)
TextButton(
onPressed: () => Navigator.of(context).pop(''),
child: Text(l10n.rubyRemove),
),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.commonCancel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(_reading.text),
child: Text(l10n.rubySave),
),
],
);
}
}

View file

@ -1,3 +1,4 @@
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart'; import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:html/dom.dart' as dom; import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart' show parseFragment; import 'package:html/parser.dart' show parseFragment;
@ -7,7 +8,7 @@ import 'package:html/parser.dart' show parseFragment;
// editor's blocks: lines (`<br>`, `<div>`, `<p>`), lists, the formatting the // editor's blocks: lines (`<br>`, `<div>`, `<p>`), lists, the formatting the
// editor has (bold, italic, underline, strikethrough, code), images and // editor has (bold, italic, underline, strikethrough, code), images and
// sounds. Other elements keep their text and lose their looks (a colour, a // sounds. Other elements keep their text and lose their looks (a colour, a
// font); a ruby's reading follows its base in parentheses. Nothing is // font); a ruby keeps its reading as furigana on its text. Nothing is
// fetched: an image from the web stays one, which the app doesn't show. // fetched: an image from the web stays one, which the app doesn't show.
/// Which side of a cloze note a field is turned into. /// Which side of a cloze note a field is turned into.
@ -151,7 +152,10 @@ class _Field(
_image(element.attributes['src'] ?? ''); _image(element.attributes['src'] ?? '');
} else if (name == 'ul' || name == 'ol') { } else if (name == 'ul' || name == 'ol') {
_list(element, name == 'ol' ? 'numbered_list' : 'bulleted_list'); _list(element, name == 'ol' ? 'numbered_list' : 'bulleted_list');
} else if (name == 'ruby') {
_ruby(element);
} else if (name == 'rt') { } else if (name == 'rt') {
// A reading without its ruby, in parentheses after what it reads.
_text('(${element.text})'); _text('(${element.text})');
} else if (name == 'rp') { } else if (name == 'rp') {
// The parentheses a ruby shows where rubies aren't; ours are above. // The parentheses a ruby shows where rubies aren't; ours are above.
@ -169,6 +173,27 @@ class _Field(
} }
} }
/// A ruby: each base text with its reading (`<rt>`) as furigana on it;
/// the parentheses for where rubies aren't shown (`<rp>`) left out.
void _ruby(dom.Element ruby) {
final base = StringBuffer();
for (final node in ruby.nodes) {
if (node is dom.Element && node.localName == 'rt') {
final reading = node.text.trim();
final outer = _attributes;
if (reading.isNotEmpty) {
_attributes = {..._attributes, rubyKey: reading};
}
_text(base.toString());
_attributes = outer;
base.clear();
} else if (node is! dom.Element || node.localName != 'rp') {
base.write(node.text);
}
}
_text(base.toString());
}
void _list(dom.Element list, String type) { void _list(dom.Element list, String type) {
_endLine(); _endLine();
for (final item in list.children) { for (final item in list.children) {

View file

@ -1,6 +1,7 @@
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart'; import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart'; import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/editor/format_bar.dart'; import 'package:halkyon_learn_flutter/src/core/editor/format_bar.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart'; import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
@ -120,6 +121,33 @@ void main() {
expect(runs()[1], {subscriptKey: true}); expect(runs()[1], {subscriptKey: true});
}); });
testWidgets('furigana gives the selected text a reading, asked for, and '
'takes it away again', (tester) async {
final editorState = await edit(tester, '漢字を読む');
await select(tester, editorState, 0, 2);
await tester.ensureVisible(
find.byTooltip('Furigana (a reading above the text)'),
);
await press(tester, 'Furigana (a reading above the text)');
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).last, 'かんじ');
await tester.tap(find.text('Save'));
await tester.pump(const Duration(seconds: 1));
expect(runs(), [
{rubyKey: 'かんじ'},
null,
]);
await select(tester, editorState, 0, 2);
await press(tester, 'Furigana (a reading above the text)');
await tester.pumpAndSettle();
expect(find.widgetWithText(TextField, 'かんじ'), findsOneWidget);
await tester.tap(find.text('Remove'));
await tester.pump(const Duration(seconds: 1));
expect(runs(), everyElement(isNull));
});
testWidgets('centres the selected line', (tester) async { testWidgets('centres the selected line', (tester) async {
final editorState = await edit(tester, 'Water is H2O'); final editorState = await edit(tester, 'Water is H2O');
await select(tester, editorState, 0, 5); await select(tester, editorState, 0, 5);

View file

@ -86,6 +86,21 @@ void main() {
); );
}); });
test("writes furigana as HTML's ruby", () {
expect(
markdown([
text('paragraph', [
{
'insert': '漢字',
'attributes': {'ruby': 'かんじ'},
},
{'insert': 'を読む'},
]),
]),
'<ruby>漢字<rt>かんじ</rt></ruby>を読む',
);
});
test("links the person's images and sounds to their files, other images " test("links the person's images and sounds to their files, other images "
'to where they are', () { 'to where they are', () {
expect( expect(

View file

@ -153,4 +153,37 @@ void main() {
expect(leftOf(tester, 'hola'), lessThan(40)); expect(leftOf(tester, 'hola'), lessThan(40));
}); });
}); });
testWidgets('sets furigana above the text it reads, the text on the line', (
tester,
) async {
await show(
tester,
RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'paragraph',
'data': {
'delta': [
{
'insert': '漢字',
'attributes': {'ruby': 'かんじ'},
},
{'insert': 'を読む'},
],
},
},
],
},
}),
);
final reading = tester.getRect(find.text('かんじ'));
final text = tester.getRect(find.text('漢字'));
expect(reading.bottom, lessThanOrEqualTo(text.top + 1));
expect(reading.center.dx, moreOrLessEquals(text.center.dx, epsilon: 1));
expect(tester.getSize(find.text('かんじ')).height, lessThan(text.height));
});
} }

View file

@ -130,11 +130,8 @@ void main() {
]); ]);
}); });
test('keeps the text of other elements, a ruby reading in parentheses', () { test('keeps the text of other elements', () {
expect(blocks('<font color="red">red</font> text'), ['paragraph red text']); expect(blocks('<font color="red">red</font> text'), ['paragraph red text']);
expect(blocks('<ruby>漢<rp>(</rp><rt>かん</rt><rp>)</rp></ruby>字'), [
'paragraph 漢(かん)字',
]);
expect(blocks('a<script>alert(1)</script><style>b{}</style>'), [ expect(blocks('a<script>alert(1)</script><style>b{}</style>'), [
'paragraph a', 'paragraph a',
]); ]);
@ -167,6 +164,26 @@ void main() {
{'cat.png', 'neko.mp3'}, {'cat.png', 'neko.mp3'},
); );
}); });
test("a ruby's reading becomes furigana on its text, each base its own", () {
final content = ankiContent([
'<ruby>漢<rp>(</rp><rt>かん</rt><rp>)</rp>字<rt>じ</rt></ruby>を読む',
], assetOf: files.get);
final document = content.editorJson['document']! as Map<String, Object?>;
final line = (document['children']! as List).single as Map;
expect((line['data']! as Map)['delta'], [
{
'insert': '漢',
'attributes': {'ruby': 'かん'},
},
{
'insert': '字',
'attributes': {'ruby': 'じ'},
},
{'insert': 'を読む'},
]);
});
} }
extension on Map<String, String> { extension on Map<String, String> {