feat(i18n): full RTL/CJK support — fonts, Japanese locale, directional fixes

Bundle Noto Sans Arabic + Noto Sans JP (SIL OFL 1.1) as per-glyph fallbacks
for both the UI text theme and every generated PDF, so RTL (Arabic) and CJK
render on all platforms — including desktop, where the system font may lack
them — instead of tofu. A shared pdf_fonts.dart centralizes the PDF theme and
the three label/catalog/recovery services reuse it.

Add Japanese (ja) as the CJK reference locale — fittingly, the app's own name
is Japanese (種, tane, 'seed'). Core strings translated; the rest fall back to
English via slang. Wired into supportedLocales (automatic) and the settings
picker.

Fix three physical Alignment.centerLeft -> AlignmentDirectional.centerStart in
variety_detail_screen, and make month-abbreviation and avatar-initial
truncation grapheme-safe (.characters) so CJK text is never cut mid-character.

Tests: CJK/Arabic PDF glyph render, ja locale resolution + English fallback.
The chat-bubble mirroring flagged by the audit was verified correct (already
respects Directionality) and left unchanged.
This commit is contained in:
vjrj 2026-07-14 11:11:19 +02:00
parent fe4591d747
commit 7ae6becd8f
31 changed files with 686 additions and 104 deletions

View file

