diff --git a/.claude/skills/fieldworks-avalonia-ui/references/style-system.md b/.claude/skills/fieldworks-avalonia-ui/references/style-system.md index 00ae845bf9..adfbe877bf 100644 --- a/.claude/skills/fieldworks-avalonia-ui/references/style-system.md +++ b/.claude/skills/fieldworks-avalonia-ui/references/style-system.md @@ -8,7 +8,7 @@ toward the Fluent theme per the migration hub's `architecture-patterns.md` §12. ## Why a global token system (and why it renders headlessly) -`FwAvaloniaApp` adds `new FluentTheme()`, which ships ~14px fonts and ~32px control floors app-wide. +`FwAvaloniaApp` adds Semi core plus Ursa Semi (no Fluent), which ships ~14px fonts and ~32px control floors app-wide. Density/borders must be layered ON TOP of Fluent. The reliable mechanism — proven by a prior agent — is **per-control-tree `.Styles`**, added to each view's own `Styles` collection by its view ctor. App-level `Application.Styles` did **not** apply in the headless test app, so that path is rejected. @@ -42,6 +42,12 @@ sit inside a `Border.fwFieldHost` that supplies the box. directly (not three independently-maintained copies) -- see `Src/Common/FwAvalonia/FwThemeResources.cs`. +**Font family:** the bundled Andika 7.000, token `FwUiFontFamily` (`fonts:FieldWorks#Andika`) in the same +file. `FwUiFont.ApplyTo` (called from both `Application.Initialize` overrides) registers the collection +and points Semi's `DefaultFontFamily`, `ContentControlThemeFontFamily` and `SemiFontFamilyRegular` at it, so +chrome inherits it. The stack continues with fallback fonts for UI languages whose script Andika lacks (`FwUiFont.FallbackFamiliesFor`). Data text keeps its writing-system font. Measure UI text with `FwUiFont.UiFontFamily`, +never `FontFamily.Default`. See ADR 0004. + **Control height:** `TextBox`/`ComboBox`/`Button` `MinHeight = 24` (WinForms runs ~21-23px; 24 is the pointer-accessibility floor — see the "Why `DialogMinControlHeight` is 24, not 22" note below — still far from Fluent's ~32px). diff --git a/Build/Src/FwBuildTasks/GenerateTokenKeys.cs b/Build/Src/FwBuildTasks/GenerateTokenKeys.cs index 543d05c5a6..4cb2d7dcc1 100644 --- a/Build/Src/FwBuildTasks/GenerateTokenKeys.cs +++ b/Build/Src/FwBuildTasks/GenerateTokenKeys.cs @@ -38,9 +38,10 @@ public class GenerateTokenKeys : Task { private static readonly XNamespace XamlNs = "http://schemas.microsoft.com/winfx/2006/xaml"; - /// Every top-level ThemeDictionary entry becomes a key constant. + /// Every top-level ThemeDictionary entry becomes a key constant. FontFamily + /// entries are included, so the interface font family has a constant too. private static readonly HashSet TokenElementNames = new HashSet( - new[] { "SolidColorBrush", "Color", "Double", "Thickness", "CornerRadius", "StaticResource" }); + new[] { "SolidColorBrush", "Color", "Double", "Thickness", "CornerRadius", "FontFamily", "StaticResource" }); /// .axaml files whose EVERY x:Key becomes a generated constant. [Required] diff --git a/Docs/architecture/adr/0004-interface-font-controlled-by-the-theme.md b/Docs/architecture/adr/0004-interface-font-controlled-by-the-theme.md new file mode 100644 index 0000000000..5d792bad24 --- /dev/null +++ b/Docs/architecture/adr/0004-interface-font-controlled-by-the-theme.md @@ -0,0 +1,85 @@ +# The Avalonia interface font is controlled by the theme; data text keeps its writing-system font + +LT-22854. FieldWorks controls its interface font instead of leaving it to the platform +default. One theme token, `FwUiFontFamily` in `FwColorTokens.axaml`, names the font that every +Avalonia surface (dialogs, detail view chrome, menus, flyouts) draws its interface text in. The +font is bundled in `FwAvaloniaTheme.dll` and registered at startup as the private font +collection `fonts:FieldWorks`, with per-language fallbacks, so every machine and every test run +looks the same, and changing the font later is a one-token change plus the font files. The +proposed default is Andika 7.000 (`fonts:FieldWorks#Andika`; see "Alternatives for the +default"). `FwUiFont.ApplyTo`, called from both +`Application.Initialize` overrides, registers the collection and points Semi's +`DefaultFontFamily`, `ContentControlThemeFontFamily` and `SemiFontFamilyRegular` resources at +the token, so text inherits it from the root. Data text keeps the font of its writing system. +The family is a stack: the default font, then the fallback fonts of the UI language the application +started in (see "Fallback fonts" below). + +**Why a bundled collection rather than an installed font**: the installer ships Andika 6.101 +as a system font and a developer machine may carry any build or none. A bundled 7.000 gives +the same glyphs and kerning in the installed product, the Preview Host and headless tests, +which would otherwise vary by machine. A writing system that names "Andika" still resolves to +the system copy through the plain family name, because a private collection key never captures +it, so data rendering is unchanged. + +**Why Semi's resource keys**: Semi sets the family on its own root and popup themes from those +three resources, and the WinForms-hosted root uses the same theme. Setting only +`FontManagerOptions.DefaultFamilyName` would leave Semi's stack (landing on Segoe UI) in +charge, and the headless test builders bypass that option anyway. + +**Five faces of the default**: Regular, Italic, Bold, BoldItalic and SemiBold, the weights and styles the +interface uses, about 4.1 MB. Avalonia matches the nearest weight for anything else. + +**Sizes stay at 11 px for surfaces and 13 px for labels.** Andika's x-height (0.508 em) matches +Segoe UI's (0.500 em), so legibility holds. Its line height is 1.61 em against 1.33 em, so +a line grows about 3 px at 11 px (14.6 to 17.7) and rows and dialogs get taller. This is +visible in the refreshed baseline screenshots. Line-height tokens to recover WinForms row +height are a separate decision. + +**Fallback fonts**: Andika lacks the scripts of five of the sixteen UI languages shipped in +`Localizations/l10ns` (az es fa fr hi hu id ko ms pt ru rw te tr vi zh-CN). Persian (fa), Hindi +(hi), Korean (ko), Telugu (te) and Simplified Chinese (zh-CN) therefore get a named fallback +after Andika in the family stack, chosen from the UI culture by `FwUiFont.FallbackFamiliesFor`. +Each list names a Windows system font first (Segoe UI, Nirmala UI, Malgun Gothic, Microsoft +YaHei UI), so the fallback is present on every Windows machine, and then the SIL or Noto family +that an SIL per-language font table names (Scheherazade New, Annapurna SIL, Noto Sans CJK KR, Noto +Sans Telugu, Noto Sans CJK SC), which is used where installed and skipped otherwise. Avalonia +tries the families in the stack per character, so Latin text stays in Andika. The table holds +only these five languages and each has a test; a language added to Crowdin later needs a new +entry and test. No fallback fonts are bundled. The other eleven languages are drawn in Andika +alone, which a test confirms with a sample string each. + +**What this deliberately does not do**: + +- It does not set `FontManagerOptions.DefaultFamilyName`, which would change the fallback for + data text whose writing-system font is missing. +- It does not bump the installer's Andika 6.101 to 7.000 (tracked under LT-19288). +- It does not bundle any fallback font; the fallbacks are Windows system fonts and installed + SIL or Noto families. +- It does not follow a UI language change after start-up: the stack is chosen once, as the + UI language itself is only read at start-up. +- It does not add FontFamily setters to dialog or surface styles; inheritance carries the font. + +**Revisit when**: the installer ships Andika 7.000 as a system font (the bundled copy could +then be dropped), line-height tokens are introduced, or testers report unreadable text on a +machine that has none of the named fallback families. + +**Alternatives for the default**: Andika is the proposed default, not a requirement of this +decision. Swapping it means replacing the font files in `Src/Common/FwAvaloniaTheme/Assets/Fonts` +(with their licence and notice) and changing the value of `FwUiFontFamily`; no code changes. Its +line height (1.61 em) is taller than Segoe UI's (1.33 em), so a font with tighter vertical +metrics would keep rows closer to the WinForms height. + +- The platform UI font (Segoe UI on Windows) is the closest to today's look (x-height 0.500, + line height 1.33). Its licence does not allow bundling, so other platforms and test machines + would differ. The token can still name it if the team prefers today's look. +- Sophia Nubian 1.0 (SIL, OFL, 2009; https://software.sil.org/sophianubian/) is a Univers-like + sans whose vertical metrics are closest to Segoe UI (x-height 0.500, line height 1.385). It has + no Cyrillic, so it cannot draw the Russian UI. It also has no SemiBold and little Greek and + IPA, and its last release was in 2009. +- Padauk 6.000 (SIL, OFL, 2025; https://software.sil.org/padauk/) is a Myanmar family with a + Latin sans. It has a SemiBold weight. Its x-height is 0.435, so it would need a size about 15% + larger, and it has no Cyrillic, IPA or Italic. + +Andika 7.000 is the only SIL Latin sans with full Latin, Cyrillic, Greek and IPA and all the +weights FieldWorks uses. Its cost is the taller line (1.61 em against 1.33 em). Metrics are per +em. diff --git a/Docs/architecture/adr/README.md b/Docs/architecture/adr/README.md index 9fd119285e..34c6291971 100644 --- a/Docs/architecture/adr/README.md +++ b/Docs/architecture/adr/README.md @@ -28,3 +28,4 @@ editing the next time the code changes, it belongs in one of the other three. | [0001](0001-alias-semi-semantic-tokens.md) | Alias Semi's semantic tokens instead of inventing a FieldWorks palette | | [0002](0002-whole-tree-token-hygiene-check.md) | token-hygiene.ps1 enforces full conformance across the Avalonia surface, with no grandfathering | | [0003](0003-geometric-assertions-not-pixel-diff.md) | Assert geometry, not pixel diffs, for layout parity | +| [0004](0004-interface-font-controlled-by-the-theme.md) | Control the Avalonia interface font through a theme token; data text keeps its writing-system font | diff --git a/Docs/migration/adjust-the-layout.md b/Docs/migration/adjust-the-layout.md index 0be482beed..6c83f51912 100644 --- a/Docs/migration/adjust-the-layout.md +++ b/Docs/migration/adjust-the-layout.md @@ -173,8 +173,9 @@ host. Treat difficulty here as a design signal, not a tooling problem. - **The host window is WinForms, not Avalonia.** Title bar, icon, and close button belong to the modal host form. You cannot style them from the `.axaml`, and neither loop above shows them. -- **Converted dialogs render on the Fluent background, not WinForms grey**, and - use the Fluent font family. That is stack-wide, not something your dialog did. +- **Converted dialogs render on the Semi theme background, not WinForms grey**, + and use the FieldWorks interface font (the bundled Andika, `FwUiFontFamily`, + ADR 0004). That is stack-wide, not something your dialog did. Changing it means editing `DialogTheme.axaml`'s `fwDialogRoot` style, which restyles **every** converted dialog -- treat it as its own change, with dark mode considered. diff --git a/Docs/migration/baseline-screenshots/AddNewSense-02-populated.png b/Docs/migration/baseline-screenshots/AddNewSense-02-populated.png index 1229559c99..476a32584e 100644 Binary files a/Docs/migration/baseline-screenshots/AddNewSense-02-populated.png and b/Docs/migration/baseline-screenshots/AddNewSense-02-populated.png differ diff --git a/Docs/migration/baseline-screenshots/Chooser-01-initial.png b/Docs/migration/baseline-screenshots/Chooser-01-initial.png index d8c5ce62c1..b09a2b6964 100644 Binary files a/Docs/migration/baseline-screenshots/Chooser-01-initial.png and b/Docs/migration/baseline-screenshots/Chooser-01-initial.png differ diff --git a/Docs/migration/baseline-screenshots/CreateFeature-01-empty.png b/Docs/migration/baseline-screenshots/CreateFeature-01-empty.png index 71f331638a..17d159b191 100644 Binary files a/Docs/migration/baseline-screenshots/CreateFeature-01-empty.png and b/Docs/migration/baseline-screenshots/CreateFeature-01-empty.png differ diff --git a/Docs/migration/baseline-screenshots/EntryGo-04-row-selected.png b/Docs/migration/baseline-screenshots/EntryGo-04-row-selected.png index b81a600544..c8b3349e46 100644 Binary files a/Docs/migration/baseline-screenshots/EntryGo-04-row-selected.png and b/Docs/migration/baseline-screenshots/EntryGo-04-row-selected.png differ diff --git a/Docs/migration/baseline-screenshots/FeatureChooser-01-initial.png b/Docs/migration/baseline-screenshots/FeatureChooser-01-initial.png index bdf5fc3879..bfcca8ac27 100644 Binary files a/Docs/migration/baseline-screenshots/FeatureChooser-01-initial.png and b/Docs/migration/baseline-screenshots/FeatureChooser-01-initial.png differ diff --git a/Docs/migration/baseline-screenshots/InsertEntry-02-populated.png b/Docs/migration/baseline-screenshots/InsertEntry-02-populated.png index 391f156b37..54b2eaa4ca 100644 Binary files a/Docs/migration/baseline-screenshots/InsertEntry-02-populated.png and b/Docs/migration/baseline-screenshots/InsertEntry-02-populated.png differ diff --git a/Docs/migration/baseline-screenshots/MessageBox-07-warning-icon.png b/Docs/migration/baseline-screenshots/MessageBox-07-warning-icon.png index 828d554fcc..60c2786122 100644 Binary files a/Docs/migration/baseline-screenshots/MessageBox-07-warning-icon.png and b/Docs/migration/baseline-screenshots/MessageBox-07-warning-icon.png differ diff --git a/Docs/migration/baseline-screenshots/MsaCreator-01-initial.png b/Docs/migration/baseline-screenshots/MsaCreator-01-initial.png index 9f8b770baf..f9e7c42b30 100644 Binary files a/Docs/migration/baseline-screenshots/MsaCreator-01-initial.png and b/Docs/migration/baseline-screenshots/MsaCreator-01-initial.png differ diff --git a/Docs/migration/baseline-screenshots/Options-01-initial.png b/Docs/migration/baseline-screenshots/Options-01-initial.png index b1ced1321f..77fd185ec4 100644 Binary files a/Docs/migration/baseline-screenshots/Options-01-initial.png and b/Docs/migration/baseline-screenshots/Options-01-initial.png differ diff --git a/Src/Common/FwAvalonia/Detail/FwFieldControls.cs b/Src/Common/FwAvalonia/Detail/FwFieldControls.cs index 91dcc132f4..ef4f50f6c0 100644 --- a/Src/Common/FwAvalonia/Detail/FwFieldControls.cs +++ b/Src/Common/FwAvalonia/Detail/FwFieldControls.cs @@ -94,7 +94,7 @@ public static double ComputeWsAbbrevColumnWidth(DetailModel model) if (model == null) return FwAvaloniaDensity.WsAbbrevWidth; - var typeface = new Typeface(FontFamily.Default); + var typeface = new Typeface(FwUiFont.UiFontFamily); double widest = 0; foreach (var abbrev in model.Fields.Where(f => f.IsMultiStringRow) .SelectMany(f => f.Values).Select(v => v.WsAbbrev) diff --git a/Src/Common/FwAvalonia/FwAvaloniaApp.cs b/Src/Common/FwAvalonia/FwAvaloniaApp.cs index 71fd4059ef..214fce4c54 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaApp.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaApp.cs @@ -36,6 +36,8 @@ public override void Initialize() // Shared dialog spacing/border tokens, so the Dialog* keys this assembly // publishes as generated constants are actually registered here too. MergeTokens("avares://FwAvaloniaTheme/Tokens/DialogTokens.axaml"); + // After the token merge: the interface font family is itself a token. + FwUiFont.ApplyTo(this); } private void MergeTokens(string avaresUri) diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/AvaloniaDialogHostTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/AvaloniaDialogHostTests.cs index 5fc21d3f0c..1e480edb73 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/AvaloniaDialogHostTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/AvaloniaDialogHostTests.cs @@ -46,7 +46,7 @@ private sealed class DisposableControl : Control, IDisposable public void Dispose() => DisposeCount++; } - [Test] + [AvaloniaTest] public void DisposeDialogResources_DisposesDisposableBodyAndViewModel() { var body = new DisposableControl(); @@ -58,7 +58,7 @@ public void DisposeDialogResources_DisposesDisposableBodyAndViewModel() Assert.That(vm.DisposeCount, Is.EqualTo(1), "the VM is disposed if IDisposable (it owns its resources)"); } - [Test] + [AvaloniaTest] public void DisposeDialogResources_NonDisposableViewModel_DoesNotThrow() { Assert.DoesNotThrow(() => AvaloniaDialogHost.DisposeDialogResources(new Control(), new PlainVm())); diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailPreviewTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailPreviewTests.cs index 9dc37d8dc1..b0e45a80f0 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailPreviewTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailPreviewTests.cs @@ -58,6 +58,18 @@ public void Preview_WsText_UsesConfiguredFonts() Assert.That(Find(window, "LexemeFormEditor.en").FontFamily.Name, Is.EqualTo("Times New Roman")); } + [AvaloniaTest] + public void Preview_WsText_KeepsWsFonts_WhileLabelsUseTheInterfaceFont() + { + var window = ShowPreview(); + Assert.That(Find(window, "LexemeFormEditor.seh").FontFamily.Name, Is.EqualTo("Charis SIL")); + Assert.That(Find(window, "LexemeFormEditor.en").FontFamily.Name, Is.EqualTo("Times New Roman")); + + var label = window.GetVisualDescendants().OfType() + .First(t => !string.IsNullOrEmpty(t.Text) && t.FindAncestorOfType() == null); + Assert.That(label.FontFamily, Is.EqualTo(SIL.FieldWorks.Common.FwAvalonia.FwUiFont.UiFontFamily)); + } + [AvaloniaTest] public void Preview_MorphTypeChooser_UsesSharedOptionPicker_AndReturnsFocus() { diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/FwUiFontTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/FwUiFontTests.cs new file mode 100644 index 0000000000..acb189be31 --- /dev/null +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/FwUiFontTests.cs @@ -0,0 +1,259 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Headless.NUnit; +using Avalonia.Media; +using Avalonia.Media.TextFormatting; +using Avalonia.Threading; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia; +using SIL.FieldWorks.Common.FwAvalonia.Detail; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; + +namespace FwAvaloniaTests +{ + /// + /// The Avalonia interface font is the one the FwUiFontFamily token names (today the Andika + /// bundled in FwAvaloniaTheme), drawn from its own font collection rather than from whatever + /// is installed on the machine, and it + /// reaches text in every kind of root the hosts create. Runs under the same headless app + /// (TestAppBuilder -> FwAvaloniaApp) as the rest of the suite. + /// + [TestFixture] + public class FwUiFontTests + { + // The family name the token declares, so a change of default font needs no test edit. + private static string DefaultName => FwUiFont.UiFontFamily.FamilyNames.First(); + + private static IGlyphTypeface Resolve(FontWeight weight, FontStyle style = FontStyle.Normal) + { + var bundled = new FontFamily(FwUiFont.CollectionKey + "#" + DefaultName); + Assert.That(FontManager.Current.TryGetGlyphTypeface(new Typeface(bundled, style, weight), out var face), + Is.True, DefaultName + " " + weight + " " + style + " must resolve from the bundled collection"); + return face; + } + + // Shapes the text in the interface font and returns the typeface and glyph id of each + // character, so a missing glyph (id 0) or a wrong face fails with the language named. + private static List<(IGlyphTypeface Face, ushort Glyph)> Shape(FontFamily family, string text) + { + var layout = new TextLayout(text, new Typeface(family), 13, Brushes.Black); + var result = new List<(IGlyphTypeface, ushort)>(); + foreach (var run in layout.TextLines.SelectMany(l => l.TextRuns).OfType()) + foreach (var info in run.GlyphRun.GlyphInfos) + result.Add((run.GlyphRun.GlyphTypeface, info.GlyphIndex)); + return result; + } + + /// UI languages of the Localizations/l10ns satellites, with a sample string in + /// each one's script. The scripts the default font lacks are the first five. + private static readonly object[] FallbackLanguages = + { + new object[] { "fa", "\u0641\u0627\u06CC\u0644" }, + new object[] { "hi", "\u092B\u093C\u093E\u0907\u0932" }, + new object[] { "ko", "\uD30C\uC77C" }, + new object[] { "te", "\u0C2B\u0C48\u0C32\u0C4D" }, + new object[] { "zh-CN", "\u6587\u4EF6" }, + }; + + private static string Sample(string tag) + => (string)FallbackLanguages.Cast().Single(r => (string)r[0] == tag)[1]; + + private static readonly object[] LanguagesWithoutFallback = + { + new object[] { "az", "\u018Flav\u0259 et \u015F\u011F\u0131" }, + new object[] { "es", "A\u00F1adir \u00BF\u00E1\u00E9\u00ED\u00F3\u00FA\u00FC" }, + new object[] { "fr", "Fichier \u00E0\u00E9\u00E8\u00EA\u00E7\u0153" }, + new object[] { "hu", "F\u00E1jl \u0151\u0171\u0150\u0170" }, + new object[] { "id", "Tambah berkas" }, + new object[] { "ms", "Tambah fail" }, + new object[] { "pt", "A\u00E7\u00E3o \u00E3\u00F5" }, + new object[] { "ru", "\u0424\u0430\u0439\u043B \u0401\u0451" }, + new object[] { "rw", "Dosiye" }, + new object[] { "tr", "Dosyas\u0131 \u011F\u015F\u0130" }, + new object[] { "vi", "T\u1EC7p tin \u1EDF \u1EBF \u1EEF" }, + }; + + private static void AssertUsesInterfaceFont(Control control) + { + Assert.That(control.GetValue(TextElement.FontFamilyProperty), Is.EqualTo(FwUiFont.UiFontFamily), + control.GetType().Name + " must inherit the interface font family"); + var latin = Shape(control.GetValue(TextElement.FontFamilyProperty), "Gloss"); + Assert.That(latin.Select(g => g.Face.FamilyName).Distinct(), Is.EqualTo(new[] { DefaultName })); + } + + [AvaloniaTest] + public void UiFontFamilyToken_ResolvesToTheBundledDefault() + { + Assert.That(FwUiFont.UiFontFamily.FamilyNames.First(), Is.EqualTo("Andika")); + Assert.That(FwUiFont.UiFontFamily.Key?.ToString(), Is.EqualTo(FwUiFont.CollectionKey)); + var face = Resolve(FontWeight.Normal); + Assert.That(face.FamilyName, Is.EqualTo(DefaultName)); + } + + [AvaloniaTest] + public void PlainFamilyName_IsNotCapturedByThePrivateCollection() + { + Assert.That(new FontFamily(DefaultName).Key, Is.Null); + } + + [AvaloniaTest] + public void BundledFaces_ResolveForEveryWeightInUse() + { + foreach (var weight in new[] { FontWeight.Normal, FontWeight.SemiBold, FontWeight.Bold }) + Assert.That(Resolve(weight).FamilyName, Does.StartWith(DefaultName)); + Assert.That(Resolve(FontWeight.Normal, FontStyle.Italic).FamilyName, Is.EqualTo(DefaultName)); + } + + private static void AssertFallbackLanguageDrawsItsSample(string tag, string sample) + { + var culture = new CultureInfo(tag); + Assert.That(FwUiFont.FallbackFamiliesFor(culture), Is.Not.Empty, tag); + var shaped = Shape(FwUiFont.FamilyFor(culture), sample); + + Assert.That(shaped, Is.Not.Empty, tag); + foreach (var (face, glyph) in shaped) + { + Assert.That(glyph, Is.Not.EqualTo((ushort)0), tag + " has a missing glyph in " + face.FamilyName); + Assert.That(face.FamilyName, Is.Not.EqualTo(DefaultName), tag + " cannot be drawn by the default font"); + } + } + + [AvaloniaTest] + public void Persian_SampleResolvesToFaceWithItsGlyphs() => AssertFallbackLanguageDrawsItsSample("fa", Sample("fa")); + + [AvaloniaTest] + public void Hindi_SampleResolvesToFaceWithItsGlyphs() => AssertFallbackLanguageDrawsItsSample("hi", Sample("hi")); + + [AvaloniaTest] + public void Korean_SampleResolvesToFaceWithItsGlyphs() => AssertFallbackLanguageDrawsItsSample("ko", Sample("ko")); + + [AvaloniaTest] + public void Telugu_SampleResolvesToFaceWithItsGlyphs() => AssertFallbackLanguageDrawsItsSample("te", Sample("te")); + + [AvaloniaTest] + public void SimplifiedChinese_SampleResolvesToFaceWithItsGlyphs() => AssertFallbackLanguageDrawsItsSample("zh-CN", Sample("zh-CN")); + + [AvaloniaTest] + public void LanguagesTheDefaultFontCovers_NeedNoFallbackAndDrawInTheDefaultFont() + { + foreach (object[] row in LanguagesWithoutFallback) + { + var tag = (string)row[0]; + var culture = new CultureInfo(tag); + Assert.That(FwUiFont.FallbackFamiliesFor(culture), Is.Empty, tag); + var shaped = Shape(FwUiFont.FamilyFor(culture), ((string)row[1]).Replace(" ", "")); + + Assert.That(shaped, Is.Not.Empty, tag); + foreach (var (face, glyph) in shaped) + { + Assert.That(glyph, Is.Not.EqualTo((ushort)0), tag); + Assert.That(face.FamilyName, Is.EqualTo(DefaultName), tag); + } + } + } + + [AvaloniaTest] + public void TheFiveFallbackLanguages_AreTheOnesTheDefaultFontLacks() + { + foreach (object[] row in FallbackLanguages) + Assert.That(FwUiFont.FallbackFamiliesFor(new CultureInfo((string)row[0])), Is.Not.Empty); + Assert.That(FwUiFont.FallbackFamiliesFor(CultureInfo.InvariantCulture), Is.Empty); + } + + [AvaloniaTest] + public void ApplyTo_CalledAgain_ChangesNothing() + { + FwUiFont.ApplyTo(Application.Current); + Assert.That(Resolve(FontWeight.Normal).FamilyName, Is.EqualTo(DefaultName)); + } + + [AvaloniaTest] + public void TextInAWindow_InheritsTheInterfaceFont() + { + var panel = new StackPanel(); + var text = new TextBlock { Text = "Lexeme Form" }; + var button = new Button { Content = "OK" }; + var label = new Label { Content = "Gloss" }; + panel.Children.Add(text); + panel.Children.Add(button); + panel.Children.Add(label); + var window = new Window { Content = panel }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + + AssertUsesInterfaceFont(text); + AssertUsesInterfaceFont(button); + AssertUsesInterfaceFont(label); + } + + [AvaloniaTest] + public void TextInAnEmbeddableControlRoot_InheritsTheInterfaceFont() + { + // The WinForms-hosted production root. If it cannot be shown headless, the same + // guarantee is the application-level resources Semi's root theme reads. + var app = Application.Current; + foreach (var key in new[] { "DefaultFontFamily", "ContentControlThemeFontFamily", "SemiFontFamilyRegular" }) + { + Assert.That(app.TryGetResource(key, app.ActualThemeVariant, out var value), Is.True, key); + Assert.That(value, Is.EqualTo(FwUiFont.UiFontFamily), key); + } + } + + [AvaloniaTest] + public void FlyoutText_InheritsTheInterfaceFont() + { + var text = new TextBlock { Text = "Insert" }; + var anchor = new Button { Content = "Menu", Flyout = new Flyout { Content = text } }; + var window = new Window { Content = anchor }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + anchor.Flyout.ShowAt(anchor); + Dispatcher.UIThread.RunJobs(); + + AssertUsesInterfaceFont(text); + } + + [AvaloniaTest] + public void ContextMenuItem_InheritsTheInterfaceFont() + { + var item = new MenuItem { Header = "Copy" }; + var anchor = new Button { Content = "Target", ContextMenu = new ContextMenu { Items = { item } } }; + var window = new Window { Content = anchor }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + anchor.ContextMenu.Open(anchor); + Dispatcher.UIThread.RunJobs(); + + AssertUsesInterfaceFont(item); + } + + [AvaloniaTest] + public void WsAbbrevColumnWidth_MeasuresInTheUiFont() + { + const string abbrev = "Sen-Sup"; + var field = new DetailField("f1", "Form", "Form", null, DetailFieldKind.Text, + EditorClassification.Known, "f1", null, HostRouting.Inherit, + new List { new DetailWsValue(abbrev, "barigi") }, null, null, isEditable: true); + field.IsMultiStringRow = true; + var model = new DetailModel("LexEntry", "Normal", new List { field }, + new List()); + + var formatted = new FormattedText(abbrev, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, + new Typeface(FwUiFont.UiFontFamily), FwAvaloniaDensity.WsAbbrevFontSize, + FwAvaloniaDensity.PickerForegroundBrush); + var expected = Math.Max(FwAvaloniaDensity.WsAbbrevWidth, + Math.Min(formatted.Width + FwAvaloniaDensity.WsAbbrevGutter, FwAvaloniaDensity.WsAbbrevMaxWidth)); + + Assert.That(FwMultiWsTextField.ComputeWsAbbrevColumnWidth(model), Is.EqualTo(expected).Within(0.001)); + } + } +} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/TestAppBuilder.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/TestAppBuilder.cs index 2e34c65caf..dde8c4c9ed 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/TestAppBuilder.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/TestAppBuilder.cs @@ -13,8 +13,8 @@ namespace FwAvaloniaTests { /// /// Headless Avalonia application builder for the FwAvalonia tests. Uses - /// so the Fluent theme is applied and the pure-C# controls receive templates under the headless - /// platform. + /// so the Semi theme and the FieldWorks interface font are applied and the pure-C# controls + /// receive templates under the headless platform. /// Skia drawing (instead of the null headless drawing backend) enables rendered-frame capture for /// visual parity evidence; all other headless behavior (input, focus, layout) is unchanged. /// diff --git a/Src/Common/FwAvalonia/FwThemeResources.cs b/Src/Common/FwAvalonia/FwThemeResources.cs index 6306339bdc..6b2c9cd6cd 100644 --- a/Src/Common/FwAvalonia/FwThemeResources.cs +++ b/Src/Common/FwAvalonia/FwThemeResources.cs @@ -32,6 +32,10 @@ internal static class FwThemeResources /// DataTree.PickerCornerRadius). public static CornerRadius RequireCornerRadius(string key) => (CornerRadius)Require(key); + /// Resolves a required token (e.g. + /// FwUiFontFamily). + public static FontFamily RequireFontFamily(string key) => (FontFamily)Require(key); + /// No fallback default on a miss: both hosts merge the token dictionaries /// unconditionally at Initialize(), so a missing key means the wiring is broken and must /// fail loudly rather than silently substitute a hardcoded value. diff --git a/Src/Common/FwAvalonia/FwUiFont.cs b/Src/Common/FwAvalonia/FwUiFont.cs new file mode 100644 index 0000000000..cfdfee2c37 --- /dev/null +++ b/Src/Common/FwAvalonia/FwUiFont.cs @@ -0,0 +1,103 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Avalonia; +using Avalonia.Media; +using Avalonia.Media.Fonts; + +namespace SIL.FieldWorks.Common.FwAvalonia +{ + /// + /// Makes the font named by the FwUiFontFamily token, bundled in FwAvaloniaTheme, the + /// interface + /// font of every Avalonia surface, followed by the fallback fonts the UI language needs for + /// scripts that font lacks. The platform default font is never used for interface text, so + /// every machine and test run draws the same. Data text is untouched: it keeps the font of + /// its writing system. Semi reads its text font from overridable application resources, so + /// overriding those keys is enough and no FontManagerOptions default is set (that would also + /// change the fallback for data text). + /// + public static class FwUiFont + { + /// Key of the private font collection holding the bundled interface + /// font. + public const string CollectionKey = "fonts:FieldWorks"; + + /// Location of the bundled interface font files inside + /// FwAvaloniaTheme.dll. + public const string AssetsUri = "avares://FwAvaloniaTheme/Assets/Fonts"; + + private const string DefaultFontFamilyKey = "DefaultFontFamily"; + + /// The fallback list of each FieldWorks UI language whose script the bundled + /// font lacks, in + /// the order tried. A family that is not installed is skipped, so each list names a + /// Windows system font first and an SIL or Noto family second. Keyed by language code, so + /// every Chinese culture shares one list. + private static readonly Dictionary FallbacksByLanguage = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["fa"] = new[] { "Segoe UI", "Scheherazade New" }, + ["hi"] = new[] { "Nirmala UI", "Annapurna SIL" }, + ["ko"] = new[] { "Malgun Gothic", "Noto Sans CJK KR" }, + ["te"] = new[] { "Nirmala UI", "Noto Sans Telugu" }, + ["zh"] = new[] { "Microsoft YaHei UI", "Noto Sans CJK SC" }, + }; + + private static FontManager s_registeredWith; + + /// The interface font family in effect: the bundled font followed by the + /// fallbacks of the UI language the application started in. + public static FontFamily UiFontFamily + => FwThemeResources.RequireFontFamily(DefaultFontFamilyKey); + + /// The fallback family names, in order, that needs + /// after the bundled font. Empty when it covers the language's script. + public static IReadOnlyList FallbackFamiliesFor(CultureInfo uiCulture) + { + var tag = (uiCulture ?? CultureInfo.InvariantCulture).Name; + var subtags = tag.Split(new[] { '-', '_' }, StringSplitOptions.RemoveEmptyEntries); + if (subtags.Length == 0) + return new string[0]; + return FallbacksByLanguage.TryGetValue(subtags[0], out var families) + ? families : new string[0]; + } + + /// The family stack for : the bundled font from + /// the FwUiFontFamily token, then that language's fallbacks. + public static FontFamily FamilyFor(CultureInfo uiCulture) + { + var names = new List + { + FwThemeResources.RequireFontFamily(GeneratedTokenKeys.FwUiFontFamily).ToString() + }; + names.AddRange(FallbackFamiliesFor(uiCulture)); + return FontFamily.Parse(string.Join(", ", names)); + } + + /// Registers the bundled font and points Semi's text-font resources at the + /// interface family stack for the current UI language. Calling it again for the same + /// process re-registers nothing. The FwAvaloniaTheme token dictionary must already be + /// merged into . + public static void ApplyTo(Application app) + { + var fonts = FontManager.Current; + if (!ReferenceEquals(s_registeredWith, fonts)) + { + var collectionUri = new Uri(CollectionKey); + fonts.AddFontCollection(new EmbeddedFontCollection(collectionUri, new Uri(AssetsUri))); + s_registeredWith = fonts; + } + + var family = FamilyFor(CultureInfo.CurrentUICulture); + app.Resources[DefaultFontFamilyKey] = family; + app.Resources["ContentControlThemeFontFamily"] = family; + app.Resources["SemiFontFamilyRegular"] = family; + } + } +} diff --git a/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/AddNewSenseDialogTests.cs b/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/AddNewSenseDialogTests.cs index 11df53c97a..16bc709ba9 100644 --- a/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/AddNewSenseDialogTests.cs +++ b/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/AddNewSenseDialogTests.cs @@ -75,6 +75,15 @@ private static TextBox GlossBox(AddNewSenseDlgViewModel vm, string wsTag) .First(b => Avalonia.Automation.AutomationProperties.GetAutomationId(b) == "AddNewSense.Gloss." + wsTag); + [AvaloniaTest] + public void DialogText_UsesTheAndikaInterfaceFont() + { + var (view, _) = Show(BasicInput(), "AddNewSense-00-font"); + var text = view.GetVisualDescendants().OfType() + .First(t => !string.IsNullOrEmpty(t.Text) && t.FindAncestorOfType() == null); + Assert.That(text.FontFamily, Is.EqualTo(FwUiFont.UiFontFamily)); + } + // ----- OK gating: empty gloss blocks OK, typing enables it (legacy ksFillInGloss) ----- [AvaloniaTest] diff --git a/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/TestAppBuilder.cs b/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/TestAppBuilder.cs index 9fc2723c6c..bd636058f6 100644 --- a/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/TestAppBuilder.cs +++ b/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/TestAppBuilder.cs @@ -13,7 +13,8 @@ namespace FwAvaloniaDialogsTests { /// /// Headless Avalonia application builder for the dialog-MVVM tests. Reuses - /// from the foundation so the Fluent theme applies and templated controls (TabControl, CheckBox, + /// from the foundation so the Semi theme and the FieldWorks interface font apply and + /// templated controls (TabControl, CheckBox, /// ComboBox, Button) realize under the headless platform. /// public static class TestAppBuilder diff --git a/Src/Common/FwAvaloniaPreviewHost/PreviewHostApp.cs b/Src/Common/FwAvaloniaPreviewHost/PreviewHostApp.cs index 93905e9d2f..a66afc1424 100644 --- a/Src/Common/FwAvaloniaPreviewHost/PreviewHostApp.cs +++ b/Src/Common/FwAvaloniaPreviewHost/PreviewHostApp.cs @@ -39,6 +39,7 @@ public override void Initialize() // Shared dialog spacing/border tokens, so the Dialog* keys this assembly // publishes as generated constants are actually registered here too. MergeTokens("avares://FwAvaloniaTheme/Tokens/DialogTokens.axaml"); + FwUiFont.ApplyTo(this); } private void MergeTokens(string avaresUri) diff --git a/Src/Common/FwAvaloniaTheme/Assets/Fonts/Andika-Bold.ttf b/Src/Common/FwAvaloniaTheme/Assets/Fonts/Andika-Bold.ttf new file mode 100644 index 0000000000..853ee5d1fb Binary files /dev/null and b/Src/Common/FwAvaloniaTheme/Assets/Fonts/Andika-Bold.ttf differ diff --git a/Src/Common/FwAvaloniaTheme/Assets/Fonts/Andika-BoldItalic.ttf b/Src/Common/FwAvaloniaTheme/Assets/Fonts/Andika-BoldItalic.ttf new file mode 100644 index 0000000000..6d9a5ed78d Binary files /dev/null and b/Src/Common/FwAvaloniaTheme/Assets/Fonts/Andika-BoldItalic.ttf differ diff --git a/Src/Common/FwAvaloniaTheme/Assets/Fonts/Andika-Italic.ttf b/Src/Common/FwAvaloniaTheme/Assets/Fonts/Andika-Italic.ttf new file mode 100644 index 0000000000..13f82e683f Binary files /dev/null and b/Src/Common/FwAvaloniaTheme/Assets/Fonts/Andika-Italic.ttf differ diff --git a/Src/Common/FwAvaloniaTheme/Assets/Fonts/Andika-Regular.ttf b/Src/Common/FwAvaloniaTheme/Assets/Fonts/Andika-Regular.ttf new file mode 100644 index 0000000000..402cb75e2c Binary files /dev/null and b/Src/Common/FwAvaloniaTheme/Assets/Fonts/Andika-Regular.ttf differ diff --git a/Src/Common/FwAvaloniaTheme/Assets/Fonts/Andika-SemiBold.ttf b/Src/Common/FwAvaloniaTheme/Assets/Fonts/Andika-SemiBold.ttf new file mode 100644 index 0000000000..5a48156e88 Binary files /dev/null and b/Src/Common/FwAvaloniaTheme/Assets/Fonts/Andika-SemiBold.ttf differ diff --git a/Src/Common/FwAvaloniaTheme/Assets/Fonts/NOTICE.txt b/Src/Common/FwAvaloniaTheme/Assets/Fonts/NOTICE.txt new file mode 100644 index 0000000000..d1fef63965 --- /dev/null +++ b/Src/Common/FwAvaloniaTheme/Assets/Fonts/NOTICE.txt @@ -0,0 +1,16 @@ +Andika 7.000 + +Copyright (c) 2004-2025 SIL Global (https://www.sil.org/), with Reserved Font Names "Andika" and "SIL". +Source: https://software.sil.org/andika/ +License: SIL Open Font License, Version 1.1. The full text is in OFL.txt beside this file. + +The font files are unmodified copies of the upstream release. They are embedded in +FwAvaloniaTheme.dll so the FieldWorks Avalonia interface draws in Andika on machines where it +is not installed. + +SHA-256 of each file: +- Andika-Regular.ttf: 27484fdc98d0d63f90407f8266e28295f6fb16d2b13c5024df0214f17152919a +- Andika-Italic.ttf: b5a2d47f6205a6b885e5a8ff6a29b5430c35222dc4dfe27158a0da3e9f853580 +- Andika-Bold.ttf: 3ad43a77a29ea060528be69a9e0e81c9dbbe8dff894a26ae1818d3c472393319 +- Andika-BoldItalic.ttf: dc5bdeb0a2c053d7c41d8985596ae76f08319814fd354fa4379a7008237a2825 +- Andika-SemiBold.ttf: 73c4df767769af2e545bf66ca4caabd4294ce61eb7206a9908eedfd6a7cc965e diff --git a/Src/Common/FwAvaloniaTheme/Assets/Fonts/OFL.txt b/Src/Common/FwAvaloniaTheme/Assets/Fonts/OFL.txt new file mode 100644 index 0000000000..4e591352dd --- /dev/null +++ b/Src/Common/FwAvaloniaTheme/Assets/Fonts/OFL.txt @@ -0,0 +1,94 @@ +Copyright (c) 2004-2025 SIL Global (https://www.sil.org/) +with Reserved Font Names "Andika" and "SIL". + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/Src/Common/FwAvaloniaTheme/FwAvaloniaTheme.csproj b/Src/Common/FwAvaloniaTheme/FwAvaloniaTheme.csproj index cc395c5468..af967881d0 100644 --- a/Src/Common/FwAvaloniaTheme/FwAvaloniaTheme.csproj +++ b/Src/Common/FwAvaloniaTheme/FwAvaloniaTheme.csproj @@ -31,6 +31,13 @@ + + + + + diff --git a/Src/Common/FwAvaloniaTheme/Tokens/FwColorTokens.axaml b/Src/Common/FwAvaloniaTheme/Tokens/FwColorTokens.axaml index 9b37ccbb8d..e097cc58ac 100644 --- a/Src/Common/FwAvaloniaTheme/Tokens/FwColorTokens.axaml +++ b/Src/Common/FwAvaloniaTheme/Tokens/FwColorTokens.axaml @@ -106,8 +106,14 @@ - + 11 + + fonts:FieldWorks#Andika + diff --git a/acknowledgments.md b/acknowledgments.md index e18255b28d..2dd563cb10 100644 --- a/acknowledgments.md +++ b/acknowledgments.md @@ -24,4 +24,14 @@ CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN -ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +## Andika 7.000 (https://software.sil.org/andika/): + +The FieldWorks Avalonia interface draws in Andika, copyright (c) 2004-2025 SIL Global, with +Reserved Font Names "Andika" and "SIL". The font files are unmodified. + +### License + +SIL Open Font License, Version 1.1. The full text is in +`Src/Common/FwAvaloniaTheme/Assets/Fonts/OFL.txt`, embedded in FwAvaloniaTheme.dll beside the font.