@ -1,11 +1,11 @@
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter/services.dart' show rootBundle;
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'file_service.dart';
import 'pdf_fonts.dart';
/// How the labels are tiled on the page: many small stickers to peel onto
/// packets, or fewer larger cards for jars and boxes.
@ -65,9 +65,10 @@ class _Geometry {
/// platform channels apart from loading the bundled font, so it runs (and is
/// tested) on the host.
///
/// The embedded DejaVu face covers Latin (extended), Cyrillic and Greek. Like
/// the OCR traineddata, the PDF font is a per-locale resource: wider scripts
/// (Arabic, CJK) ship alongside their locales, not hardcoded here.
/// The embedded DejaVu face covers Latin (extended), Cyrillic and Greek, with
/// Noto Arabic + Noto CJK as per-glyph fallbacks (see [buildPdfTheme]) so RTL
/// and CJK text render. Fonts are a per-locale resource, extensible to any
/// script.
class LabelSheetService {
LabelSheetService({required FileService files}) : _files = files;
@ -103,15 +104,8 @@ class LabelSheetService {
bool rtl = false,
}) async {
final textDirection = rtl ? pw.TextDirection.rtl : pw.TextDirection.ltr;
final base = pw.Font.ttf(
await rootBundle.load('assets/fonts/DejaVuSans.ttf'),
);
final bold = pw.Font.ttf(
await rootBundle.load('assets/fonts/DejaVuSans-Bold.ttf'),
);
final doc = pw.Document(
theme: pw.ThemeData.withFont(base: base, bold: bold, italic: base),
);
final fonts = await loadPdfFonts();
final doc = pw.Document(theme: fonts.theme);
final g = _geometryOf(format);
final labelWidth = (PdfPageFormat.a4.width - _margin * 2) / g.columns;
@ -150,7 +144,7 @@ class LabelSheetService {
maxLines: 1,
overflow: pw.TextOverflow.clip,
style: pw.TextStyle(
font: bold,
font: fonts.bold,
fontSize: g.common,
color: PdfColors.grey800,
),
@ -159,7 +153,7 @@ class LabelSheetService {
label.varietyLabel,
maxLines: 2,
overflow: pw.TextOverflow.clip,
style: pw.TextStyle(font: bold, fontSize: g.title),
style: pw.TextStyle(font: fonts.bold, fontSize: g.title),
),
if (label.scientificName != null)
pw.Text(

View file

@ -0,0 +1,38 @@
import 'package:flutter/services.dart' show rootBundle;
import 'package:pdf/widgets.dart' as pw;
/// Fonts and theme shared by every generated PDF (labels, catalog, recovery
/// sheet). [theme] is the document-wide default; [bold] is exposed for the few
/// places that set a heading weight explicitly merging against [theme] keeps
/// its script fallbacks, so bold Arabic/CJK still renders.
typedef PdfFonts = ({pw.ThemeData theme, pw.Font bold});
/// Loads the PDF fonts.
///
/// The base face is DejaVu (Latin extended, Cyrillic, Greek) with Noto Arabic
/// and Noto CJK registered as per-glyph fallbacks, so vernacular species names,
/// user notes, and localized labels in Arabic (RTL) or CJK render instead of
/// showing tofu boxes. The `pdf` package subsets fonts to the glyphs actually
/// used, so bundling the full Noto faces does not bloat the output file.
///
/// Fonts are a per-locale resource, extensible to any script widen the
/// fallback list as more scripts land.
Future<PdfFonts> loadPdfFonts() async {
final base = pw.Font.ttf(
await rootBundle.load('assets/fonts/DejaVuSans.ttf'),
);
final bold = pw.Font.ttf(
await rootBundle.load('assets/fonts/DejaVuSans-Bold.ttf'),
);
final arabic = pw.Font.ttf(
await rootBundle.load('assets/fonts/NotoSansArabic.ttf'),
);
final cjk = pw.Font.ttf(await rootBundle.load('assets/fonts/NotoSansJP.ttf'));
final theme = pw.ThemeData.withFont(
base: base,
bold: bold,
italic: base,
fontFallback: [arabic, cjk],
);
return (theme: theme, bold: bold);
}

View file

@ -1,10 +1,10 @@
import 'dart:typed_data';
import 'package:flutter/services.dart' show rootBundle;
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'file_service.dart';
import 'pdf_fonts.dart';
/// Renders the printable recovery sheet a QR plus the typed code and short
/// instructions ("keep two paper copies, like your best seed"). All strings
@ -19,22 +19,15 @@ class RecoverySheetService {
required String intro,
required String code,
}) async {
final base = pw.Font.ttf(
await rootBundle.load('assets/fonts/DejaVuSans.ttf'),
);
final bold = pw.Font.ttf(
await rootBundle.load('assets/fonts/DejaVuSans-Bold.ttf'),
);
final doc = pw.Document(
theme: pw.ThemeData.withFont(base: base, bold: bold),
);
final fonts = await loadPdfFonts();
final doc = pw.Document(theme: fonts.theme);
doc.addPage(
pw.Page(
pageFormat: PdfPageFormat.a4,
build: (context) => pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(title, style: pw.TextStyle(font: bold, fontSize: 20)),
pw.Text(title, style: pw.TextStyle(font: fonts.bold, fontSize: 20)),
pw.SizedBox(height: 12),
pw.Text(intro, style: const pw.TextStyle(fontSize: 11)),
pw.SizedBox(height: 24),
@ -54,7 +47,7 @@ class RecoverySheetService {
child: pw.Text(
code,
textAlign: pw.TextAlign.center,
style: pw.TextStyle(font: bold, fontSize: 12),
style: pw.TextStyle(font: fonts.bold, fontSize: 12),
),
),
],

View file

@ -1,10 +1,10 @@
import 'dart:typed_data';
import 'package:flutter/services.dart' show rootBundle;
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'file_service.dart';
import 'pdf_fonts.dart';
/// One printable line of the "what I share" catalog. All strings arrive
/// already localized this service knows nothing about i18n or the domain.
@ -47,20 +47,13 @@ class ShareCatalogService {
required String date,
required List<ShareCatalogRow> rows,
}) async {
final base = pw.Font.ttf(
await rootBundle.load('assets/fonts/DejaVuSans.ttf'),
);
final bold = pw.Font.ttf(
await rootBundle.load('assets/fonts/DejaVuSans-Bold.ttf'),
);
final doc = pw.Document(
theme: pw.ThemeData.withFont(base: base, bold: bold, italic: base),
);
final fonts = await loadPdfFonts();
final doc = pw.Document(theme: fonts.theme);
doc.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
build: (context) => [
pw.Text(title, style: pw.TextStyle(font: bold, fontSize: 20)),
pw.Text(title, style: pw.TextStyle(font: fonts.bold, fontSize: 20)),
pw.SizedBox(height: 2),
pw.Text(
date,
@ -84,7 +77,7 @@ class ShareCatalogService {
children: [
pw.Text(
row.name,
style: pw.TextStyle(font: bold, fontSize: 12),
style: pw.TextStyle(font: fonts.bold, fontSize: 12),
),
if (row.scientificName != null)
pw.Text(