From 095fe1f1b15070894905de061c001b6d3a49e144 Mon Sep 17 00:00:00 2001
From: SeoJCalive <180237232+SeoJCalive@users.noreply.github.com>
Date: Sun, 25 Oct 2026 14:41:33 +0900
Subject: [PATCH 1/9] Add bitmap .fon input converted per size to TTF, OTF,
WOFF, WOFF2 and EOT
Read monochrome FNT 2.0/3.0 resources from NE .fon files and build a
pixel-exact TrueType font for each size, then reuse the existing font
writers for the other formats. A single resource keeps the plain name;
several resources get one file per size. Characters map through the
code page named by the font charset. Vector, PE, colour, double-byte
and corrupt files are refused before anything is written.
Closes #177
---
README.ar.md | 2 +-
README.es.md | 2 +-
README.fr.md | 2 +-
README.hi.md | 2 +-
README.ja.md | 2 +-
README.ko.md | 2 +-
README.md | 2 +-
README.pl.md | 2 +-
README.pt-BR.md | 2 +-
README.pt-PT.md | 2 +-
README.ru.md | 2 +-
README.th.md | 2 +-
README.zh-CN.md | 2 +-
README.zh-TW.md | 2 +-
docs/ENGINES.md | 20 +-
src/Filee.App/Assets/i18n/ar.json | 2 +-
src/Filee.App/Assets/i18n/en.json | 2 +-
src/Filee.App/Assets/i18n/es.json | 2 +-
src/Filee.App/Assets/i18n/fr.json | 2 +-
src/Filee.App/Assets/i18n/hi.json | 2 +-
src/Filee.App/Assets/i18n/ja.json | 2 +-
src/Filee.App/Assets/i18n/ko.json | 2 +-
src/Filee.App/Assets/i18n/ne.json | 2 +-
src/Filee.App/Assets/i18n/pl.json | 2 +-
src/Filee.App/Assets/i18n/pt-BR.json | 2 +-
src/Filee.App/Assets/i18n/pt-PT.json | 2 +-
src/Filee.App/Assets/i18n/ru.json | 2 +-
src/Filee.App/Assets/i18n/th.json | 2 +-
src/Filee.App/Assets/i18n/tr.json | 2 +-
src/Filee.App/Assets/i18n/zh-CN.json | 2 +-
src/Filee.App/Assets/i18n/zh-TW.json | 2 +-
src/Filee.Core/Formats/FormatRegistry.cs | 1 +
src/Filee.Engines/Fonts/FonOutlineBuilder.cs | 395 ++++++
src/Filee.Engines/Fonts/FonReader.cs | 193 +++
src/Filee.Engines/Fonts/FontConverter.cs | 52 +-
tests/Filee.App.Tests/EnginesPageTests.cs | 34 +
.../PresetEditorTargetTests.cs | 45 +
tests/Filee.Cli.Tests/CliTests.cs | 131 ++
tests/Filee.Cli.Tests/Filee.Cli.Tests.csproj | 4 +
tests/Filee.Core.Tests/FonFormatTests.cs | 36 +
.../Filee.Engines.Tests.csproj | 2 +-
tests/Filee.Engines.Tests/FonTests.cs | 1099 +++++++++++++++++
.../Filee.Engines.Tests/Fonts/FON-FIXTURES.md | 31 +
tests/Filee.Engines.Tests/Fonts/Fon-edge.fon | Bin 0 -> 1312 bytes
tests/Filee.Engines.Tests/Fonts/Fon-multi.fon | Bin 0 -> 6021 bytes
.../Fonts/Fon-v2-fixed.fon | Bin 0 -> 1933 bytes
.../Fonts/Fon-v2-proportional.fon | Bin 0 -> 3283 bytes
.../Fonts/Fon-v3-fixed.fon | Bin 0 -> 2157 bytes
.../Fonts/Fon-v3-flags-04.fon | Bin 0 -> 2351 bytes
.../Fonts/Fon-v3-flags-08.fon | Bin 0 -> 3377 bytes
.../Fonts/Fon-v3-flags-10.fon | Bin 0 -> 4819 bytes
.../Fonts/Fon-v3-flags-20.fon | Bin 0 -> 7509 bytes
.../Fonts/Fon-v3-flags-40.fon | Bin 0 -> 12695 bytes
.../Fonts/Fon-v3-flags-80.fon | Bin 0 -> 22873 bytes
.../Fonts/Fon-v3-proportional.fon | Bin 0 -> 2755 bytes
.../Fonts/make-test-fon.py | 136 ++
56 files changed, 2203 insertions(+), 36 deletions(-)
create mode 100644 src/Filee.Engines/Fonts/FonOutlineBuilder.cs
create mode 100644 src/Filee.Engines/Fonts/FonReader.cs
create mode 100644 tests/Filee.App.Tests/PresetEditorTargetTests.cs
create mode 100644 tests/Filee.Core.Tests/FonFormatTests.cs
create mode 100644 tests/Filee.Engines.Tests/FonTests.cs
create mode 100644 tests/Filee.Engines.Tests/Fonts/FON-FIXTURES.md
create mode 100644 tests/Filee.Engines.Tests/Fonts/Fon-edge.fon
create mode 100644 tests/Filee.Engines.Tests/Fonts/Fon-multi.fon
create mode 100644 tests/Filee.Engines.Tests/Fonts/Fon-v2-fixed.fon
create mode 100644 tests/Filee.Engines.Tests/Fonts/Fon-v2-proportional.fon
create mode 100644 tests/Filee.Engines.Tests/Fonts/Fon-v3-fixed.fon
create mode 100644 tests/Filee.Engines.Tests/Fonts/Fon-v3-flags-04.fon
create mode 100644 tests/Filee.Engines.Tests/Fonts/Fon-v3-flags-08.fon
create mode 100644 tests/Filee.Engines.Tests/Fonts/Fon-v3-flags-10.fon
create mode 100644 tests/Filee.Engines.Tests/Fonts/Fon-v3-flags-20.fon
create mode 100644 tests/Filee.Engines.Tests/Fonts/Fon-v3-flags-40.fon
create mode 100644 tests/Filee.Engines.Tests/Fonts/Fon-v3-flags-80.fon
create mode 100644 tests/Filee.Engines.Tests/Fonts/Fon-v3-proportional.fon
create mode 100644 tests/Filee.Engines.Tests/Fonts/make-test-fon.py
diff --git a/README.ar.md b/README.ar.md
index 2e83d7cec..29d745abb 100644
--- a/README.ar.md
+++ b/README.ar.md
@@ -167,7 +167,7 @@ Set-ProcessMitigation -Name filee-cli.exe -Disable UserShadowStack
| **الترجمة**: SRT, ASS/SSA, WebVTT، SAMI (SMI)، LRC, TTML, MicroDVD, SCC | فيما بينها بترميز UTF-8 (تُقرأ ملفات CP949 الكورية بشكل صحيح) |
| **الأرشيفات**: ZIP، وANSYS Workbench (WBPZ)، و7Z, RAR, TAR, TAR.GZ, TAR.BZ2, TAR.XZ, GZ, ISO, CAB, DMG, ALZ, EGG وغيرها | الاستخراج، ZIP, 7Z, TAR, TAR.GZ؛
وجمع أي ملفات في ZIP واحد |
| **CAD**: DWG, DXF | PDF, SVG, PNG, DWG ↔ DXF |
-| **الخطوط**: TTF, OTF, WOFF, WOFF2, EOT | فيما بينها |
+| **الخطوط**: TTF, OTF, WOFF, WOFF2, EOT؛ FON (NE وFNT v2/v3 أحادي اللون وترميزات أحادية البايت المدعومة، إدخال فقط) | تحويل إلى الصيغ الخمس، ملف لكل حجم (دون لاحقة حجم للمورد الواحد). لا يدعم PE أو الخطوط المتجهية أو FNT المنفرد |
| **البيانات**: JSON, JSON Lines, YAML, TOML, XML
vCard, iCalendar | فيما بينها، وإلى CSV وXLSX (للجداول)؛
vCard, iCalendar ↔ CSV |
| **النماذج ثلاثية الأبعاد**: GLB, glTF, STL, OBJ (+ MTL), 3MF, PLY؛
وFBX للقراءة | فيما بينها، معاينة PNG |
| **البيانات الجغرافية المتجهية**: Shapefile, GeoJSON, KML, KMZ, GPX
ملفات CSV فيها إحداثيات؛
وGarmin FIT للقراءة | فيما بينها، وإلى CSV / XLSX / JSON؛
وتُحوَّل البيانات المُسقَطة (UTM, Web Mercator, Lambert, Albers) إلى خطوط الطول والعرض في WGS 84 |
diff --git a/README.es.md b/README.es.md
index 1d74252fd..0ad81cbc6 100644
--- a/README.es.md
+++ b/README.es.md
@@ -161,7 +161,7 @@ Para deshacerlo, quita esas entradas en Seguridad de Windows → Control de apli
| **Subtítulos**: SRT, ASS/SSA, WebVTT, SAMI (SMI), LRC, TTML, MicroDVD, SCC | entre sí, en UTF-8 (los archivos coreanos en CP949 se leen correctamente) |
| **Archivos comprimidos**: ZIP, ANSYS Workbench (WBPZ), 7Z, RAR, TAR, TAR.GZ, TAR.BZ2, TAR.XZ, GZ, ISO, CAB, DMG, ALZ, EGG, … | extraer, ZIP, 7Z, TAR, TAR.GZ;
cualquier conjunto de archivos → un ZIP |
| **CAD**: DWG, DXF | PDF, SVG, PNG, DWG ↔ DXF |
-| **Tipografías**: TTF, OTF, WOFF, WOFF2, EOT | entre sí |
+| **Tipografías**: TTF, OTF, WOFF, WOFF2, EOT; FON (NE, FNT v2/v3 monocromo y juegos de un byte compatibles; solo entrada) | conversión entre los cinco formatos, un archivo por tamaño (sin sufijo si hay un solo recurso). Sin PE, fuentes vectoriales ni FNT independiente |
| **Datos**: JSON, JSON Lines, YAML, TOML, XML
vCard, iCalendar | entre sí, CSV y XLSX (tablas);
vCard, iCalendar ↔ CSV |
| **Modelos 3D**: GLB, glTF, STL, OBJ (+ MTL), 3MF, PLY;
FBX solo lectura | entre sí, vista previa en PNG |
| **Datos geoespaciales vectoriales**: Shapefile, GeoJSON, KML, KMZ, GPX
CSV con coordenadas;
Garmin FIT solo lectura | entre sí y a CSV / XLSX / JSON;
los datos proyectados (UTM, Web Mercator, Lambert, Albers) salen como longitud / latitud WGS 84 |
diff --git a/README.fr.md b/README.fr.md
index 813b35368..a3484c1cc 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -161,7 +161,7 @@ Pour annuler, supprimez ces entrées dans Sécurité Windows → Contrôle des a
| **Sous-titres** : SRT, ASS/SSA, WebVTT, SAMI (SMI), LRC, TTML, MicroDVD, SCC | entre eux, en UTF-8 (les fichiers coréens en CP949 sont lus correctement) |
| **Archives** : ZIP, ANSYS Workbench (WBPZ), 7Z, RAR, TAR, TAR.GZ, TAR.BZ2, TAR.XZ, GZ, ISO, CAB, DMG, ALZ, EGG, … | extraire, ZIP, 7Z, TAR, TAR.GZ ;
n'importe quels fichiers → un seul ZIP |
| **CAO** : DWG, DXF | PDF, SVG, PNG, DWG ↔ DXF |
-| **Polices** : TTF, OTF, WOFF, WOFF2, EOT | entre elles |
+| **Polices** : TTF, OTF, WOFF, WOFF2, EOT ; FON (NE, FNT v2/v3 monochrome et jeux de caractères mono-octet pris en charge, entrée seule) | conversion entre les cinq formats, un fichier par taille (sans suffixe pour une seule ressource). PE, polices vectorielles et FNT autonome exclus |
| **Données** : JSON, JSON Lines, YAML, TOML, XML
vCard, iCalendar | entre eux, CSV et XLSX (tableaux) ;
vCard, iCalendar ↔ CSV |
| **Modèles 3D** : GLB, glTF, STL, OBJ (+ MTL), 3MF, PLY ;
FBX en lecture | entre eux, aperçu PNG |
| **Données géospatiales vectorielles** : Shapefile, GeoJSON, KML, KMZ, GPX
CSV avec coordonnées ;
Garmin FIT en lecture | entre eux et CSV / XLSX / JSON ;
les données projetées (UTM, Web Mercator, Lambert, Albers) sortent en longitude / latitude WGS 84 |
diff --git a/README.hi.md b/README.hi.md
index 4d4de1530..bca7cf91a 100644
--- a/README.hi.md
+++ b/README.hi.md
@@ -161,7 +161,7 @@ Set-ProcessMitigation -Name filee-cli.exe -Disable UserShadowStack
| **सबटाइटल**: SRT, ASS/SSA, WebVTT, SAMI (SMI), LRC, TTML, MicroDVD, SCC | आपस में, UTF-8 में (कोरियाई CP949 फ़ाइलें भी सही पढ़ी जाती हैं) |
| **आर्काइव**: ZIP, ANSYS Workbench (WBPZ), 7Z, RAR, TAR, TAR.GZ, TAR.BZ2, TAR.XZ, GZ, ISO, CAB, DMG, ALZ, EGG, … | निकालना, ZIP, 7Z, TAR, TAR.GZ;
कोई भी फ़ाइलें → एक ZIP |
| **CAD**: DWG, DXF | PDF, SVG, PNG, DWG ↔ DXF |
-| **फ़ॉन्ट**: TTF, OTF, WOFF, WOFF2, EOT | आपस में |
+| **फ़ॉन्ट**: TTF, OTF, WOFF, WOFF2, EOT; FON (NE मोनोक्रोम FNT v2/v3 और समर्थित एक-बाइट वर्णसेट; केवल इनपुट) | पाँच प्रारूपों में रूपांतरण, हर आकार की अलग फ़ाइल (एक संसाधन पर आकार प्रत्यय नहीं)। PE, वेक्टर और अलग FNT असमर्थित |
| **डेटा**: JSON, JSON Lines, YAML, TOML, XML
vCard, iCalendar | आपस में, CSV और XLSX (टेबल);
vCard, iCalendar ↔ CSV |
| **3D मॉडल**: GLB, glTF, STL, OBJ (+ MTL), 3MF, PLY;
FBX सिर्फ़ पढ़ने के लिए | आपस में, PNG प्रीव्यू |
| **भू-स्थानिक वेक्टर**: Shapefile, GeoJSON, KML, KMZ, GPX
निर्देशांक वाली CSV;
Garmin FIT सिर्फ़ पढ़ने के लिए | आपस में और CSV / XLSX / JSON;
प्रोजेक्टेड डेटा (UTM, Web Mercator, Lambert, Albers) WGS 84 देशांतर / अक्षांश में बदलकर आता है |
diff --git a/README.ja.md b/README.ja.md
index d060f6d6d..1294d0414 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -164,7 +164,7 @@ Set-ProcessMitigation -Name filee-cli.exe -Disable UserShadowStack
| **字幕**:SRT、ASS/SSA、WebVTT、SAMI(SMI)、LRC、TTML、MicroDVD、SCC | 相互変換、UTF-8で保存(CP949の韓国語ファイルも正しく読み込み) |
| **アーカイブ**:ZIP、ANSYS Workbench(WBPZ)、7Z、RAR、TAR、TAR.GZ、TAR.BZ2、TAR.XZ、GZ、ISO、CAB、DMG、ALZ、EGG など | 展開、ZIP、7Z、TAR、TAR.GZ。
複数のファイル → 1つのZIP |
| **CAD**:DWG、DXF | PDF、SVG、PNG、DWG ↔ DXF |
-| **フォント**:TTF、OTF、WOFF、WOFF2、EOT | 相互変換 |
+| **フォント**:TTF、OTF、WOFF、WOFF2、EOT;FON(NE・単色 FNT v2/v3・対応する1バイト文字セット、入力専用) | 5形式間の変換、サイズごとに1ファイル(単一リソースはサイズ接尾辞なし)。PE・ベクター・単独FNTは対象外 |
| **データ**:JSON、JSON Lines、YAML、TOML、XML
vCard、iCalendar | 相互変換、CSVとXLSX(表形式のデータ)。
vCard、iCalendar ↔ CSV |
| **3Dモデル**:GLB、glTF、STL、OBJ(+ MTL)、3MF、PLY。
FBXは読み込みのみ | 相互変換、PNGプレビュー |
| **地理空間ベクターデータ**:Shapefile、GeoJSON、KML、KMZ、GPX
座標付きのCSV。
Garmin FITは読み込みのみ | 相互変換、CSV/XLSX/JSON。
投影座標系(UTM、Webメルカトル、ランベルト、アルベルス)のデータはWGS 84の経度/緯度で出力 |
diff --git a/README.ko.md b/README.ko.md
index 2c0d5094d..321aec90c 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -162,7 +162,7 @@ Set-ProcessMitigation -Name filee-cli.exe -Disable UserShadowStack
| **자막**: SRT, ASS/SSA, WebVTT, SAMI(SMI), LRC, TTML, MicroDVD, SCC | 서로 변환, UTF-8로 저장(CP949 한글 자막도 깨지지 않아요) |
| **압축 파일**: ZIP, ANSYS Workbench(WBPZ), 7Z, RAR, TAR, TAR.GZ, TAR.BZ2, TAR.XZ, GZ, ISO, CAB, DMG, ALZ, EGG 등 | 압축 풀기, ZIP, 7Z, TAR, TAR.GZ;
여러 파일 → ZIP 하나 |
| **CAD**: DWG, DXF | PDF, SVG, PNG, DWG ↔ DXF |
-| **글꼴**: TTF, OTF, WOFF, WOFF2, EOT | 서로 변환 |
+| **글꼴**: TTF, OTF, WOFF, WOFF2, EOT; FON(NE 단색 FNT v2/v3·지원 단일 바이트, 입력 전용) | 다섯 형식으로 변환, 비트맵 크기마다 파일 하나(리소스 하나면 크기 접미사 없음). PE·벡터·독립 FNT 제외 |
| **데이터**: JSON, JSON Lines, YAML, TOML, XML
vCard, iCalendar | 서로 변환, CSV·XLSX(표 모양 데이터);
vCard, iCalendar ↔ CSV |
| **3D 모델**: GLB, glTF, STL, OBJ (+ MTL), 3MF, PLY;
FBX는 읽기 | 서로 변환, PNG 미리보기 |
| **지리 정보(벡터)**: Shapefile, GeoJSON, KML, KMZ, GPX
좌표가 있는 CSV;
Garmin FIT는 읽기 | 서로 변환, CSV·XLSX·JSON;
투영 좌표계(UTM, 웹 메르카토르, 람베르트, 알버스)는 WGS 84 경위도로 변환 |
diff --git a/README.md b/README.md
index fe3b8222e..bec67ad3d 100644
--- a/README.md
+++ b/README.md
@@ -161,7 +161,7 @@ To undo it, remove the entries in Windows Security → App & browser control →
| **Subtitles**: SRT, ASS/SSA, WebVTT, SAMI (SMI), LRC, TTML, MicroDVD, SCC | each other as UTF-8 (Korean CP949 files read correctly) |
| **Archives**: ZIP, ANSYS Workbench (WBPZ), 7Z, RAR, TAR, TAR.GZ, TAR.BZ2, TAR.XZ, GZ, ISO, CAB, DMG, ALZ, EGG, … | extract, ZIP, 7Z, TAR, TAR.GZ;
any files → one ZIP |
| **CAD**: DWG, DXF | PDF, SVG, PNG, DWG ↔ DXF |
-| **Fonts**: TTF, OTF, WOFF, WOFF2, EOT | each other |
+| **Fonts**: TTF, OTF, WOFF, WOFF2, EOT; FON (NE monochrome FNT v2/v3, supported single-byte charsets; input only) | TTF, OTF, WOFF, WOFF2, EOT; one file per bitmap size (one resource: no size suffix). No PE, vector or standalone FNT support |
| **Data**: JSON, JSON Lines, YAML, TOML, XML
vCard, iCalendar | each other, CSV and XLSX (tables);
vCard, iCalendar ↔ CSV |
| **3D models**: GLB, glTF, STL, OBJ (+ MTL), 3MF, PLY;
FBX read | each other, PNG preview |
| **Geospatial vectors**: Shapefile, GeoJSON, KML, KMZ, GPX
CSV with coordinates; Garmin FIT read | each other and CSV / XLSX / JSON;
projected data (UTM, Web Mercator, Lambert, Albers) comes out as WGS 84 longitude / latitude |
diff --git a/README.pl.md b/README.pl.md
index 228af92e8..795ca6923 100644
--- a/README.pl.md
+++ b/README.pl.md
@@ -161,7 +161,7 @@ Aby to cofnąć, usuń te wpisy w Zabezpieczenia Windows → Kontrola aplikacji
| **Napisy**: SRT, ASS/SSA, WebVTT, SAMI (SMI), LRC, TTML, MicroDVD, SCC | między sobą, w UTF-8 (koreańskie pliki CP949 są odczytywane poprawnie) |
| **Archiwa**: ZIP, ANSYS Workbench (WBPZ), 7Z, RAR, TAR, TAR.GZ, TAR.BZ2, TAR.XZ, GZ, ISO, CAB, DMG, ALZ, EGG, … | rozpakowanie, ZIP, 7Z, TAR, TAR.GZ;
dowolne pliki → jeden ZIP |
| **CAD**: DWG, DXF | PDF, SVG, PNG, DWG ↔ DXF |
-| **Czcionki**: TTF, OTF, WOFF, WOFF2, EOT | między sobą |
+| **Czcionki**: TTF, OTF, WOFF, WOFF2, EOT; FON (NE, monochromatyczny FNT v2/v3 i obsługiwane zestawy jednobajtowe; tylko wejście) | konwersja między pięcioma formatami, plik na rozmiar (bez sufiksu przy jednym zasobie). Bez PE, fontów wektorowych i samodzielnego FNT |
| **Dane**: JSON, JSON Lines, YAML, TOML, XML
vCard, iCalendar | między sobą, CSV i XLSX (tabele);
vCard, iCalendar ↔ CSV |
| **Modele 3D**: GLB, glTF, STL, OBJ (+ MTL), 3MF, PLY;
FBX tylko odczyt | między sobą, podgląd PNG |
| **Dane geoprzestrzenne (wektorowe)**: Shapefile, GeoJSON, KML, KMZ, GPX
CSV ze współrzędnymi;
Garmin FIT tylko odczyt | między sobą oraz CSV / XLSX / JSON;
dane w odwzorowaniach (UTM, Web Mercator, Lambert, Albers) wychodzą jako długość / szerokość geograficzna WGS 84 |
diff --git a/README.pt-BR.md b/README.pt-BR.md
index 148464d7e..8a25bc8f3 100644
--- a/README.pt-BR.md
+++ b/README.pt-BR.md
@@ -161,7 +161,7 @@ Para desfazer, remova essas entradas em Segurança do Windows → Controle de ap
| **Legendas**: SRT, ASS/SSA, WebVTT, SAMI (SMI), LRC, TTML, MicroDVD, SCC | entre si, em UTF-8 (arquivos coreanos em CP949 são lidos corretamente) |
| **Arquivos compactados**: ZIP, ANSYS Workbench (WBPZ), 7Z, RAR, TAR, TAR.GZ, TAR.BZ2, TAR.XZ, GZ, ISO, CAB, DMG, ALZ, EGG, … | extrair, ZIP, 7Z, TAR, TAR.GZ;
quaisquer arquivos → um ZIP |
| **CAD**: DWG, DXF | PDF, SVG, PNG, DWG ↔ DXF |
-| **Fontes**: TTF, OTF, WOFF, WOFF2, EOT | entre si |
+| **Fontes**: TTF, OTF, WOFF, WOFF2, EOT; FON (NE, FNT v2/v3 monocromático e conjuntos de um byte compatíveis; somente entrada) | conversão entre os cinco formatos, um arquivo por tamanho (sem sufixo com um recurso). PE, fontes vetoriais e FNT avulso não são aceitos |
| **Dados**: JSON, JSON Lines, YAML, TOML, XML
vCard, iCalendar | entre si, CSV e XLSX (tabelas);
vCard, iCalendar ↔ CSV |
| **Modelos 3D**: GLB, glTF, STL, OBJ (+ MTL), 3MF, PLY;
leitura de FBX | entre si, prévia em PNG |
| **Vetores geoespaciais**: Shapefile, GeoJSON, KML, KMZ, GPX
CSV com coordenadas;
leitura de Garmin FIT | entre si e CSV / XLSX / JSON;
dados projetados (UTM, Web Mercator, Lambert, Albers) saem como longitude / latitude em WGS 84 |
diff --git a/README.pt-PT.md b/README.pt-PT.md
index 26abe04c9..f8b4d468e 100644
--- a/README.pt-PT.md
+++ b/README.pt-PT.md
@@ -161,7 +161,7 @@ Para reverter a alteração, remove as entradas em Segurança do Windows → Con
| **Legendas**: SRT, ASS/SSA, WebVTT, SAMI (SMI), LRC, TTML, MicroDVD, SCC | conversão entre estes formatos em UTF-8 (leitura correta de ficheiros coreanos em CP949) |
| **Arquivos**: ZIP, ANSYS Workbench (WBPZ), 7Z, RAR, TAR, TAR.GZ, TAR.BZ2, TAR.XZ, GZ, ISO, CAB, DMG, ALZ, EGG, … | extrair, ZIP, 7Z, TAR, TAR.GZ;
quaisquer ficheiros → um ZIP |
| **CAD**: DWG, DXF | PDF, SVG, PNG, DWG ↔ DXF |
-| **Tipos de letra**: TTF, OTF, WOFF, WOFF2, EOT | conversão entre estes formatos |
+| **Tipos de letra**: TTF, OTF, WOFF, WOFF2, EOT; FON (NE, FNT v2/v3 monocromático e conjuntos de um byte compatíveis; apenas entrada) | conversão entre os cinco formatos, um ficheiro por tamanho (sem sufixo com um recurso). PE, tipos de letra vetoriais e FNT avulso não são aceites |
| **Dados**: JSON, JSON Lines, YAML, TOML, XML
vCard, iCalendar | conversão entre estes formatos, CSV e XLSX (tabelas);
vCard, iCalendar ↔ CSV |
| **Modelos 3D**: GLB, glTF, STL, OBJ (+ MTL), 3MF, PLY;
leitura de FBX | conversão entre estes formatos, pré-visualização em PNG |
| **Vetores geoespaciais**: Shapefile, GeoJSON, KML, KMZ, GPX
CSV com coordenadas | conversão entre estes formatos e CSV / XLSX / JSON;
os dados projetados (UTM, Web Mercator, Lambert, Albers) são convertidos em longitude / latitude WGS 84 |
diff --git a/README.ru.md b/README.ru.md
index d71e55d22..df9cabd9f 100644
--- a/README.ru.md
+++ b/README.ru.md
@@ -161,7 +161,7 @@ Set-ProcessMitigation -Name filee-cli.exe -Disable UserShadowStack
| **Субтитры**: SRT, ASS/SSA, WebVTT, SAMI (SMI), LRC, TTML, MicroDVD, SCC | друг в друга, в UTF-8 (корейские файлы в CP949 читаются правильно) |
| **Архивы**: ZIP, ANSYS Workbench (WBPZ), 7Z, RAR, TAR, TAR.GZ, TAR.BZ2, TAR.XZ, GZ, ISO, CAB, DMG, ALZ, EGG, … | распаковка, ZIP, 7Z, TAR, TAR.GZ;
любые файлы → один ZIP |
| **CAD**: DWG, DXF | PDF, SVG, PNG, DWG ↔ DXF |
-| **Шрифты**: TTF, OTF, WOFF, WOFF2, EOT | друг в друга |
+| **Шрифты**: TTF, OTF, WOFF, WOFF2, EOT; FON (NE, монохромный FNT v2/v3 и поддерживаемые однобайтовые кодировки; только ввод) | взаимное преобразование пяти форматов, файл на размер (без суффикса для одного ресурса). PE, векторные и отдельные FNT не поддерживаются |
| **Данные**: JSON, JSON Lines, YAML, TOML, XML
vCard, iCalendar | друг в друга, CSV и XLSX (таблицы);
vCard, iCalendar ↔ CSV |
| **3D-модели**: GLB, glTF, STL, OBJ (+ MTL), 3MF, PLY;
FBX только чтение | друг в друга, превью в PNG |
| **Геоданные (векторные)**: Shapefile, GeoJSON, KML, KMZ, GPX
CSV с координатами;
Garmin FIT только чтение | друг в друга и CSV / XLSX / JSON;
данные в проекциях (UTM, Web Mercator, Ламберта, Альберса) переводятся в долготу / широту WGS 84 |
diff --git a/README.th.md b/README.th.md
index 9ea1d204e..42b122d75 100644
--- a/README.th.md
+++ b/README.th.md
@@ -161,7 +161,7 @@ Set-ProcessMitigation -Name filee-cli.exe -Disable UserShadowStack
| **คำบรรยาย**: SRT, ASS/SSA, WebVTT, SAMI (SMI), LRC, TTML, MicroDVD, SCC | แปลงสลับกันเป็น UTF-8 (อ่านไฟล์ SMI/SRT เกาหลีรหัส CP949 ได้ถูกต้อง) |
| **ไฟล์บีบอัด**: ZIP, ANSYS Workbench (WBPZ), 7Z, RAR, TAR, TAR.GZ, TAR.BZ2, TAR.XZ, GZ, ISO, CAB, DMG, ALZ, EGG, … | แตกไฟล์, บีบอัดเป็น ZIP, 7Z, TAR, TAR.GZ;
รวมทุกไฟล์เป็นหนึ่ง ZIP |
| **CAD**: DWG, DXF | PDF, SVG, PNG, DWG ↔ DXF |
-| **ฟอนต์**: TTF, OTF, WOFF, WOFF2, EOT | แปลงสลับกันได้ |
+| **ฟอนต์**: TTF, OTF, WOFF, WOFF2, EOT; FON (NE, FNT v2/v3 ขาวดำ และชุดอักขระไบต์เดียวที่รองรับ; อินพุตเท่านั้น) | แปลงระหว่างห้ารูปแบบ ออกหนึ่งไฟล์ต่อขนาด (ทรัพยากรเดียวไม่เติมขนาด) ไม่รองรับ PE, เวกเตอร์ หรือ FNT เดี่ยว |
| **ข้อมูล**: JSON, JSON Lines, YAML, TOML, XML
vCard, iCalendar | แปลงสลับกันได้, CSV และ XLSX (ตาราง);
vCard, iCalendar ↔ CSV |
| **โมเดล 3D**: GLB, glTF, STL, OBJ (+ MTL), 3MF, PLY;
อ่าน FBX | แปลงสลับกันได้, แสดงภาพตัวอย่าง PNG |
| **ข้อมูลเวกเตอร์เชิงพื้นที่**: Shapefile, GeoJSON, KML, KMZ, GPX
CSV พร้อมพิกัด;
อ่าน Garmin FIT | แปลงสลับกันได้ และเป็น CSV / XLSX / JSON
โดยพิกัดโปรเจกชัน (UTM, Web Mercator, Lambert, Albers) จะถูกแปลงเป็น WGS 84 ลองจิจูด/ละติจูด |
diff --git a/README.zh-CN.md b/README.zh-CN.md
index c56159602..895cb1ed1 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -161,7 +161,7 @@ Set-ProcessMitigation -Name filee-cli.exe -Disable UserShadowStack
| **字幕** SRT、ASS/SSA、WebVTT、SAMI(SMI)、LRC、TTML、MicroDVD、SCC | 互相转换,保存为 UTF-8(也能读取 CP949 韩文字幕) |
| **压缩包** ZIP、ANSYS Workbench(WBPZ)、7Z、RAR、TAR、TAR.GZ、TAR.BZ2、TAR.XZ、GZ、ISO、CAB、DMG、ALZ、EGG 等 | 解压、ZIP、7Z、TAR、TAR.GZ,
多个文件 → 一个 ZIP |
| **CAD** DWG、DXF | PDF、SVG、PNG、DWG ↔ DXF |
-| **字体** TTF、OTF、WOFF、WOFF2、EOT | 互相转换 |
+| **字体** TTF、OTF、WOFF、WOFF2、EOT;FON(NE、单色 FNT v2/v3、支持的单字节字符集,仅输入) | 五种格式相互转换,每个尺寸输出一个文件(单资源不加尺寸后缀);不支持 PE、矢量或独立 FNT |
| **数据** JSON、JSON Lines、YAML、TOML、XML
vCard、iCalendar | 互相转换、CSV 和 XLSX(表格数据)、
vCard、iCalendar ↔ CSV |
| **3D 模型** GLB、glTF、STL、OBJ(+ MTL)、3MF、PLY,
FBX 仅读取 | 互相转换、PNG 预览 |
| **地理信息(矢量)** Shapefile、GeoJSON、KML、KMZ、GPX
带坐标的 CSV,
Garmin FIT 仅读取 | 互相转换、CSV / XLSX / JSON,
投影坐标系(UTM、Web 墨卡托、兰伯特、阿尔伯斯)会转换为 WGS 84 经纬度 |
diff --git a/README.zh-TW.md b/README.zh-TW.md
index 5ebe922bf..cf7eda3a9 100644
--- a/README.zh-TW.md
+++ b/README.zh-TW.md
@@ -161,7 +161,7 @@ Set-ProcessMitigation -Name filee-cli.exe -Disable UserShadowStack
| **字幕** SRT、ASS/SSA、WebVTT、SAMI(SMI)、LRC、TTML、MicroDVD、SCC | 互相轉換,儲存為 UTF-8(也能讀取 CP949 韓文字幕) |
| **壓縮檔** ZIP、ANSYS Workbench(WBPZ)、7Z、RAR、TAR、TAR.GZ、TAR.BZ2、TAR.XZ、GZ、ISO、CAB、DMG、ALZ、EGG 等 | 解壓縮、ZIP、7Z、TAR、TAR.GZ,
多個檔案 → 一個 ZIP |
| **CAD** DWG、DXF | PDF、SVG、PNG、DWG ↔ DXF |
-| **字型** TTF、OTF、WOFF、WOFF2、EOT | 互相轉換 |
+| **字型** TTF、OTF、WOFF、WOFF2、EOT;FON(NE、單色 FNT v2/v3、支援的單位元組字元集,僅輸入) | 五種格式相互轉換,每個尺寸輸出一個檔案(單一資源不加尺寸後綴);不支援 PE、向量或獨立 FNT |
| **資料** JSON、JSON Lines、YAML、TOML、XML
vCard、iCalendar | 互相轉換、CSV 和 XLSX(表格資料)、
vCard、iCalendar ↔ CSV |
| **3D 模型** GLB、glTF、STL、OBJ(+ MTL)、3MF、PLY,
FBX 僅讀取 | 互相轉換、PNG 預覽 |
| **地理資訊(向量)** Shapefile、GeoJSON、KML、KMZ、GPX
帶座標的 CSV,
Garmin FIT 僅讀取 | 互相轉換、CSV / XLSX / JSON,
投影座標系統(UTM、Web 麥卡托、蘭伯特、阿爾伯斯)會轉換為 WGS 84 經緯度 |
diff --git a/docs/ENGINES.md b/docs/ENGINES.md
index cd7db0aca..c06f53695 100644
--- a/docs/ENGINES.md
+++ b/docs/ENGINES.md
@@ -15,7 +15,7 @@ Filee chooses engines automatically. Settings → *Engines* shows their status,
| Text & Markdown (Markdig) | built in (library) | **Markdown → HTML, TXT**; **TXT, LOG, PY, PAC, BAT, COMMAND, LUA, CSS, PS1 → UTF-8 TXT**, preserving source text without running scripts (and → PDF / DOCX through the HWPX writer, rhwp or LibreOffice) |
| Spreadsheets (ExcelDataReader for XLS) | built in (library) | **XLSX, XLS, ODS, CSV, TSV → XLSX, ODS, CSV, TSV** (one CSV / TSV per sheet) |
| Office Open XML | built in | **DOCM / DOTX / DOTM ↔ DOCX, XLSM / XLTX ↔ XLSX, PPTM / POTX / POTM / PPSX ↔ PPTX** (macros removed for macro-free types) |
-| Fonts | built in | **TTF, OTF, WOFF, WOFF2, EOT ↔ each other**; CFF (PostScript) outlines become TrueType for TTF and EOT |
+| Fonts | built in | **TTF, OTF, WOFF, WOFF2, EOT ↔ each other**; **FON (NE monochrome FNT v2/v3, supported single-byte charsets) → TTF, OTF, WOFF, WOFF2, EOT only**; CFF (PostScript) outlines become TrueType for TTF and EOT |
| **HWPX writer** | built in | **DOCX (+ DOCM/DOTX/DOTM), XLSX (+ XLSM/XLTX), XLS, ODS, CSV, TSV, PPTX (+ PPTM/POTX/POTM/PPSX), PDF, OFD, HTML, MHTML, EPUB, MOBI/AZW3, FB2, HWPX, TXT, Markdown → HWPX** (and with rhwp → PDF and images); ODT, RTF, reStructuredText, LaTeX → HWPX with Pandoc |
| **DOCX writer** | built in | **HWPX (and HWP through rhwp), PDF, OFD, HTML, MHTML, EPUB, MOBI/AZW3, FB2, Markdown, TXT, XLSX, XLS, ODS, CSV, PPTX (+ variants) → DOCX** |
| PDF text | built in (PdfPig) | **PDF → TXT, Markdown** (reading order, also two columns; Markdown keeps headings, emphasis, links and the pictures; no OCR) |
@@ -461,7 +461,8 @@ built-in XLSX route reads the workbook into Filee's document model.
## Fonts
Fonts are converted in-process (`Filee.Engines/Fonts`); zlib and Brotli come with .NET. The input format is
-recognised by content, so a mislabelled file still converts.
+recognised by content, so a mislabelled file still converts. FON is the exception: it is chosen by its `.fon`
+extension, and the file must then have an NE header with FNT resources.
- **WOFF 1.0**: every table zlib-compressed (stored as-is when that isn't smaller). Lossless both ways.
- **WOFF2**: one Brotli stream at the highest quality with the glyf/loca transform and, where it applies, the hmtx
@@ -481,12 +482,27 @@ recognised by content, so a mislabelled file still converts.
CID fonts) are built; head, hhea, hmtx and, from VORG, vmtx are updated; CFF, VORG and DSIG are dropped; every
other table (cmap, name, OS/2, GSUB, GPOS, kern, ...) is kept. PostScript hints are not converted: the font gets a
gasp table (smoothing at every size) and a prep that switches on dropout control instead.
+- **FON → TTF, OTF, WOFF, WOFF2, EOT** (input only): Filee reads the monochrome FNT v2/v3 bitmap resources of an NE
+ executable and turns each lit pixel into a straight, grid-aligned TrueType outline. The result retains the
+ original pixel shape, per-glyph advance, ascent and baseline; there is no smoothing or curve tracing. A FON with a
+ single font resource produces `Name.ttf` (or the corresponding OTF/WOFF/WOFF2/EOT extension). Several resources
+ produce one file per strike, such as `Name-13px.ttf`, `Name-13px-2.ttf` and `Name-14px.ttf`; the font family names
+ also distinguish the sizes and repeated heights. Existing output files follow the usual Skip, Rename and Overwrite
+ policies independently for each strike. Invalid resources fail the whole input before output files are allocated.
+ Character mapping uses the Windows single-byte code page indicated by the resource charset, not the computer's
+ locale. Undefined bytes (including those that Windows decodes to Private Use code points in the Greek, Hebrew,
+ Baltic and Thai code pages), non-round-tripping bytes and Unicode control characters retain their glyphs and
+ spacing but receive no text mapping; charset hints cannot recover the meaning of custom glyphs. PE resources, vector or
+ externally stored bitmaps, color FNT, standalone `.fnt` files and double-byte or unrecognized charsets are not
+ supported. FON cannot be an output format.
- Not supported: CFF2 (variable) outlines to TTF or EOT (to WOFF, WOFF2 and OTF they convert), writing font
collections, and WOFF/WOFF2 extended metadata (dropped).
`tests/Filee.Engines.Tests/FontTests.cs` checks every conversion with small OFL fonts in
`tests/Filee.Engines.Tests/Fonts` (see `OFL.txt` there): round trips keep every table byte for byte, a WOFF2 made
by Google's encoder decodes to the source font, and converted CFF glyphs are compared with the originals in Skia.
+`tests/Filee.Engines.Tests/FonTests.cs` converts the synthetic `Fon-*.fon` samples that `make-test-fon.py` draws
+from pixel art (see `FON-FIXTURES.md`) and compares every rendered glyph with its source bitmap.
## DOCX writer
diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json
index db4918252..6bf3d1916 100644
--- a/src/Filee.App/Assets/i18n/ar.json
+++ b/src/Filee.App/Assets/i18n/ar.json
@@ -180,7 +180,7 @@
"engine.icns.name": "ICNS (built-in)",
"engine.icns.description": "تحويل أيقونات تطبيقات macOS (ICNS) من الصور وإليها.",
"engine.font.name": "Fonts (built-in)",
- "engine.font.description": "التحويل بين الخطوط: TTF وOTF وWOFF وWOFF2 وEOT.",
+ "engine.font.description": "التحويل بين TTF وOTF وWOFF وWOFF2 وEOT؛ قراءة FON بنمط NE أحادي اللون وبترميز أحادي البايت فقط.",
"engine.cad.name": "CAD (built-in)",
"engine.cad.description": "رسومات AutoCAD: التحويل بين DWG وDXF، وتحويل كليهما إلى PDF وSVG وPNG.",
"engine.model3d.name": "3D models (built-in)",
diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json
index 63d8e6af9..b816284de 100644
--- a/src/Filee.App/Assets/i18n/en.json
+++ b/src/Filee.App/Assets/i18n/en.json
@@ -187,7 +187,7 @@
"engine.icns.name": "ICNS (built-in)",
"engine.icns.description": "macOS app icons (ICNS) from and to images.",
"engine.font.name": "Fonts (built-in)",
- "engine.font.description": "Fonts: TTF, OTF, WOFF, WOFF2 and EOT into each other.",
+ "engine.font.description": "Convert between TTF, OTF, WOFF, WOFF2 and EOT; read-only FON input (NE, monochrome, single-byte).",
"engine.cad.name": "CAD (built-in)",
"engine.cad.description": "AutoCAD drawings: DWG ↔ DXF, and both to PDF, SVG and PNG.",
"engine.model3d.name": "3D models (built-in)",
diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json
index 951937fc3..f6a3f8156 100644
--- a/src/Filee.App/Assets/i18n/es.json
+++ b/src/Filee.App/Assets/i18n/es.json
@@ -180,7 +180,7 @@
"engine.icns.name": "ICNS (built-in)",
"engine.icns.description": "Iconos de apps de macOS (ICNS) desde y hacia imágenes.",
"engine.font.name": "Fonts (built-in)",
- "engine.font.description": "Tipografías: TTF, OTF, WOFF, WOFF2 y EOT entre sí.",
+ "engine.font.description": "Convierte entre TTF, OTF, WOFF, WOFF2 y EOT; FON solo de entrada (NE, monocromo, un byte).",
"engine.cad.name": "CAD (built-in)",
"engine.cad.description": "Planos de AutoCAD: DWG ↔ DXF, y ambos a PDF, SVG y PNG.",
"engine.model3d.name": "3D models (built-in)",
diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json
index d6d5b6a2d..cfa24007a 100644
--- a/src/Filee.App/Assets/i18n/fr.json
+++ b/src/Filee.App/Assets/i18n/fr.json
@@ -187,7 +187,7 @@
"engine.icns.name": "ICNS (built-in)",
"engine.icns.description": "Icônes d'apps macOS (ICNS) depuis et vers des images.",
"engine.font.name": "Fonts (built-in)",
- "engine.font.description": "Polices : TTF, OTF, WOFF, WOFF2 et EOT entre eux.",
+ "engine.font.description": "Convertit entre TTF, OTF, WOFF, WOFF2 et EOT ; FON en entrée uniquement (NE, monochrome, un octet).",
"engine.cad.name": "CAD (built-in)",
"engine.cad.description": "Dessins AutoCAD : DWG ↔ DXF, et les deux en PDF, SVG et PNG.",
"engine.model3d.name": "3D models (built-in)",
diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json
index 85e95ede5..093937a49 100644
--- a/src/Filee.App/Assets/i18n/hi.json
+++ b/src/Filee.App/Assets/i18n/hi.json
@@ -180,7 +180,7 @@
"engine.icns.name": "ICNS (built-in)",
"engine.icns.description": "इमेज से और इमेज में macOS ऐप आइकन (ICNS)।",
"engine.font.name": "Fonts (built-in)",
- "engine.font.description": "फ़ॉन्ट: TTF, OTF, WOFF, WOFF2 और EOT एक-दूसरे में।",
+ "engine.font.description": "TTF, OTF, WOFF, WOFF2 और EOT के बीच बदलें; FON केवल इनपुट (NE, एकरंगी, एक-बाइट)।",
"engine.cad.name": "CAD (built-in)",
"engine.cad.description": "AutoCAD ड्रॉइंग: DWG ↔ DXF, और दोनों से PDF, SVG और PNG।",
"engine.model3d.name": "3D models (built-in)",
diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json
index e521133e3..8da00aee9 100644
--- a/src/Filee.App/Assets/i18n/ja.json
+++ b/src/Filee.App/Assets/i18n/ja.json
@@ -180,7 +180,7 @@
"engine.icns.name": "ICNS (built-in)",
"engine.icns.description": "macOSアプリのアイコン(ICNS)を画像と相互変換します。",
"engine.font.name": "Fonts (built-in)",
- "engine.font.description": "フォント形式(TTF、OTF、WOFF、WOFF2、EOT)を相互変換します。",
+ "engine.font.description": "TTF、OTF、WOFF、WOFF2、EOTを相互変換します。FONは入力専用(NE・単色・1バイト)です。",
"engine.cad.name": "CAD (built-in)",
"engine.cad.description": "AutoCAD図面に対応します。DWGとDXFの相互変換、および両形式からPDF、SVG、PNGへの変換ができます。",
"engine.model3d.name": "3D models (built-in)",
diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json
index 1d765e275..9abda378a 100644
--- a/src/Filee.App/Assets/i18n/ko.json
+++ b/src/Filee.App/Assets/i18n/ko.json
@@ -187,7 +187,7 @@
"engine.icns.name": "ICNS (built-in)",
"engine.icns.description": "macOS 앱 아이콘(ICNS)을 이미지로, 이미지를 ICNS로.",
"engine.font.name": "Fonts (built-in)",
- "engine.font.description": "글꼴: TTF, OTF, WOFF, WOFF2, EOT를 서로 변환해요.",
+ "engine.font.description": "TTF, OTF, WOFF, WOFF2, EOT를 서로 변환해요. FON은 NE·단색·단일 바이트 입력만 읽어요.",
"engine.cad.name": "CAD (built-in)",
"engine.cad.description": "오토캐드 도면: DWG ↔ DXF, 그리고 둘 다 PDF, SVG, PNG로.",
"engine.model3d.name": "3D models (built-in)",
diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json
index 41ddfa884..2b79bedc9 100644
--- a/src/Filee.App/Assets/i18n/ne.json
+++ b/src/Filee.App/Assets/i18n/ne.json
@@ -180,7 +180,7 @@
"engine.icns.name": "ICNS (built-in)",
"engine.icns.description": "छविहरूबाट र छविहरूमा macOS एप आइकन (ICNS)।",
"engine.font.name": "Fonts (built-in)",
- "engine.font.description": "TTF, OTF, WOFF, WOFF2 फन्टहरू एकअर्कामा रूपान्तरण गर्दछ।",
+ "engine.font.description": "TTF, OTF, WOFF, WOFF2 र EOT बीच रूपान्तरण; FON इनपुट मात्र (NE, एकरङ्गी, एक-बाइट)।",
"engine.cad.name": "CAD (built-in)",
"engine.cad.description": "AutoCAD रेखाचित्रहरू: DWG ↔ DXF, र दुवैबाट PDF, SVG र PNG।",
"engine.model3d.name": "3D models (built-in)",
diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json
index 7c12a4d49..9b80e10e6 100644
--- a/src/Filee.App/Assets/i18n/pl.json
+++ b/src/Filee.App/Assets/i18n/pl.json
@@ -187,7 +187,7 @@
"engine.icns.name": "ICNS (built-in)",
"engine.icns.description": "Ikony aplikacji macOS (ICNS) z obrazów i do obrazów.",
"engine.font.name": "Fonts (built-in)",
- "engine.font.description": "Czcionki: TTF, OTF, WOFF, WOFF2 i EOT między sobą.",
+ "engine.font.description": "Konwertuje między TTF, OTF, WOFF, WOFF2 i EOT; FON tylko na wejściu (NE, monochromatyczny, jednobajtowy).",
"engine.cad.name": "CAD (built-in)",
"engine.cad.description": "Rysunki AutoCAD: DWG ↔ DXF, oba także do PDF, SVG i PNG.",
"engine.model3d.name": "3D models (built-in)",
diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json
index 3a77563ae..d5722144b 100644
--- a/src/Filee.App/Assets/i18n/pt-BR.json
+++ b/src/Filee.App/Assets/i18n/pt-BR.json
@@ -187,7 +187,7 @@
"engine.icns.name": "ICNS (mecanismo integrado)",
"engine.icns.description": "Ícones de apps do macOS (ICNS) a partir de imagens e para imagens.",
"engine.font.name": "Fontes (mecanismo integrado)",
- "engine.font.description": "Fontes: TTF, OTF, WOFF, WOFF2 e EOT entre si.",
+ "engine.font.description": "Converte entre TTF, OTF, WOFF, WOFF2 e EOT; FON somente como entrada (NE, monocromático, um byte).",
"engine.cad.name": "CAD (mecanismo integrado)",
"engine.cad.description": "Desenhos do AutoCAD: DWG ↔ DXF, e os dois para PDF, SVG e PNG.",
"engine.model3d.name": "Modelos 3D (mecanismo integrado)",
diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json
index 7dcdecb88..c92436f46 100644
--- a/src/Filee.App/Assets/i18n/pt-PT.json
+++ b/src/Filee.App/Assets/i18n/pt-PT.json
@@ -187,7 +187,7 @@
"engine.icns.name": "ICNS (motor integrado)",
"engine.icns.description": "Ícones de aplicações macOS (ICNS) para imagens e vice-versa.",
"engine.font.name": "Tipos de letra (motor integrado)",
- "engine.font.description": "Converte tipos de letra entre TTF, OTF, WOFF, WOFF2 e EOT.",
+ "engine.font.description": "Converte tipos de letra entre TTF, OTF, WOFF, WOFF2 e EOT; FON apenas como entrada (NE, monocromático, um byte).",
"engine.cad.name": "CAD (motor integrado)",
"engine.cad.description": "Desenhos AutoCAD: DWG ↔ DXF e ambos para PDF, SVG e PNG.",
"engine.model3d.name": "Modelos 3D (motor integrado)",
diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json
index 4fc991f12..8624ad12e 100644
--- a/src/Filee.App/Assets/i18n/ru.json
+++ b/src/Filee.App/Assets/i18n/ru.json
@@ -187,7 +187,7 @@
"engine.icns.name": "ICNS (built-in)",
"engine.icns.description": "Значки приложений macOS (ICNS) из картинок и в картинки.",
"engine.font.name": "Fonts (built-in)",
- "engine.font.description": "Шрифты: TTF, OTF, WOFF, WOFF2 и EOT друг в друга.",
+ "engine.font.description": "Преобразует TTF, OTF, WOFF, WOFF2 и EOT друг в друга; FON только на вход (NE, монохромный, однобайтовый).",
"engine.cad.name": "CAD (built-in)",
"engine.cad.description": "Чертежи AutoCAD: DWG ↔ DXF, и оба — в PDF, SVG и PNG.",
"engine.model3d.name": "3D models (built-in)",
diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json
index 50c594f12..ceeee4e34 100644
--- a/src/Filee.App/Assets/i18n/th.json
+++ b/src/Filee.App/Assets/i18n/th.json
@@ -180,7 +180,7 @@
"engine.icns.name": "ICNS (built-in)",
"engine.icns.description": "ไอคอนแอป macOS (ICNS) จากและเป็นรูปภาพ",
"engine.font.name": "Fonts (built-in)",
- "engine.font.description": "ฟอนต์: TTF, OTF, WOFF, WOFF2 และ EOT สลับกันได้",
+ "engine.font.description": "แปลง TTF, OTF, WOFF, WOFF2 ไปมารวมถึง EOT; FON ใช้เป็นอินพุตเท่านั้น (NE, ขาวดำ, ไบต์เดียว)",
"engine.cad.name": "CAD (built-in)",
"engine.cad.description": "แบบแปลน AutoCAD: DWG ↔ DXF และแปลงทั้งสองเป็น PDF, SVG และ PNG",
"engine.model3d.name": "3D models (built-in)",
diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json
index a805dcdf4..ead356c04 100644
--- a/src/Filee.App/Assets/i18n/tr.json
+++ b/src/Filee.App/Assets/i18n/tr.json
@@ -180,7 +180,7 @@
"engine.icns.name": "ICNS (built-in)",
"engine.icns.description": "macOS uygulama simgeleri (ICNS) ile görseller arasında dönüştürme.",
"engine.font.name": "Fonts (built-in)",
- "engine.font.description": "Yazı tipleri: TTF, OTF, WOFF, WOFF2 ve EOT arasında dönüştürme.",
+ "engine.font.description": "TTF, OTF, WOFF, WOFF2 ve EOT arasında dönüştürür; FON yalnızca girdi (NE, tek renk, tek bayt).",
"engine.cad.name": "CAD (built-in)",
"engine.cad.description": "AutoCAD çizimleri: DWG ↔ DXF ve her ikisinden PDF, SVG ve PNG.",
"engine.model3d.name": "3D models (built-in)",
diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json
index 87bbadd05..77048b17c 100644
--- a/src/Filee.App/Assets/i18n/zh-CN.json
+++ b/src/Filee.App/Assets/i18n/zh-CN.json
@@ -187,7 +187,7 @@
"engine.icns.name": "ICNS (built-in)",
"engine.icns.description": "macOS 应用图标(ICNS)与图片互转。",
"engine.font.name": "Fonts (built-in)",
- "engine.font.description": "字体:TTF、OTF、WOFF、WOFF2 和 EOT 互相转换。",
+ "engine.font.description": "TTF、OTF、WOFF、WOFF2 和 EOT 相互转换;FON 仅作输入(NE、单色、单字节)。",
"engine.cad.name": "CAD (built-in)",
"engine.cad.description": "AutoCAD 图纸:DWG ↔ DXF,并可转为 PDF、SVG 和 PNG。",
"engine.model3d.name": "3D models (built-in)",
diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json
index 4d6b64754..2776ef228 100644
--- a/src/Filee.App/Assets/i18n/zh-TW.json
+++ b/src/Filee.App/Assets/i18n/zh-TW.json
@@ -187,7 +187,7 @@
"engine.icns.name": "ICNS (built-in)",
"engine.icns.description": "macOS 應用程式圖示(ICNS)與圖片互轉。",
"engine.font.name": "Fonts (built-in)",
- "engine.font.description": "字型:TTF、OTF、WOFF、WOFF2 和 EOT 互相轉換。",
+ "engine.font.description": "TTF、OTF、WOFF、WOFF2 和 EOT 相互轉換;FON 僅作輸入(NE、單色、單位元組)。",
"engine.cad.name": "CAD (built-in)",
"engine.cad.description": "AutoCAD 圖紙:DWG ↔ DXF,並可轉為 PDF、SVG 和 PNG。",
"engine.model3d.name": "3D models (built-in)",
diff --git a/src/Filee.Core/Formats/FormatRegistry.cs b/src/Filee.Core/Formats/FormatRegistry.cs
index c036e1727..02f7d689d 100644
--- a/src/Filee.Core/Formats/FormatRegistry.cs
+++ b/src/Filee.Core/Formats/FormatRegistry.cs
@@ -304,6 +304,7 @@ public static class FormatRegistry
new("woff", "WOFF", FormatCategory.Font, ["woff"]),
new("woff2", "WOFF2", FormatCategory.Font, ["woff2"]),
new("eot", "EOT", FormatCategory.Font, ["eot"]),
+ new("fon", "FON", FormatCategory.Font, ["fon"], Writable: false),
// Subtitles (text)
new("srt", "SRT", FormatCategory.Subtitle, ["srt"]),
diff --git a/src/Filee.Engines/Fonts/FonOutlineBuilder.cs b/src/Filee.Engines/Fonts/FonOutlineBuilder.cs
new file mode 100644
index 000000000..de9a6b7ca
--- /dev/null
+++ b/src/Filee.Engines/Fonts/FonOutlineBuilder.cs
@@ -0,0 +1,395 @@
+using System.Buffers.Binary;
+using System.Globalization;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace Filee.Engines.Fonts;
+
+/// Turns a validated monochrome FNT strike into an unhinted TrueType outline font.
+internal static class FonOutlineBuilder
+{
+ private readonly record struct Corner(int X, int Y);
+ private readonly record struct Edge(Corner From, Corner To);
+
+ public static SfntFont Build(FonStrike strike, int resourceCount = 1, int sameHeightOrdinal = 1)
+ {
+ if (resourceCount < 1 || sameHeightOrdinal < 1 || strike.Glyphs.Count is < 1 or > 65534)
+ throw new InvalidDataException("FON resource or glyph count exceeds TrueType limits.");
+ var height = strike.PixelHeight;
+ var scale = height <= 1024 ? 1024 / height : 1;
+ var upem = checked(height * scale);
+ if (upem is < 16 or > 16384)
+ throw new InvalidDataException("FON pixel height exceeds TrueType units-per-em limits.");
+ var ascent = Signed(strike.Ascent * scale);
+ var descent = Signed(-(height - strike.Ascent) * scale);
+ var lineGap = Signed(strike.ExternalLeading * scale);
+
+ var glyphs = new List { null };
+ var widths = new List { 0 };
+ var cmap = new SortedDictionary();
+ var encoding = FonReader.CharSetEncoding(strike.CharSet);
+ foreach (var source in strike.Glyphs)
+ {
+ var advance = checked(source.Width * scale);
+ if (advance > ushort.MaxValue)
+ throw new InvalidDataException("FON glyph advance exceeds TrueType limits.");
+ glyphs.Add(Outline(source, strike.Ascent, scale));
+ widths.Add(advance);
+ try
+ {
+ var text = encoding.GetString([source.ByteValue]);
+ if (text.EnumerateRunes().Count() != 1)
+ continue;
+ var scalar = Rune.GetRuneAt(text, 0);
+ // Windows best-fit tables decode undefined bytes of CP1253/1255/1257/874 to Private Use code points.
+ if (scalar.Value > ushort.MaxValue ||
+ Rune.GetUnicodeCategory(scalar) is UnicodeCategory.Control or UnicodeCategory.PrivateUse ||
+ !encoding.GetBytes(text).AsSpan().SequenceEqual([source.ByteValue]))
+ continue;
+ cmap.TryAdd(scalar.Value, glyphs.Count - 1);
+ }
+ catch (DecoderFallbackException) { }
+ catch (EncoderFallbackException) { }
+ }
+ glyphs[0] = glyphs[strike.DefaultChar + 1];
+ widths[0] = widths[strike.DefaultChar + 1];
+ var (glyf, loca, longLoca) = GlyfTable.Build(
+ [.. glyphs.Select(g => g?.Serialize() ?? [])], longFormat: false, alignment: 2);
+ var leftBearings = glyphs.Select(g => g?.XMin ?? (short)0).ToArray();
+ var covered = glyphs.Where(g => g is not null).Cast().ToArray();
+ var xMin = covered.Length == 0 ? 0 : covered.Min(g => g.XMin);
+ var xMax = covered.Length == 0 ? 0 : covered.Max(g => g.XMax);
+ var yMin = covered.Length == 0 ? 0 : covered.Min(g => g.YMin);
+ var yMax = covered.Length == 0 ? 0 : covered.Max(g => g.YMax);
+ var minRight = covered.Length == 0 ? 0 : glyphs.Select((g, i) => (g, i))
+ .Where(p => p.g is not null).Min(p => widths[p.i] - p.g!.XMax);
+ var maxPoints = covered.Length == 0 ? 0 : covered.Max(g => g.Points.Length);
+ var maxContours = covered.Length == 0 ? 0 : covered.Max(g => g.NumberOfContours);
+
+ var weight = strike.Weight;
+ var bold = weight >= 700;
+ var baseName = resourceCount == 1 ? strike.Face :
+ $"{strike.Face} {height}px{(sameHeightOrdinal > 1 ? $"-{sameHeightOrdinal}" : "")}";
+ var family = weight is 400 or 700 ? baseName : $"{baseName} Weight{weight}";
+ var style = (bold, strike.Italic) switch
+ {
+ (true, true) => "Bold Italic",
+ (true, false) => "Bold",
+ (false, true) => "Italic",
+ _ => "Regular"
+ };
+ var full = $"{family} {style}";
+ var psName = PostScriptName(full);
+ var font = new SfntFont(SfntFont.TrueTypeFlavor);
+ font.Tables["glyf"] = glyf;
+ font.Tables["loca"] = loca;
+ font.Tables["hmtx"] = Metrics(widths, leftBearings);
+ font.Tables["head"] = Head(upem, xMin, yMin, xMax, yMax, longLoca,
+ (bold ? 1 : 0) | (strike.Italic ? 2 : 0));
+ font.Tables["hhea"] = Hhea(ascent, descent, lineGap, widths.Max(),
+ covered.Length == 0 ? 0 : covered.Min(g => g.XMin), minRight,
+ covered.Length == 0 ? 0 : covered.Max(g => g.XMax), glyphs.Count);
+ font.Tables["maxp"] = Maxp(glyphs.Count, maxPoints, maxContours);
+ font.Tables["OS/2"] = Os2(strike, widths, cmap, encoding.CodePage, weight, bold,
+ ascent, descent, lineGap, yMin, yMax);
+ font.Tables["name"] = Name(family, style, full, psName);
+ font.Tables["cmap"] = Cmap(cmap);
+ var post = new byte[32];
+ Put32(post, 0, 0x00030000);
+ Put32(post, 12, strike.Glyphs.Select(g => g.Width).Distinct().Count() == 1 ? 1u : 0u);
+ font.Tables["post"] = post;
+ return font;
+ }
+
+ private static TrueTypeGlyph? Outline(FonGlyph source, int ascent, int scale)
+ {
+ var height = source.Pixels.GetLength(0);
+ if (source.Pixels.GetLength(1) != source.Width)
+ throw new InvalidDataException("FON glyph pixels and width disagree.");
+ // Extend identical horizontal runs into maximal rectangles, then cancel each shared unit edge.
+ var active = new Dictionary<(int, int), (int Start, int End)>();
+ var rectangles = new List<(int X0, int X1, int Y0, int Y1)>();
+ for (var y = 0; y <= height; y++)
+ {
+ var runs = new HashSet<(int, int)>();
+ if (y < height)
+ for (var x = 0; x < source.Width;)
+ {
+ if (!source.Pixels[y, x]) { x++; continue; }
+ var start = x;
+ while (x < source.Width && source.Pixels[y, x]) x++;
+ runs.Add((start, x));
+ }
+ foreach (var (run, interval) in active.ToArray())
+ if (!runs.Contains(run))
+ {
+ rectangles.Add((run.Item1, run.Item2, interval.Start, y));
+ active.Remove(run);
+ }
+ foreach (var run in runs)
+ if (!active.ContainsKey(run))
+ active[run] = (y, y);
+ }
+ if (rectangles.Count == 0)
+ return null;
+ var edges = new HashSet();
+ void Add(Corner from, Corner to)
+ {
+ if (!edges.Remove(new Edge(to, from)))
+ edges.Add(new Edge(from, to));
+ }
+ foreach (var (x0, x1, y0, y1) in rectangles)
+ {
+ for (var x = x0; x < x1; x++)
+ {
+ Add(new(x, y0), new(x + 1, y0));
+ Add(new(x + 1, y1), new(x, y1));
+ }
+ for (var y = y0; y < y1; y++)
+ {
+ Add(new(x1, y), new(x1, y + 1));
+ Add(new(x0, y + 1), new(x0, y));
+ }
+ }
+ var outgoing = edges.GroupBy(e => e.From).ToDictionary(g => g.Key, g => g.ToList());
+ var points = new List();
+ var ends = new List();
+ while (edges.Count != 0)
+ {
+ var start = edges.First();
+ var edge = start;
+ var loop = new List();
+ do
+ {
+ if (!edges.Remove(edge))
+ throw new InvalidDataException("FON outline has an open boundary.");
+ loop.Add(edge.From);
+ if (edge.To == start.From)
+ break;
+ if (!outgoing.TryGetValue(edge.To, out var choices))
+ throw new InvalidDataException("FON outline has an open boundary.");
+ var dx = edge.To.X - edge.From.X;
+ var dy = edge.To.Y - edge.From.Y;
+ edge = choices.Where(edges.Contains).OrderByDescending(e =>
+ dx * (e.To.Y - e.From.Y) - dy * (e.To.X - e.From.X)).FirstOrDefault();
+ if (edge == default)
+ throw new InvalidDataException("FON outline has an open boundary.");
+ } while (true);
+ for (var i = 0; i < loop.Count; i++)
+ {
+ var prev = loop[(i + loop.Count - 1) % loop.Count];
+ var curr = loop[i];
+ var next = loop[(i + 1) % loop.Count];
+ if ((curr.X - prev.X) * (next.Y - curr.Y) ==
+ (curr.Y - prev.Y) * (next.X - curr.X))
+ continue;
+ var x = checked(curr.X * scale);
+ var y = checked((ascent - curr.Y) * scale);
+ points.Add(new GlyphPoint(Signed(x), Signed(y), true));
+ }
+ if (points.Count > ushort.MaxValue || ends.Count == short.MaxValue)
+ throw new InvalidDataException("FON outline exceeds TrueType point or contour limits.");
+ ends.Add((ushort)(points.Count - 1));
+ }
+ var result = new TrueTypeGlyph
+ {
+ NumberOfContours = (short)ends.Count,
+ Points = [.. points],
+ EndPoints = [.. ends]
+ };
+ (result.XMin, result.YMin, result.XMax, result.YMax) = result.ComputeBounds();
+ return result;
+ }
+
+ private static byte[] Head(int upem, int xMin, int yMin, int xMax, int yMax, bool longLoca, int style)
+ {
+ var b = new byte[54];
+ Put32(b, 0, 0x00010000); Put32(b, 4, 0x00010000);
+ Put32(b, 12, 0x5F0F3CF5); Put16(b, 16, 3); Put16(b, 18, upem);
+ Put16(b, 36, xMin); Put16(b, 38, yMin); Put16(b, 40, xMax); Put16(b, 42, yMax);
+ Put16(b, 44, style); Put16(b, 46, 8); Put16(b, 48, 2); Put16(b, 50, longLoca ? 1 : 0);
+ return b;
+ }
+
+ private static byte[] Hhea(int asc, int desc, int gap, int maxAdvance,
+ int minLsb, int minRsb, int extent, int count)
+ {
+ var b = new byte[36];
+ Put32(b, 0, 0x00010000);
+ Put16(b, 4, asc); Put16(b, 6, desc); Put16(b, 8, gap);
+ Put16(b, 10, maxAdvance); Put16(b, 12, Signed(minLsb));
+ Put16(b, 14, Signed(minRsb)); Put16(b, 16, Signed(extent));
+ Put16(b, 18, 1); Put16(b, 34, count);
+ return b;
+ }
+
+ private static byte[] Maxp(int count, int points, int contours)
+ {
+ var b = new byte[32];
+ Put32(b, 0, 0x00010000);
+ Put16(b, 4, count); Put16(b, 6, points); Put16(b, 8, contours);
+ Put16(b, 14, 1); // maxZones: no twilight zone
+ return b;
+ }
+
+ private static byte[] Metrics(List widths, short[] bearings)
+ {
+ var b = new byte[widths.Count * 4];
+ for (var i = 0; i < widths.Count; i++)
+ {
+ Put16(b, i * 4, widths[i]);
+ Put16(b, i * 4 + 2, bearings[i]);
+ }
+ return b;
+ }
+
+ private static byte[] Os2(FonStrike strike, List widths, SortedDictionary cmap,
+ int cp, int weight, bool bold, int asc, int desc, int gap, int yMin, int yMax)
+ {
+ var b = new byte[86];
+ Put16(b, 0, 1);
+ Put16(b, 2, Signed((int)Math.Round(widths.Skip(1).Average())));
+ Put16(b, 4, weight); Put16(b, 6, 5);
+ // The PANOSE field is left zero (no invented classification).
+ foreach (var scalar in cmap.Keys)
+ {
+ var bit = scalar switch
+ {
+ <= 0x007F => 0,
+ <= 0x00FF => 1,
+ <= 0x017F => 2,
+ <= 0x024F => 3,
+ <= 0x02AF => 4,
+ <= 0x02FF => 5,
+ >= 0x0300 and <= 0x036F => 6,
+ >= 0x0370 and <= 0x03FF => 7,
+ >= 0x0400 and <= 0x052F => 9,
+ >= 0x0590 and <= 0x05FF => 11,
+ >= 0x0600 and <= 0x06FF => 13,
+ >= 0x0E00 and <= 0x0E7F => 24,
+ >= 0x1E00 and <= 0x1EFF => 29,
+ >= 0x2000 and <= 0x206F => 31,
+ >= 0x20A0 and <= 0x20CF => 33,
+ >= 0x2100 and <= 0x214F => 35,
+ _ => -1
+ };
+ if (bit >= 0)
+ Put32(b, 42 + bit / 32 * 4, Get32(b, 42 + bit / 32 * 4) | (1u << (bit % 32)));
+ }
+ b[58] = (byte)'F'; b[59] = (byte)'I'; b[60] = (byte)'L'; b[61] = (byte)'E';
+ Put16(b, 62, (strike.Italic ? 1 : 0) | (bold ? 0x20 : 0) |
+ (!strike.Italic && !bold ? 0x40 : 0));
+ Put16(b, 64, cmap.Count == 0 ? 0 : cmap.Keys.First());
+ Put16(b, 66, cmap.Count == 0 ? 0 : cmap.Keys.Last());
+ Put16(b, 68, asc); Put16(b, 70, desc); Put16(b, 72, gap);
+ Put16(b, 74, Math.Max(0, Math.Max(asc, yMax)));
+ Put16(b, 76, Math.Max(0, -Math.Min(desc, yMin)));
+ var cpBit = cp == 874 ? 16 : cp switch
+ {
+ 1252 => 0,
+ 1250 => 1,
+ 1251 => 2,
+ 1253 => 3,
+ 1254 => 4,
+ 1255 => 5,
+ 1256 => 6,
+ 1257 => 7,
+ 1258 => 8,
+ _ => throw new InvalidDataException("FON code page is invalid.")
+ };
+ Put32(b, 78, 1u << cpBit);
+ return b;
+ }
+
+ private static byte[] Name(string family, string style, string full, string psName)
+ {
+ var names = new SortedDictionary
+ {
+ [1] = family,
+ [2] = style,
+ [3] = psName,
+ [4] = full,
+ [5] = "Version 1.0",
+ [6] = psName
+ };
+ var records = (from platform in new[] { 0, 3 }
+ from pair in names
+ select (Platform: platform, Encoding: platform == 0 ? 3 : 1,
+ Language: platform == 0 ? 0 : 0x409, Id: pair.Key,
+ Bytes: Encoding.BigEndianUnicode.GetBytes(pair.Value))).ToArray();
+ var w = new BigEndianWriter();
+ w.U16(0); w.U16(records.Length); w.U16(6 + records.Length * 12);
+ var offset = 0;
+ foreach (var record in records)
+ {
+ if (record.Bytes.Length > ushort.MaxValue || offset > ushort.MaxValue - record.Bytes.Length)
+ throw new InvalidDataException("FON name strings exceed TrueType limits.");
+ w.U16(record.Platform); w.U16(record.Encoding); w.U16(record.Language);
+ w.U16(record.Id); w.U16(record.Bytes.Length); w.U16(offset);
+ offset += record.Bytes.Length;
+ }
+ foreach (var record in records) w.Bytes(record.Bytes);
+ return w.ToArray();
+ }
+
+ private static string PostScriptName(string full)
+ {
+ var bytes = Encoding.UTF8.GetBytes(full);
+ var tokens = bytes.Select(b =>
+ b is >= (byte)'A' and <= (byte)'Z' or >= (byte)'a' and <= (byte)'z' or
+ >= (byte)'0' and <= (byte)'9' or (byte)'-'
+ ? ((char)b).ToString() : $"_{b:X2}").ToArray();
+ var name = string.Concat(tokens);
+ if (name.Length <= 63) return name;
+ var prefix = new StringBuilder();
+ foreach (var token in tokens)
+ {
+ if (prefix.Length + token.Length > 50) break;
+ prefix.Append(token);
+ }
+ return $"{prefix}-{Convert.ToHexString(SHA256.HashData(bytes).AsSpan(0, 6))}";
+ }
+
+ private static byte[] Cmap(SortedDictionary map)
+ {
+ var count = map.Count + 1;
+ var length = 16 + 8 * count;
+ var sub = new byte[length];
+ Put16(sub, 0, 4); Put16(sub, 2, length);
+ Put16(sub, 6, count * 2);
+ var pow = 1;
+ var selector = 0;
+ while (pow * 2 <= count) { pow *= 2; selector++; }
+ Put16(sub, 8, pow * 2); Put16(sub, 10, selector); Put16(sub, 12, count * 2 - pow * 2);
+ var i = 0;
+ foreach (var (scalar, gid) in map)
+ {
+ Put16(sub, 14 + i * 2, scalar);
+ Put16(sub, 16 + count * 2 + i * 2, scalar);
+ Put16(sub, 16 + count * 4 + i * 2, (gid - scalar) & 0xffff);
+ i++;
+ }
+ Put16(sub, 14 + i * 2, 0xffff);
+ Put16(sub, 16 + count * 2 + i * 2, 0xffff);
+ Put16(sub, 16 + count * 4 + i * 2, 1);
+ var w = new BigEndianWriter();
+ w.U16(0); w.U16(2);
+ w.U16(0); w.U16(3); w.U32(20);
+ w.U16(3); w.U16(1); w.U32(20);
+ w.Bytes(sub);
+ return w.ToArray();
+ }
+
+ private static short Signed(int value) =>
+ value is >= short.MinValue and <= short.MaxValue
+ ? (short)value : throw new InvalidDataException("FON outline or metrics exceed signed TrueType limits.");
+
+ private static void Put16(byte[] b, int offset, int value) =>
+ BinaryPrimitives.WriteUInt16BigEndian(b.AsSpan(offset), checked((ushort)(value & 0xffff)));
+
+ private static void Put32(byte[] b, int offset, uint value) =>
+ BinaryPrimitives.WriteUInt32BigEndian(b.AsSpan(offset), value);
+
+ private static uint Get32(byte[] b, int offset) =>
+ BinaryPrimitives.ReadUInt32BigEndian(b.AsSpan(offset));
+}
diff --git a/src/Filee.Engines/Fonts/FonReader.cs b/src/Filee.Engines/Fonts/FonReader.cs
new file mode 100644
index 000000000..df651320a
--- /dev/null
+++ b/src/Filee.Engines/Fonts/FonReader.cs
@@ -0,0 +1,193 @@
+using System.Buffers.Binary;
+using System.Text;
+
+namespace Filee.Engines.Fonts;
+
+/// A validated raster glyph; pixels are indexed by row, then column.
+internal sealed record FonGlyph(byte ByteValue, int Width, bool[,] Pixels);
+
+/// One RT_FONT resource, independent of its NE container.
+internal sealed record FonStrike(
+ int Version, int PixelWidth, int PixelHeight, int Ascent, int ExternalLeading,
+ bool Italic, int Weight, byte CharSet, string Face, int DefaultChar, int BreakChar,
+ IReadOnlyList Glyphs);
+
+/// Reads the monochrome FNT v2/v3 resources of a 16-bit NE font library.
+internal static class FonReader
+{
+ public static IReadOnlyList Read(byte[] data)
+ {
+ if (data.Length < 0x40 || data[0] != 'M' || data[1] != 'Z')
+ throw new InvalidDataException("FON requires an MZ executable header.");
+
+ var ne = (long)BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(0x3c));
+ if (!Fits(data.Length, ne, 0x28))
+ throw new InvalidDataException("FON has a truncated NE header.");
+ if (data[ne] == 'P' && data[ne + 1] == 'E')
+ throw new NotSupportedException("PE FON resources are not supported; an NE font is required.");
+ if (data[ne] != 'N' || data[ne + 1] != 'E')
+ throw new InvalidDataException("FON has no NE executable header.");
+
+ var table = ne + U16(data, ne + 0x24);
+ var end = ne + U16(data, ne + 0x26);
+ if (end <= table || !Fits(data.Length, table, 2) || end > data.Length)
+ throw new InvalidDataException("FON has an invalid NE resource table.");
+ var shift = U16(data, table);
+ if (shift > 15)
+ throw new InvalidDataException("FON has an invalid NE resource alignment shift.");
+
+ var strikes = new List();
+ var ranges = new List<(long Start, long End)>();
+ var cursor = table + 2;
+ var terminated = false;
+ while (Fits(end, cursor, 2))
+ {
+ var type = U16(data, cursor);
+ if (type == 0)
+ {
+ terminated = true;
+ break;
+ }
+ if (!Fits(end, cursor, 8))
+ throw new InvalidDataException("FON has a truncated NE resource type.");
+ var count = U16(data, cursor + 2);
+ cursor += 8;
+ if (!Fits(end, cursor, (long)count * 12))
+ throw new InvalidDataException("FON has a truncated NE resource name table.");
+ if (type == 0x8008)
+ {
+ for (var i = 0; i < count; i++)
+ {
+ var entry = cursor + (long)i * 12;
+ var start = (long)U16(data, entry) << shift;
+ var length = (long)U16(data, entry + 2) << shift;
+ if (length == 0 || start < end || !Fits(data.Length, start, length) ||
+ ranges.Any(r => start < r.End && r.Start < start + length))
+ throw new InvalidDataException("FON has an overlapping or out-of-bounds RT_FONT resource.");
+ ranges.Add((start, start + length));
+ strikes.Add(ReadFnt(data.AsSpan((int)start, (int)length)));
+ }
+ }
+ cursor += (long)count * 12;
+ }
+ if (!terminated)
+ throw new InvalidDataException("FON has an unterminated NE resource table.");
+ if (strikes.Count == 0)
+ throw new InvalidDataException("FON contains no RT_FONT resources.");
+ return strikes;
+ }
+
+ private static FonStrike ReadFnt(ReadOnlySpan resource)
+ {
+ if (resource.Length < 6)
+ throw new InvalidDataException("RT_FONT has a truncated FNT header.");
+ var version = U16(resource, 0);
+ if (version is not (0x0200 or 0x0300))
+ throw new NotSupportedException($"FNT version 0x{version:X4} is not supported (only v2/v3).");
+ var header = version == 0x0200 ? 0x76 : 0x94;
+ var size = (long)U32(resource, 2);
+ if (size < header || size > resource.Length)
+ throw new InvalidDataException("RT_FONT has an invalid FNT dfSize or truncated header.");
+ var fnt = resource[..(int)size];
+ var type = U16(fnt, 0x42);
+ if ((type & 1) != 0)
+ throw new NotSupportedException("Vector FNT fonts are not supported.");
+ if ((type & 4) != 0)
+ throw new NotSupportedException("FNT bitmap pixels stored outside the file are not supported.");
+ if (version == 0x0300)
+ {
+ var flags = U32(fnt, 0x76);
+ if (flags is not (1 or 2))
+ throw new NotSupportedException($"FNT v3 dfFlags 0x{flags:X8} uses an unsupported glyph table or color format.");
+ }
+
+ var height = U16(fnt, 0x58);
+ var ascent = U16(fnt, 0x4a);
+ var first = fnt[0x5f];
+ var last = fnt[0x60];
+ var count = last - first + 1;
+ if (height == 0 || ascent > height || count <= 0)
+ throw new InvalidDataException("FNT has an invalid height, ascent or character range.");
+ var defaultChar = fnt[0x61];
+ var breakChar = fnt[0x62];
+ if (defaultChar >= count || breakChar >= count)
+ throw new InvalidDataException("FNT default/break characters must be relative indices within the glyph range.");
+ var weight = U16(fnt, 0x53);
+ if (weight > 1000)
+ throw new InvalidDataException("FNT weight is outside the supported 0..1000 range.");
+ var charset = fnt[0x55];
+ var encoding = CharSetEncoding(charset);
+ var faceOffset = (long)U32(fnt, 0x69);
+ if (!Fits(size, faceOffset, 1))
+ throw new InvalidDataException("FNT face offset is outside the resource.");
+ var faceEnd = fnt[(int)faceOffset..].IndexOf((byte)0);
+ if (faceEnd < 0)
+ throw new InvalidDataException("FNT face name is not NUL terminated.");
+ string face;
+ try
+ {
+ face = encoding.GetString(fnt.Slice((int)faceOffset, faceEnd)).Trim();
+ }
+ catch (DecoderFallbackException ex)
+ {
+ throw new InvalidDataException("FNT face name cannot be decoded with its character set.", ex);
+ }
+ if (face.Length == 0)
+ throw new InvalidDataException("FNT face name is empty.");
+
+ var entrySize = version == 0x0200 ? 4 : 6;
+ var tableEnd = header + (long)(count + 1) * entrySize;
+ if (tableEnd > size)
+ throw new InvalidDataException("FNT character table or sentinel is truncated.");
+ var bitsOffset = (long)U32(fnt, 0x71);
+ if (bitsOffset < tableEnd || bitsOffset > size)
+ throw new InvalidDataException("FNT bitmap region offset is outside the resource.");
+ var glyphs = new List(count);
+ for (var i = 0; i < count; i++)
+ {
+ var entry = header + i * entrySize;
+ var width = U16(fnt, entry);
+ var offset = version == 0x0200 ? U16(fnt, entry + 2) : U32(fnt, entry + 2);
+ var bytes = ((long)width + 7) / 8 * height;
+ if (offset < bitsOffset || !Fits(size, offset, bytes))
+ throw new InvalidDataException($"FNT bitmap for character {first + i} is outside the resource.");
+ var pixels = new bool[height, width];
+ for (var y = 0; y < height; y++)
+ for (var x = 0; x < width; x++)
+ pixels[y, x] = (fnt[(int)(offset + (long)(x / 8) * height + y)] & (0x80 >> (x % 8))) != 0;
+ glyphs.Add(new FonGlyph((byte)(first + i), width, pixels));
+ }
+ return new FonStrike(version, U16(fnt, 0x56), height, ascent, U16(fnt, 0x4e),
+ fnt[0x50] != 0, weight == 0 ? 400 : weight, charset, face, defaultChar, breakChar, glyphs);
+ }
+
+ /// The strict Windows code page encoding of a supported single-byte FNT charset.
+ internal static Encoding CharSetEncoding(byte charset)
+ {
+ var codePage = charset switch
+ {
+ 0 => 1252,
+ 238 => 1250,
+ 204 => 1251,
+ 161 => 1253,
+ 162 => 1254,
+ 177 => 1255,
+ 178 => 1256,
+ 186 => 1257,
+ 163 => 1258,
+ 222 => 874,
+ _ => throw new NotSupportedException($"FNT character set {charset} is not a supported single-byte Windows charset.")
+ };
+ Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
+ return Encoding.GetEncoding(codePage, EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback);
+ }
+
+ private static bool Fits(long limit, long start, long length) =>
+ start >= 0 && length >= 0 && start <= limit && length <= limit - start;
+
+ private static ushort U16(ReadOnlySpan data, long offset) =>
+ BinaryPrimitives.ReadUInt16LittleEndian(data.Slice((int)offset, 2));
+
+ private static uint U32(ReadOnlySpan data, long offset) =>
+ BinaryPrimitives.ReadUInt32LittleEndian(data.Slice((int)offset, 4));
+}
diff --git a/src/Filee.Engines/Fonts/FontConverter.cs b/src/Filee.Engines/Fonts/FontConverter.cs
index b41a92064..529078327 100644
--- a/src/Filee.Engines/Fonts/FontConverter.cs
+++ b/src/Filee.Engines/Fonts/FontConverter.cs
@@ -13,15 +13,22 @@ public sealed class FontConverter : IConverter
public string DisplayName => "Fonts (built-in)";
public int MaxParallelism => 0;
- /// Every format to every other one, directly.
+ /// Every format to every other one, directly, plus read-only Windows bitmap FON to each of them.
public IReadOnlyList Edges { get; } =
- [.. from source in FontFile.Formats from target in FontFile.Formats where source != target select new ConversionEdge(source, target)];
+ [
+ .. from source in FontFile.Formats from target in FontFile.Formats where source != target select new ConversionEdge(source, target),
+ .. FontFile.Formats.Select(target => new ConversionEdge("fon", target)),
+ ];
- public EngineStatus GetStatus() => EngineStatus.Available("TTF, OTF, WOFF, WOFF2, EOT", EngineVersions.BuiltIn);
+ public EngineStatus GetStatus() => EngineStatus.Available(
+ "TTF, OTF, WOFF, WOFF2, EOT; FON input only (NE, monochrome FNT v2/v3, supported single-byte charsets; no PE or vector fonts)",
+ EngineVersions.BuiltIn);
public Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) =>
Task.Run>(() =>
{
+ if (step.From == "fon")
+ return ConvertFon(step, progress, cancellationToken);
var font = FontFile.Load(File.ReadAllBytes(step.InputPath), cancellationToken);
progress?.Report(0.2);
var outlines = progress is null ? null : new SubProgress(progress, 0.2, 0.6);
@@ -36,6 +43,45 @@ public Task> ConvertAsync(ConversionStep step, IProgress
+ /// One output per RT_FONT resource. Every resource is parsed and every output is built in memory before the first
+ /// path is allocated, so a failure or cancellation before writing leaves no new file and no existing file changed.
+ ///
+ private static IReadOnlyList ConvertFon(ConversionStep step, IProgress? progress, CancellationToken cancellationToken)
+ {
+ var strikes = FonReader.Read(File.ReadAllBytes(step.InputPath));
+ cancellationToken.ThrowIfCancellationRequested();
+ progress?.Report(0.1);
+
+ // Same-height ordinals and the resource count are fixed now, so skipped outputs never rename the others.
+ var ordinals = new int[strikes.Count];
+ for (var i = 0; i < strikes.Count; i++)
+ ordinals[i] = strikes.Take(i + 1).Count(s => s.PixelHeight == strikes[i].PixelHeight);
+
+ var results = new byte[strikes.Count][];
+ for (var i = 0; i < strikes.Count; i++)
+ {
+ var font = FonOutlineBuilder.Build(strikes[i], strikes.Count, ordinals[i]);
+ results[i] = FontFile.Save(font, step.To, cancellationToken);
+ progress?.Report(i == 0 ? 0.85 : 0.85 + 0.05 * i / strikes.Count);
+ cancellationToken.ThrowIfCancellationRequested();
+ }
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var outputs = new List();
+ for (var i = 0; i < strikes.Count; i++)
+ {
+ var suffix = strikes.Count == 1 ? null
+ : $"-{strikes[i].PixelHeight}px{(ordinals[i] > 1 ? $"-{ordinals[i]}" : "")}";
+ if (step.Output.Allocate(step.To, suffix) is not { } output)
+ continue;
+ File.WriteAllBytes(output, results[i]);
+ outputs.Add(output);
+ }
+ progress?.Report(1);
+ return outputs;
+ }
+
/// Maps 0–1 progress of one phase onto a slice of the step's progress, synchronously.
private sealed class SubProgress(IProgress parent, double start, double length) : IProgress
{
diff --git a/tests/Filee.App.Tests/EnginesPageTests.cs b/tests/Filee.App.Tests/EnginesPageTests.cs
index f1c6edec4..eb6f4d660 100644
--- a/tests/Filee.App.Tests/EnginesPageTests.cs
+++ b/tests/Filee.App.Tests/EnginesPageTests.cs
@@ -85,6 +85,40 @@ public void Dragging_a_card_below_the_next_one_moves_it_there_and_saves_the_orde
}
}
+ [AvaloniaFact]
+ public void Font_card_describes_fon_input_only()
+ {
+ TestServices.EnsureInitialized("en");
+ var vm = new MainWindowViewModel(AppHost.Services, AppHost.Get(), AppHost.Get());
+ vm.Navigate("engines");
+ var window = new MainWindow { DataContext = vm, Width = 1080, Height = 820 };
+ window.Show();
+ Pump();
+ try
+ {
+ var page = window.GetVisualDescendants().OfType().Single();
+ var engines = ((EnginesPageViewModel)page.DataContext!).Engines;
+ var font = engines.Single(e => e.Converter.Id == "font");
+ var card = page.FindControl("EngineList")!.ContainerFromIndex(engines.IndexOf(font))!;
+
+ // The card's tooltip is the engine's own detail, and FON is only ever a source.
+ var tip = card.GetVisualDescendants().OfType().Select(ToolTip.GetTip).OfType().Single();
+ Assert.Equal(font.Converter.GetStatus().Detail, tip);
+ Assert.Contains("FON", tip);
+ Assert.Equal(["eot", "otf", "ttf", "woff", "woff2"], font.Converter.Edges.Where(e => e.From == "fon").Select(e => e.To).Order());
+ Assert.DoesNotContain(font.Converter.Edges, e => e.To == "fon");
+
+ var description = AppHost.Get()["engine.font.description"];
+ Assert.Equal(description, font.Description);
+ Assert.Contains(card.GetVisualDescendants().OfType(), t => t.Text == description && t.IsVisible);
+ Assert.Contains("FON", description);
+ }
+ finally
+ {
+ window.Close();
+ }
+ }
+
[AvaloniaFact]
public void Only_LibreOffice_has_settings_and_they_change_the_saved_switches()
{
diff --git a/tests/Filee.App.Tests/PresetEditorTargetTests.cs b/tests/Filee.App.Tests/PresetEditorTargetTests.cs
new file mode 100644
index 000000000..0ba2c581b
--- /dev/null
+++ b/tests/Filee.App.Tests/PresetEditorTargetTests.cs
@@ -0,0 +1,45 @@
+// The preset editor's target list offers only formats Filee can write: read-only FON (#177) is a source, never a target.
+
+using Avalonia.Controls;
+using Avalonia.Headless;
+using Avalonia.Headless.XUnit;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+using Filee.App.Services;
+using Filee.App.ViewModels;
+using Filee.App.Views;
+using Filee.Core.Localization;
+using Filee.Core.Settings;
+
+namespace Filee.App.Tests;
+
+public class PresetEditorTargetTests
+{
+ [AvaloniaFact]
+ public void Preset_editor_target_box_excludes_fon_and_shows_ttf()
+ {
+ TestServices.EnsureInitialized("en");
+ var store = AppHost.Get();
+ var editor = new PresetEditorViewModel(store.FindPreset("to-ttf")!, AppHost.Get());
+ var window = new PresetEditorWindow { DataContext = editor, Height = 900 };
+ window.Show();
+ for (var i = 0; i < 20; i++)
+ {
+ Dispatcher.UIThread.RunJobs();
+ AvaloniaHeadlessPlatform.ForceRenderTimerTick();
+ }
+ try
+ {
+ var box = window.GetVisualDescendants().OfType().Single(c => c.Name == "TargetBox");
+ var targets = box.Items.OfType>().Select(c => c.Value).ToList();
+ Assert.NotEmpty(targets);
+ Assert.DoesNotContain("fon", targets);
+ Assert.Single(targets, t => t == "ttf");
+ Assert.Equal("ttf", ((Choice)box.SelectedItem!).Value);
+ }
+ finally
+ {
+ window.Close();
+ }
+ }
+}
diff --git a/tests/Filee.Cli.Tests/CliTests.cs b/tests/Filee.Cli.Tests/CliTests.cs
index 88ad21cdd..4c36b21ce 100644
--- a/tests/Filee.Cli.Tests/CliTests.cs
+++ b/tests/Filee.Cli.Tests/CliTests.cs
@@ -437,6 +437,137 @@ public async Task Unicode_spaces_and_quotes_stay_in_a_single_input_argument()
Assert.True(File.Exists(source));
}
+ // ──────────── Windows bitmap fonts (#177): the engine tests' committed samples, linked into this project ────────────
+
+ private static readonly string[] FontFormats = ["ttf", "otf", "woff", "woff2", "eot"];
+
+ private static string FonSample(string name) => Path.Combine(AppContext.BaseDirectory, "Fonts", name);
+
+ /// Copies a committed sample to Name.fon in this test's folder (or a sub folder of it).
+ private string Fon(string sample, string? folder = null)
+ {
+ folder = Path.Combine(_folder, folder ?? "");
+ Directory.CreateDirectory(folder);
+ var path = Path.Combine(folder, "Name.fon");
+ File.Copy(FonSample(sample), path);
+ return path;
+ }
+
+ private static string[] Outputs(string json)
+ {
+ using var document = JsonDocument.Parse(json);
+ return [.. document.RootElement.GetProperty("files")[0].GetProperty("outputs").EnumerateArray().Select(o => o.GetString()!)];
+ }
+
+ [Fact]
+ public void Committed_fon_samples_are_copied_to_cli_output()
+ {
+ foreach (var sample in new[] { "Fon-v2-fixed.fon", "Fon-multi.fon" })
+ {
+ var bytes = File.ReadAllBytes(FonSample(sample));
+ Assert.Equal("MZ"u8.ToArray(), bytes[..2]);
+ Assert.Equal(bytes, File.ReadAllBytes(Fon(sample, Path.GetFileNameWithoutExtension(sample))));
+ }
+ }
+
+ [Theory]
+ [InlineData("ttf")]
+ [InlineData("otf")]
+ [InlineData("woff")]
+ [InlineData("woff2")]
+ [InlineData("eot")]
+ public async Task Fon_single_resource_omits_size_suffix_in_all_five_formats(string format)
+ {
+ var source = Fon("Fon-v2-fixed.fon");
+ var json = Path.Combine(_folder, "json");
+ var text = Path.Combine(_folder, "text");
+
+ var (code, output, errors) = await Run("convert", source, "--to", format, "-o", json, "--json");
+ Assert.Equal(Cli.Success, code);
+ Assert.Equal("", errors);
+ Assert.Equal([Path.Combine(json, "Name." + format)], Outputs(output));
+ Assert.Equal(["Name." + format], Directory.EnumerateFiles(json).Select(Path.GetFileName));
+
+ (code, output, _) = await Run("convert", source, "--to", format, "-o", text);
+ Assert.Equal(Cli.Success, code);
+ Assert.Contains("Name." + format, output);
+ Assert.DoesNotContain("13px", output);
+ Assert.Equal(["Name." + format], Directory.EnumerateFiles(text).Select(Path.GetFileName));
+ }
+
+ [Fact]
+ public async Task Fon_reports_each_strike_and_obeys_conflicts()
+ {
+ var source = Fon("Fon-multi.fon");
+ string[] names = ["Name-13px.ttf", "Name-13px-2.ttf", "Name-14px.ttf"];
+
+ // No conflict, in each format: one file per RT_FONT resource, every path in the text and JSON output.
+ foreach (var format in FontFormats)
+ {
+ var clean = Path.Combine(_folder, "clean-" + format);
+ var (code, output, _) = await Run("convert", source, "--to", format, "-o", clean, "--json");
+ Assert.Equal(Cli.Success, code);
+ Assert.Equal(names.Select(n => Path.Combine(clean, Path.ChangeExtension(n, format))), Outputs(output));
+ }
+ var seed = Path.Combine(_folder, "clean-ttf");
+ var (textCode, text, _) = await Run("convert", source, "--to", "ttf", "-o", Path.Combine(_folder, "text"));
+ Assert.Equal(Cli.Success, textCode);
+ Assert.All(names, n => Assert.Contains(n, text));
+
+ // One size already there: skip writes the other two, rename and overwrite write three.
+ string Folder(string name)
+ {
+ var folder = Path.Combine(_folder, name);
+ Directory.CreateDirectory(folder);
+ File.WriteAllText(Path.Combine(folder, "Name-13px.ttf"), "old");
+ return folder;
+ }
+
+ var skip = Folder("skip");
+ var (skipCode, skipped, _) = await Run("convert", source, "--to", "ttf", "-o", skip, "--skip", "--json");
+ Assert.Equal(Cli.Success, skipCode);
+ Assert.Equal([Path.Combine(skip, "Name-13px-2.ttf"), Path.Combine(skip, "Name-14px.ttf")], Outputs(skipped));
+ Assert.Equal("old", File.ReadAllText(Path.Combine(skip, "Name-13px.ttf")));
+
+ var rename = Folder("rename");
+ var (renameCode, renamed, _) = await Run("convert", source, "--to", "ttf", "-o", rename, "--json");
+ Assert.Equal(Cli.Success, renameCode);
+ Assert.Equal([Path.Combine(rename, "Name-13px (2).ttf"), Path.Combine(rename, "Name-13px-2.ttf"), Path.Combine(rename, "Name-14px.ttf")],
+ Outputs(renamed));
+ Assert.Equal("old", File.ReadAllText(Path.Combine(rename, "Name-13px.ttf")));
+
+ var overwrite = Folder("overwrite");
+ var (overwriteCode, overwritten, _) = await Run("convert", source, "--to", "ttf", "-o", overwrite, "--overwrite", "--json");
+ Assert.Equal(Cli.Success, overwriteCode);
+ Assert.Equal(names.Select(n => Path.Combine(overwrite, n)), Outputs(overwritten));
+ Assert.All(names, n => Assert.Equal(File.ReadAllBytes(Path.Combine(seed, n)), File.ReadAllBytes(Path.Combine(overwrite, n))));
+
+ // Every size there: skipped, with no outputs.
+ var (allCode, all, _) = await Run("convert", source, "--to", "ttf", "-o", seed, "--skip", "--json");
+ Assert.Equal(Cli.Success, allCode);
+ using var json = JsonDocument.Parse(all);
+ Assert.Equal("skipped", json.RootElement.GetProperty("files")[0].GetProperty("state").GetString());
+ Assert.Equal(0, json.RootElement.GetProperty("files")[0].GetProperty("outputs").GetArrayLength());
+ }
+
+ [Fact]
+ public async Task Fon_formats_are_read_only_and_no_to_fon()
+ {
+ var (code, output, _) = await Run("formats");
+ Assert.Equal(Cli.Success, code);
+ Assert.Contains(output.Split('\n'), line => line.TrimStart().StartsWith("fon ", StringComparison.Ordinal) && line.Contains("(read only)"));
+
+ (code, output, _) = await Run("formats", "fon");
+ Assert.Equal(Cli.Success, code);
+ var targets = output.Split('\n')[1].Split(',', StringSplitOptions.TrimEntries);
+ Assert.Equal(FontFormats.Order(), targets.Order());
+
+ var (refused, _, errors) = await Run("convert", Fon("Fon-v2-fixed.fon"), "--to", "fon");
+ Assert.Equal(Cli.UsageError, refused);
+ Assert.Contains("can't write", errors);
+ Assert.Equal(["Name.fon"], Directory.EnumerateFiles(_folder).Select(Path.GetFileName));
+ }
+
[Fact]
public async Task Refuses_tool_presets()
{
diff --git a/tests/Filee.Cli.Tests/Filee.Cli.Tests.csproj b/tests/Filee.Cli.Tests/Filee.Cli.Tests.csproj
index 488b0a460..fab82bdbe 100644
--- a/tests/Filee.Cli.Tests/Filee.Cli.Tests.csproj
+++ b/tests/Filee.Cli.Tests/Filee.Cli.Tests.csproj
@@ -18,4 +18,8 @@
+
+
+
+
diff --git a/tests/Filee.Core.Tests/FonFormatTests.cs b/tests/Filee.Core.Tests/FonFormatTests.cs
new file mode 100644
index 000000000..eeb47a895
--- /dev/null
+++ b/tests/Filee.Core.Tests/FonFormatTests.cs
@@ -0,0 +1,36 @@
+// Windows bitmap fonts (issue #177): .fon is a read-only font format; a new library's fonts donut takes it.
+
+using Filee.Core.Formats;
+using Filee.Core.Presets;
+using Filee.Core.Profiles;
+
+namespace Filee.Core.Tests;
+
+public class FonFormatTests
+{
+ [Theory]
+ [InlineData("Fixed.fon")]
+ [InlineData("SERIFE.FON")]
+ public void Fon_is_input_only(string file)
+ {
+ var format = FormatRegistry.Detect(file);
+
+ Assert.Equal("fon", format?.Id);
+ Assert.Equal(FormatCategory.Font, format!.Category);
+ Assert.False(format.Writable);
+ Assert.Null(FormatRegistry.Detect("font.fnt"));
+ }
+
+ [Fact]
+ public void Fon_is_read_only_and_new_font_profile_claims_it()
+ {
+ var profiles = BuiltInData.CreateProfiles();
+ var fonts = ProfileSelector.Select(profiles, ["Fixed.fon"]);
+
+ Assert.Equal("fonts", fonts?.Id);
+ Assert.Contains("fon", fonts!.Extensions);
+ var targets = fonts.PresetIds.Select(id => BuiltInData.CreatePresets().Single(p => p.Id == id).TargetFormat).Order();
+ Assert.Equal(["eot", "otf", "ttf", "woff", "woff2"], targets);
+ Assert.All(targets, t => Assert.True(FormatRegistry.Known.Single(f => f.Id == t).Writable));
+ }
+}
diff --git a/tests/Filee.Engines.Tests/Filee.Engines.Tests.csproj b/tests/Filee.Engines.Tests/Filee.Engines.Tests.csproj
index 90946d300..6a640661a 100644
--- a/tests/Filee.Engines.Tests/Filee.Engines.Tests.csproj
+++ b/tests/Filee.Engines.Tests/Filee.Engines.Tests.csproj
@@ -22,7 +22,7 @@
-
+
diff --git a/tests/Filee.Engines.Tests/FonTests.cs b/tests/Filee.Engines.Tests/FonTests.cs
new file mode 100644
index 000000000..f0fdaf0fd
--- /dev/null
+++ b/tests/Filee.Engines.Tests/FonTests.cs
@@ -0,0 +1,1099 @@
+// Windows bitmap FON (NE + FNT v2/v3) to TTF/OTF/WOFF/WOFF2/EOT (issue #177). The committed Fonts/Fon-*.fon files are
+// synthetic, made by Fonts/make-test-fon.py; the expected pixels below are written from that generator's art, never
+// from the engine's own reader.
+
+using System.Buffers.Binary;
+using System.Globalization;
+using System.Security.Cryptography;
+using System.Text;
+using Filee.Core.Conversion;
+using Filee.Core.Presets;
+using Filee.Engines.Fonts;
+using SkiaSharp;
+
+namespace Filee.Engines.Tests;
+
+public class FonTests(EngineFixture fx) : IClassFixture
+{
+ private static readonly string[] Formats = ["ttf", "otf", "woff", "woff2", "eot"];
+
+ private static string Sample(string name) => Path.Combine(AppContext.BaseDirectory, "Fonts", name);
+
+ private static byte[] Data(string name) => File.ReadAllBytes(Sample(name));
+
+ private static int FntStart(byte[] data, int resource = 0)
+ {
+ var ne = (int)BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(0x3c));
+ var table = ne + BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(ne + 0x24));
+ var entry = table + 2 + 8 + 12 + 8 + resource * 12;
+ return BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(entry));
+ }
+
+ // ───────────────────────── The generator's art, independent of the reader ─────────────────────────
+
+ /// One committed single-resource sample: FNT version, fixed or proportional, first character.
+ public sealed record Kind(string File, bool Fixed, int First);
+
+ private static readonly Kind[] Kinds =
+ [
+ new("Fon-v2-fixed.fon", true, 32),
+ new("Fon-v2-proportional.fon", false, 0),
+ new("Fon-v3-fixed.fon", true, 32),
+ new("Fon-v3-proportional.fon", false, 32),
+ ];
+
+ private static int ArtWidth(int code, bool isFixed) => isFixed ? 8 : code == 32 ? 0 : code % 2 == 1 ? 5 : 9;
+
+ private static bool ArtLit(int code, int x, int y, int width, int height)
+ {
+ if (x >= width || code == 32)
+ return false;
+ var rows = Math.Min(height, 7);
+ return code switch
+ {
+ 33 => x == 2 && y == 0,
+ 34 => x == 2 + y % Math.Max(1, width - 2),
+ _ => x >= 2 && y < rows && (x == 2 || x == width - 1 || y == 0 || y == rows - 1),
+ };
+ }
+
+ private static int ArtAscent(int height) => Math.Min(height, 10);
+
+ private static int Scale(int height) => 1024 / height;
+
+ // ───────────────────────── Samples and queue helpers ─────────────────────────
+
+ [Theory]
+ [InlineData("Fon-v2-fixed.fon", 0x200, 8)]
+ [InlineData("Fon-v2-proportional.fon", 0x200, 0)]
+ [InlineData("Fon-v3-fixed.fon", 0x300, 8)]
+ [InlineData("Fon-v3-proportional.fon", 0x300, 0)]
+ [InlineData("Fon-multi.fon", 0x200, 8)]
+ [InlineData("Fon-edge.fon", 0x300, 8)]
+ public void Committed_fon_samples_are_copied_to_test_output(string name, int version, int width)
+ {
+ var bytes = Data(name);
+ Assert.Equal("MZ", Encoding.ASCII.GetString(bytes, 0, 2));
+ var strikes = FonReader.Read(bytes);
+ Assert.Equal(name == "Fon-multi.fon" ? 3 : name == "Fon-edge.fon" ? 2 : 1, strikes.Count);
+ Assert.Equal(version, strikes[0].Version);
+ Assert.Equal(width, strikes[0].PixelWidth);
+ Assert.Equal("Fixed", strikes[0].Face);
+ }
+
+ /// A folder holding as Name.fon.
+ private string Source(byte[] data, out string folder)
+ {
+ folder = fx.NewFolder();
+ var path = Path.Combine(folder, "Name.fon");
+ File.WriteAllBytes(path, data);
+ return path;
+ }
+
+ private static Preset PresetFor(string target, ConflictPolicy conflict = ConflictPolicy.Rename) =>
+ new() { TargetFormat = target, Output = { Location = OutputLocation.SameFolder, Conflict = conflict } };
+
+ /// Every file below with the SHA-256 of its bytes.
+ private static Dictionary Snapshot(string folder) =>
+ Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories)
+ .ToDictionary(p => Path.GetRelativePath(folder, p), p => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(p))));
+
+ /// SFNT bytes Skia can read on every platform (web containers are decoded by Filee first).
+ private static byte[] SfntBytes(string path, string format)
+ {
+ var bytes = File.ReadAllBytes(path);
+ return format is "ttf" or "otf" ? bytes : FontFile.Load(bytes).Write();
+ }
+
+ // ───────────────────────── Rendering, tables ─────────────────────────
+
+ private const int Pad = 2;
+
+ ///
+ /// The glyph drawn by Skia at its original pixel size, unhinted and aliased, with the cell's top-left at
+ /// (, ); the padding shows pixels drawn outside the cell.
+ ///
+ private static bool[,] Render(SKTypeface face, int gid, int height, int ascent, int width)
+ {
+ using var font = new SKFont(face, height) { Hinting = SKFontHinting.None, Edging = SKFontEdging.Alias, Subpixel = false };
+ using var path = font.GetGlyphPath((ushort)gid) ?? new SKPath();
+ var (w, h) = (width + Pad * 2, height + Pad * 2);
+ using var bitmap = new SKBitmap(new SKImageInfo(w, h, SKColorType.Alpha8));
+ using (var canvas = new SKCanvas(bitmap))
+ using (var paint = new SKPaint { IsAntialias = false, Color = SKColors.Black })
+ {
+ canvas.Clear(SKColors.Transparent);
+ canvas.Translate(Pad, Pad + ascent);
+ canvas.DrawPath(path, paint);
+ }
+ var bytes = bitmap.Bytes;
+ var mask = new bool[h, w];
+ for (var y = 0; y < h; y++)
+ for (var x = 0; x < w; x++)
+ mask[y, x] = bytes[y * bitmap.RowBytes + x] > 127;
+ return mask;
+ }
+
+ private static bool[,] ExpectedMask(int code, int width, int height)
+ {
+ var mask = new bool[height + Pad * 2, width + Pad * 2];
+ for (var y = 0; y < height; y++)
+ for (var x = 0; x < width; x++)
+ mask[y + Pad, x + Pad] = ArtLit(code, x, y, width, height);
+ return mask;
+ }
+
+ private static void AssertMask(bool[,] expected, bool[,] actual, string what)
+ {
+ Assert.Equal(expected.GetLength(0), actual.GetLength(0));
+ Assert.Equal(expected.GetLength(1), actual.GetLength(1));
+ for (var y = 0; y < expected.GetLength(0); y++)
+ for (var x = 0; x < expected.GetLength(1); x++)
+ Assert.True(expected[y, x] == actual[y, x], $"{what}: pixel ({x - Pad}, {y - Pad}) should be {(expected[y, x] ? "on" : "off")}");
+ }
+
+ private static int U16(byte[] table, int offset) => BinaryPrimitives.ReadUInt16BigEndian(table.AsSpan(offset));
+
+ private static int I16(byte[] table, int offset) => BinaryPrimitives.ReadInt16BigEndian(table.AsSpan(offset));
+
+ private static int Advance(SfntFont font, int gid) => U16(font.Require("hmtx"), gid * 4);
+
+ private static int Lsb(SfntFont font, int gid) => I16(font.Require("hmtx"), gid * 4 + 2);
+
+ /// The set bits of OS/2 ulUnicodeRange1..4.
+ private static int[] UnicodeRangeBits(SfntFont font) =>
+ [.. Enumerable.Range(0, 128).Where(bit =>
+ (BinaryPrimitives.ReadUInt32BigEndian(font.Require("OS/2").AsSpan(42 + bit / 32 * 4)) & (1u << (bit % 32))) != 0)];
+
+ /// The Windows Unicode BMP (3, 1) format 4 subtable as code point → glyph id.
+ private static Dictionary Cmap(SfntFont font)
+ {
+ var t = font.Require("cmap");
+ var result = new Dictionary();
+ for (var i = 0; i < U16(t, 2); i++)
+ {
+ if (U16(t, 4 + i * 8) != 3 || U16(t, 6 + i * 8) != 1)
+ continue;
+ var at = (int)BinaryPrimitives.ReadUInt32BigEndian(t.AsSpan(8 + i * 8));
+ Assert.Equal(4, U16(t, at));
+ var segX2 = U16(t, at + 6);
+ for (var s = 0; s < segX2 / 2; s++)
+ {
+ var end = U16(t, at + 14 + s * 2);
+ var start = U16(t, at + 16 + segX2 + s * 2);
+ var delta = U16(t, at + 16 + segX2 * 2 + s * 2);
+ var rangeAt = at + 16 + segX2 * 3 + s * 2;
+ var range = U16(t, rangeAt);
+ for (var c = start; c <= end && c != 0xFFFF; c++)
+ {
+ var gid = range == 0 ? (c + delta) & 0xFFFF : U16(t, rangeAt + range + (c - start) * 2);
+ if (range != 0 && gid != 0)
+ gid = (gid + delta) & 0xFFFF;
+ if (gid != 0)
+ result[c] = gid;
+ }
+ }
+ return result;
+ }
+ throw new Xunit.Sdk.XunitException("no Windows Unicode BMP cmap");
+ }
+
+ private static string? Name(SfntFont font, int id)
+ {
+ var table = font.Require("name");
+ var count = U16(table, 2);
+ var strings = U16(table, 4);
+ for (var i = 0; i < count; i++)
+ {
+ var offset = 6 + i * 12;
+ if (U16(table, offset) != 3 || U16(table, offset + 6) != id)
+ continue;
+ return Encoding.BigEndianUnicode.GetString(table, strings + U16(table, offset + 10), U16(table, offset + 8));
+ }
+ return null;
+ }
+
+ // ───────────────────────── C1: every version and width, every target ─────────────────────────
+
+ public static TheoryData KindsAndFormats()
+ {
+ var data = new TheoryData();
+ for (var k = 0; k < Kinds.Length; k++)
+ foreach (var format in Formats)
+ data.Add(k, format);
+ return data;
+ }
+
+ [Theory]
+ [MemberData(nameof(KindsAndFormats))]
+ public async Task Fon_v2_v3_each_strike_opens_in_all_five_formats(int kindIndex, string format)
+ {
+ var kind = Kinds[kindIndex];
+ var source = Source(Data(kind.File), out var folder);
+ var job = await fx.ConvertAsync([source], PresetFor(format));
+
+ Assert.True(job.State == JobState.Completed, string.Join("; ", job.Files.Select(f => $"{f.ErrorKey} {f.ErrorDetail}")));
+ var output = Assert.Single(job.Outputs);
+ Assert.Equal(Path.Combine(folder, "Name." + format), output);
+ var bytes = File.ReadAllBytes(output);
+ var signature = BinaryPrimitives.ReadUInt32BigEndian(bytes);
+ switch (format)
+ {
+ case "ttf" or "otf": Assert.Equal(0x00010000u, signature); break;
+ case "woff": Assert.Equal(0x774F4646u, signature); break;
+ case "woff2": Assert.Equal(0x774F4632u, signature); break;
+ case "eot": Assert.Equal(bytes.Length, (int)BinaryPrimitives.ReadUInt32LittleEndian(bytes)); break;
+ }
+
+ var font = FontFile.Load(bytes, TestContext.Current.CancellationToken);
+ Assert.Equal(SfntFont.TrueTypeFlavor, font.Flavor);
+ foreach (var tag in new[] { "head", "hhea", "maxp", "OS/2", "name", "cmap", "post", "glyf", "loca", "hmtx" })
+ Assert.NotNull(font[tag]);
+ Assert.Equal(1, U16(font.Require("maxp"), 14)); // maxZones
+ Assert.Equal(2, I16(font.Require("head"), 48)); // fontDirectionHint
+ var codes = Enumerable.Range(kind.First, 127 - kind.First + 1).ToArray();
+ Assert.Equal(codes.Length + 1, font.GlyphCount);
+
+ using var face = SKTypeface.FromStream(new MemoryStream(SfntBytes(output, format)));
+ Assert.NotNull(face);
+ Assert.Equal(codes.Length + 1, face.GlyphCount);
+ var s = Scale(13);
+ for (var i = 0; i < codes.Length; i++)
+ {
+ var width = ArtWidth(codes[i], kind.Fixed);
+ Assert.Equal(width * s, Advance(font, i + 1));
+ AssertMask(ExpectedMask(codes[i], width, 13), Render(face, i + 1, 13, ArtAscent(13), width), $"{kind.File} {format} code {codes[i]}");
+ }
+ }
+
+ // ───────────────────────── C2: pixel masks ─────────────────────────
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ public void Every_bitmap_mask_matches_unhinted_integer_raster(int kindIndex)
+ {
+ var kind = Kinds[kindIndex];
+ var font = FonOutlineBuilder.Build(Assert.Single(FonReader.Read(Data(kind.File))));
+ var glyphs = GlyfTable.ReadAll(font);
+ using var face = SKTypeface.FromStream(new MemoryStream(font.Write()));
+ var codes = Enumerable.Range(kind.First, 127 - kind.First + 1).ToArray();
+ // Single pixel (33), diagonal (34), a box with a hole (35+), blank space (32), and control glyphs (0..31, 127).
+ Assert.Contains(33, codes);
+ Assert.Contains(34, codes);
+ for (var i = 0; i < codes.Length; i++)
+ {
+ var width = ArtWidth(codes[i], kind.Fixed);
+ if (glyphs[i + 1] is { } glyph)
+ Assert.All(glyph.Points, p => Assert.True(p.OnCurve, $"code {codes[i]} has an off-curve point"));
+ AssertMask(ExpectedMask(codes[i], width, 13), Render(face, i + 1, 13, ArtAscent(13), width), $"{kind.File} code {codes[i]}");
+ }
+ // The first lit column is x=2: two blank columns on the left survive.
+ var box = glyphs[35 - kind.First + 1]!;
+ Assert.Equal(2 * Scale(13), box.XMin);
+ Assert.Equal(2, box.NumberOfContours); // the outline and its hole
+ }
+
+ // ───────────────────────── C3: cmap ─────────────────────────
+
+ /// CP1252 0x80..0x9F; 0 marks the five undefined bytes.
+ private static readonly int[] Cp1252High =
+ [
+ 0x20AC, 0, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0, 0x017D, 0,
+ 0, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0, 0x017E, 0x0178,
+ ];
+
+ [Fact]
+ public void Cmap_excludes_controls_and_undefined_bytes_without_losing_glyphs()
+ {
+ // 0x00..0x7F from the committed sample: controls 0x00..0x1F and DEL 0x7F keep glyphs but no cmap entry.
+ var strike = Assert.Single(FonReader.Read(Data("Fon-v2-proportional.fon")));
+ var font = FonOutlineBuilder.Build(strike);
+ var glyphs = GlyfTable.ReadAll(font);
+ Assert.Equal(128 + 1, font.GlyphCount);
+ var expected = Enumerable.Range(0x20, 0x7F - 0x20).ToDictionary(c => c, c => c + 1);
+ Assert.Equal(expected.OrderBy(p => p.Key), Cmap(font).OrderBy(p => p.Key));
+ foreach (var code in Enumerable.Range(0, 0x20).Append(0x7F))
+ {
+ Assert.NotNull(glyphs[code + 1]);
+ Assert.Equal(ArtWidth(code, false) * Scale(13), Advance(font, code + 1));
+ }
+
+ // 0x7E..0xA0: the CP1252 high half, its five undefined bytes, and NBSP.
+ var pixels = new bool[13, 8];
+ pixels[0, 2] = true;
+ var high = strike with
+ {
+ Glyphs = [.. Enumerable.Range(0x7E, 0xA0 - 0x7E + 1).Select(b => new FonGlyph((byte)b, 8, pixels))],
+ DefaultChar = 0,
+ };
+ font = FonOutlineBuilder.Build(high);
+ glyphs = GlyfTable.ReadAll(font);
+ Assert.Equal(high.Glyphs.Count + 1, font.GlyphCount);
+ var map = new Dictionary { [0x7E] = 1, [0xA0] = high.Glyphs.Count };
+ for (var b = 0x80; b <= 0x9F; b++)
+ if (Cp1252High[b - 0x80] != 0)
+ map[Cp1252High[b - 0x80]] = b - 0x7E + 1;
+ Assert.Equal(map.OrderBy(p => p.Key), Cmap(font).OrderBy(p => p.Key));
+ Assert.Equal(map.Count, Cmap(font).Values.Distinct().Count());
+ Assert.All(Enumerable.Range(1, high.Glyphs.Count), gid =>
+ {
+ Assert.NotNull(glyphs[gid]);
+ Assert.Equal(8 * Scale(13), Advance(font, gid));
+ });
+ Assert.DoesNotContain(0xFFFD, Cmap(font).Keys);
+ // Basic Latin, Latin-1, Latin Extended-A/B, spacing modifiers, punctuation, currency and letterlike symbols.
+ Assert.Equal([0, 1, 2, 3, 5, 31, 33, 35], UnicodeRangeBits(font));
+ }
+
+ [Fact]
+ public void Supported_charsets_are_locale_independent()
+ {
+ Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
+ var charsets = new Dictionary
+ {
+ [0] = 1252,
+ [238] = 1250,
+ [204] = 1251,
+ [161] = 1253,
+ [162] = 1254,
+ [177] = 1255,
+ [178] = 1256,
+ [186] = 1257,
+ [163] = 1258,
+ [222] = 874,
+ };
+ var strike = Assert.Single(FonReader.Read(Data("Fon-v2-fixed.fon")));
+ var pixels = new bool[13, 8];
+ pixels[1, 3] = true;
+ var glyphs = Enumerable.Range(0xA1, 0xFE - 0xA1 + 1).Select(b => new FonGlyph((byte)b, 8, pixels)).ToList();
+ var culture = CultureInfo.CurrentCulture;
+ try
+ {
+ foreach (var locale in new[] { "tr-TR", "ja-JP", "ru-RU" })
+ {
+ CultureInfo.CurrentCulture = CultureInfo.CurrentUICulture = new CultureInfo(locale);
+ foreach (var (charset, codePage) in charsets)
+ {
+ var encoding = Encoding.GetEncoding(codePage, EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback);
+ var expected = new Dictionary();
+ for (var i = 0; i < glyphs.Count; i++)
+ {
+ string text;
+ try { text = encoding.GetString([glyphs[i].ByteValue]); }
+ catch (DecoderFallbackException) { continue; }
+ if (text.Length != 1 || char.IsControl(text[0]) || char.IsSurrogate(text[0]) ||
+ encoding.GetBytes(text).Single() != glyphs[i].ByteValue ||
+ UndefinedHigh.GetValueOrDefault(charset, []).Contains(glyphs[i].ByteValue))
+ continue;
+ expected.TryAdd(text[0], i + 1);
+ }
+ var cmap = Cmap(FonOutlineBuilder.Build(strike with { CharSet = charset, Glyphs = glyphs, DefaultChar = 0 }));
+ Assert.Equal(expected.OrderBy(p => p.Key), cmap.OrderBy(p => p.Key));
+ }
+ }
+ }
+ finally
+ {
+ CultureInfo.CurrentCulture = CultureInfo.CurrentUICulture = culture;
+ }
+ // Spot checks that the tables really differ: 0xC0 is À, А (Cyrillic) or ภ (Thai) depending on the charset.
+ Assert.Equal(0xC0 - 0xA1 + 1, Cmap(FonOutlineBuilder.Build(strike with { CharSet = 0, Glyphs = glyphs, DefaultChar = 0 }))[0x00C0]);
+ Assert.Equal(0xC0 - 0xA1 + 1, Cmap(FonOutlineBuilder.Build(strike with { CharSet = 204, Glyphs = glyphs, DefaultChar = 0 }))[0x0410]);
+ Assert.Equal(0xC0 - 0xA1 + 1, Cmap(FonOutlineBuilder.Build(strike with { CharSet = 222, Glyphs = glyphs, DefaultChar = 0 }))[0x0E20]);
+ // ulUnicodeRange follows the mapped blocks of 0xA1..0xFE in each code page.
+ Assert.Equal([1, 2, 5], UnicodeRangeBits(FonOutlineBuilder.Build(strike with { CharSet = 238, Glyphs = glyphs, DefaultChar = 0 })));
+ Assert.Equal([1, 2, 3, 6, 33], UnicodeRangeBits(FonOutlineBuilder.Build(strike with { CharSet = 163, Glyphs = glyphs, DefaultChar = 0 })));
+ Assert.Equal([24], UnicodeRangeBits(FonOutlineBuilder.Build(strike with { CharSet = 222, Glyphs = glyphs, DefaultChar = 0 })));
+ }
+
+ /// Bytes the Unicode MICSFT tables mark UNDEFINED that .NET decodes to Private Use code points, by charset.
+ private static readonly Dictionary UndefinedHigh = new()
+ {
+ [161] = [0xAA, 0xD2, 0xFF], // CP1253
+ [177] = [0xD9, 0xDA, 0xDB, 0xDC, 0xDD, 0xDE, 0xDF, 0xFB, 0xFC, 0xFF], // CP1255
+ [186] = [0xA1, 0xA5], // CP1257
+ [222] = [0xDB, 0xDC, 0xDD, 0xDE, 0xFC, 0xFD, 0xFE, 0xFF], // CP874
+ };
+
+ [Theory]
+ [InlineData(161)]
+ [InlineData(177)]
+ [InlineData(186)]
+ [InlineData(222)]
+ public void Undefined_bytes_decoded_to_private_use_keep_glyphs_but_no_cmap(int charset)
+ {
+ var strike = Assert.Single(FonReader.Read(Data("Fon-v2-fixed.fon")));
+ var pixels = new bool[13, 8];
+ pixels[1, 3] = true;
+ var glyphs = Enumerable.Range(0x80, 0x80).Select(b => new FonGlyph((byte)b, 8, pixels)).ToList();
+ var font = FonOutlineBuilder.Build(strike with { CharSet = (byte)charset, Glyphs = glyphs, DefaultChar = 0 });
+ var cmap = Cmap(font);
+ var outlines = GlyfTable.ReadAll(font);
+ foreach (var b in UndefinedHigh[(byte)charset])
+ {
+ var gid = b - 0x80 + 1;
+ Assert.DoesNotContain(gid, cmap.Values);
+ Assert.NotNull(outlines[gid]);
+ Assert.Equal(8 * Scale(13), Advance(font, gid));
+ }
+ Assert.DoesNotContain(cmap.Keys, c => c is >= 0xE000 and <= 0xF8FF);
+ Assert.Equal(cmap.Keys.Max(), U16(font.Require("OS/2"), 66)); // usLastCharIndex
+ Assert.True(U16(font.Require("OS/2"), 66) < 0xE000);
+ Assert.DoesNotContain(60, UnicodeRangeBits(font)); // Private Use Area
+ }
+
+ // ───────────────────────── C4: metrics ─────────────────────────
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(1)]
+ public async Task Metrics_preserve_ascent_baseline_proportional_and_space(int kindIndex)
+ {
+ var kind = Kinds[kindIndex];
+ var job = await fx.ConvertAsync([Source(Data(kind.File), out _)], PresetFor("ttf"));
+ var output = Assert.Single(job.Outputs);
+ var font = FontFile.Load(File.ReadAllBytes(output), TestContext.Current.CancellationToken);
+ var upem = U16(font.Require("head"), 18);
+ var s = upem / 13;
+ Assert.Equal(13 * s, upem);
+ var (hhea, os2) = (font.Require("hhea"), font.Require("OS/2"));
+ Assert.Equal((10 * s, -3 * s, 2 * s), (I16(hhea, 4), I16(hhea, 6), I16(hhea, 8)));
+ Assert.Equal((10 * s, -3 * s, 2 * s), (I16(os2, 68), I16(os2, 70), I16(os2, 72)));
+
+ var glyphs = GlyfTable.ReadAll(font);
+ using var face = SKTypeface.FromFile(output);
+ using var skFont = new SKFont(face, 13) { Hinting = SKFontHinting.None, Subpixel = true };
+ Assert.Equal(-10f, skFont.Metrics.Ascent, 3);
+ Assert.Equal(3f, skFont.Metrics.Descent, 3);
+ var codes = Enumerable.Range(kind.First, 127 - kind.First + 1).ToArray();
+ var widths = new float[codes.Length + 1];
+ skFont.GetGlyphWidths([.. Enumerable.Range(0, codes.Length + 1).Select(g => (ushort)g)], widths, []);
+ for (var i = 0; i < codes.Length; i++)
+ {
+ var width = ArtWidth(codes[i], kind.Fixed);
+ Assert.Equal(width * s, Advance(font, i + 1));
+ Assert.Equal(width, widths[i + 1], 3);
+ if (codes[i] == 32)
+ {
+ Assert.Null(glyphs[i + 1]);
+ Assert.Equal(0, Lsb(font, i + 1));
+ continue;
+ }
+ var firstLit = Enumerable.Range(0, width).First(x => Enumerable.Range(0, 13).Any(y => ArtLit(codes[i], x, y, width, 13)));
+ Assert.Equal(2, firstLit);
+ Assert.Equal(firstLit * s, glyphs[i + 1]!.XMin);
+ Assert.Equal(glyphs[i + 1]!.XMin, Lsb(font, i + 1)); // pp1 = xMin - lsb = 0
+ var mask = Render(face, i + 1, 13, 10, width);
+ var renderedFirst = Enumerable.Range(0, width + Pad * 2).First(x => Enumerable.Range(0, 13 + Pad * 2).Any(y => mask[y, x])) - Pad;
+ Assert.Equal(2, renderedFirst);
+ }
+ // Proportional widths stay different; the fixed sample's blank space keeps its 8 pixels.
+ Assert.Equal(kind.Fixed ? 1 : 3, codes.Select(c => ArtWidth(c, kind.Fixed)).Distinct().Count());
+ Assert.Equal(ArtWidth(32, kind.Fixed) * s, Advance(font, 32 - kind.First + 1));
+ }
+
+ [Fact]
+ public void Hhea_extrema_and_caret_match_contoured_glyphs()
+ {
+ var kind = Kinds[1]; // proportional, 0x00..0x7F, default char 0
+ var font = FonOutlineBuilder.Build(Assert.Single(FonReader.Read(Data(kind.File))));
+ var s = Scale(13);
+ var contoured = new List<(int Advance, int Lsb, int XMin, int XMax)>();
+ var advances = new List();
+ foreach (var code in new[] { kind.First }.Concat(Enumerable.Range(kind.First, 128))) // .notdef copies code 0
+ {
+ var width = ArtWidth(code, false);
+ advances.Add(width * s);
+ var lit = Enumerable.Range(0, width).Where(x => Enumerable.Range(0, 13).Any(y => ArtLit(code, x, y, width, 13))).ToList();
+ if (lit.Count > 0)
+ contoured.Add((width * s, lit.Min() * s, lit.Min() * s, (lit.Max() + 1) * s));
+ }
+ var hhea = font.Require("hhea");
+ Assert.Equal(advances.Max(), U16(hhea, 10));
+ Assert.Equal(contoured.Min(g => g.Lsb), I16(hhea, 12));
+ Assert.Equal(contoured.Min(g => g.Advance - (g.Lsb + g.XMax - g.XMin)), I16(hhea, 14));
+ Assert.Equal(contoured.Max(g => g.Lsb + g.XMax - g.XMin), I16(hhea, 16));
+ Assert.Equal((1, 0, 0), (I16(hhea, 18), I16(hhea, 20), I16(hhea, 22)));
+ Assert.Equal([0, 0, 0, 0, 0], new[] { I16(hhea, 24), I16(hhea, 26), I16(hhea, 28), I16(hhea, 30), I16(hhea, 32) });
+ Assert.Equal(font.GlyphCount, U16(hhea, 34));
+ }
+
+ // ───────────────────────── C5: names ─────────────────────────
+
+ private static string PsEscape(string full) => string.Concat(Encoding.UTF8.GetBytes(full).Select(b =>
+ char.IsAsciiLetterOrDigit((char)b) || b == '-' ? ((char)b).ToString() : $"_{b:X2}"));
+
+ [Fact]
+ public void Names_and_style_bits_match_each_resource()
+ {
+ var single = Assert.Single(FonReader.Read(Data("Fon-v2-fixed.fon")));
+ void Check(FonStrike strike, string family, string style, int weight, int fsSelection, int macStyle, int count = 1, int ordinal = 1)
+ {
+ var font = FonOutlineBuilder.Build(strike, count, ordinal);
+ var full = $"{family} {style}";
+ Assert.Equal(family, Name(font, 1));
+ Assert.Equal(style, Name(font, 2));
+ Assert.Equal(full, Name(font, 4));
+ var ps = PsEscape(full);
+ Assert.Equal(ps.Length <= 63 ? ps : Name(font, 6), Name(font, 6));
+ Assert.Null(Name(font, 16));
+ Assert.Null(Name(font, 17));
+ Assert.Equal(weight, U16(font.Require("OS/2"), 4));
+ Assert.Equal(fsSelection, U16(font.Require("OS/2"), 62));
+ Assert.Equal(macStyle, U16(font.Require("head"), 44));
+ }
+
+ Check(single, "Fixed", "Regular", 400, 0x40, 0);
+ Assert.Equal("Fixed_20Regular", Name(FonOutlineBuilder.Build(single), 6));
+ Check(single with { Weight = 700, Italic = true }, "Fixed", "Bold Italic", 700, 0x21, 3);
+ Check(single with { Weight = 700 }, "Fixed", "Bold", 700, 0x20, 1);
+ Check(single with { Italic = true }, "Fixed", "Italic", 400, 0x01, 2);
+ var unweighted = Data("Fon-v2-fixed.fon");
+ BinaryPrimitives.WriteUInt16LittleEndian(unweighted.AsSpan(FntStart(unweighted) + 0x53), 0);
+ Check(Assert.Single(FonReader.Read(unweighted)), "Fixed", "Regular", 400, 0x40, 0);
+ Check(single with { Weight = 500 }, "Fixed Weight500", "Regular", 500, 0x40, 0);
+ Check(single, "Fixed 13px", "Regular", 400, 0x40, 0, count: 3);
+ Check(single, "Fixed 13px-2", "Regular", 400, 0x40, 0, count: 3, ordinal: 2);
+ Assert.Equal("Fixed_2013px-2_20Regular", Name(FonOutlineBuilder.Build(single, 3, 2), 6));
+
+ // A long non-ASCII face: nameID 6 is cut at a token boundary (at most 50) and gets a hash of the full name.
+ var face = string.Concat(Enumerable.Repeat("Ä", 40));
+ var font = FonOutlineBuilder.Build(single with { Face = face });
+ var full = $"{face} Regular";
+ var ps = Name(font, 6)!;
+ var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(full)).AsSpan(0, 6));
+ Assert.Equal(PsEscape(full)[..48] + "-" + hash, ps);
+ Assert.True(ps.Length <= 63);
+ Assert.Equal(face, Name(font, 1));
+ }
+
+ [Theory]
+ [InlineData("ttf")]
+ [InlineData("otf")]
+ [InlineData("woff")]
+ [InlineData("woff2")]
+ [InlineData("eot")]
+ public async Task Multi_resource_outputs_have_distinct_name_ids(string format)
+ {
+ var source = Source(Data("Fon-multi.fon"), out var folder);
+ var job = await fx.ConvertAsync([source], PresetFor(format));
+ Assert.True(job.State == JobState.Completed, string.Join("; ", job.Files.Select(f => $"{f.ErrorKey} {f.ErrorDetail}")));
+ var expected = new[]
+ {
+ ("Name-13px", "Fixed 13px", "Fixed_2013px_20Regular"),
+ ("Name-13px-2", "Fixed 13px-2", "Fixed_2013px-2_20Regular"),
+ ("Name-14px", "Fixed 14px", "Fixed_2014px_20Regular"),
+ };
+ Assert.Equal(expected.Select(e => Path.Combine(folder, $"{e.Item1}.{format}")), job.Outputs);
+ var names = new List<(string?, string?, string?)>();
+ foreach (var (file, family, ps) in expected)
+ {
+ var font = FontFile.Load(File.ReadAllBytes(Path.Combine(folder, $"{file}.{format}")), TestContext.Current.CancellationToken);
+ Assert.Equal(family, Name(font, 1));
+ Assert.Equal("Regular", Name(font, 2));
+ Assert.Equal(family + " Regular", Name(font, 4));
+ Assert.Equal(ps, Name(font, 6));
+ Assert.Null(Name(font, 16));
+ Assert.Null(Name(font, 17));
+ names.Add((Name(font, 1), Name(font, 4), Name(font, 6)));
+ }
+ Assert.Equal(3, names.Select(n => n.Item1).Distinct().Count());
+ Assert.Equal(3, names.Select(n => n.Item2).Distinct().Count());
+ Assert.Equal(3, names.Select(n => n.Item3).Distinct().Count());
+ }
+
+ /// Hands out Name{suffix}.{ext} paths in a folder and records each request.
+ private sealed class RecordingAllocator(string folder) : IOutputAllocator
+ {
+ public List Suffixes { get; } = [];
+
+ public string? Allocate(string extension, string? suffix = null)
+ {
+ Suffixes.Add(suffix);
+ return Path.Combine(folder, $"Name{suffix}.{extension}");
+ }
+ }
+
+ private sealed class SyncProgress(Action report) : IProgress
+ {
+ public void Report(double value) => report(value);
+ }
+
+ [Fact]
+ public async Task Same_height_resources_remain_separate()
+ {
+ var data = Data("Fon-multi.fon");
+ var second = FntStart(data, 1);
+ BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(second + 0x53), 700);
+ data[second + 0x50] = 1;
+ var source = Source(data, out var folder);
+ var allocator = new RecordingAllocator(folder);
+ var outputs = await new FontConverter().ConvertAsync(
+ new ConversionStep(source, "fon", "ttf", PresetFor("ttf"), allocator, fx.NewFolder()), null, TestContext.Current.CancellationToken);
+
+ Assert.Equal(["-13px", "-13px-2", "-14px"], allocator.Suffixes);
+ Assert.Equal(3, outputs.Count);
+ var fonts = outputs.Select(o => FontFile.Load(File.ReadAllBytes(o))).ToArray();
+ Assert.Equal(["Fixed 13px", "Fixed 13px-2", "Fixed 14px"], fonts.Select(f => Name(f, 1)));
+ Assert.Equal(["Regular", "Bold Italic", "Regular"], fonts.Select(f => Name(f, 2)));
+ Assert.Equal(3, outputs.Select(o => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(o)))).Distinct().Count());
+ Assert.Equal(fonts[0].Require("glyf"), fonts[1].Require("glyf")); // same pixels, still two fonts
+ }
+
+ // ───────────────────────── C6/C8: queue post-processing ─────────────────────────
+
+ [Fact]
+ public async Task Single_strike_subfolder_only_follows_queue_postprocessing()
+ {
+ Preset Subfolder(ConflictPolicy conflict) => new()
+ {
+ TargetFormat = "ttf",
+ Output = { Location = OutputLocation.Subfolder, SubfolderOnlyForSeveralFiles = true, Conflict = conflict },
+ };
+
+ // No conflict: the one file goes next to the source, without a size suffix, and no subfolder is left.
+ var source = Source(Data("Fon-v2-fixed.fon"), out var folder);
+ var job = await fx.ConvertAsync([source], Subfolder(ConflictPolicy.Rename));
+ Assert.Equal([Path.Combine(folder, "Name.ttf")], job.Outputs);
+ Assert.False(Directory.Exists(Path.Combine(folder, "converted")));
+
+ // Skip: the existing file next to the source stays, the result stays in the subfolder.
+ source = Source(Data("Fon-v2-fixed.fon"), out folder);
+ var existing = Path.Combine(folder, "Name.ttf");
+ File.WriteAllText(existing, "old");
+ job = await fx.ConvertAsync([source], Subfolder(ConflictPolicy.Skip));
+ Assert.Equal(FileState.Done, job.Files[0].State);
+ Assert.Equal([Path.Combine(folder, "converted", "Name.ttf")], job.Outputs);
+ Assert.Equal("old", File.ReadAllText(existing));
+
+ // Overwrite: the result replaces it.
+ source = Source(Data("Fon-v2-fixed.fon"), out folder);
+ existing = Path.Combine(folder, "Name.ttf");
+ File.WriteAllText(existing, "old");
+ job = await fx.ConvertAsync([source], Subfolder(ConflictPolicy.Overwrite));
+ Assert.Equal([existing], job.Outputs);
+ Assert.Equal(0x00010000u, BinaryPrimitives.ReadUInt32BigEndian(File.ReadAllBytes(existing)));
+ Assert.False(Directory.Exists(Path.Combine(folder, "converted")));
+
+ // The move fails (a folder holds the name): the queue keeps the result where it was written.
+ source = Source(Data("Fon-v2-fixed.fon"), out folder);
+ Directory.CreateDirectory(Path.Combine(folder, "Name.ttf"));
+ job = await fx.ConvertAsync([source], Subfolder(ConflictPolicy.Overwrite));
+ Assert.Equal([Path.Combine(folder, "converted", "Name.ttf")], job.Outputs);
+ Assert.True(Directory.Exists(Path.Combine(folder, "Name.ttf")));
+ }
+
+ // ───────────────────────── C8: nothing written before every output is built ─────────────────────────
+
+ private static byte[] Resource(byte[] fon, int index)
+ {
+ var ne = (int)BinaryPrimitives.ReadUInt32LittleEndian(fon.AsSpan(0x3c));
+ var table = ne + BinaryPrimitives.ReadUInt16LittleEndian(fon.AsSpan(ne + 0x24));
+ var entry = table + 2 + 8 + 12 + 8 + index * 12;
+ var start = BinaryPrimitives.ReadUInt16LittleEndian(fon.AsSpan(entry));
+ return fon[start..(start + BinaryPrimitives.ReadUInt16LittleEndian(fon.AsSpan(entry + 2)))];
+ }
+
+ /// MZ/NE with a FONTDIR and the given RT_FONT resources (the layout of make-test-fon.py).
+ private static byte[] Container(params byte[][] resources)
+ {
+ const int ne = 0x40, table = ne + 0x40;
+ var payload = table + 2 + 8 + 12 + 8 + 12 * resources.Length + 2;
+ var data = new byte[payload + 1 + resources.Sum(r => r.Length)];
+ data[0] = (byte)'M';
+ data[1] = (byte)'Z';
+ BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x3c), ne);
+ data[ne] = (byte)'N';
+ data[ne + 1] = (byte)'E';
+ BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(ne + 0x24), table - ne);
+ BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(ne + 0x26), (ushort)(payload - ne));
+ BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(table + 2), 0x8007);
+ BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(table + 4), 1);
+ BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(table + 10), (ushort)payload);
+ BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(table + 12), 1);
+ BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(table + 22), 0x8008);
+ BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(table + 24), (ushort)resources.Length);
+ var at = payload + 1;
+ for (var i = 0; i < resources.Length; i++)
+ {
+ var entry = table + 30 + i * 12;
+ BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(entry), (ushort)at);
+ BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(entry + 2), (ushort)resources[i].Length);
+ BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(entry + 6), (ushort)(0x8001 + i));
+ resources[i].CopyTo(data, at);
+ at += resources[i].Length;
+ }
+ return data;
+ }
+
+ /// A valid FNT v2: 1 pixel high, one glyph 33 pixels wide whose last column (x=32) is lit.
+ private static byte[] WideOnePixelFnt()
+ {
+ const int table = 0x76, face = table + 2 * 4, bits = face + 6;
+ var data = new byte[bits + 5];
+ BinaryPrimitives.WriteUInt16LittleEndian(data, 0x0200);
+ BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(2), (uint)data.Length);
+ BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(0x4a), 1);
+ BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(0x53), 400);
+ BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(0x58), 1);
+ data[0x5f] = data[0x60] = 32;
+ BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x69), face);
+ BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x71), bits);
+ BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(table), 33);
+ BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(table + 2), bits);
+ BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(table + 6), (ushort)data.Length);
+ "Fixed\0"u8.CopyTo(data.AsSpan(face));
+ data[bits + 4] = 0x80;
+ return data;
+ }
+
+ ///
+ /// Runs as Name.fon under each conflict policy, with every name it would write already
+ /// taken, and checks that nothing changed and nothing was reported.
+ ///
+ private async Task AssertNothingWrittenAsync(byte[] data, string[] taken, FileState state, string? error = null)
+ {
+ foreach (var policy in Enum.GetValues())
+ {
+ var source = Source(data, out var folder);
+ for (var i = 0; i < taken.Length; i++)
+ File.WriteAllText(Path.Combine(folder, taken[i]), $"old {i}");
+ var before = Snapshot(folder);
+
+ var job = await fx.ConvertAsync([source], PresetFor("ttf", policy));
+
+ var file = Assert.Single(job.Files);
+ Assert.Equal(state, file.State);
+ if (error is not null)
+ Assert.Contains(error, file.ErrorDetail);
+ Assert.Empty(job.Outputs);
+ Assert.Equal(before.OrderBy(p => p.Key), Snapshot(folder).OrderBy(p => p.Key));
+ }
+ }
+
+ [Fact]
+ public async Task Fon_prewrite_parser_failure_preserves_outputs_under_each_conflict_policy()
+ {
+ // The second RT_FONT's first bitmap points outside the resource: the reader fails before any output is built.
+ var data = Data("Fon-multi.fon");
+ BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(FntStart(data, 1) + 0x94 + 2), 0x7fffffff);
+ Assert.Throws(() => FonReader.Read(data));
+
+ await AssertNothingWrittenAsync(data, ["Name-13px.ttf", "Name-13px-2.ttf", "Name-14px.ttf"], FileState.Failed, "outside the resource");
+ }
+
+ [Fact]
+ public async Task Fon_prewrite_builder_failure_preserves_outputs_under_each_conflict_policy()
+ {
+ var first = Resource(Data("Fon-multi.fon"), 0);
+ var data = Container(first, WideOnePixelFnt());
+ var strikes = FonReader.Read(data);
+ Assert.Equal([13, 1], strikes.Select(s => s.PixelHeight));
+ // The first resource builds completely on its own; the second passes the reader but not the builder.
+ Assert.NotEmpty(FontFile.Save(FonOutlineBuilder.Build(strikes[0], 2), "ttf", TestContext.Current.CancellationToken));
+ Assert.Contains("limits", Assert.Throws(() => FonOutlineBuilder.Build(strikes[1], 2)).Message);
+
+ await AssertNothingWrittenAsync(data, ["Name-13px.ttf", "Name-1px.ttf"], FileState.Failed, "limits");
+ }
+
+ [Fact]
+ public async Task Fon_prewrite_cancellation_leaves_no_outputs()
+ {
+ var source = Source(Data("Fon-multi.fon"), out var folder);
+ File.WriteAllText(Path.Combine(folder, "Name-13px.ttf"), "old");
+ var before = Snapshot(folder);
+ var allocator = new RecordingAllocator(folder);
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ var reports = new List();
+ // Synchronous: the report right after the first output was built cancels before anything else happens.
+ var progress = new SyncProgress(value =>
+ {
+ reports.Add(value);
+ if (value == 0.85)
+ cts.Cancel();
+ });
+
+ var conversion = new FontConverter().ConvertAsync(
+ new ConversionStep(source, "fon", "ttf", PresetFor("ttf"), allocator, fx.NewFolder()), progress, cts.Token);
+
+ await Assert.ThrowsAnyAsync(() => conversion.WaitAsync(TimeSpan.FromSeconds(60), TestContext.Current.CancellationToken));
+ Assert.Contains(0.85, reports);
+ Assert.Empty(allocator.Suffixes);
+ Assert.Equal(before.OrderBy(p => p.Key), Snapshot(folder).OrderBy(p => p.Key));
+ }
+
+ [Fact]
+ public async Task Queue_precancel_preserves_existing_outputs()
+ {
+ foreach (var policy in Enum.GetValues())
+ {
+ var source = Source(Data("Fon-multi.fon"), out var folder);
+ File.WriteAllText(Path.Combine(folder, "Name-13px.ttf"), "old");
+ var before = Snapshot(folder);
+ await using var queue = new JobQueue(fx.Catalog);
+ var job = new ConversionJob([source], PresetFor("ttf", policy), "test");
+ job.Cancel();
+
+ await queue.RunAsync(job);
+
+ Assert.Equal(JobState.Cancelled, job.State);
+ Assert.Equal(FileState.Cancelled, Assert.Single(job.Files).State);
+ Assert.Empty(job.Outputs);
+ Assert.Equal(before.OrderBy(p => p.Key), Snapshot(folder).OrderBy(p => p.Key));
+ }
+ }
+
+ // ───────────────────────── C9: extremes ─────────────────────────
+
+ [Fact]
+ public async Task Extreme_heights_preserve_masks_and_overflows_fail()
+ {
+ var strikes = FonReader.Read(Data("Fon-edge.fon"));
+ Assert.Equal([1, 256], strikes.Select(s => s.PixelHeight));
+ foreach (var strike in strikes)
+ {
+ var height = strike.PixelHeight;
+ var font = FonOutlineBuilder.Build(strike);
+ Assert.Equal(1024, U16(font.Require("head"), 18));
+ using var face = SKTypeface.FromStream(new MemoryStream(font.Write()));
+ for (var code = 32; code <= 34; code++)
+ AssertMask(ExpectedMask(code, 8, height), Render(face, code - 32 + 1, height, ArtAscent(height), 8), $"{height}px code {code}");
+ }
+
+ // x=32 at 1024 units per pixel is 32768: past signed 16-bit, an explicit error, never a wrapped coordinate.
+ var pixels = new bool[1, 33];
+ pixels[0, 32] = true;
+ var overflowing = strikes[0] with { Glyphs = [new FonGlyph(32, 33, pixels)], DefaultChar = 0 };
+ Assert.Contains("limits", Assert.Throws(() => FonOutlineBuilder.Build(overflowing)).Message);
+ // 31 pixels wide, x=30 lit: every coordinate and advance ends at 31 * 1024 = 31744 and fits.
+ pixels = new bool[1, 31];
+ pixels[0, 30] = true;
+ var fits = FonOutlineBuilder.Build(strikes[0] with { Glyphs = [new FonGlyph(32, 31, pixels)], DefaultChar = 0 });
+ Assert.Equal(31 * 1024, GlyfTable.ReadAll(fits)[1]!.XMax);
+
+ // A bitmap that ends exactly at the end of the resource is read; one byte further is a clear error.
+ var data = Data("Fon-v2-fixed.fon");
+ var fnt = FntStart(data);
+ var size = (int)BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(fnt + 2));
+ BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(fnt + 0x76 + 2), (ushort)(size - 13));
+ Assert.Single(FonReader.Read(data));
+ BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(fnt + 0x76 + 2), (ushort)(size - 12));
+ Assert.Contains("outside the resource", Assert.Throws(() => FonReader.Read(data)).Message);
+
+ // Through the queue: an overflowing resource writes nothing.
+ var source = Source(Container(WideOnePixelFnt()), out var folder);
+ var job = await fx.ConvertAsync([source], PresetFor("ttf"));
+ Assert.Equal(FileState.Failed, Assert.Single(job.Files).State);
+ Assert.Equal(["Name.fon"], Directory.EnumerateFiles(folder).Select(Path.GetFileName));
+ }
+
+ // ───────────────────────── C10/C12: routes ─────────────────────────
+
+ [Fact]
+ public void Fon_edges_only_leave_existing_font_routes_intact()
+ {
+ var converter = fx.Converters.OfType().Single();
+ Assert.Equal(Formats.Select(t => ("fon", t)).Order(), converter.Edges.Where(e => e.From == "fon").Select(e => (e.From, e.To)).Order());
+ Assert.DoesNotContain(fx.Converters.SelectMany(c => c.Edges), e => e.To == "fon");
+ foreach (var from in Formats)
+ foreach (var to in Formats.Where(t => t != from))
+ Assert.Contains(new ConversionEdge(from, to), converter.Edges);
+ Assert.Equal(20 + 5, converter.Edges.Count);
+
+ var planner = fx.Catalog.CreatePlanner();
+ foreach (var target in Formats)
+ {
+ Assert.Equal("font", Assert.Single(planner.Plan("fon", target)!.Steps).Converter.Id);
+ Assert.Null(planner.Plan(target, "fon"));
+ }
+ }
+
+ // ───────────────────────── Edge cases (problem definition, section 6) ─────────────────────────
+
+ [Fact]
+ public void Zero_pix_width_uses_each_entry_width()
+ {
+ var strike = Assert.Single(FonReader.Read(Data("Fon-v3-proportional.fon")));
+ Assert.Equal(0, strike.PixelWidth);
+ Assert.Equal(0, strike.Glyphs[0].Width);
+ Assert.Equal(5, strike.Glyphs[1].Width);
+ Assert.Equal(9, strike.Glyphs[2].Width);
+ Assert.Equal((13, 5), (strike.Glyphs[1].Pixels.GetLength(0), strike.Glyphs[1].Pixels.GetLength(1)));
+ Assert.True(strike.Glyphs[1].Pixels[0, 2]);
+ Assert.False(strike.Glyphs[0].Pixels.Cast().Any());
+ }
+
+ [Fact]
+ public void Control_bytes_and_delete_keep_glyphs()
+ {
+ var strike = Assert.Single(FonReader.Read(Data("Fon-v2-proportional.fon")));
+ Assert.Equal(128, strike.Glyphs.Count);
+ Assert.Equal((byte)0, strike.Glyphs[0].ByteValue);
+ Assert.Equal((byte)31, strike.Glyphs[31].ByteValue);
+ Assert.Equal((byte)127, strike.Glyphs[127].ByteValue);
+ Assert.True(strike.Glyphs[127].Pixels[0, 2]);
+ }
+
+ [Theory]
+ [InlineData("Fon-v2-fixed.fon", true)]
+ [InlineData("Fon-v3-proportional.fon", false)]
+ public void Blank_and_zero_width_glyphs_keep_metrics(string sample, bool isFixed)
+ {
+ var strike = Assert.Single(FonReader.Read(Data(sample)));
+ var font = FonOutlineBuilder.Build(strike);
+ var glyphs = GlyfTable.ReadAll(font);
+ Assert.Equal(96 + 1, font.GlyphCount);
+ Assert.Null(glyphs[1]); // the blank space, 0 or 8 pixels wide
+ Assert.Equal((isFixed ? 8 : 0) * Scale(13), Advance(font, 1));
+ Assert.Equal(0, Lsb(font, 1));
+ Assert.Equal(1, Cmap(font)[0x20]);
+ for (var code = 33; code <= 127; code++)
+ Assert.Equal(ArtWidth(code, isFixed) * Scale(13), Advance(font, code - 32 + 1));
+ }
+
+ [Fact]
+ public void Default_and_break_are_relative_indices()
+ {
+ var data = Data("Fon-v2-fixed.fon");
+ var fnt = FntStart(data);
+ data[fnt + 0x61] = 1;
+ data[fnt + 0x62] = 2;
+ var strike = Assert.Single(FonReader.Read(data));
+ Assert.Equal((1, 2, (byte)33), (strike.DefaultChar, strike.BreakChar, strike.Glyphs[strike.DefaultChar].ByteValue));
+ // .notdef is the default glyph (code 33, the single pixel); the break glyph (34) is not forced onto U+0020.
+ var font = FonOutlineBuilder.Build(strike);
+ var glyphs = GlyfTable.ReadAll(font);
+ Assert.Equal(glyphs[2]!.Serialize(), glyphs[0]!.Serialize());
+ Assert.Equal(Advance(font, 2), Advance(font, 0));
+ Assert.Equal(1, Cmap(font)[0x20]);
+ Assert.Equal(3, Cmap(font)[0x22]);
+
+ data[fnt + 0x61] = 96;
+ Assert.Contains("relative indices", Assert.Throws(() => FonReader.Read(data)).Message);
+ }
+
+ [Theory]
+ [InlineData(0x04)]
+ [InlineData(0x08)]
+ [InlineData(0x10)]
+ [InlineData(0x20)]
+ [InlineData(0x40)]
+ [InlineData(0x80)]
+ public void V3_non_six_byte_flags_are_rejected_before_glyph_table(int flag)
+ {
+ var data = Data($"Fon-v3-flags-{flag:x2}.fon");
+ var error = Assert.Throws(() => FonReader.Read(data));
+ Assert.Contains($"dfFlags 0x{flag:X8}", error.Message);
+ }
+
+ [Theory]
+ [InlineData(0u)]
+ [InlineData(3u)]
+ [InlineData(5u)]
+ public void V3_zero_or_mixed_flags_are_rejected(uint flags)
+ {
+ var data = Data("Fon-v3-fixed.fon");
+ BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(FntStart(data) + 0x76), flags);
+ var error = Assert.Throws(() => FonReader.Read(data));
+ Assert.Contains($"dfFlags 0x{flags:X8}", error.Message);
+ }
+
+ [Fact]
+ public void Unsupported_containers_charsets_and_resources_fail_as_a_whole()
+ {
+ var data = Data("Fon-multi.fon");
+ var second = FntStart(data, 1);
+ data[second + 0x42] = 1;
+ Assert.Contains("Vector", Assert.Throws(() => FonReader.Read(data)).Message);
+ data[second + 0x42] = 4;
+ Assert.Contains("outside the file", Assert.Throws(() => FonReader.Read(data)).Message);
+ data[second + 0x42] = 0;
+ foreach (var charset in new byte[] { 128, 129, 130, 134, 136, 2, 1, 255, 77, 99 }) // DBCS, SYMBOL, DEFAULT, OEM, MAC, unknown
+ {
+ data[second + 0x55] = charset;
+ Assert.Contains("character set", Assert.Throws(() => FonReader.Read(data)).Message);
+ }
+ data[second + 0x55] = 0;
+ data[second] = 0;
+ data[second + 1] = 1;
+ Assert.Contains("version", Assert.Throws(() => FonReader.Read(data)).Message);
+
+ var pe = Data("Fon-v2-fixed.fon");
+ pe[0x40] = (byte)'P';
+ pe[0x41] = (byte)'E';
+ Assert.Contains("PE FON", Assert.Throws(() => FonReader.Read(pe)).Message);
+ // A bare FNT and an MZ program without fonts.
+ var fixedFon = Data("Fon-v2-fixed.fon");
+ Assert.Contains("MZ", Assert.Throws(() => FonReader.Read(fixedFon[FntStart(fixedFon)..])).Message);
+ var program = Data("Fon-v2-fixed.fon");
+ var ne = 0x40;
+ var table = ne + BinaryPrimitives.ReadUInt16LittleEndian(program.AsSpan(ne + 0x24));
+ BinaryPrimitives.WriteUInt16LittleEndian(program.AsSpan(table + 22), 0x8006);
+ Assert.Contains("no RT_FONT", Assert.Throws(() => FonReader.Read(program)).Message);
+ }
+
+ [Fact]
+ public void Truncated_ne_fnt_and_face_never_produce_outputs()
+ {
+ var source = Data("Fon-v2-fixed.fon");
+ Assert.Contains("NE header", Assert.Throws(() => FonReader.Read(source[..0x40])).Message);
+ var data = (byte[])source.Clone();
+ var ne = 0x40;
+ BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(ne + 0x26), 0x7fff);
+ Assert.Contains("resource table", Assert.Throws(() => FonReader.Read(data)).Message);
+
+ data = (byte[])source.Clone();
+ var table = ne + BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(ne + 0x24));
+ BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(table + 30), (ushort)table);
+ Assert.Contains("overlapping", Assert.Throws(() => FonReader.Read(data)).Message);
+
+ data = (byte[])source.Clone();
+ var fnt = FntStart(data);
+ BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(fnt + 2), 0x7fffffff);
+ Assert.Contains("dfSize", Assert.Throws(() => FonReader.Read(data)).Message);
+
+ data = (byte[])source.Clone();
+ BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(fnt + 0x69), 0x7fffffff);
+ Assert.Contains("face offset", Assert.Throws(() => FonReader.Read(data)).Message);
+
+ data = (byte[])source.Clone();
+ var size = (int)BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(fnt + 2));
+ BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(fnt + 0x69), (uint)(size - 1));
+ data[fnt + size - 1] = (byte)'F';
+ Assert.Contains("NUL", Assert.Throws(() => FonReader.Read(data)).Message);
+
+ data = (byte[])source.Clone();
+ BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(fnt + 0x76 + 2), 0xffff);
+ Assert.Contains("bitmap", Assert.Throws(() => FonReader.Read(data)).Message);
+ }
+
+ [Fact]
+ public void Builder_preserves_pixel_outlines_metrics_and_control_glyphs()
+ {
+ var strike = Assert.Single(FonReader.Read(Data("Fon-v2-proportional.fon")));
+ var font = FonOutlineBuilder.Build(strike);
+ Assert.Equal(strike.Glyphs.Count + 1, font.GlyphCount);
+ Assert.Equal(13 * (1024 / 13), FontTables.UnitsPerEm(font.Require("head")));
+ Assert.Equal(0x00010000u, font.Flavor);
+ var glyphs = GlyfTable.ReadAll(font);
+ var scaled = 1024 / 13;
+ for (var i = 0; i < strike.Glyphs.Count; i++)
+ {
+ var original = strike.Glyphs[i];
+ Assert.Equal(original.Width * scaled, Advance(font, i + 1));
+ if (!original.Pixels.Cast().Any())
+ {
+ Assert.Null(glyphs[i + 1]);
+ Assert.Equal(0, Lsb(font, i + 1));
+ continue;
+ }
+ Assert.NotNull(glyphs[i + 1]);
+ Assert.Equal(glyphs[i + 1]!.XMin, Lsb(font, i + 1));
+ }
+ Assert.Equal(glyphs[strike.DefaultChar + 1]?.Serialize(), glyphs[0]?.Serialize());
+ Assert.Equal((ushort)0x40, BinaryPrimitives.ReadUInt16BigEndian(font.Require("OS/2").AsSpan(62)));
+ }
+}
diff --git a/tests/Filee.Engines.Tests/Fonts/FON-FIXTURES.md b/tests/Filee.Engines.Tests/Fonts/FON-FIXTURES.md
new file mode 100644
index 000000000..b9a3384ce
--- /dev/null
+++ b/tests/Filee.Engines.Tests/Fonts/FON-FIXTURES.md
@@ -0,0 +1,31 @@
+# Synthetic FON test fixtures
+
+`make-test-fon.py` builds NE RT_FONT containers with FNT v2/v3 resources from pixel art defined in the script. No
+bytes or outlines are taken from any existing font, so the samples are original synthetic data under the project's
+MIT licence. The tests read only the committed files and never run the generator.
+
+Rebuild: `uv run tests/Filee.Engines.Tests/Fonts/make-test-fon.py`
+
+Check: `sha256sum tests/Filee.Engines.Tests/Fonts/Fon-*.fon`
+
+| File | SHA-256 |
+|---|---|
+| Fon-v2-fixed.fon | `3b1b05572ba5ce6e0c011e5fb7bcc9ecedf367abd1d58fdb31dca161ca75e9f4` |
+| Fon-v2-proportional.fon | `802ec675899d50afd8b22907a505c8029cb837d555b59dac76492267e977d4b2` |
+| Fon-v3-fixed.fon | `ebc793d05862802a57ee2af3d2abd7425bd97a2957e3f4fd449a50863a8804a8` |
+| Fon-v3-proportional.fon | `0744fc346722abd93f735a6bc42540ccdb5248b703d196d1b5c4dd957b88525d` |
+| Fon-multi.fon | `e1f5379b11a5d4bddcfbb5b7c03c08556d8bb32528e973e58fcecb3290697273` |
+| Fon-edge.fon | `fa8dd5d6424850d6054ae89685cffae30ab2b5757d13be86b97c8d3b24dd8b09` |
+| Fon-v3-flags-04.fon | `6c789389ed490fd4f88dd1e67329ba10464cd76e0c22c1acb95eebf17142f03e` |
+| Fon-v3-flags-08.fon | `c7f99f904af5d83d9b47603fe13645c701bca7ef1c22f362f181c1d506d0d6a3` |
+| Fon-v3-flags-10.fon | `6e5538d1cf78a776f4631861da563163bcd087f7205ebd8898171a6f00071917` |
+| Fon-v3-flags-20.fon | `c218cd58e2bfb33653cf0726137f4c6eb6943c73866d815c72feb43e8f77d58d` |
+| Fon-v3-flags-40.fon | `86caba38ada299971ca997b458e6c15162f3765149aec8070ce1d7bb79d73365` |
+| Fon-v3-flags-80.fon | `89f79b840576b013100e39f87fc306c71d5b161be6bc9eec77e8f5eafa965d5b` |
+
+The basic samples cover 0x20..0x7F at 13 px; only the v2 proportional sample also holds the control codes
+0x00..0x1F. The space is blank, `!` is a single pixel at `(2,0)`, `"` is a diagonal, and every other glyph is a
+framed rectilinear shape. Proportional widths are 0 px for the space, 9 px for even codes and 5 px for odd codes.
+`Fon-multi.fon` holds 13, 13 and 14 px resources, and `Fon-edge.fon` holds 1 and 256 px resources. The flag samples
+check that unsupported v3 dfFlags are rejected; each uses a glyph table entry size, widths and offsets that differ
+from the allowed 6-byte table. The external `fixed.fon` and `tektite.fon` are not included.
diff --git a/tests/Filee.Engines.Tests/Fonts/Fon-edge.fon b/tests/Filee.Engines.Tests/Fonts/Fon-edge.fon
new file mode 100644
index 0000000000000000000000000000000000000000..3e5ff6d90780a108a7eed58a3ade15b5999e2d7c
GIT binary patch
literal 1312
zcmeZ`Vjvqh0CoDg64vg(P=QAgdjlg_b_WB525~qVn80E?8MXpx#s&~4k8u+-0|OJ7
z4dOF{q^JQv)`G2@z{tP>aReuWf)dQ=HBiPT5RGsmOd6TyU;ruNVAu>Mw}7QUu5`<+
zNKIj2P*7lC2Kj*+fD32{!qH%NQRwJRK)(S!05%llN~oi$;n*Q;hQbhbJ2jlHAi%-G
I#5i(E01=HMH~;_u
literal 0
HcmV?d00001
diff --git a/tests/Filee.Engines.Tests/Fonts/Fon-multi.fon b/tests/Filee.Engines.Tests/Fonts/Fon-multi.fon
new file mode 100644
index 0000000000000000000000000000000000000000..98a152f4515496012f2cf0e70d6182e9feab863d
GIT binary patch
literal 6021
zcmeI$drXye9LMqBxp>YKshrHpT(P4w^3uh~QL`q;ThdB~EScdgQ`E>+vr;S@M~4iZ
z_q>&^7!h_;(a=>>XPP=MGncNIQK_XfM~s?UQmLQc`vB`~EH}p=jy=!zex2XpJe?GaP>KRX4Z9v=P5#BS{!C+g#CyUp={F`lTs^L}tg&=@c4
z+{CbBjE}W_%xiaRi7mlCnSG+g9^;sHc6EaZ7uZ|7)i?f#b|3u&UhShgtGiVn)n8?(
zk!rGfRn1n5)Ef1n+M;%;!|DfhR{g2`KF8dol2kACuo|qg)L1n|%}_<^O;w`SsV~%C
zbyS^Dwd!x>_#JbL>ZTqQg5hvYNaYu8`XAoKvk(z>JN2UwQb{=+f}l9
zP({>Gm7^x9SJW)EP_0%UsLg7p`dU@1U)2TW3pggE?o>V1Lu!y3rpBn}Rlb^|ma2Ev
zC+c&xM;*~`{CME8=kjwW@T+Me+_cgn9CG|#PlIV)?3NaL+`VIrdkoxTVBZbyd2oFn
z1LL!!uO+st%(r9AKK)^}w~e!|`PX&W$uYlaSI5+2P*dA69kC1EhmT-74##KlMVyIq
zaRt7I6}S!e<9GNA{*IUMM!$XCPIwolU>ZJ&Pvb;<8KbxW-^R6AiC^L&{2tHXdGxli
zub7B;U=K{iftZP-F%PF>Auhpp@MHW8cjGts6V~8G3~1^(=4K4zz1SCr;8QpbU%=O~
z7?)!yZosX$55L8qu@3*m1f745xedGF1K1Crz!8{>Q?UT&<6BscpGsrwIqlAG_w$JR
znaqAHbV8}sTjqDxCTGM*qq+O`Y4{nI=qB!nVOxkJEr0w%))0dHlb&-o`yQB2J6vbrrwIN*_^^Uf*Cjp^DrNaa2b|j1#ZWKcnr_r1=K$Q^f{mdXR|%0o0;Dx
m9CxC}U4e(TWPl3ZfA9VOfsp(
z{7q4A%t+0~CEe&^5=i-ssE)V(~hqPo@b_Vp^dMo~%)
zN>Pmlv>=FX3}GD8Sil;#v4>+^;1-W~h3QsOgmToP34U~<7hz0b7R%T`0tYz774Gna
zcPLXyF)C4qX0)RVeHg_g=CFcI?BEb*xW+x6@qvOIB_*grJ!}LJ!XU;lg?X%E3%fYN
zId1TP7q~r2yeLBr8qtal^k4)L%wP%Yh+`ioxa7u@p0?r0K!56LvdPN!=vZE5x{OTr
ZRp{`g=beFwa|X^C=)b}F4*vHUd;*Nssf+*s
literal 0
HcmV?d00001
diff --git a/tests/Filee.Engines.Tests/Fonts/Fon-v2-proportional.fon b/tests/Filee.Engines.Tests/Fonts/Fon-v2-proportional.fon
new file mode 100644
index 0000000000000000000000000000000000000000..e9ae81e9aa96dc4b9b20082c8df35a302ddeb2f4
GIT binary patch
literal 3283
zcmeIze`Hos9LMp`^X#7Ix#!tdGiJ(c56g_C{-}AzY)iISDrSm)5H%_*)*oq0b*+d*
zrJ}{il&l|PBB`i|q+}a0kyLCEnI>D5dY^muchN@w-0vUfb-(AFdoRcLd++)!`sZlm
z&J9g}zr9f%f7p}`65;ACMY_bFox8270x1!M8yJ!tQe23pL#z}#%hT%s>gNMjtB5G_Tn9UjBjuP6Dg|&DWhs!g&T1j9>n9=
zhd1ykj^JmU!Q!-4McSwqn{fy3!;^Rc2k`;E#t--dt#4H6Tdlwr+>D)g6ffa5e1tD?
z9H+3*S}nFlD{(b$#ogG0XK(=T;xN9&Y4kEi~2ji(Hh%+obqF
z_Lb6dkMPs(e_mT7gAhEaUn&2rvK6lzX7@L
BzV-kB
literal 0
HcmV?d00001
diff --git a/tests/Filee.Engines.Tests/Fonts/Fon-v3-fixed.fon b/tests/Filee.Engines.Tests/Fonts/Fon-v3-fixed.fon
new file mode 100644
index 0000000000000000000000000000000000000000..298e1887f048d7b481f2513900cf24fe4f3d544e
GIT binary patch
literal 2157
zcmeIxKPW{}9KiA4eeZeicoIc1815?u1I1vVyjSw4{K-I}4CE;i#XwQ-4hD*1pco7k
z1ELrV6oY}mV4xT%1_Q-lpvXPn_sXAvlEHA#@Hyvqf4_6j?ex3VP4epzvUk>${=7XT
zUEdV>EQ9QdL}wkS*t1pH^@$i31vYLS{>AXyFLCJ?O&FpiQLo~~jOba9GjbxAdVc>b
za$issDY>&tl{}-*t=Fj-z+5cC3arOg?7<-%#c5o?b=<*2JjH8#z*kK0=yTIC3-d9E
zRoIB_*oPL5<18-WChp-8p5qNZqAO0Hn1mUajRjbOwb+cE*pI_FiF3G&Tey$Mc!9V0
zgkG;cHw8`1!9py@I&8si9K;cv!g*Z7Z9Kpeyuy2YLBHL9O42YB^RO5zu>r%_ixC{d
w8C=8-TcO3vItIdRpF7L^7@En-<1%6f19}9bc_$EY?!dVN{Wmy$@W1ci4GFlbMF0Q*
literal 0
HcmV?d00001
diff --git a/tests/Filee.Engines.Tests/Fonts/Fon-v3-flags-04.fon b/tests/Filee.Engines.Tests/Fonts/Fon-v3-flags-04.fon
new file mode 100644
index 0000000000000000000000000000000000000000..344c51921ca34b8bd992f087f11ee95cd7ac5fbf
GIT binary patch
literal 2351
zcmeIxKPW|E7{~GFa_@C1l7V7yi()WP6opU>guy@<3>3vckqoXF3=9SX#XvF0pJFgj
z378|zKi^&^-QP@#
z8S#X#u8K<=1h6$34TyL~!V~lmZNtB)I9(*l7Mk)%K;rEzR)Qf*tGQ^Gi8Qdx@~Csb=%c6Kf=%P8~hP}!{hW3Ha|tv*2&bgb@K31yasQ=JMca{giqqL
z_%gnQ@8c)G&?=4-^`pm(L@G86kZ^L`=A$%O4#uxB)duFk~QtU
zWoTNTxp)a)jW^=$crQMTPvA57626J=;m7y|ev3ch`pJj;iPyA#QZ=p5EIc1C$LsJG
zyc6%&w6edls%x;L-3>pE!H*NmU}C`gx#zla*9)_)3f-VM)2i4FBGW`7FGM8rg@{D{
Jh5zs`yaR=_v^@X-
literal 0
HcmV?d00001
diff --git a/tests/Filee.Engines.Tests/Fonts/Fon-v3-flags-08.fon b/tests/Filee.Engines.Tests/Fonts/Fon-v3-flags-08.fon
new file mode 100644
index 0000000000000000000000000000000000000000..c241cefa28cb059128ffd97c50e393089f3930ce
GIT binary patch
literal 3377
zcmeIyKS)(!9LDkYUbTDfRjCFC2jMzMh(kj{fx$iC9~xA|p~1l+G%6el3=|SiP(wr{
zI5;>ogh-%+LV++-O)p|?gzE6GNe_5Av*#+$H2P{^JB8^kxk6`(Jw7Yrz}b5(GBzq`U9;jQxVO*
zVo7rwEotRW^d9;ceT}|DKcHLa9{LNteBrQegC*6yVM%qn(E)TA9Y<%;MRXheh900*
znZvqQEvfEJOR9SteTa^r6X+bejP9V{(LZRlZmsR}HA|`+T2kE}bP#=tPNMJ8Rdg5q
ziI!wlO)J+}(*3<|Np)M$UUUe3j=n(`&^7cEx{qe`uv^_)ORC#sNp)|bchD#33-m3z
zgs!8X(cfsfy)gQ}b(U1OU`ch`(Yt7bzCx$bkLV`)6;0chK`URhq`LK%RJR%JLLXSt
z-o#&(w);cdzEG=Hh_4m$YQ>W%ZYRh}sJ2p+WC53K
aqD%I`zYfWBknDlJipg`3?7{hX4*mjDl@9iXeU!JtcKUZj;Yl)-2?A)Zu0RZ1{~5fxmtV+NydJ}F7d
zRXca=>YMc#i>Z`^`e@ob(Wx5Dd(|o9HAT!4F?+=L6*dSy_f^B^g=+W=5;H-}0x`dd
zNhYl)d}>w0r$IHcdWjh!W|o*=#2gTF!z=DNs~TAys*x2D^NETw&szz2nF~h`sC1#D7Kg8UMUjm$VU+PsO>!oUBy(Q)|F+Yge
zA?8|janE;E!>3s_vR)H2M$9}h8^lPB^@PuD)$nOljXie}^MRN-VwQ>7C*~$zcT@0r
zpc+}Ns*&}Em`P$5iPZfdlRuI
i&7C**{-AxhbKuT_I|uH6q0k9jKXCoP^@IPq9~=U;-7ls9
literal 0
HcmV?d00001
diff --git a/tests/Filee.Engines.Tests/Fonts/Fon-v3-flags-20.fon b/tests/Filee.Engines.Tests/Fonts/Fon-v3-flags-20.fon
new file mode 100644
index 0000000000000000000000000000000000000000..daaa906dd7d489e692b49fac97c0f9a23762620f
GIT binary patch
literal 7509
zcmeI0QG`@c7{|}ure=0(Yiet&Z6EILvcwV*J)|A6i8RC#OZ2cwX<1@z#Eu@)LzIXm
zmgpg=h=?VIi0C0bL_`d+L=Wj9qV4|fo$uehtDR<6PwO1!p81{o`@TEweCNB|6XR~x
zqZ~z6M*kgNb{CsS`DzfKedLlEC^Xg{y9a&F1=U!H4(q`GF^W-=4ieo8T)}nmt<#0G
z%#8Z_WL{(rqffsZaD&O(vSS#XkF|KcKQj9^m@*JWofcuvi!k4bFuU^>hdCy~ye`6g
zEy64oEDrOq2y<41SrcJ?7hxXgv@Dp@BFu*(%r7F$VQytSFwclE?};$$BFvtR7KeFC
zggGa|d?Ui_+GKH<6%poD5#~z~W-k7diN!o9!n`cPTo7S?6=Ck%oWA;$2=l%O^RtMY
zLt88kb6kXZSA_Xdgjp(C9Og+8=4}z?D-mXXtHog+6JcHvVZIPy{t{vC-)31bFNrXp
zi7*#Mn0vb|4)cNtb6$k`NrV}0w>Zo(5#}8c=6exlafiiW9v5NW5MkCtm_d)lVIC1-
zPKz*~i!gtPFo$mN$m)AuZpk^v?26^rn3$eXIO#^t+LISotyKE5uib
ruaLNBW_O8y$~0>mnTFzfi0>i3hxi`i-}ANfN;EE%#w*b^@Ra!t9&oav
literal 0
HcmV?d00001
diff --git a/tests/Filee.Engines.Tests/Fonts/Fon-v3-flags-40.fon b/tests/Filee.Engines.Tests/Fonts/Fon-v3-flags-40.fon
new file mode 100644
index 0000000000000000000000000000000000000000..dfa07c58595473429b447955da2a479e894f627e
GIT binary patch
literal 12695
zcmeH}O=uHQ6ouckVyD$=jn(>7>(pwk)@pGhh|o#YY{b%yn;^J}LP10jp$O6-f(Rml
zAl(R31VN(UrU-6=;6?-yL=ZvT2-1xp-3Zcl&P>8RPmIaLiL3jdnVdP_+-VN?&D@hY
z)9~_xE60!5zT7wG{{poYoX{0h?+QC=*67fGXj{~Ea-{5{g(
z7fFM^wASXokp@4}B|i99q*4FyI`P5Jk_O+C5+D36(%=_JgP&Y)^PfqB&vuIs{sn2&
zAKf56_!-jRJ2#3C{u*iU^Q6I_>9P6uq`@EABtH0Oq)~rxv-sd|lLp_qMSSp6q``k7
z4gS52V4L*e*W!5^2;Q^27&!pEUSX
zzxd#zmf)@8?^bCq`@EB
zEk5`nY1Ho?5+D3c(%=_KgFm;&=0A}JKe1PQ@Xtx3{>ZTS;AcsL@7O0k_-WGM7f6FY
zJ!13kNQ2Mr7a#mn(x^XhKz#5sq`@0{>wv#Z8dvgLDLXlJHZSFx{LO1}{XdrXy4)2l
zj%5}6@T7GvSuy5ejYUP5!b9|F{#`g&O7isyJ(`CXq*iQRwsPP
Upw26o$Wo3TG{x?i(L=_BTtC43n1;)Lv2wj13FC*(hfLJWz*)>V!#HB~
zM}p%Zj;{AVVv^=`z@~h<-uHf3nvZzV_0jaDcarq}iepS*eYXg#$GENZf^~g*`qDdr
z<>oPg)#2a%B!yL#KY4{CtlXJQV7;~otosEV1MAW(Cb0TuGlA7KhY75*xlCYvvIwka
z^Ed|9twJWShUPPY)m_8{R&6m8Sm6asV7;>ltcN8W1MBQUCa?xdnZQbvF@aUMhzYE(
z7J>EB;TTv~7c+r%s+>p>02z`9(^1Xlk#Ca{|8
zn80$@GlBKlBCwus;22o9H!^{BauX9+J)4=ps@uW@R?b!?u-;n))}wlkfpu;h6IjO@
zn80dnWCE)w&IHysi@B>_O6;37&_87HAMWiBmN08Jb+UW%?bY=
zpAhNBeV9R#@>QdQm1*Hbo`=&nQBd39
zGSfHF$jO}cL_x`#(4Hu#vaFd;d!nE+)1D|OS=tjNB}?Z-p&OX?L`h|)bE42P(>YO4
znQ2cHlq{VSB_&JeM4=m)_C!f#rgNguGSfLxP?>2@6qGET6D1`}=R~0!nD#_TWu|kY
z&@$6GQBav_PZX3aof9P`OXozP8<_S)NoA&UqR=waIZ;rVX-^cCES(c2B}?Z-p&OX?
zL`h|)bE42P(>YO4nQ2cHlq{VSB_&JeM4=m)_C!f#rgNguGSfLxP?>2@6qGET6D1`}
N=R~0!*#FZLe*ulzw`l+X
literal 0
HcmV?d00001
diff --git a/tests/Filee.Engines.Tests/Fonts/Fon-v3-proportional.fon b/tests/Filee.Engines.Tests/Fonts/Fon-v3-proportional.fon
new file mode 100644
index 0000000000000000000000000000000000000000..f6327a63e23eb5ff966b77e6d851159ca0855fc6
GIT binary patch
literal 2755
zcmeIzKPW|U6u|NGo_D=>U-?rcW$*@yk}{AOo)iXuQc@BFp%exNl6tQg7?gp5K}i|>
zi9s0{7?hNOfubY^29h!`kl*>e0!Pn
zeADEQdZ@07v+5=gyS652K@m@s29x4+`xl{OygFz~e>6d5ODLkN#yK^SD_vuz@Kw?#
zi&k46us=@6rJ8I>J}$+LxE+t+3A~6m@F70KPxu|D`*dcu$(Dq14er7NcoNUyO}vLM
z@E!JAIw#3wOLB1$uE(u-5Rc(`yn^@f3BJd#I3=FPXR;+FxC*!7K0JDMA;v9`)
iXA=%-gq=;)3AfFCAnplq-vx0$4kGT?QsiI%FMI$@gHH$m
literal 0
HcmV?d00001
diff --git a/tests/Filee.Engines.Tests/Fonts/make-test-fon.py b/tests/Filee.Engines.Tests/Fonts/make-test-fon.py
new file mode 100644
index 000000000..64df49e8e
--- /dev/null
+++ b/tests/Filee.Engines.Tests/Fonts/make-test-fon.py
@@ -0,0 +1,136 @@
+#!/usr/bin/env -S uv run --script
+# /// script
+# requires-python = ">=3.10"
+# dependencies = []
+# ///
+# How to run: uv run tests/Filee.Engines.Tests/Fonts/make-test-fon.py
+# Rebuild committed synthetic fixtures only when changing the generator; tests never run it.
+
+from __future__ import annotations
+
+import hashlib
+import struct
+from pathlib import Path
+
+HERE = Path(__file__).resolve().parent
+
+
+def put(data: bytearray, offset: int, fmt: str, value: int) -> None:
+ struct.pack_into("<" + fmt, data, offset, value)
+
+
+def fnt(version: int, height: int = 13, *, fixed: bool = True, first: int = 32,
+ last: int = 127, flags: int | None = None) -> bytes:
+ """Create a complete, independent FNT resource with column-major bitmaps."""
+ count = last - first + 1
+ # Rejected flag samples deliberately do not have the allowed 6-byte table.
+ entry_size = 4 if version == 2 else 6 if flags is None else 8 + (flags.bit_length() - 3) * 2
+ table = 0x76 if version == 2 else 0x94
+ face = table + (count + 1) * entry_size
+ bits = face + len(b"Fixed\0")
+ data = bytearray(bits)
+ put(data, 0, "H", version << 8)
+ put(data, 0x42, "H", 0)
+ put(data, 0x4A, "H", min(height, 10))
+ put(data, 0x4E, "H", 2)
+ put(data, 0x53, "H", 400)
+ data[0x55] = 0
+ put(data, 0x56, "H", 8 if fixed else 0)
+ put(data, 0x58, "H", height)
+ put(data, 0x5D, "H", 9)
+ data[0x5F:0x63] = bytes((first, last, 0, 0))
+ put(data, 0x69, "I", face)
+ put(data, 0x71, "I", bits)
+ if version == 3:
+ put(data, 0x76, "I", flags if flags is not None else (1 if fixed else 2))
+ if flags is not None:
+ put(data, 0x7A, "H", flags + 1)
+ put(data, 0x7C, "H", flags + 2)
+ put(data, 0x7E, "H", flags + 3)
+ data[face:bits] = b"Fixed\0"
+
+ for index, code in enumerate(range(first, last + 1)):
+ width = (flags + index % 3) if flags is not None else (
+ 8 if fixed else (0 if code == 32 else 5 if code % 2 else 9))
+ entry = table + index * entry_size
+ put(data, entry, "H", width)
+ if flags is not None:
+ put(data, entry + 2, "H", flags + index % 3)
+ put(data, entry + 4, "I", len(data))
+ else:
+ put(data, entry + 2, "H" if version == 2 else "I", len(data))
+ # x=2 creates a left bearing; an isolated pixel, diagonal and hole are
+ # defined independently of the parser. Space and zero-width glyphs are blank.
+ for column in range((width + 7) // 8):
+ for y in range(height):
+ value = 0
+ for bit in range(8):
+ x = column * 8 + bit
+ if x >= width or code == 32:
+ continue
+ lit = (code == 33 and x == 2 and y == 0) or (
+ code == 34 and x == 2 + y % max(1, width - 2)) or (
+ code != 33 and code != 34 and
+ 2 <= x < width and y < min(height, 7) and
+ (x in (2, width - 1) or y in (0, min(height, 7) - 1)))
+ if lit:
+ value |= 0x80 >> bit
+ data.append(value)
+ sentinel = table + count * entry_size
+ put(data, sentinel, "H", 0)
+ put(data, sentinel + (4 if flags is not None else 2),
+ "I" if flags is not None or version == 3 else "H", len(data))
+ put(data, 2, "I", len(data))
+ return bytes(data)
+
+
+def fon(resources: list[bytes]) -> bytes:
+ """Write MZ/NE, a FONTDIR entry, RT_FONT entries, and aligned FNT payloads."""
+ ne = 0x40
+ table = ne + 0x40
+ payload = table + 2 + 8 + 12 + 8 + 12 * len(resources) + 2
+ data = bytearray(payload)
+ data[:2] = b"MZ"
+ put(data, 0x3C, "I", ne)
+ data[ne:ne + 2] = b"NE"
+ put(data, ne + 0x24, "H", table - ne)
+ put(data, ne + 0x26, "H", payload - ne)
+ put(data, table + 2, "H", 0x8007)
+ put(data, table + 4, "H", 1)
+ put(data, table + 10, "H", len(data))
+ put(data, table + 12, "H", 1)
+ data.append(0)
+ font_type = table + 22
+ put(data, font_type, "H", 0x8008)
+ put(data, font_type + 2, "H", len(resources))
+ for index, resource in enumerate(resources):
+ if len(data) > 0xFFFF or len(resource) > 0xFFFF:
+ raise OverflowError("NE unshifted resource exceeds WORD range")
+ entry = font_type + 8 + index * 12
+ put(data, entry, "H", len(data))
+ put(data, entry + 2, "H", len(resource))
+ put(data, entry + 6, "H", 0x8001 + index)
+ data.extend(resource)
+ return bytes(data)
+
+
+def main() -> None:
+ samples = {
+ "Fon-v2-fixed.fon": [fnt(2)],
+ "Fon-v2-proportional.fon": [fnt(2, fixed=False, first=0)],
+ "Fon-v3-fixed.fon": [fnt(3)],
+ "Fon-v3-proportional.fon": [fnt(3, fixed=False)],
+ "Fon-multi.fon": [fnt(2), fnt(3), fnt(3, 14)],
+ "Fon-edge.fon": [fnt(3, 1, first=32, last=34), fnt(3, 256, first=32, last=34)],
+ }
+ for flag in (0x04, 0x08, 0x10, 0x20, 0x40, 0x80):
+ samples[f"Fon-v3-flags-{flag:02x}.fon"] = [fnt(3, flags=flag)]
+ for name, resources in samples.items():
+ path = HERE / name
+ contents = fon(resources)
+ path.write_bytes(contents)
+ print(f"{hashlib.sha256(contents).hexdigest()} {path.name}")
+
+
+if __name__ == "__main__":
+ main()
From 2d420944a93782dd1f4ed88336f9956cbd54b963 Mon Sep 17 00:00:00 2001
From: SeoJCalive <180237232+SeoJCalive@users.noreply.github.com>
Date: Sun, 25 Oct 2026 15:08:42 +0900
Subject: [PATCH 2/9] Learn .fon in saved libraries with settings schema 47
---
src/Filee.Core/Settings/UserDataStore.cs | 4 +--
tests/Filee.Core.Tests/FonFormatTests.cs | 33 +++++++++++++++++++++++-
2 files changed, 34 insertions(+), 3 deletions(-)
diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs
index 2d23b4600..e073ef9f9 100644
--- a/src/Filee.Core/Settings/UserDataStore.cs
+++ b/src/Filee.Core/Settings/UserDataStore.cs
@@ -883,9 +883,9 @@ public static bool ApplyToLibrary(int from, List presets, List Assert.True(FormatRegistry.Known.Single(f => f.Id == t).Writable));
}
+
+ [Fact]
+ public void Version_46_libraries_learn_fon_once()
+ {
+ var presets = BuiltInData.CreatePresets();
+ var profiles = BuiltInData.CreateProfiles();
+ var fonts = profiles.Single(p => p.Id == "fonts");
+ fonts.Extensions.Remove("fon");
+
+ Assert.True(SettingsMigrations.ApplyToLibrary(46, presets, profiles));
+
+ Assert.Contains("fon", fonts.Extensions);
+ Assert.Equal("fonts", ProfileSelector.Select(profiles, ["Fixed.fon"])?.Id);
+ Assert.False(SettingsMigrations.ApplyToLibrary(46, presets, profiles));
+ }
+
+ [Fact]
+ public void Version_46_migration_keeps_a_custom_fon_claim()
+ {
+ var presets = BuiltInData.CreatePresets();
+ var profiles = BuiltInData.CreateProfiles();
+ var fonts = profiles.Single(p => p.Id == "fonts");
+ fonts.Extensions.Remove("fon");
+ profiles.Insert(0, new ToolbarProfile { Id = "retro", Extensions = ["fon"] });
+
+ SettingsMigrations.ApplyToLibrary(46, presets, profiles);
+
+ Assert.DoesNotContain("fon", fonts.Extensions);
+ Assert.Equal("retro", ProfileSelector.Select(profiles, ["Fixed.fon"])?.Id);
+ }
}
From 283a8a3ef6d1c81fc398dd89716a21b3d2d3d3d5 Mon Sep 17 00:00:00 2001
From: SeoJCalive <180237232+SeoJCalive@users.noreply.github.com>
Date: Sun, 25 Oct 2026 20:01:24 +0900
Subject: [PATCH 3/9] Bound .fon glyph bitmaps before allocation and honour
cancellation
A small crafted .fon could alias one large bitmap from every glyph and force
minutes of work and about 1 GB of memory. The reader now charges
max(width, 1) * height per glyph across the whole file against a 4,194,304-unit
budget and rejects heights above 16384 before allocating any pixels. Resource
overlap is checked after sorting, same-height ordinals are counted in one pass,
and cancellation is observed in the reader, the converter and every outline and
serialization stage. Output for valid fonts is unchanged.
---
src/Filee.Engines/Fonts/FonOutlineBuilder.cs | 46 +++-
src/Filee.Engines/Fonts/FonReader.cs | 50 +++-
src/Filee.Engines/Fonts/FontConverter.cs | 11 +-
tests/Filee.Engines.Tests/FonTests.cs | 260 +++++++++++++++----
4 files changed, 292 insertions(+), 75 deletions(-)
diff --git a/src/Filee.Engines/Fonts/FonOutlineBuilder.cs b/src/Filee.Engines/Fonts/FonOutlineBuilder.cs
index de9a6b7ca..18a55c3b4 100644
--- a/src/Filee.Engines/Fonts/FonOutlineBuilder.cs
+++ b/src/Filee.Engines/Fonts/FonOutlineBuilder.cs
@@ -11,7 +11,15 @@ internal static class FonOutlineBuilder
private readonly record struct Corner(int X, int Y);
private readonly record struct Edge(Corner From, Corner To);
- public static SfntFont Build(FonStrike strike, int resourceCount = 1, int sameHeightOrdinal = 1)
+ public static SfntFont Build(FonStrike strike, int resourceCount = 1, int sameHeightOrdinal = 1) =>
+ Build(strike, CancellationToken.None, resourceCount, sameHeightOrdinal);
+
+ ///
+ /// Builds a validated strike, observing for each glyph and raster row,
+ /// and at bounded intervals during edge expansion, grouping, contour walking and point compaction.
+ ///
+ public static SfntFont Build(FonStrike strike, CancellationToken cancellationToken,
+ int resourceCount = 1, int sameHeightOrdinal = 1)
{
if (resourceCount < 1 || sameHeightOrdinal < 1 || strike.Glyphs.Count is < 1 or > 65534)
throw new InvalidDataException("FON resource or glyph count exceeds TrueType limits.");
@@ -30,10 +38,11 @@ public static SfntFont Build(FonStrike strike, int resourceCount = 1, int sameHe
var encoding = FonReader.CharSetEncoding(strike.CharSet);
foreach (var source in strike.Glyphs)
{
+ cancellationToken.ThrowIfCancellationRequested();
var advance = checked(source.Width * scale);
if (advance > ushort.MaxValue)
throw new InvalidDataException("FON glyph advance exceeds TrueType limits.");
- glyphs.Add(Outline(source, strike.Ascent, scale));
+ glyphs.Add(Outline(source, strike.Ascent, scale, cancellationToken));
widths.Add(advance);
try
{
@@ -54,7 +63,12 @@ public static SfntFont Build(FonStrike strike, int resourceCount = 1, int sameHe
glyphs[0] = glyphs[strike.DefaultChar + 1];
widths[0] = widths[strike.DefaultChar + 1];
var (glyf, loca, longLoca) = GlyfTable.Build(
- [.. glyphs.Select(g => g?.Serialize() ?? [])], longFormat: false, alignment: 2);
+ [.. glyphs.Select(g =>
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return g?.Serialize() ?? [];
+ })], longFormat: false, alignment: 2);
+ cancellationToken.ThrowIfCancellationRequested();
var leftBearings = glyphs.Select(g => g?.XMin ?? (short)0).ToArray();
var covered = glyphs.Where(g => g is not null).Cast().ToArray();
var xMin = covered.Length == 0 ? 0 : covered.Min(g => g.XMin);
@@ -101,16 +115,19 @@ public static SfntFont Build(FonStrike strike, int resourceCount = 1, int sameHe
return font;
}
- private static TrueTypeGlyph? Outline(FonGlyph source, int ascent, int scale)
+ private static TrueTypeGlyph? Outline(FonGlyph source, int ascent, int scale, CancellationToken cancellationToken)
{
var height = source.Pixels.GetLength(0);
if (source.Pixels.GetLength(1) != source.Width)
throw new InvalidDataException("FON glyph pixels and width disagree.");
+ if (source.Width == 0)
+ return null;
// Extend identical horizontal runs into maximal rectangles, then cancel each shared unit edge.
var active = new Dictionary<(int, int), (int Start, int End)>();
var rectangles = new List<(int X0, int X1, int Y0, int Y1)>();
for (var y = 0; y <= height; y++)
{
+ cancellationToken.ThrowIfCancellationRequested();
var runs = new HashSet<(int, int)>();
if (y < height)
for (var x = 0; x < source.Width;)
@@ -138,29 +155,46 @@ void Add(Corner from, Corner to)
if (!edges.Remove(new Edge(to, from)))
edges.Add(new Edge(from, to));
}
+ var work = 0;
foreach (var (x0, x1, y0, y1) in rectangles)
{
+ cancellationToken.ThrowIfCancellationRequested();
for (var x = x0; x < x1; x++)
{
+ if ((++work & 4095) == 0)
+ cancellationToken.ThrowIfCancellationRequested();
Add(new(x, y0), new(x + 1, y0));
Add(new(x + 1, y1), new(x, y1));
}
for (var y = y0; y < y1; y++)
{
+ if ((++work & 4095) == 0)
+ cancellationToken.ThrowIfCancellationRequested();
Add(new(x1, y), new(x1, y + 1));
Add(new(x0, y + 1), new(x0, y));
}
}
- var outgoing = edges.GroupBy(e => e.From).ToDictionary(g => g.Key, g => g.ToList());
+ var outgoing = new Dictionary>();
+ foreach (var edge in edges)
+ {
+ if ((++work & 4095) == 0)
+ cancellationToken.ThrowIfCancellationRequested();
+ if (!outgoing.TryGetValue(edge.From, out var choices))
+ outgoing.Add(edge.From, choices = []);
+ choices.Add(edge);
+ }
var points = new List();
var ends = new List();
while (edges.Count != 0)
{
+ cancellationToken.ThrowIfCancellationRequested();
var start = edges.First();
var edge = start;
var loop = new List();
do
{
+ if ((++work & 4095) == 0)
+ cancellationToken.ThrowIfCancellationRequested();
if (!edges.Remove(edge))
throw new InvalidDataException("FON outline has an open boundary.");
loop.Add(edge.From);
@@ -177,6 +211,8 @@ void Add(Corner from, Corner to)
} while (true);
for (var i = 0; i < loop.Count; i++)
{
+ if ((++work & 4095) == 0)
+ cancellationToken.ThrowIfCancellationRequested();
var prev = loop[(i + loop.Count - 1) % loop.Count];
var curr = loop[i];
var next = loop[(i + 1) % loop.Count];
diff --git a/src/Filee.Engines/Fonts/FonReader.cs b/src/Filee.Engines/Fonts/FonReader.cs
index df651320a..987bb3d82 100644
--- a/src/Filee.Engines/Fonts/FonReader.cs
+++ b/src/Filee.Engines/Fonts/FonReader.cs
@@ -15,7 +15,19 @@ internal sealed record FonStrike(
/// Reads the monochrome FNT v2/v3 resources of a 16-bit NE font library.
internal static class FonReader
{
- public static IReadOnlyList Read(byte[] data)
+ ///
+ /// Maximum sum of max(width, 1) * height for every glyph entry across all RT_FONT resources;
+ /// zero-width rows and entries aliasing the same bitmap bytes each count separately.
+ ///
+ internal const int MaxGlyphPixels = 4_194_304;
+
+ public static IReadOnlyList Read(byte[] data) => Read(data, CancellationToken.None);
+
+ ///
+ /// Validates every glyph entry's max(width, 1) * height across all RT_FONT resources before decoding
+ /// (aliases count per entry), observing at each glyph during preflight and decoding.
+ ///
+ public static IReadOnlyList Read(byte[] data, CancellationToken cancellationToken)
{
if (data.Length < 0x40 || data[0] != 'M' || data[1] != 'Z')
throw new InvalidDataException("FON requires an MZ executable header.");
@@ -36,7 +48,6 @@ public static IReadOnlyList Read(byte[] data)
if (shift > 15)
throw new InvalidDataException("FON has an invalid NE resource alignment shift.");
- var strikes = new List();
var ranges = new List<(long Start, long End)>();
var cursor = table + 2;
var terminated = false;
@@ -61,23 +72,32 @@ public static IReadOnlyList Read(byte[] data)
var entry = cursor + (long)i * 12;
var start = (long)U16(data, entry) << shift;
var length = (long)U16(data, entry + 2) << shift;
- if (length == 0 || start < end || !Fits(data.Length, start, length) ||
- ranges.Any(r => start < r.End && r.Start < start + length))
+ if (length == 0 || start < end || !Fits(data.Length, start, length))
throw new InvalidDataException("FON has an overlapping or out-of-bounds RT_FONT resource.");
ranges.Add((start, start + length));
- strikes.Add(ReadFnt(data.AsSpan((int)start, (int)length)));
}
}
cursor += (long)count * 12;
}
if (!terminated)
throw new InvalidDataException("FON has an unterminated NE resource table.");
- if (strikes.Count == 0)
+ if (ranges.Count == 0)
throw new InvalidDataException("FON contains no RT_FONT resources.");
+ var sorted = ranges.OrderBy(r => r.Start).ToArray();
+ for (var i = 1; i < sorted.Length; i++)
+ if (sorted[i].Start < sorted[i - 1].End)
+ throw new InvalidDataException("FON has an overlapping or out-of-bounds RT_FONT resource.");
+ long glyphPixels = 0;
+ foreach (var (start, stop) in ranges)
+ ReadFnt(data.AsSpan((int)start, (int)(stop - start)), false, ref glyphPixels, cancellationToken);
+ var strikes = new List(ranges.Count);
+ foreach (var (start, stop) in ranges)
+ strikes.Add(ReadFnt(data.AsSpan((int)start, (int)(stop - start)), true, ref glyphPixels, cancellationToken)!);
return strikes;
}
- private static FonStrike ReadFnt(ReadOnlySpan resource)
+ private static FonStrike? ReadFnt(ReadOnlySpan resource, bool decode, ref long glyphPixels,
+ CancellationToken cancellationToken)
{
if (resource.Length < 6)
throw new InvalidDataException("RT_FONT has a truncated FNT header.");
@@ -106,7 +126,7 @@ private static FonStrike ReadFnt(ReadOnlySpan resource)
var first = fnt[0x5f];
var last = fnt[0x60];
var count = last - first + 1;
- if (height == 0 || ascent > height || count <= 0)
+ if (height == 0 || height > 16384 || ascent > height || count <= 0)
throw new InvalidDataException("FNT has an invalid height, ascent or character range.");
var defaultChar = fnt[0x61];
var breakChar = fnt[0x62];
@@ -142,21 +162,31 @@ private static FonStrike ReadFnt(ReadOnlySpan resource)
var bitsOffset = (long)U32(fnt, 0x71);
if (bitsOffset < tableEnd || bitsOffset > size)
throw new InvalidDataException("FNT bitmap region offset is outside the resource.");
- var glyphs = new List(count);
+ var glyphs = decode ? new List(count) : null;
for (var i = 0; i < count; i++)
{
+ cancellationToken.ThrowIfCancellationRequested();
var entry = header + i * entrySize;
var width = U16(fnt, entry);
var offset = version == 0x0200 ? U16(fnt, entry + 2) : U32(fnt, entry + 2);
var bytes = ((long)width + 7) / 8 * height;
if (offset < bitsOffset || !Fits(size, offset, bytes))
throw new InvalidDataException($"FNT bitmap for character {first + i} is outside the resource.");
+ if (!decode)
+ {
+ glyphPixels += (long)Math.Max((int)width, 1) * height;
+ if (glyphPixels > MaxGlyphPixels)
+ throw new InvalidDataException($"FON glyph bitmap rows/pixels exceed the {MaxGlyphPixels:N0}-unit limit.");
+ continue;
+ }
var pixels = new bool[height, width];
for (var y = 0; y < height; y++)
for (var x = 0; x < width; x++)
pixels[y, x] = (fnt[(int)(offset + (long)(x / 8) * height + y)] & (0x80 >> (x % 8))) != 0;
- glyphs.Add(new FonGlyph((byte)(first + i), width, pixels));
+ glyphs!.Add(new FonGlyph((byte)(first + i), width, pixels));
}
+ if (glyphs is null)
+ return null;
return new FonStrike(version, U16(fnt, 0x56), height, ascent, U16(fnt, 0x4e),
fnt[0x50] != 0, weight == 0 ? 400 : weight, charset, face, defaultChar, breakChar, glyphs);
}
diff --git a/src/Filee.Engines/Fonts/FontConverter.cs b/src/Filee.Engines/Fonts/FontConverter.cs
index 529078327..32a875a1f 100644
--- a/src/Filee.Engines/Fonts/FontConverter.cs
+++ b/src/Filee.Engines/Fonts/FontConverter.cs
@@ -49,19 +49,24 @@ public Task> ConvertAsync(ConversionStep step, IProgress
private static IReadOnlyList ConvertFon(ConversionStep step, IProgress? progress, CancellationToken cancellationToken)
{
- var strikes = FonReader.Read(File.ReadAllBytes(step.InputPath));
+ var strikes = FonReader.Read(File.ReadAllBytes(step.InputPath), cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
progress?.Report(0.1);
// Same-height ordinals and the resource count are fixed now, so skipped outputs never rename the others.
var ordinals = new int[strikes.Count];
+ var counts = new Dictionary();
for (var i = 0; i < strikes.Count; i++)
- ordinals[i] = strikes.Take(i + 1).Count(s => s.PixelHeight == strikes[i].PixelHeight);
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var height = strikes[i].PixelHeight;
+ ordinals[i] = counts[height] = counts.GetValueOrDefault(height) + 1;
+ }
var results = new byte[strikes.Count][];
for (var i = 0; i < strikes.Count; i++)
{
- var font = FonOutlineBuilder.Build(strikes[i], strikes.Count, ordinals[i]);
+ var font = FonOutlineBuilder.Build(strikes[i], cancellationToken, strikes.Count, ordinals[i]);
results[i] = FontFile.Save(font, step.To, cancellationToken);
progress?.Report(i == 0 ? 0.85 : 0.85 + 0.05 * i / strikes.Count);
cancellationToken.ThrowIfCancellationRequested();
diff --git a/tests/Filee.Engines.Tests/FonTests.cs b/tests/Filee.Engines.Tests/FonTests.cs
index f0fdaf0fd..dabb7065d 100644
--- a/tests/Filee.Engines.Tests/FonTests.cs
+++ b/tests/Filee.Engines.Tests/FonTests.cs
@@ -74,7 +74,7 @@ public void Committed_fon_samples_are_copied_to_test_output(string name, int ver
{
var bytes = Data(name);
Assert.Equal("MZ", Encoding.ASCII.GetString(bytes, 0, 2));
- var strikes = FonReader.Read(bytes);
+ var strikes = FonReader.Read(bytes, TestContext.Current.CancellationToken);
Assert.Equal(name == "Fon-multi.fon" ? 3 : name == "Fon-edge.fon" ? 2 : 1, strikes.Count);
Assert.Equal(version, strikes[0].Version);
Assert.Equal(width, strikes[0].PixelWidth);
@@ -276,7 +276,7 @@ public async Task Fon_v2_v3_each_strike_opens_in_all_five_formats(int kindIndex,
public void Every_bitmap_mask_matches_unhinted_integer_raster(int kindIndex)
{
var kind = Kinds[kindIndex];
- var font = FonOutlineBuilder.Build(Assert.Single(FonReader.Read(Data(kind.File))));
+ var font = FonOutlineBuilder.Build(Assert.Single(FonReader.Read(Data(kind.File), TestContext.Current.CancellationToken)), TestContext.Current.CancellationToken);
var glyphs = GlyfTable.ReadAll(font);
using var face = SKTypeface.FromStream(new MemoryStream(font.Write()));
var codes = Enumerable.Range(kind.First, 127 - kind.First + 1).ToArray();
@@ -309,8 +309,8 @@ public void Every_bitmap_mask_matches_unhinted_integer_raster(int kindIndex)
public void Cmap_excludes_controls_and_undefined_bytes_without_losing_glyphs()
{
// 0x00..0x7F from the committed sample: controls 0x00..0x1F and DEL 0x7F keep glyphs but no cmap entry.
- var strike = Assert.Single(FonReader.Read(Data("Fon-v2-proportional.fon")));
- var font = FonOutlineBuilder.Build(strike);
+ var strike = Assert.Single(FonReader.Read(Data("Fon-v2-proportional.fon"), TestContext.Current.CancellationToken));
+ var font = FonOutlineBuilder.Build(strike, TestContext.Current.CancellationToken);
var glyphs = GlyfTable.ReadAll(font);
Assert.Equal(128 + 1, font.GlyphCount);
var expected = Enumerable.Range(0x20, 0x7F - 0x20).ToDictionary(c => c, c => c + 1);
@@ -329,7 +329,7 @@ public void Cmap_excludes_controls_and_undefined_bytes_without_losing_glyphs()
Glyphs = [.. Enumerable.Range(0x7E, 0xA0 - 0x7E + 1).Select(b => new FonGlyph((byte)b, 8, pixels))],
DefaultChar = 0,
};
- font = FonOutlineBuilder.Build(high);
+ font = FonOutlineBuilder.Build(high, TestContext.Current.CancellationToken);
glyphs = GlyfTable.ReadAll(font);
Assert.Equal(high.Glyphs.Count + 1, font.GlyphCount);
var map = new Dictionary { [0x7E] = 1, [0xA0] = high.Glyphs.Count };
@@ -365,7 +365,7 @@ public void Supported_charsets_are_locale_independent()
[163] = 1258,
[222] = 874,
};
- var strike = Assert.Single(FonReader.Read(Data("Fon-v2-fixed.fon")));
+ var strike = Assert.Single(FonReader.Read(Data("Fon-v2-fixed.fon"), TestContext.Current.CancellationToken));
var pixels = new bool[13, 8];
pixels[1, 3] = true;
var glyphs = Enumerable.Range(0xA1, 0xFE - 0xA1 + 1).Select(b => new FonGlyph((byte)b, 8, pixels)).ToList();
@@ -390,7 +390,7 @@ public void Supported_charsets_are_locale_independent()
continue;
expected.TryAdd(text[0], i + 1);
}
- var cmap = Cmap(FonOutlineBuilder.Build(strike with { CharSet = charset, Glyphs = glyphs, DefaultChar = 0 }));
+ var cmap = Cmap(FonOutlineBuilder.Build(strike with { CharSet = charset, Glyphs = glyphs, DefaultChar = 0 }, TestContext.Current.CancellationToken));
Assert.Equal(expected.OrderBy(p => p.Key), cmap.OrderBy(p => p.Key));
}
}
@@ -400,13 +400,13 @@ public void Supported_charsets_are_locale_independent()
CultureInfo.CurrentCulture = CultureInfo.CurrentUICulture = culture;
}
// Spot checks that the tables really differ: 0xC0 is À, А (Cyrillic) or ภ (Thai) depending on the charset.
- Assert.Equal(0xC0 - 0xA1 + 1, Cmap(FonOutlineBuilder.Build(strike with { CharSet = 0, Glyphs = glyphs, DefaultChar = 0 }))[0x00C0]);
- Assert.Equal(0xC0 - 0xA1 + 1, Cmap(FonOutlineBuilder.Build(strike with { CharSet = 204, Glyphs = glyphs, DefaultChar = 0 }))[0x0410]);
- Assert.Equal(0xC0 - 0xA1 + 1, Cmap(FonOutlineBuilder.Build(strike with { CharSet = 222, Glyphs = glyphs, DefaultChar = 0 }))[0x0E20]);
+ Assert.Equal(0xC0 - 0xA1 + 1, Cmap(FonOutlineBuilder.Build(strike with { CharSet = 0, Glyphs = glyphs, DefaultChar = 0 }, TestContext.Current.CancellationToken))[0x00C0]);
+ Assert.Equal(0xC0 - 0xA1 + 1, Cmap(FonOutlineBuilder.Build(strike with { CharSet = 204, Glyphs = glyphs, DefaultChar = 0 }, TestContext.Current.CancellationToken))[0x0410]);
+ Assert.Equal(0xC0 - 0xA1 + 1, Cmap(FonOutlineBuilder.Build(strike with { CharSet = 222, Glyphs = glyphs, DefaultChar = 0 }, TestContext.Current.CancellationToken))[0x0E20]);
// ulUnicodeRange follows the mapped blocks of 0xA1..0xFE in each code page.
- Assert.Equal([1, 2, 5], UnicodeRangeBits(FonOutlineBuilder.Build(strike with { CharSet = 238, Glyphs = glyphs, DefaultChar = 0 })));
- Assert.Equal([1, 2, 3, 6, 33], UnicodeRangeBits(FonOutlineBuilder.Build(strike with { CharSet = 163, Glyphs = glyphs, DefaultChar = 0 })));
- Assert.Equal([24], UnicodeRangeBits(FonOutlineBuilder.Build(strike with { CharSet = 222, Glyphs = glyphs, DefaultChar = 0 })));
+ Assert.Equal([1, 2, 5], UnicodeRangeBits(FonOutlineBuilder.Build(strike with { CharSet = 238, Glyphs = glyphs, DefaultChar = 0 }, TestContext.Current.CancellationToken)));
+ Assert.Equal([1, 2, 3, 6, 33], UnicodeRangeBits(FonOutlineBuilder.Build(strike with { CharSet = 163, Glyphs = glyphs, DefaultChar = 0 }, TestContext.Current.CancellationToken)));
+ Assert.Equal([24], UnicodeRangeBits(FonOutlineBuilder.Build(strike with { CharSet = 222, Glyphs = glyphs, DefaultChar = 0 }, TestContext.Current.CancellationToken)));
}
/// Bytes the Unicode MICSFT tables mark UNDEFINED that .NET decodes to Private Use code points, by charset.
@@ -425,11 +425,11 @@ public void Supported_charsets_are_locale_independent()
[InlineData(222)]
public void Undefined_bytes_decoded_to_private_use_keep_glyphs_but_no_cmap(int charset)
{
- var strike = Assert.Single(FonReader.Read(Data("Fon-v2-fixed.fon")));
+ var strike = Assert.Single(FonReader.Read(Data("Fon-v2-fixed.fon"), TestContext.Current.CancellationToken));
var pixels = new bool[13, 8];
pixels[1, 3] = true;
var glyphs = Enumerable.Range(0x80, 0x80).Select(b => new FonGlyph((byte)b, 8, pixels)).ToList();
- var font = FonOutlineBuilder.Build(strike with { CharSet = (byte)charset, Glyphs = glyphs, DefaultChar = 0 });
+ var font = FonOutlineBuilder.Build(strike with { CharSet = (byte)charset, Glyphs = glyphs, DefaultChar = 0 }, TestContext.Current.CancellationToken);
var cmap = Cmap(font);
var outlines = GlyfTable.ReadAll(font);
foreach (var b in UndefinedHigh[(byte)charset])
@@ -499,7 +499,7 @@ public async Task Metrics_preserve_ascent_baseline_proportional_and_space(int ki
public void Hhea_extrema_and_caret_match_contoured_glyphs()
{
var kind = Kinds[1]; // proportional, 0x00..0x7F, default char 0
- var font = FonOutlineBuilder.Build(Assert.Single(FonReader.Read(Data(kind.File))));
+ var font = FonOutlineBuilder.Build(Assert.Single(FonReader.Read(Data(kind.File), TestContext.Current.CancellationToken)), TestContext.Current.CancellationToken);
var s = Scale(13);
var contoured = new List<(int Advance, int Lsb, int XMin, int XMax)>();
var advances = new List();
@@ -529,10 +529,10 @@ private static string PsEscape(string full) => string.Concat(Encoding.UTF8.GetBy
[Fact]
public void Names_and_style_bits_match_each_resource()
{
- var single = Assert.Single(FonReader.Read(Data("Fon-v2-fixed.fon")));
+ var single = Assert.Single(FonReader.Read(Data("Fon-v2-fixed.fon"), TestContext.Current.CancellationToken));
void Check(FonStrike strike, string family, string style, int weight, int fsSelection, int macStyle, int count = 1, int ordinal = 1)
{
- var font = FonOutlineBuilder.Build(strike, count, ordinal);
+ var font = FonOutlineBuilder.Build(strike, TestContext.Current.CancellationToken, count, ordinal);
var full = $"{family} {style}";
Assert.Equal(family, Name(font, 1));
Assert.Equal(style, Name(font, 2));
@@ -547,21 +547,21 @@ void Check(FonStrike strike, string family, string style, int weight, int fsSele
}
Check(single, "Fixed", "Regular", 400, 0x40, 0);
- Assert.Equal("Fixed_20Regular", Name(FonOutlineBuilder.Build(single), 6));
+ Assert.Equal("Fixed_20Regular", Name(FonOutlineBuilder.Build(single, TestContext.Current.CancellationToken), 6));
Check(single with { Weight = 700, Italic = true }, "Fixed", "Bold Italic", 700, 0x21, 3);
Check(single with { Weight = 700 }, "Fixed", "Bold", 700, 0x20, 1);
Check(single with { Italic = true }, "Fixed", "Italic", 400, 0x01, 2);
var unweighted = Data("Fon-v2-fixed.fon");
BinaryPrimitives.WriteUInt16LittleEndian(unweighted.AsSpan(FntStart(unweighted) + 0x53), 0);
- Check(Assert.Single(FonReader.Read(unweighted)), "Fixed", "Regular", 400, 0x40, 0);
+ Check(Assert.Single(FonReader.Read(unweighted, TestContext.Current.CancellationToken)), "Fixed", "Regular", 400, 0x40, 0);
Check(single with { Weight = 500 }, "Fixed Weight500", "Regular", 500, 0x40, 0);
Check(single, "Fixed 13px", "Regular", 400, 0x40, 0, count: 3);
Check(single, "Fixed 13px-2", "Regular", 400, 0x40, 0, count: 3, ordinal: 2);
- Assert.Equal("Fixed_2013px-2_20Regular", Name(FonOutlineBuilder.Build(single, 3, 2), 6));
+ Assert.Equal("Fixed_2013px-2_20Regular", Name(FonOutlineBuilder.Build(single, TestContext.Current.CancellationToken, 3, 2), 6));
// A long non-ASCII face: nameID 6 is cut at a token boundary (at most 50) and gets a hash of the full name.
var face = string.Concat(Enumerable.Repeat("Ä", 40));
- var font = FonOutlineBuilder.Build(single with { Face = face });
+ var font = FonOutlineBuilder.Build(single with { Face = face }, TestContext.Current.CancellationToken);
var full = $"{face} Regular";
var ps = Name(font, 6)!;
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(full)).AsSpan(0, 6));
@@ -750,6 +750,31 @@ private static byte[] WideOnePixelFnt()
return data;
}
+ /// Every glyph aliases one blank bitmap; the decoded pixel count is not bounded by FNT size.
+ private static byte[] AliasedFnt(int width, int height, int glyphCount = 128)
+ {
+ const int table = 0x76;
+ var face = table + (glyphCount + 1) * 4;
+ var bits = face + 6;
+ var data = new byte[bits + ((width + 7) / 8) * height];
+ BinaryPrimitives.WriteUInt16LittleEndian(data, 0x0200);
+ BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(2), (uint)data.Length);
+ BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(0x4a), (ushort)height);
+ BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(0x53), 400);
+ BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(0x58), (ushort)height);
+ data[0x60] = (byte)(glyphCount - 1);
+ BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x69), (uint)face);
+ BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x71), (uint)bits);
+ for (var i = 0; i < glyphCount; i++)
+ {
+ BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(table + i * 4), (ushort)width);
+ BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(table + i * 4 + 2), (ushort)bits);
+ }
+ BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(table + glyphCount * 4 + 2), (ushort)data.Length);
+ "Fixed\0"u8.CopyTo(data.AsSpan(face));
+ return data;
+ }
+
///
/// Runs as Name.fon under each conflict policy, with every name it would write already
/// taken, and checks that nothing changed and nothing was reported.
@@ -780,7 +805,7 @@ public async Task Fon_prewrite_parser_failure_preserves_outputs_under_each_confl
// The second RT_FONT's first bitmap points outside the resource: the reader fails before any output is built.
var data = Data("Fon-multi.fon");
BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(FntStart(data, 1) + 0x94 + 2), 0x7fffffff);
- Assert.Throws(() => FonReader.Read(data));
+ Assert.Throws(() => FonReader.Read(data, TestContext.Current.CancellationToken));
await AssertNothingWrittenAsync(data, ["Name-13px.ttf", "Name-13px-2.ttf", "Name-14px.ttf"], FileState.Failed, "outside the resource");
}
@@ -790,11 +815,11 @@ public async Task Fon_prewrite_builder_failure_preserves_outputs_under_each_conf
{
var first = Resource(Data("Fon-multi.fon"), 0);
var data = Container(first, WideOnePixelFnt());
- var strikes = FonReader.Read(data);
+ var strikes = FonReader.Read(data, TestContext.Current.CancellationToken);
Assert.Equal([13, 1], strikes.Select(s => s.PixelHeight));
// The first resource builds completely on its own; the second passes the reader but not the builder.
- Assert.NotEmpty(FontFile.Save(FonOutlineBuilder.Build(strikes[0], 2), "ttf", TestContext.Current.CancellationToken));
- Assert.Contains("limits", Assert.Throws(() => FonOutlineBuilder.Build(strikes[1], 2)).Message);
+ Assert.NotEmpty(FontFile.Save(FonOutlineBuilder.Build(strikes[0], TestContext.Current.CancellationToken, 2), "ttf", TestContext.Current.CancellationToken));
+ Assert.Contains("limits", Assert.Throws(() => FonOutlineBuilder.Build(strikes[1], TestContext.Current.CancellationToken, 2)).Message);
await AssertNothingWrittenAsync(data, ["Name-13px.ttf", "Name-1px.ttf"], FileState.Failed, "limits");
}
@@ -825,6 +850,127 @@ public async Task Fon_prewrite_cancellation_leaves_no_outputs()
Assert.Equal(before.OrderBy(p => p.Key), Snapshot(folder).OrderBy(p => p.Key));
}
+ [Fact]
+ public async Task Aliased_glyphs_over_filewide_pixel_limit_fail_without_outputs()
+ {
+ var data = Container(AliasedFnt(2048, 8), AliasedFnt(2049, 8));
+ var error = Assert.Throws(() => FonReader.Read(data, TestContext.Current.CancellationToken));
+ Assert.Contains("unit limit", error.Message);
+ Assert.Contains($"{FonReader.MaxGlyphPixels:N0}", error.Message);
+
+ await AssertNothingWrittenAsync(data, ["Name-8px.ttf", "Name-8px-2.ttf"], FileState.Failed, "unit limit");
+ }
+
+ [Fact]
+ public void Aliased_glyphs_at_filewide_pixel_limit_are_accepted()
+ {
+ var data = Container(AliasedFnt(2048, 8), AliasedFnt(2048, 8));
+ var strikes = FonReader.Read(data, TestContext.Current.CancellationToken);
+ Assert.Equal(FonReader.MaxGlyphPixels, strikes.Sum(s => s.Glyphs.Sum(g => (long)g.Width * s.PixelHeight)));
+ Assert.All(strikes, s => Assert.All(s.Glyphs, g => Assert.False(g.Pixels[0, 0])));
+ }
+
+ [Fact]
+ public void Zero_width_glyph_rows_count_toward_filewide_budget()
+ {
+ var atLimit = Container(AliasedFnt(0, 16384, 256));
+ var strikes = FonReader.Read(atLimit, TestContext.Current.CancellationToken);
+ var strike = Assert.Single(strikes);
+ Assert.Equal(256, strike.Glyphs.Count);
+ Assert.Equal(FonReader.MaxGlyphPixels, strike.Glyphs.Sum(g => (long)Math.Max(g.Width, 1) * strike.PixelHeight));
+
+ var overLimit = Container(AliasedFnt(0, 16384, 256), AliasedFnt(0, 1, 1));
+ var error = Assert.Throws(() => FonReader.Read(overLimit, TestContext.Current.CancellationToken));
+ Assert.Contains("unit limit", error.Message);
+ }
+
+ [Fact]
+ public void Already_cancelled_token_stops_reader_on_valid_fon()
+ {
+ var data = Data("Fon-v2-fixed.fon");
+ Assert.ThrowsAny(() => FonReader.Read(data, new CancellationToken(true)));
+ }
+
+ [Fact]
+ public void Already_cancelled_token_stops_builder_on_legal_strike()
+ {
+ var strike = Assert.Single(FonReader.Read(Data("Fon-v2-fixed.fon"), TestContext.Current.CancellationToken));
+ Assert.ThrowsAny(() => FonOutlineBuilder.Build(strike, new CancellationToken(true)));
+ }
+
+ /// Cancels its token when the first full pass over the glyphs ends, i.e. after every outline is traced.
+ private sealed class CancelAfterFirstPass(IReadOnlyList glyphs, CancellationTokenSource cts) : IReadOnlyList
+ {
+ public FonGlyph this[int index] => glyphs[index];
+ public int Count => glyphs.Count;
+ public IEnumerator GetEnumerator()
+ {
+ foreach (var glyph in glyphs)
+ yield return glyph;
+ cts.Cancel();
+ }
+ System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
+ }
+
+ [Fact]
+ public void Cancellation_after_tracing_stops_builder_before_serialization()
+ {
+ var strike = Assert.Single(FonReader.Read(Data("Fon-v2-fixed.fon"), TestContext.Current.CancellationToken));
+ using var cts = new CancellationTokenSource();
+ var watched = strike with { Glyphs = new CancelAfterFirstPass(strike.Glyphs, cts) };
+
+ Assert.ThrowsAny(() => FonOutlineBuilder.Build(watched, cts.Token));
+ Assert.True(cts.IsCancellationRequested);
+ }
+
+ [Fact]
+ public void Pixel_height_above_units_per_em_limit_fails_in_reader()
+ {
+ var data = Container(AliasedFnt(0, 16385, 1));
+ Assert.Contains("height", Assert.Throws(() => FonReader.Read(data, TestContext.Current.CancellationToken)).Message);
+ }
+
+ [Fact]
+ public async Task Cancelled_large_legal_strike_stops_builder_and_preserves_outputs()
+ {
+ var data = Container(AliasedFnt(128, 128, 256));
+ var source = Source(data, out var folder);
+ File.WriteAllText(Path.Combine(folder, "Name.ttf"), "old");
+ var before = Snapshot(folder);
+ var allocator = new RecordingAllocator(folder);
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ var reports = new List();
+ var progress = new SyncProgress(value =>
+ {
+ reports.Add(value);
+ if (value == 0.1)
+ cts.Cancel();
+ });
+
+ await Assert.ThrowsAnyAsync(() => new FontConverter().ConvertAsync(
+ new ConversionStep(source, "fon", "ttf", PresetFor("ttf"), allocator, fx.NewFolder()), progress, cts.Token));
+ Assert.Contains(0.1, reports);
+ Assert.Empty(allocator.Suffixes);
+ Assert.Equal(before.OrderBy(p => p.Key), Snapshot(folder).OrderBy(p => p.Key));
+ }
+
+ [Fact]
+ public void Later_rt_font_overlapping_nonadjacent_resource_is_rejected()
+ {
+ var resource = AliasedFnt(8, 8, 1);
+ var data = Container(resource, resource, resource);
+ var ne = (int)BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(0x3c));
+ var table = ne + BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(ne + 0x24));
+ var thirdEntry = table + 30 + 2 * 12;
+ var thirdStart = FntStart(data) + 1;
+ Assert.True(thirdStart + 1 <= FntStart(data, 1));
+ BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(thirdEntry), (ushort)thirdStart);
+ BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(thirdEntry + 2), 1);
+
+ Assert.Contains("overlapping or out-of-bounds RT_FONT resource",
+ Assert.Throws(() => FonReader.Read(data, TestContext.Current.CancellationToken)).Message);
+ }
+
[Fact]
public async Task Queue_precancel_preserves_existing_outputs()
{
@@ -851,12 +997,12 @@ public async Task Queue_precancel_preserves_existing_outputs()
[Fact]
public async Task Extreme_heights_preserve_masks_and_overflows_fail()
{
- var strikes = FonReader.Read(Data("Fon-edge.fon"));
+ var strikes = FonReader.Read(Data("Fon-edge.fon"), TestContext.Current.CancellationToken);
Assert.Equal([1, 256], strikes.Select(s => s.PixelHeight));
foreach (var strike in strikes)
{
var height = strike.PixelHeight;
- var font = FonOutlineBuilder.Build(strike);
+ var font = FonOutlineBuilder.Build(strike, TestContext.Current.CancellationToken);
Assert.Equal(1024, U16(font.Require("head"), 18));
using var face = SKTypeface.FromStream(new MemoryStream(font.Write()));
for (var code = 32; code <= 34; code++)
@@ -867,11 +1013,11 @@ public async Task Extreme_heights_preserve_masks_and_overflows_fail()
var pixels = new bool[1, 33];
pixels[0, 32] = true;
var overflowing = strikes[0] with { Glyphs = [new FonGlyph(32, 33, pixels)], DefaultChar = 0 };
- Assert.Contains("limits", Assert.Throws(() => FonOutlineBuilder.Build(overflowing)).Message);
+ Assert.Contains("limits", Assert.Throws(() => FonOutlineBuilder.Build(overflowing, TestContext.Current.CancellationToken)).Message);
// 31 pixels wide, x=30 lit: every coordinate and advance ends at 31 * 1024 = 31744 and fits.
pixels = new bool[1, 31];
pixels[0, 30] = true;
- var fits = FonOutlineBuilder.Build(strikes[0] with { Glyphs = [new FonGlyph(32, 31, pixels)], DefaultChar = 0 });
+ var fits = FonOutlineBuilder.Build(strikes[0] with { Glyphs = [new FonGlyph(32, 31, pixels)], DefaultChar = 0 }, TestContext.Current.CancellationToken);
Assert.Equal(31 * 1024, GlyfTable.ReadAll(fits)[1]!.XMax);
// A bitmap that ends exactly at the end of the resource is read; one byte further is a clear error.
@@ -879,9 +1025,9 @@ public async Task Extreme_heights_preserve_masks_and_overflows_fail()
var fnt = FntStart(data);
var size = (int)BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(fnt + 2));
BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(fnt + 0x76 + 2), (ushort)(size - 13));
- Assert.Single(FonReader.Read(data));
+ Assert.Single(FonReader.Read(data, TestContext.Current.CancellationToken));
BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(fnt + 0x76 + 2), (ushort)(size - 12));
- Assert.Contains("outside the resource", Assert.Throws(() => FonReader.Read(data)).Message);
+ Assert.Contains("outside the resource", Assert.Throws(() => FonReader.Read(data, TestContext.Current.CancellationToken)).Message);
// Through the queue: an overflowing resource writes nothing.
var source = Source(Container(WideOnePixelFnt()), out var folder);
@@ -916,7 +1062,7 @@ public void Fon_edges_only_leave_existing_font_routes_intact()
[Fact]
public void Zero_pix_width_uses_each_entry_width()
{
- var strike = Assert.Single(FonReader.Read(Data("Fon-v3-proportional.fon")));
+ var strike = Assert.Single(FonReader.Read(Data("Fon-v3-proportional.fon"), TestContext.Current.CancellationToken));
Assert.Equal(0, strike.PixelWidth);
Assert.Equal(0, strike.Glyphs[0].Width);
Assert.Equal(5, strike.Glyphs[1].Width);
@@ -929,7 +1075,7 @@ public void Zero_pix_width_uses_each_entry_width()
[Fact]
public void Control_bytes_and_delete_keep_glyphs()
{
- var strike = Assert.Single(FonReader.Read(Data("Fon-v2-proportional.fon")));
+ var strike = Assert.Single(FonReader.Read(Data("Fon-v2-proportional.fon"), TestContext.Current.CancellationToken));
Assert.Equal(128, strike.Glyphs.Count);
Assert.Equal((byte)0, strike.Glyphs[0].ByteValue);
Assert.Equal((byte)31, strike.Glyphs[31].ByteValue);
@@ -942,8 +1088,8 @@ public void Control_bytes_and_delete_keep_glyphs()
[InlineData("Fon-v3-proportional.fon", false)]
public void Blank_and_zero_width_glyphs_keep_metrics(string sample, bool isFixed)
{
- var strike = Assert.Single(FonReader.Read(Data(sample)));
- var font = FonOutlineBuilder.Build(strike);
+ var strike = Assert.Single(FonReader.Read(Data(sample), TestContext.Current.CancellationToken));
+ var font = FonOutlineBuilder.Build(strike, TestContext.Current.CancellationToken);
var glyphs = GlyfTable.ReadAll(font);
Assert.Equal(96 + 1, font.GlyphCount);
Assert.Null(glyphs[1]); // the blank space, 0 or 8 pixels wide
@@ -961,10 +1107,10 @@ public void Default_and_break_are_relative_indices()
var fnt = FntStart(data);
data[fnt + 0x61] = 1;
data[fnt + 0x62] = 2;
- var strike = Assert.Single(FonReader.Read(data));
+ var strike = Assert.Single(FonReader.Read(data, TestContext.Current.CancellationToken));
Assert.Equal((1, 2, (byte)33), (strike.DefaultChar, strike.BreakChar, strike.Glyphs[strike.DefaultChar].ByteValue));
// .notdef is the default glyph (code 33, the single pixel); the break glyph (34) is not forced onto U+0020.
- var font = FonOutlineBuilder.Build(strike);
+ var font = FonOutlineBuilder.Build(strike, TestContext.Current.CancellationToken);
var glyphs = GlyfTable.ReadAll(font);
Assert.Equal(glyphs[2]!.Serialize(), glyphs[0]!.Serialize());
Assert.Equal(Advance(font, 2), Advance(font, 0));
@@ -972,7 +1118,7 @@ public void Default_and_break_are_relative_indices()
Assert.Equal(3, Cmap(font)[0x22]);
data[fnt + 0x61] = 96;
- Assert.Contains("relative indices", Assert.Throws(() => FonReader.Read(data)).Message);
+ Assert.Contains("relative indices", Assert.Throws(() => FonReader.Read(data, TestContext.Current.CancellationToken)).Message);
}
[Theory]
@@ -985,7 +1131,7 @@ public void Default_and_break_are_relative_indices()
public void V3_non_six_byte_flags_are_rejected_before_glyph_table(int flag)
{
var data = Data($"Fon-v3-flags-{flag:x2}.fon");
- var error = Assert.Throws(() => FonReader.Read(data));
+ var error = Assert.Throws(() => FonReader.Read(data, TestContext.Current.CancellationToken));
Assert.Contains($"dfFlags 0x{flag:X8}", error.Message);
}
@@ -997,7 +1143,7 @@ public void V3_zero_or_mixed_flags_are_rejected(uint flags)
{
var data = Data("Fon-v3-fixed.fon");
BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(FntStart(data) + 0x76), flags);
- var error = Assert.Throws(() => FonReader.Read(data));
+ var error = Assert.Throws(() => FonReader.Read(data, TestContext.Current.CancellationToken));
Assert.Contains($"dfFlags 0x{flags:X8}", error.Message);
}
@@ -1007,74 +1153,74 @@ public void Unsupported_containers_charsets_and_resources_fail_as_a_whole()
var data = Data("Fon-multi.fon");
var second = FntStart(data, 1);
data[second + 0x42] = 1;
- Assert.Contains("Vector", Assert.Throws(() => FonReader.Read(data)).Message);
+ Assert.Contains("Vector", Assert.Throws(() => FonReader.Read(data, TestContext.Current.CancellationToken)).Message);
data[second + 0x42] = 4;
- Assert.Contains("outside the file", Assert.Throws(() => FonReader.Read(data)).Message);
+ Assert.Contains("outside the file", Assert.Throws(() => FonReader.Read(data, TestContext.Current.CancellationToken)).Message);
data[second + 0x42] = 0;
foreach (var charset in new byte[] { 128, 129, 130, 134, 136, 2, 1, 255, 77, 99 }) // DBCS, SYMBOL, DEFAULT, OEM, MAC, unknown
{
data[second + 0x55] = charset;
- Assert.Contains("character set", Assert.Throws(() => FonReader.Read(data)).Message);
+ Assert.Contains("character set", Assert.Throws(() => FonReader.Read(data, TestContext.Current.CancellationToken)).Message);
}
data[second + 0x55] = 0;
data[second] = 0;
data[second + 1] = 1;
- Assert.Contains("version", Assert.Throws(() => FonReader.Read(data)).Message);
+ Assert.Contains("version", Assert.Throws(() => FonReader.Read(data, TestContext.Current.CancellationToken)).Message);
var pe = Data("Fon-v2-fixed.fon");
pe[0x40] = (byte)'P';
pe[0x41] = (byte)'E';
- Assert.Contains("PE FON", Assert.Throws(() => FonReader.Read(pe)).Message);
+ Assert.Contains("PE FON", Assert.Throws(() => FonReader.Read(pe, TestContext.Current.CancellationToken)).Message);
// A bare FNT and an MZ program without fonts.
var fixedFon = Data("Fon-v2-fixed.fon");
- Assert.Contains("MZ", Assert.Throws(() => FonReader.Read(fixedFon[FntStart(fixedFon)..])).Message);
+ Assert.Contains("MZ", Assert.Throws(() => FonReader.Read(fixedFon[FntStart(fixedFon)..], TestContext.Current.CancellationToken)).Message);
var program = Data("Fon-v2-fixed.fon");
var ne = 0x40;
var table = ne + BinaryPrimitives.ReadUInt16LittleEndian(program.AsSpan(ne + 0x24));
BinaryPrimitives.WriteUInt16LittleEndian(program.AsSpan(table + 22), 0x8006);
- Assert.Contains("no RT_FONT", Assert.Throws(() => FonReader.Read(program)).Message);
+ Assert.Contains("no RT_FONT", Assert.Throws(() => FonReader.Read(program, TestContext.Current.CancellationToken)).Message);
}
[Fact]
public void Truncated_ne_fnt_and_face_never_produce_outputs()
{
var source = Data("Fon-v2-fixed.fon");
- Assert.Contains("NE header", Assert.Throws(() => FonReader.Read(source[..0x40])).Message);
+ Assert.Contains("NE header", Assert.Throws(() => FonReader.Read(source[..0x40], TestContext.Current.CancellationToken)).Message);
var data = (byte[])source.Clone();
var ne = 0x40;
BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(ne + 0x26), 0x7fff);
- Assert.Contains("resource table", Assert.Throws(() => FonReader.Read(data)).Message);
+ Assert.Contains("resource table", Assert.Throws(() => FonReader.Read(data, TestContext.Current.CancellationToken)).Message);
data = (byte[])source.Clone();
var table = ne + BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(ne + 0x24));
BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(table + 30), (ushort)table);
- Assert.Contains("overlapping", Assert.Throws(() => FonReader.Read(data)).Message);
+ Assert.Contains("overlapping", Assert.Throws(() => FonReader.Read(data, TestContext.Current.CancellationToken)).Message);
data = (byte[])source.Clone();
var fnt = FntStart(data);
BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(fnt + 2), 0x7fffffff);
- Assert.Contains("dfSize", Assert.Throws(() => FonReader.Read(data)).Message);
+ Assert.Contains("dfSize", Assert.Throws(() => FonReader.Read(data, TestContext.Current.CancellationToken)).Message);
data = (byte[])source.Clone();
BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(fnt + 0x69), 0x7fffffff);
- Assert.Contains("face offset", Assert.Throws(() => FonReader.Read(data)).Message);
+ Assert.Contains("face offset", Assert.Throws(() => FonReader.Read(data, TestContext.Current.CancellationToken)).Message);
data = (byte[])source.Clone();
var size = (int)BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(fnt + 2));
BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(fnt + 0x69), (uint)(size - 1));
data[fnt + size - 1] = (byte)'F';
- Assert.Contains("NUL", Assert.Throws(() => FonReader.Read(data)).Message);
+ Assert.Contains("NUL", Assert.Throws(() => FonReader.Read(data, TestContext.Current.CancellationToken)).Message);
data = (byte[])source.Clone();
BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(fnt + 0x76 + 2), 0xffff);
- Assert.Contains("bitmap", Assert.Throws(() => FonReader.Read(data)).Message);
+ Assert.Contains("bitmap", Assert.Throws(() => FonReader.Read(data, TestContext.Current.CancellationToken)).Message);
}
[Fact]
public void Builder_preserves_pixel_outlines_metrics_and_control_glyphs()
{
- var strike = Assert.Single(FonReader.Read(Data("Fon-v2-proportional.fon")));
- var font = FonOutlineBuilder.Build(strike);
+ var strike = Assert.Single(FonReader.Read(Data("Fon-v2-proportional.fon"), TestContext.Current.CancellationToken));
+ var font = FonOutlineBuilder.Build(strike, TestContext.Current.CancellationToken);
Assert.Equal(strike.Glyphs.Count + 1, font.GlyphCount);
Assert.Equal(13 * (1024 / 13), FontTables.UnitsPerEm(font.Require("head")));
Assert.Equal(0x00010000u, font.Flavor);
From 711e827442b12d7ab71cc89f89f00d5b950e6d9e Mon Sep 17 00:00:00 2001
From: SeoJCalive <180237232+SeoJCalive@users.noreply.github.com>
Date: Sun, 25 Oct 2026 21:52:44 +0900
Subject: [PATCH 4/9] Build the .fon test samples in code instead of committing
them
---
docs/ENGINES.md | 4 +-
tests/Filee.Cli.Tests/CliTests.cs | 13 +-
tests/Filee.Cli.Tests/Filee.Cli.Tests.csproj | 2 +-
.../Filee.Engines.Tests.csproj | 2 +-
tests/Filee.Engines.Tests/FonBuilder.cs | 183 ++++++++++++++++++
tests/Filee.Engines.Tests/FonTests.cs | 34 +++-
.../Filee.Engines.Tests/Fonts/FON-FIXTURES.md | 31 ---
tests/Filee.Engines.Tests/Fonts/Fon-edge.fon | Bin 1312 -> 0 bytes
tests/Filee.Engines.Tests/Fonts/Fon-multi.fon | Bin 6021 -> 0 bytes
.../Fonts/Fon-v2-fixed.fon | Bin 1933 -> 0 bytes
.../Fonts/Fon-v2-proportional.fon | Bin 3283 -> 0 bytes
.../Fonts/Fon-v3-fixed.fon | Bin 2157 -> 0 bytes
.../Fonts/Fon-v3-flags-04.fon | Bin 2351 -> 0 bytes
.../Fonts/Fon-v3-flags-08.fon | Bin 3377 -> 0 bytes
.../Fonts/Fon-v3-flags-10.fon | Bin 4819 -> 0 bytes
.../Fonts/Fon-v3-flags-20.fon | Bin 7509 -> 0 bytes
.../Fonts/Fon-v3-flags-40.fon | Bin 12695 -> 0 bytes
.../Fonts/Fon-v3-flags-80.fon | Bin 22873 -> 0 bytes
.../Fonts/Fon-v3-proportional.fon | Bin 2755 -> 0 bytes
.../Fonts/make-test-fon.py | 136 -------------
20 files changed, 217 insertions(+), 188 deletions(-)
create mode 100644 tests/Filee.Engines.Tests/FonBuilder.cs
delete mode 100644 tests/Filee.Engines.Tests/Fonts/FON-FIXTURES.md
delete mode 100644 tests/Filee.Engines.Tests/Fonts/Fon-edge.fon
delete mode 100644 tests/Filee.Engines.Tests/Fonts/Fon-multi.fon
delete mode 100644 tests/Filee.Engines.Tests/Fonts/Fon-v2-fixed.fon
delete mode 100644 tests/Filee.Engines.Tests/Fonts/Fon-v2-proportional.fon
delete mode 100644 tests/Filee.Engines.Tests/Fonts/Fon-v3-fixed.fon
delete mode 100644 tests/Filee.Engines.Tests/Fonts/Fon-v3-flags-04.fon
delete mode 100644 tests/Filee.Engines.Tests/Fonts/Fon-v3-flags-08.fon
delete mode 100644 tests/Filee.Engines.Tests/Fonts/Fon-v3-flags-10.fon
delete mode 100644 tests/Filee.Engines.Tests/Fonts/Fon-v3-flags-20.fon
delete mode 100644 tests/Filee.Engines.Tests/Fonts/Fon-v3-flags-40.fon
delete mode 100644 tests/Filee.Engines.Tests/Fonts/Fon-v3-flags-80.fon
delete mode 100644 tests/Filee.Engines.Tests/Fonts/Fon-v3-proportional.fon
delete mode 100644 tests/Filee.Engines.Tests/Fonts/make-test-fon.py
diff --git a/docs/ENGINES.md b/docs/ENGINES.md
index c06f53695..e15a3775f 100644
--- a/docs/ENGINES.md
+++ b/docs/ENGINES.md
@@ -501,8 +501,8 @@ extension, and the file must then have an NE header with FNT resources.
`tests/Filee.Engines.Tests/FontTests.cs` checks every conversion with small OFL fonts in
`tests/Filee.Engines.Tests/Fonts` (see `OFL.txt` there): round trips keep every table byte for byte, a WOFF2 made
by Google's encoder decodes to the source font, and converted CFF glyphs are compared with the originals in Skia.
-`tests/Filee.Engines.Tests/FonTests.cs` converts the synthetic `Fon-*.fon` samples that `make-test-fon.py` draws
-from pixel art (see `FON-FIXTURES.md`) and compares every rendered glyph with its source bitmap.
+`tests/Filee.Engines.Tests/FonTests.cs` builds synthetic FON samples in code (`FonBuilder`) and compares every
+rendered glyph with its source bitmap.
## DOCX writer
diff --git a/tests/Filee.Cli.Tests/CliTests.cs b/tests/Filee.Cli.Tests/CliTests.cs
index 4c36b21ce..3e00d5289 100644
--- a/tests/Filee.Cli.Tests/CliTests.cs
+++ b/tests/Filee.Cli.Tests/CliTests.cs
@@ -4,6 +4,7 @@
using System.Text.Json;
using System.Xml.Linq;
using Filee.Core.Presets;
+using Filee.Engines.Tests;
namespace Filee.Cli.Tests;
@@ -437,19 +438,17 @@ public async Task Unicode_spaces_and_quotes_stay_in_a_single_input_argument()
Assert.True(File.Exists(source));
}
- // ──────────── Windows bitmap fonts (#177): the engine tests' committed samples, linked into this project ────────────
+ // ──────────── Windows bitmap fonts (#177): in-memory samples shared with the engine tests ────────────
private static readonly string[] FontFormats = ["ttf", "otf", "woff", "woff2", "eot"];
- private static string FonSample(string name) => Path.Combine(AppContext.BaseDirectory, "Fonts", name);
-
- /// Copies a committed sample to Name.fon in this test's folder (or a sub folder of it).
+ /// Writes a synthetic sample to Name.fon in this test's folder (or a subfolder).
private string Fon(string sample, string? folder = null)
{
folder = Path.Combine(_folder, folder ?? "");
Directory.CreateDirectory(folder);
var path = Path.Combine(folder, "Name.fon");
- File.Copy(FonSample(sample), path);
+ File.WriteAllBytes(path, FonBuilder.Sample(sample));
return path;
}
@@ -460,11 +459,11 @@ private static string[] Outputs(string json)
}
[Fact]
- public void Committed_fon_samples_are_copied_to_cli_output()
+ public void Generated_fon_samples_are_written_to_cli_input()
{
foreach (var sample in new[] { "Fon-v2-fixed.fon", "Fon-multi.fon" })
{
- var bytes = File.ReadAllBytes(FonSample(sample));
+ var bytes = FonBuilder.Sample(sample);
Assert.Equal("MZ"u8.ToArray(), bytes[..2]);
Assert.Equal(bytes, File.ReadAllBytes(Fon(sample, Path.GetFileNameWithoutExtension(sample))));
}
diff --git a/tests/Filee.Cli.Tests/Filee.Cli.Tests.csproj b/tests/Filee.Cli.Tests/Filee.Cli.Tests.csproj
index fab82bdbe..d02ae79ae 100644
--- a/tests/Filee.Cli.Tests/Filee.Cli.Tests.csproj
+++ b/tests/Filee.Cli.Tests/Filee.Cli.Tests.csproj
@@ -20,6 +20,6 @@
-
+
diff --git a/tests/Filee.Engines.Tests/Filee.Engines.Tests.csproj b/tests/Filee.Engines.Tests/Filee.Engines.Tests.csproj
index 6a640661a..90946d300 100644
--- a/tests/Filee.Engines.Tests/Filee.Engines.Tests.csproj
+++ b/tests/Filee.Engines.Tests/Filee.Engines.Tests.csproj
@@ -22,7 +22,7 @@
-
+
diff --git a/tests/Filee.Engines.Tests/FonBuilder.cs b/tests/Filee.Engines.Tests/FonBuilder.cs
new file mode 100644
index 000000000..a5232e13a
--- /dev/null
+++ b/tests/Filee.Engines.Tests/FonBuilder.cs
@@ -0,0 +1,183 @@
+// Creates FON samples in memory; no font files are checked into the repository.
+
+using System.Numerics;
+
+namespace Filee.Engines.Tests;
+
+internal static class FonBuilder
+{
+ private static void Put(List data, int offset, ushort value)
+ {
+ data[offset] = (byte)value;
+ data[offset + 1] = (byte)(value >> 8);
+ }
+
+ private static void Put(List data, int offset, uint value)
+ {
+ Put(data, offset, (ushort)value);
+ Put(data, offset + 2, (ushort)(value >> 16));
+ }
+
+ private static byte[] Fnt(int version, int height = 13, bool fixedWidth = true, int first = 32,
+ int last = 127, int? flags = null)
+ {
+ var count = last - first + 1;
+ var entrySize = version == 2 ? 4 : flags is null ? 6 : 8 + (BitOperations.Log2((uint)flags.Value) - 2) * 2;
+ var table = version == 2 ? 0x76 : 0x94;
+ var face = table + (count + 1) * entrySize;
+ var bits = face + 6;
+ var data = new List(new byte[bits]);
+ Put(data, 0, (ushort)(version << 8));
+ Put(data, 0x42, (ushort)0);
+ Put(data, 0x4A, (ushort)Math.Min(height, 10));
+ Put(data, 0x4E, (ushort)2);
+ Put(data, 0x53, (ushort)400);
+ data[0x55] = 0;
+ Put(data, 0x56, (ushort)(fixedWidth ? 8 : 0));
+ Put(data, 0x58, (ushort)height);
+ Put(data, 0x5D, (ushort)9);
+ data[0x5F] = (byte)first;
+ data[0x60] = (byte)last;
+ Put(data, 0x69, (uint)face);
+ Put(data, 0x71, (uint)bits);
+ if (version == 3)
+ {
+ Put(data, 0x76, (uint)(flags ?? (fixedWidth ? 1 : 2)));
+ if (flags is not null)
+ {
+ Put(data, 0x7A, (ushort)(flags + 1));
+ Put(data, 0x7C, (ushort)(flags + 2));
+ Put(data, 0x7E, (ushort)(flags + 3));
+ }
+ }
+ for (var i = 0; i < 6; i++)
+ data[face + i] = "Fixed\0"u8[i];
+
+ for (var index = 0; index < count; index++)
+ {
+ var code = first + index;
+ var width = flags is not null ? flags.Value + index % 3 :
+ fixedWidth ? 8 : code == 32 ? 0 : code % 2 == 1 ? 5 : 9;
+ var entry = table + index * entrySize;
+ Put(data, entry, (ushort)width);
+ if (flags is not null)
+ {
+ Put(data, entry + 2, (ushort)(flags + index % 3));
+ Put(data, entry + 4, (uint)data.Count);
+ }
+ else if (version == 2)
+ Put(data, entry + 2, (ushort)data.Count);
+ else
+ Put(data, entry + 2, (uint)data.Count);
+
+ // Pixel art is independent of the reader: a single pixel, diagonal, and hollow box.
+ for (var column = 0; column < (width + 7) / 8; column++)
+ for (var y = 0; y < height; y++)
+ {
+ byte value = 0;
+ for (var bit = 0; bit < 8; bit++)
+ {
+ var x = column * 8 + bit;
+ if (x >= width || code == 32)
+ continue;
+ var rows = Math.Min(height, 7);
+ var lit = code == 33 ? x == 2 && y == 0 :
+ code == 34 ? x == 2 + y % Math.Max(1, width - 2) :
+ x >= 2 && y < rows && (x == 2 || x == width - 1 || y == 0 || y == rows - 1);
+ if (lit)
+ value |= (byte)(0x80 >> bit);
+ }
+ data.Add(value);
+ }
+ }
+ var sentinel = table + count * entrySize;
+ Put(data, sentinel, (ushort)0);
+ if (flags is not null)
+ Put(data, sentinel + 4, (uint)data.Count);
+ else if (version == 2)
+ Put(data, sentinel + 2, (ushort)data.Count);
+ else
+ Put(data, sentinel + 2, (uint)data.Count);
+ Put(data, 2, (uint)data.Count);
+ return [.. data];
+ }
+
+ /// Writes NE FONTDIR and RT_FONT entries with an optional resource alignment shift.
+ public static byte[] Container(byte[][] resources, int alignmentShift = 0)
+ {
+ const int ne = 0x40, table = ne + 0x40;
+ var payload = table + 2 + 8 + 12 + 8 + 12 * resources.Length + 2;
+ var data = new List(new byte[payload]);
+ data[0] = (byte)'M';
+ data[1] = (byte)'Z';
+ Put(data, 0x3C, (uint)ne);
+ data[ne] = (byte)'N';
+ data[ne + 1] = (byte)'E';
+ Put(data, ne + 0x24, (ushort)(table - ne));
+ Put(data, ne + 0x26, (ushort)(payload - ne));
+ Put(data, table, (ushort)alignmentShift);
+ Put(data, table + 2, (ushort)0x8007);
+ Put(data, table + 4, (ushort)1);
+ var unit = 1 << alignmentShift;
+ // The one-byte FONTDIR, like every resource, starts on an alignment unit.
+ while (data.Count % unit != 0)
+ data.Add(0);
+ Put(data, table + 10, (ushort)(data.Count / unit));
+ Put(data, table + 12, (ushort)1);
+ data.Add(0);
+ var fontType = table + 22;
+ Put(data, fontType, (ushort)0x8008);
+ Put(data, fontType + 2, (ushort)resources.Length);
+ for (var index = 0; index < resources.Length; index++)
+ {
+ while (data.Count % unit != 0)
+ data.Add(0);
+ var resource = resources[index];
+ var offset = data.Count / unit;
+ var length = (resource.Length + unit - 1) / unit;
+ if (offset > ushort.MaxValue || length > ushort.MaxValue)
+ throw new ArgumentOutOfRangeException(nameof(resources), "NE resource exceeds WORD range.");
+ var entry = fontType + 8 + index * 12;
+ Put(data, entry, (ushort)offset);
+ Put(data, entry + 2, (ushort)length);
+ Put(data, entry + 6, (ushort)(0x8001 + index));
+ data.AddRange(resource);
+ while (data.Count % unit != 0)
+ data.Add(0);
+ }
+ return [.. data];
+ }
+
+ /// Builds the fixed-width FNT v2 sample.
+ public static byte[] V2Fixed() => Container([Fnt(2)]);
+ /// Builds the proportional FNT v2 sample with control characters.
+ public static byte[] V2Proportional() => Container([Fnt(2, fixedWidth: false, first: 0)]);
+ /// Builds the fixed-width FNT v3 sample.
+ public static byte[] V3Fixed() => Container([Fnt(3)]);
+ /// Builds the proportional FNT v3 sample.
+ public static byte[] V3Proportional() => Container([Fnt(3, fixedWidth: false)]);
+ /// Builds three strikes, two at the same height.
+ public static byte[] Multi() => Container([Fnt(2), Fnt(3), Fnt(3, 14)]);
+ /// Builds the one- and 256-pixel strikes.
+ public static byte[] Edge() => Container([Fnt(3, 1, first: 32, last: 34), Fnt(3, 256, first: 32, last: 34)]);
+ /// Builds a v3 resource with the given unsupported glyph table flags.
+ public static byte[] V3Flags(int flag) => Container([Fnt(3, flags: flag)]);
+
+ /// Returns one of the twelve named synthetic samples.
+ public static byte[] Sample(string name) => name switch
+ {
+ "Fon-v2-fixed.fon" => V2Fixed(),
+ "Fon-v2-proportional.fon" => V2Proportional(),
+ "Fon-v3-fixed.fon" => V3Fixed(),
+ "Fon-v3-proportional.fon" => V3Proportional(),
+ "Fon-multi.fon" => Multi(),
+ "Fon-edge.fon" => Edge(),
+ "Fon-v3-flags-04.fon" => V3Flags(0x04),
+ "Fon-v3-flags-08.fon" => V3Flags(0x08),
+ "Fon-v3-flags-10.fon" => V3Flags(0x10),
+ "Fon-v3-flags-20.fon" => V3Flags(0x20),
+ "Fon-v3-flags-40.fon" => V3Flags(0x40),
+ "Fon-v3-flags-80.fon" => V3Flags(0x80),
+ _ => throw new ArgumentOutOfRangeException(nameof(name)),
+ };
+}
diff --git a/tests/Filee.Engines.Tests/FonTests.cs b/tests/Filee.Engines.Tests/FonTests.cs
index dabb7065d..638a02ae7 100644
--- a/tests/Filee.Engines.Tests/FonTests.cs
+++ b/tests/Filee.Engines.Tests/FonTests.cs
@@ -1,6 +1,5 @@
-// Windows bitmap FON (NE + FNT v2/v3) to TTF/OTF/WOFF/WOFF2/EOT (issue #177). The committed Fonts/Fon-*.fon files are
-// synthetic, made by Fonts/make-test-fon.py; the expected pixels below are written from that generator's art, never
-// from the engine's own reader.
+// Windows bitmap FON (NE + FNT v2/v3) to TTF/OTF/WOFF/WOFF2/EOT (issue #177). FonBuilder creates
+// synthetic samples in memory; the expected pixels below are independent of the engine's reader.
using System.Buffers.Binary;
using System.Globalization;
@@ -17,9 +16,7 @@ public class FonTests(EngineFixture fx) : IClassFixture
{
private static readonly string[] Formats = ["ttf", "otf", "woff", "woff2", "eot"];
- private static string Sample(string name) => Path.Combine(AppContext.BaseDirectory, "Fonts", name);
-
- private static byte[] Data(string name) => File.ReadAllBytes(Sample(name));
+ private static byte[] Data(string name) => FonBuilder.Sample(name);
private static int FntStart(byte[] data, int resource = 0)
{
@@ -31,7 +28,7 @@ private static int FntStart(byte[] data, int resource = 0)
// ───────────────────────── The generator's art, independent of the reader ─────────────────────────
- /// One committed single-resource sample: FNT version, fixed or proportional, first character.
+ /// One synthetic single-resource sample: FNT version, fixed or proportional, first character.
public sealed record Kind(string File, bool Fixed, int First);
private static readonly Kind[] Kinds =
@@ -70,7 +67,7 @@ private static bool ArtLit(int code, int x, int y, int width, int height)
[InlineData("Fon-v3-proportional.fon", 0x300, 0)]
[InlineData("Fon-multi.fon", 0x200, 8)]
[InlineData("Fon-edge.fon", 0x300, 8)]
- public void Committed_fon_samples_are_copied_to_test_output(string name, int version, int width)
+ public void Generated_fon_samples_have_expected_resources(string name, int version, int width)
{
var bytes = Data(name);
Assert.Equal("MZ", Encoding.ASCII.GetString(bytes, 0, 2));
@@ -81,6 +78,23 @@ public void Committed_fon_samples_are_copied_to_test_output(string name, int ver
Assert.Equal("Fixed", strikes[0].Face);
}
+ [Fact]
+ public void Aligned_ne_container_keeps_resource_lengths_inside_file()
+ {
+ var sample = FonBuilder.V2Fixed();
+ var bytes = FonBuilder.Container([Resource(sample, 0)], alignmentShift: 9);
+ // FONTDIR (first entry at 0x8A) and RT_FONT (0x9E) both resolve inside the file in 512-byte units.
+ foreach (var entry in new[] { 0x80 + 10, 0x80 + 30 })
+ {
+ var offset = BitConverter.ToUInt16(bytes, entry) << 9;
+ var length = BitConverter.ToUInt16(bytes, entry + 2) << 9;
+ Assert.InRange(offset, 0x100, bytes.Length - length);
+ }
+ var strike = Assert.Single(FonReader.Read(bytes, TestContext.Current.CancellationToken));
+ Assert.Equal(0x200, strike.Version);
+ Assert.Equal(13, strike.PixelHeight);
+ }
+
/// A folder holding as Name.fon.
private string Source(byte[] data, out string folder)
{
@@ -308,7 +322,7 @@ public void Every_bitmap_mask_matches_unhinted_integer_raster(int kindIndex)
[Fact]
public void Cmap_excludes_controls_and_undefined_bytes_without_losing_glyphs()
{
- // 0x00..0x7F from the committed sample: controls 0x00..0x1F and DEL 0x7F keep glyphs but no cmap entry.
+ // 0x00..0x7F from the generated sample: controls 0x00..0x1F and DEL 0x7F keep glyphs but no cmap entry.
var strike = Assert.Single(FonReader.Read(Data("Fon-v2-proportional.fon"), TestContext.Current.CancellationToken));
var font = FonOutlineBuilder.Build(strike, TestContext.Current.CancellationToken);
var glyphs = GlyfTable.ReadAll(font);
@@ -697,7 +711,7 @@ private static byte[] Resource(byte[] fon, int index)
return fon[start..(start + BinaryPrimitives.ReadUInt16LittleEndian(fon.AsSpan(entry + 2)))];
}
- /// MZ/NE with a FONTDIR and the given RT_FONT resources (the layout of make-test-fon.py).
+ /// MZ/NE with a FONTDIR and the given RT_FONT resources.
private static byte[] Container(params byte[][] resources)
{
const int ne = 0x40, table = ne + 0x40;
diff --git a/tests/Filee.Engines.Tests/Fonts/FON-FIXTURES.md b/tests/Filee.Engines.Tests/Fonts/FON-FIXTURES.md
deleted file mode 100644
index b9a3384ce..000000000
--- a/tests/Filee.Engines.Tests/Fonts/FON-FIXTURES.md
+++ /dev/null
@@ -1,31 +0,0 @@
-# Synthetic FON test fixtures
-
-`make-test-fon.py` builds NE RT_FONT containers with FNT v2/v3 resources from pixel art defined in the script. No
-bytes or outlines are taken from any existing font, so the samples are original synthetic data under the project's
-MIT licence. The tests read only the committed files and never run the generator.
-
-Rebuild: `uv run tests/Filee.Engines.Tests/Fonts/make-test-fon.py`
-
-Check: `sha256sum tests/Filee.Engines.Tests/Fonts/Fon-*.fon`
-
-| File | SHA-256 |
-|---|---|
-| Fon-v2-fixed.fon | `3b1b05572ba5ce6e0c011e5fb7bcc9ecedf367abd1d58fdb31dca161ca75e9f4` |
-| Fon-v2-proportional.fon | `802ec675899d50afd8b22907a505c8029cb837d555b59dac76492267e977d4b2` |
-| Fon-v3-fixed.fon | `ebc793d05862802a57ee2af3d2abd7425bd97a2957e3f4fd449a50863a8804a8` |
-| Fon-v3-proportional.fon | `0744fc346722abd93f735a6bc42540ccdb5248b703d196d1b5c4dd957b88525d` |
-| Fon-multi.fon | `e1f5379b11a5d4bddcfbb5b7c03c08556d8bb32528e973e58fcecb3290697273` |
-| Fon-edge.fon | `fa8dd5d6424850d6054ae89685cffae30ab2b5757d13be86b97c8d3b24dd8b09` |
-| Fon-v3-flags-04.fon | `6c789389ed490fd4f88dd1e67329ba10464cd76e0c22c1acb95eebf17142f03e` |
-| Fon-v3-flags-08.fon | `c7f99f904af5d83d9b47603fe13645c701bca7ef1c22f362f181c1d506d0d6a3` |
-| Fon-v3-flags-10.fon | `6e5538d1cf78a776f4631861da563163bcd087f7205ebd8898171a6f00071917` |
-| Fon-v3-flags-20.fon | `c218cd58e2bfb33653cf0726137f4c6eb6943c73866d815c72feb43e8f77d58d` |
-| Fon-v3-flags-40.fon | `86caba38ada299971ca997b458e6c15162f3765149aec8070ce1d7bb79d73365` |
-| Fon-v3-flags-80.fon | `89f79b840576b013100e39f87fc306c71d5b161be6bc9eec77e8f5eafa965d5b` |
-
-The basic samples cover 0x20..0x7F at 13 px; only the v2 proportional sample also holds the control codes
-0x00..0x1F. The space is blank, `!` is a single pixel at `(2,0)`, `"` is a diagonal, and every other glyph is a
-framed rectilinear shape. Proportional widths are 0 px for the space, 9 px for even codes and 5 px for odd codes.
-`Fon-multi.fon` holds 13, 13 and 14 px resources, and `Fon-edge.fon` holds 1 and 256 px resources. The flag samples
-check that unsupported v3 dfFlags are rejected; each uses a glyph table entry size, widths and offsets that differ
-from the allowed 6-byte table. The external `fixed.fon` and `tektite.fon` are not included.
diff --git a/tests/Filee.Engines.Tests/Fonts/Fon-edge.fon b/tests/Filee.Engines.Tests/Fonts/Fon-edge.fon
deleted file mode 100644
index 3e5ff6d90780a108a7eed58a3ade15b5999e2d7c..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 1312
zcmeZ`Vjvqh0CoDg64vg(P=QAgdjlg_b_WB525~qVn80E?8MXpx#s&~4k8u+-0|OJ7
z4dOF{q^JQv)`G2@z{tP>aReuWf)dQ=HBiPT5RGsmOd6TyU;ruNVAu>Mw}7QUu5`<+
zNKIj2P*7lC2Kj*+fD32{!qH%NQRwJRK)(S!05%llN~oi$;n*Q;hQbhbJ2jlHAi%-G
I#5i(E01=HMH~;_u
diff --git a/tests/Filee.Engines.Tests/Fonts/Fon-multi.fon b/tests/Filee.Engines.Tests/Fonts/Fon-multi.fon
deleted file mode 100644
index 98a152f4515496012f2cf0e70d6182e9feab863d..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 6021
zcmeI$drXye9LMqBxp>YKshrHpT(P4w^3uh~QL`q;ThdB~EScdgQ`E>+vr;S@M~4iZ
z_q>&^7!h_;(a=>>XPP=MGncNIQK_XfM~s?UQmLQc`vB`~EH}p=jy=!zex2XpJe?GaP>KRX4Z9v=P5#BS{!C+g#CyUp={F`lTs^L}tg&=@c4
z+{CbBjE}W_%xiaRi7mlCnSG+g9^;sHc6EaZ7uZ|7)i?f#b|3u&UhShgtGiVn)n8?(
zk!rGfRn1n5)Ef1n+M;%;!|DfhR{g2`KF8dol2kACuo|qg)L1n|%}_<^O;w`SsV~%C
zbyS^Dwd!x>_#JbL>ZTqQg5hvYNaYu8`XAoKvk(z>JN2UwQb{=+f}l9
zP({>Gm7^x9SJW)EP_0%UsLg7p`dU@1U)2TW3pggE?o>V1Lu!y3rpBn}Rlb^|ma2Ev
zC+c&xM;*~`{CME8=kjwW@T+Me+_cgn9CG|#PlIV)?3NaL+`VIrdkoxTVBZbyd2oFn
z1LL!!uO+st%(r9AKK)^}w~e!|`PX&W$uYlaSI5+2P*dA69kC1EhmT-74##KlMVyIq
zaRt7I6}S!e<9GNA{*IUMM!$XCPIwolU>ZJ&Pvb;<8KbxW-^R6AiC^L&{2tHXdGxli
zub7B;U=K{iftZP-F%PF>Auhpp@MHW8cjGts6V~8G3~1^(=4K4zz1SCr;8QpbU%=O~
z7?)!yZosX$55L8qu@3*m1f745xedGF1K1Crz!8{>Q?UT&<6BscpGsrwIqlAG_w$JR
znaqAHbV8}sTjqDxCTGM*qq+O`Y4{nI=qB!nVOxkJEr0w%))0dHlb&-o`yQB2J6vbrrwIN*_^^Uf*Cjp^DrNaa2b|j1#ZWKcnr_r1=K$Q^f{mdXR|%0o0;Dx
m9CxC}U4e(TWPl3ZfA9VOfsp(
z{7q4A%t+0~CEe&^5=i-ssE)V(~hqPo@b_Vp^dMo~%)
zN>Pmlv>=FX3}GD8Sil;#v4>+^;1-W~h3QsOgmToP34U~<7hz0b7R%T`0tYz774Gna
zcPLXyF)C4qX0)RVeHg_g=CFcI?BEb*xW+x6@qvOIB_*grJ!}LJ!XU;lg?X%E3%fYN
zId1TP7q~r2yeLBr8qtal^k4)L%wP%Yh+`ioxa7u@p0?r0K!56LvdPN!=vZE5x{OTr
ZRp{`g=beFwa|X^C=)b}F4*vHUd;*Nssf+*s
diff --git a/tests/Filee.Engines.Tests/Fonts/Fon-v2-proportional.fon b/tests/Filee.Engines.Tests/Fonts/Fon-v2-proportional.fon
deleted file mode 100644
index e9ae81e9aa96dc4b9b20082c8df35a302ddeb2f4..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 3283
zcmeIze`Hos9LMp`^X#7Ix#!tdGiJ(c56g_C{-}AzY)iISDrSm)5H%_*)*oq0b*+d*
zrJ}{il&l|PBB`i|q+}a0kyLCEnI>D5dY^muchN@w-0vUfb-(AFdoRcLd++)!`sZlm
z&J9g}zr9f%f7p}`65;ACMY_bFox8270x1!M8yJ!tQe23pL#z}#%hT%s>gNMjtB5G_Tn9UjBjuP6Dg|&DWhs!g&T1j9>n9=
zhd1ykj^JmU!Q!-4McSwqn{fy3!;^Rc2k`;E#t--dt#4H6Tdlwr+>D)g6ffa5e1tD?
z9H+3*S}nFlD{(b$#ogG0XK(=T;xN9&Y4kEi~2ji(Hh%+obqF
z_Lb6dkMPs(e_mT7gAhEaUn&2rvK6lzX7@L
BzV-kB
diff --git a/tests/Filee.Engines.Tests/Fonts/Fon-v3-fixed.fon b/tests/Filee.Engines.Tests/Fonts/Fon-v3-fixed.fon
deleted file mode 100644
index 298e1887f048d7b481f2513900cf24fe4f3d544e..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 2157
zcmeIxKPW{}9KiA4eeZeicoIc1815?u1I1vVyjSw4{K-I}4CE;i#XwQ-4hD*1pco7k
z1ELrV6oY}mV4xT%1_Q-lpvXPn_sXAvlEHA#@Hyvqf4_6j?ex3VP4epzvUk>${=7XT
zUEdV>EQ9QdL}wkS*t1pH^@$i31vYLS{>AXyFLCJ?O&FpiQLo~~jOba9GjbxAdVc>b
za$issDY>&tl{}-*t=Fj-z+5cC3arOg?7<-%#c5o?b=<*2JjH8#z*kK0=yTIC3-d9E
zRoIB_*oPL5<18-WChp-8p5qNZqAO0Hn1mUajRjbOwb+cE*pI_FiF3G&Tey$Mc!9V0
zgkG;cHw8`1!9py@I&8si9K;cv!g*Z7Z9Kpeyuy2YLBHL9O42YB^RO5zu>r%_ixC{d
w8C=8-TcO3vItIdRpF7L^7@En-<1%6f19}9bc_$EY?!dVN{Wmy$@W1ci4GFlbMF0Q*
diff --git a/tests/Filee.Engines.Tests/Fonts/Fon-v3-flags-04.fon b/tests/Filee.Engines.Tests/Fonts/Fon-v3-flags-04.fon
deleted file mode 100644
index 344c51921ca34b8bd992f087f11ee95cd7ac5fbf..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 2351
zcmeIxKPW|E7{~GFa_@C1l7V7yi()WP6opU>guy@<3>3vckqoXF3=9SX#XvF0pJFgj
z378|zKi^&^-QP@#
z8S#X#u8K<=1h6$34TyL~!V~lmZNtB)I9(*l7Mk)%K;rEzR)Qf*tGQ^Gi8Qdx@~Csb=%c6Kf=%P8~hP}!{hW3Ha|tv*2&bgb@K31yasQ=JMca{giqqL
z_%gnQ@8c)G&?=4-^`pm(L@G86kZ^L`=A$%O4#uxB)duFk~QtU
zWoTNTxp)a)jW^=$crQMTPvA57626J=;m7y|ev3ch`pJj;iPyA#QZ=p5EIc1C$LsJG
zyc6%&w6edls%x;L-3>pE!H*NmU}C`gx#zla*9)_)3f-VM)2i4FBGW`7FGM8rg@{D{
Jh5zs`yaR=_v^@X-
diff --git a/tests/Filee.Engines.Tests/Fonts/Fon-v3-flags-08.fon b/tests/Filee.Engines.Tests/Fonts/Fon-v3-flags-08.fon
deleted file mode 100644
index c241cefa28cb059128ffd97c50e393089f3930ce..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 3377
zcmeIyKS)(!9LDkYUbTDfRjCFC2jMzMh(kj{fx$iC9~xA|p~1l+G%6el3=|SiP(wr{
zI5;>ogh-%+LV++-O)p|?gzE6GNe_5Av*#+$H2P{^JB8^kxk6`(Jw7Yrz}b5(GBzq`U9;jQxVO*
zVo7rwEotRW^d9;ceT}|DKcHLa9{LNteBrQegC*6yVM%qn(E)TA9Y<%;MRXheh900*
znZvqQEvfEJOR9SteTa^r6X+bejP9V{(LZRlZmsR}HA|`+T2kE}bP#=tPNMJ8Rdg5q
ziI!wlO)J+}(*3<|Np)M$UUUe3j=n(`&^7cEx{qe`uv^_)ORC#sNp)|bchD#33-m3z
zgs!8X(cfsfy)gQ}b(U1OU`ch`(Yt7bzCx$bkLV`)6;0chK`URhq`LK%RJR%JLLXSt
z-o#&(w);cdzEG=Hh_4m$YQ>W%ZYRh}sJ2p+WC53K
aqD%I`zYfWBknDlJipg`3?7{hX4*mjDl@9iXeU!JtcKUZj;Yl)-2?A)Zu0RZ1{~5fxmtV+NydJ}F7d
zRXca=>YMc#i>Z`^`e@ob(Wx5Dd(|o9HAT!4F?+=L6*dSy_f^B^g=+W=5;H-}0x`dd
zNhYl)d}>w0r$IHcdWjh!W|o*=#2gTF!z=DNs~TAys*x2D^NETw&szz2nF~h`sC1#D7Kg8UMUjm$VU+PsO>!oUBy(Q)|F+Yge
zA?8|janE;E!>3s_vR)H2M$9}h8^lPB^@PuD)$nOljXie}^MRN-VwQ>7C*~$zcT@0r
zpc+}Ns*&}Em`P$5iPZfdlRuI
i&7C**{-AxhbKuT_I|uH6q0k9jKXCoP^@IPq9~=U;-7ls9
diff --git a/tests/Filee.Engines.Tests/Fonts/Fon-v3-flags-20.fon b/tests/Filee.Engines.Tests/Fonts/Fon-v3-flags-20.fon
deleted file mode 100644
index daaa906dd7d489e692b49fac97c0f9a23762620f..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 7509
zcmeI0QG`@c7{|}ure=0(Yiet&Z6EILvcwV*J)|A6i8RC#OZ2cwX<1@z#Eu@)LzIXm
zmgpg=h=?VIi0C0bL_`d+L=Wj9qV4|fo$uehtDR<6PwO1!p81{o`@TEweCNB|6XR~x
zqZ~z6M*kgNb{CsS`DzfKedLlEC^Xg{y9a&F1=U!H4(q`GF^W-=4ieo8T)}nmt<#0G
z%#8Z_WL{(rqffsZaD&O(vSS#XkF|KcKQj9^m@*JWofcuvi!k4bFuU^>hdCy~ye`6g
zEy64oEDrOq2y<41SrcJ?7hxXgv@Dp@BFu*(%r7F$VQytSFwclE?};$$BFvtR7KeFC
zggGa|d?Ui_+GKH<6%poD5#~z~W-k7diN!o9!n`cPTo7S?6=Ck%oWA;$2=l%O^RtMY
zLt88kb6kXZSA_Xdgjp(C9Og+8=4}z?D-mXXtHog+6JcHvVZIPy{t{vC-)31bFNrXp
zi7*#Mn0vb|4)cNtb6$k`NrV}0w>Zo(5#}8c=6exlafiiW9v5NW5MkCtm_d)lVIC1-
zPKz*~i!gtPFo$mN$m)AuZpk^v?26^rn3$eXIO#^t+LISotyKE5uib
ruaLNBW_O8y$~0>mnTFzfi0>i3hxi`i-}ANfN;EE%#w*b^@Ra!t9&oav
diff --git a/tests/Filee.Engines.Tests/Fonts/Fon-v3-flags-40.fon b/tests/Filee.Engines.Tests/Fonts/Fon-v3-flags-40.fon
deleted file mode 100644
index dfa07c58595473429b447955da2a479e894f627e..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 12695
zcmeH}O=uHQ6ouckVyD$=jn(>7>(pwk)@pGhh|o#YY{b%yn;^J}LP10jp$O6-f(Rml
zAl(R31VN(UrU-6=;6?-yL=ZvT2-1xp-3Zcl&P>8RPmIaLiL3jdnVdP_+-VN?&D@hY
z)9~_xE60!5zT7wG{{poYoX{0h?+QC=*67fGXj{~Ea-{5{g(
z7fFM^wASXokp@4}B|i99q*4FyI`P5Jk_O+C5+D36(%=_JgP&Y)^PfqB&vuIs{sn2&
zAKf56_!-jRJ2#3C{u*iU^Q6I_>9P6uq`@EABtH0Oq)~rxv-sd|lLp_qMSSp6q``k7
z4gS52V4L*e*W!5^2;Q^27&!pEUSX
zzxd#zmf)@8?^bCq`@EB
zEk5`nY1Ho?5+D3c(%=_KgFm;&=0A}JKe1PQ@Xtx3{>ZTS;AcsL@7O0k_-WGM7f6FY
zJ!13kNQ2Mr7a#mn(x^XhKz#5sq`@0{>wv#Z8dvgLDLXlJHZSFx{LO1}{XdrXy4)2l
zj%5}6@T7GvSuy5ejYUP5!b9|F{#`g&O7isyJ(`CXq*iQRwsPP
Upw26o$Wo3TG{x?i(L=_BTtC43n1;)Lv2wj13FC*(hfLJWz*)>V!#HB~
zM}p%Zj;{AVVv^=`z@~h<-uHf3nvZzV_0jaDcarq}iepS*eYXg#$GENZf^~g*`qDdr
z<>oPg)#2a%B!yL#KY4{CtlXJQV7;~otosEV1MAW(Cb0TuGlA7KhY75*xlCYvvIwka
z^Ed|9twJWShUPPY)m_8{R&6m8Sm6asV7;>ltcN8W1MBQUCa?xdnZQbvF@aUMhzYE(
z7J>EB;TTv~7c+r%s+>p>02z`9(^1Xlk#Ca{|8
zn80$@GlBKlBCwus;22o9H!^{BauX9+J)4=ps@uW@R?b!?u-;n))}wlkfpu;h6IjO@
zn80dnWCE)w&IHysi@B>_O6;37&_87HAMWiBmN08Jb+UW%?bY=
zpAhNBeV9R#@>QdQm1*Hbo`=&nQBd39
zGSfHF$jO}cL_x`#(4Hu#vaFd;d!nE+)1D|OS=tjNB}?Z-p&OX?L`h|)bE42P(>YO4
znQ2cHlq{VSB_&JeM4=m)_C!f#rgNguGSfLxP?>2@6qGET6D1`}=R~0!nD#_TWu|kY
z&@$6GQBav_PZX3aof9P`OXozP8<_S)NoA&UqR=waIZ;rVX-^cCES(c2B}?Z-p&OX?
zL`h|)bE42P(>YO4nQ2cHlq{VSB_&JeM4=m)_C!f#rgNguGSfLxP?>2@6qGET6D1`}
N=R~0!*#FZLe*ulzw`l+X
diff --git a/tests/Filee.Engines.Tests/Fonts/Fon-v3-proportional.fon b/tests/Filee.Engines.Tests/Fonts/Fon-v3-proportional.fon
deleted file mode 100644
index f6327a63e23eb5ff966b77e6d851159ca0855fc6..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 2755
zcmeIzKPW|U6u|NGo_D=>U-?rcW$*@yk}{AOo)iXuQc@BFp%exNl6tQg7?gp5K}i|>
zi9s0{7?hNOfubY^29h!`kl*>e0!Pn
zeADEQdZ@07v+5=gyS652K@m@s29x4+`xl{OygFz~e>6d5ODLkN#yK^SD_vuz@Kw?#
zi&k46us=@6rJ8I>J}$+LxE+t+3A~6m@F70KPxu|D`*dcu$(Dq14er7NcoNUyO}vLM
z@E!JAIw#3wOLB1$uE(u-5Rc(`yn^@f3BJd#I3=FPXR;+FxC*!7K0JDMA;v9`)
iXA=%-gq=;)3AfFCAnplq-vx0$4kGT?QsiI%FMI$@gHH$m
diff --git a/tests/Filee.Engines.Tests/Fonts/make-test-fon.py b/tests/Filee.Engines.Tests/Fonts/make-test-fon.py
deleted file mode 100644
index 64df49e8e..000000000
--- a/tests/Filee.Engines.Tests/Fonts/make-test-fon.py
+++ /dev/null
@@ -1,136 +0,0 @@
-#!/usr/bin/env -S uv run --script
-# /// script
-# requires-python = ">=3.10"
-# dependencies = []
-# ///
-# How to run: uv run tests/Filee.Engines.Tests/Fonts/make-test-fon.py
-# Rebuild committed synthetic fixtures only when changing the generator; tests never run it.
-
-from __future__ import annotations
-
-import hashlib
-import struct
-from pathlib import Path
-
-HERE = Path(__file__).resolve().parent
-
-
-def put(data: bytearray, offset: int, fmt: str, value: int) -> None:
- struct.pack_into("<" + fmt, data, offset, value)
-
-
-def fnt(version: int, height: int = 13, *, fixed: bool = True, first: int = 32,
- last: int = 127, flags: int | None = None) -> bytes:
- """Create a complete, independent FNT resource with column-major bitmaps."""
- count = last - first + 1
- # Rejected flag samples deliberately do not have the allowed 6-byte table.
- entry_size = 4 if version == 2 else 6 if flags is None else 8 + (flags.bit_length() - 3) * 2
- table = 0x76 if version == 2 else 0x94
- face = table + (count + 1) * entry_size
- bits = face + len(b"Fixed\0")
- data = bytearray(bits)
- put(data, 0, "H", version << 8)
- put(data, 0x42, "H", 0)
- put(data, 0x4A, "H", min(height, 10))
- put(data, 0x4E, "H", 2)
- put(data, 0x53, "H", 400)
- data[0x55] = 0
- put(data, 0x56, "H", 8 if fixed else 0)
- put(data, 0x58, "H", height)
- put(data, 0x5D, "H", 9)
- data[0x5F:0x63] = bytes((first, last, 0, 0))
- put(data, 0x69, "I", face)
- put(data, 0x71, "I", bits)
- if version == 3:
- put(data, 0x76, "I", flags if flags is not None else (1 if fixed else 2))
- if flags is not None:
- put(data, 0x7A, "H", flags + 1)
- put(data, 0x7C, "H", flags + 2)
- put(data, 0x7E, "H", flags + 3)
- data[face:bits] = b"Fixed\0"
-
- for index, code in enumerate(range(first, last + 1)):
- width = (flags + index % 3) if flags is not None else (
- 8 if fixed else (0 if code == 32 else 5 if code % 2 else 9))
- entry = table + index * entry_size
- put(data, entry, "H", width)
- if flags is not None:
- put(data, entry + 2, "H", flags + index % 3)
- put(data, entry + 4, "I", len(data))
- else:
- put(data, entry + 2, "H" if version == 2 else "I", len(data))
- # x=2 creates a left bearing; an isolated pixel, diagonal and hole are
- # defined independently of the parser. Space and zero-width glyphs are blank.
- for column in range((width + 7) // 8):
- for y in range(height):
- value = 0
- for bit in range(8):
- x = column * 8 + bit
- if x >= width or code == 32:
- continue
- lit = (code == 33 and x == 2 and y == 0) or (
- code == 34 and x == 2 + y % max(1, width - 2)) or (
- code != 33 and code != 34 and
- 2 <= x < width and y < min(height, 7) and
- (x in (2, width - 1) or y in (0, min(height, 7) - 1)))
- if lit:
- value |= 0x80 >> bit
- data.append(value)
- sentinel = table + count * entry_size
- put(data, sentinel, "H", 0)
- put(data, sentinel + (4 if flags is not None else 2),
- "I" if flags is not None or version == 3 else "H", len(data))
- put(data, 2, "I", len(data))
- return bytes(data)
-
-
-def fon(resources: list[bytes]) -> bytes:
- """Write MZ/NE, a FONTDIR entry, RT_FONT entries, and aligned FNT payloads."""
- ne = 0x40
- table = ne + 0x40
- payload = table + 2 + 8 + 12 + 8 + 12 * len(resources) + 2
- data = bytearray(payload)
- data[:2] = b"MZ"
- put(data, 0x3C, "I", ne)
- data[ne:ne + 2] = b"NE"
- put(data, ne + 0x24, "H", table - ne)
- put(data, ne + 0x26, "H", payload - ne)
- put(data, table + 2, "H", 0x8007)
- put(data, table + 4, "H", 1)
- put(data, table + 10, "H", len(data))
- put(data, table + 12, "H", 1)
- data.append(0)
- font_type = table + 22
- put(data, font_type, "H", 0x8008)
- put(data, font_type + 2, "H", len(resources))
- for index, resource in enumerate(resources):
- if len(data) > 0xFFFF or len(resource) > 0xFFFF:
- raise OverflowError("NE unshifted resource exceeds WORD range")
- entry = font_type + 8 + index * 12
- put(data, entry, "H", len(data))
- put(data, entry + 2, "H", len(resource))
- put(data, entry + 6, "H", 0x8001 + index)
- data.extend(resource)
- return bytes(data)
-
-
-def main() -> None:
- samples = {
- "Fon-v2-fixed.fon": [fnt(2)],
- "Fon-v2-proportional.fon": [fnt(2, fixed=False, first=0)],
- "Fon-v3-fixed.fon": [fnt(3)],
- "Fon-v3-proportional.fon": [fnt(3, fixed=False)],
- "Fon-multi.fon": [fnt(2), fnt(3), fnt(3, 14)],
- "Fon-edge.fon": [fnt(3, 1, first=32, last=34), fnt(3, 256, first=32, last=34)],
- }
- for flag in (0x04, 0x08, 0x10, 0x20, 0x40, 0x80):
- samples[f"Fon-v3-flags-{flag:02x}.fon"] = [fnt(3, flags=flag)]
- for name, resources in samples.items():
- path = HERE / name
- contents = fon(resources)
- path.write_bytes(contents)
- print(f"{hashlib.sha256(contents).hexdigest()} {path.name}")
-
-
-if __name__ == "__main__":
- main()
From 9986e341134da98dff8a5f791fa45ca0fb252434 Mon Sep 17 00:00:00 2001
From: SeoJCalive <180237232+SeoJCalive@users.noreply.github.com>
Date: Sun, 25 Oct 2026 22:03:16 +0900
Subject: [PATCH 5/9] Refuse .fon glyphs too wide for TrueType before decoding
them
---
src/Filee.Engines/Fonts/FonOutlineBuilder.cs | 5 ++-
src/Filee.Engines/Fonts/FonReader.cs | 3 ++
tests/Filee.Engines.Tests/FonTests.cs | 45 ++++++++++++++++++--
3 files changed, 49 insertions(+), 4 deletions(-)
diff --git a/src/Filee.Engines/Fonts/FonOutlineBuilder.cs b/src/Filee.Engines/Fonts/FonOutlineBuilder.cs
index 18a55c3b4..726b880b7 100644
--- a/src/Filee.Engines/Fonts/FonOutlineBuilder.cs
+++ b/src/Filee.Engines/Fonts/FonOutlineBuilder.cs
@@ -11,6 +11,9 @@ internal static class FonOutlineBuilder
private readonly record struct Corner(int X, int Y);
private readonly record struct Edge(Corner From, Corner To);
+ /// Scales the strike's pixel height into TrueType font units.
+ internal static int Scale(int height) => height <= 1024 ? 1024 / height : 1;
+
public static SfntFont Build(FonStrike strike, int resourceCount = 1, int sameHeightOrdinal = 1) =>
Build(strike, CancellationToken.None, resourceCount, sameHeightOrdinal);
@@ -24,7 +27,7 @@ public static SfntFont Build(FonStrike strike, CancellationToken cancellationTok
if (resourceCount < 1 || sameHeightOrdinal < 1 || strike.Glyphs.Count is < 1 or > 65534)
throw new InvalidDataException("FON resource or glyph count exceeds TrueType limits.");
var height = strike.PixelHeight;
- var scale = height <= 1024 ? 1024 / height : 1;
+ var scale = Scale(height);
var upem = checked(height * scale);
if (upem is < 16 or > 16384)
throw new InvalidDataException("FON pixel height exceeds TrueType units-per-em limits.");
diff --git a/src/Filee.Engines/Fonts/FonReader.cs b/src/Filee.Engines/Fonts/FonReader.cs
index 987bb3d82..1bc65706c 100644
--- a/src/Filee.Engines/Fonts/FonReader.cs
+++ b/src/Filee.Engines/Fonts/FonReader.cs
@@ -162,6 +162,7 @@ public static IReadOnlyList Read(byte[] data, CancellationToken cance
var bitsOffset = (long)U32(fnt, 0x71);
if (bitsOffset < tableEnd || bitsOffset > size)
throw new InvalidDataException("FNT bitmap region offset is outside the resource.");
+ var scale = FonOutlineBuilder.Scale(height);
var glyphs = decode ? new List(count) : null;
for (var i = 0; i < count; i++)
{
@@ -170,6 +171,8 @@ public static IReadOnlyList Read(byte[] data, CancellationToken cance
var width = U16(fnt, entry);
var offset = version == 0x0200 ? U16(fnt, entry + 2) : U32(fnt, entry + 2);
var bytes = ((long)width + 7) / 8 * height;
+ if ((long)width * scale > ushort.MaxValue)
+ throw new InvalidDataException($"FON glyph advance exceeds the {ushort.MaxValue}-unit TrueType limit.");
if (offset < bitsOffset || !Fits(size, offset, bytes))
throw new InvalidDataException($"FNT bitmap for character {first + i} is outside the resource.");
if (!decode)
diff --git a/tests/Filee.Engines.Tests/FonTests.cs b/tests/Filee.Engines.Tests/FonTests.cs
index 638a02ae7..f0e434cfa 100644
--- a/tests/Filee.Engines.Tests/FonTests.cs
+++ b/tests/Filee.Engines.Tests/FonTests.cs
@@ -867,18 +867,57 @@ public async Task Fon_prewrite_cancellation_leaves_no_outputs()
[Fact]
public async Task Aliased_glyphs_over_filewide_pixel_limit_fail_without_outputs()
{
- var data = Container(AliasedFnt(2048, 8), AliasedFnt(2049, 8));
+ var data = Container(AliasedFnt(16, 1024), AliasedFnt(17, 1024));
var error = Assert.Throws(() => FonReader.Read(data, TestContext.Current.CancellationToken));
Assert.Contains("unit limit", error.Message);
Assert.Contains($"{FonReader.MaxGlyphPixels:N0}", error.Message);
- await AssertNothingWrittenAsync(data, ["Name-8px.ttf", "Name-8px-2.ttf"], FileState.Failed, "unit limit");
+ await AssertNothingWrittenAsync(data, ["Name-1024px.ttf", "Name-1024px-2.ttf"], FileState.Failed, "unit limit");
+ }
+
+ [Fact]
+ public void Glyph_advance_above_65535_is_rejected_before_decoding()
+ {
+ var data = FonBuilder.Container([AliasedFnt(64, 1, 1)]);
+
+ var error = Assert.Throws(() => FonReader.Read(data, TestContext.Current.CancellationToken));
+ Assert.Contains("65535", error.Message);
+ }
+
+ [Fact]
+ public void Blank_glyph_with_advance_above_signed_coordinate_limit_converts()
+ {
+ var resource = AliasedFnt(33, 1, 2);
+ BinaryPrimitives.WriteUInt16LittleEndian(resource.AsSpan(0x76 + 4), 0);
+ var data = FonBuilder.Container([resource]);
+ var strike = Assert.Single(FonReader.Read(data, TestContext.Current.CancellationToken));
+ var font = FontFile.Load(FontFile.Save(
+ FonOutlineBuilder.Build(strike, TestContext.Current.CancellationToken), "ttf", TestContext.Current.CancellationToken),
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal(33 * 1024, Advance(font, 1));
+ Assert.Null(GlyfTable.ReadAll(font)[1]);
+ }
+
+ [Fact]
+ public void Shared_large_bitmap_is_refused_with_less_than_one_megabyte_allocated()
+ {
+ var data = FonBuilder.Container([AliasedFnt(4096, 1024, 224)], alignmentShift: 9);
+ Assert.Single(FonReader.Read(FonBuilder.V2Fixed(), TestContext.Current.CancellationToken));
+
+ var before = GC.GetAllocatedBytesForCurrentThread();
+ var error = Assert.Throws(() => FonReader.Read(data, TestContext.Current.CancellationToken));
+ var allocated = GC.GetAllocatedBytesForCurrentThread() - before;
+
+ Assert.Contains("unit limit", error.Message);
+ Assert.Contains($"{FonReader.MaxGlyphPixels:N0}", error.Message);
+ Assert.True(allocated < 1_000_000, $"Reader allocated {allocated:N0} bytes.");
}
[Fact]
public void Aliased_glyphs_at_filewide_pixel_limit_are_accepted()
{
- var data = Container(AliasedFnt(2048, 8), AliasedFnt(2048, 8));
+ var data = Container(AliasedFnt(16, 1024), AliasedFnt(16, 1024));
var strikes = FonReader.Read(data, TestContext.Current.CancellationToken);
Assert.Equal(FonReader.MaxGlyphPixels, strikes.Sum(s => s.Glyphs.Sum(g => (long)g.Width * s.PixelHeight)));
Assert.All(strikes, s => Assert.All(s.Glyphs, g => Assert.False(g.Pixels[0, 0])));
From 4307ed56efe7b5b8bd444c76a40efee1c63851ce Mon Sep 17 00:00:00 2001
From: SeoJCalive <180237232+SeoJCalive@users.noreply.github.com>
Date: Sun, 25 Oct 2026 22:15:14 +0900
Subject: [PATCH 6/9] Refuse unsupported and damaged .fon files with localized
messages
---
src/Filee.App/Assets/i18n/ar.json | 4 +
src/Filee.App/Assets/i18n/de.json | 4 +
src/Filee.App/Assets/i18n/en.json | 4 +
src/Filee.App/Assets/i18n/es.json | 4 +
src/Filee.App/Assets/i18n/fr.json | 4 +
src/Filee.App/Assets/i18n/hi.json | 4 +
src/Filee.App/Assets/i18n/ja.json | 4 +
src/Filee.App/Assets/i18n/ko.json | 4 +
src/Filee.App/Assets/i18n/ne.json | 4 +
src/Filee.App/Assets/i18n/pl.json | 4 +
src/Filee.App/Assets/i18n/pt-BR.json | 4 +
src/Filee.App/Assets/i18n/pt-PT.json | 4 +
src/Filee.App/Assets/i18n/ru.json | 4 +
src/Filee.App/Assets/i18n/th.json | 4 +
src/Filee.App/Assets/i18n/tr.json | 4 +
src/Filee.App/Assets/i18n/zh-CN.json | 4 +
src/Filee.App/Assets/i18n/zh-TW.json | 4 +
src/Filee.Engines/Fonts/FonOutlineBuilder.cs | 27 +++--
src/Filee.Engines/Fonts/FonReader.cs | 70 ++++++-----
tests/Filee.Engines.Tests/FonTests.cs | 119 ++++++++++++-------
20 files changed, 196 insertions(+), 88 deletions(-)
diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json
index 6bf3d1916..24f66fb4e 100644
--- a/src/Filee.App/Assets/i18n/ar.json
+++ b/src/Filee.App/Assets/i18n/ar.json
@@ -121,6 +121,10 @@
"error.fit_not_activity": "ملف FIT هذا ليس نشاطًا (يتم تحويل الأنشطة فقط)",
"error.fit_no_positions": "لا يحتوي النشاط على مواقع مسجلة: حوّله إلى CSV أو XLSX أو JSON",
"error.fit_no_records": "لا يحتوي النشاط على بيانات مسجلة",
+ "error.fon_vector": "هذا خط متجهي؛ يمكن تحويل خطوط .fon النقطية فقط",
+ "error.fon_unsupported": "هذا النوع من خطوط .fon غير مدعوم",
+ "error.fon_too_large": "خط .fon كبير جدًا بحيث لا يمكن تحويله",
+ "error.fon_damaged": "ملف .fon تالف أو غير مكتمل",
"notice.pdf_not_smaller": "تعذّر تقليل الحجم، لذا نُسخ الملف كما هو",
"notice.cad_newer_left_out": "حُذفت كائنات لا يدعمها إصدار AutoCAD المختار (أسهم توضيحية متعددة، شبكات …)",
"notice.original_kept": "تم الاحتفاظ بالأصل وحُفظت النتيجة كنسخة",
diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json
index 431f46a82..574710095 100644
--- a/src/Filee.App/Assets/i18n/de.json
+++ b/src/Filee.App/Assets/i18n/de.json
@@ -121,6 +121,10 @@
"error.fit_not_activity": "Diese FIT-Datei ist keine Aktivität (nur Aktivitäten werden konvertiert)",
"error.fit_no_positions": "Die Aktivität enthält keine aufgezeichneten Positionen. Konvertiere sie in CSV, XLSX oder JSON",
"error.fit_no_records": "Die Aktivität enthält keine aufgezeichneten Daten",
+ "error.fon_vector": "Dies ist eine Vektorschrift; nur Bitmap-Schriften im .fon-Format können konvertiert werden",
+ "error.fon_unsupported": "Diese Art von .fon-Schrift wird nicht unterstützt",
+ "error.fon_too_large": "Die .fon-Schrift ist zu groß zum Konvertieren",
+ "error.fon_damaged": "Die .fon-Datei ist beschädigt oder unvollständig",
"notice.pdf_not_smaller": "Die Datei ließ sich nicht verkleinern und wurde unverändert kopiert",
"notice.cad_newer_left_out": "Objekte, die die gewählte AutoCAD-Version nicht unterstützt (Multiführungslinien, Netze …), wurden weggelassen",
"notice.original_kept": "Das Original wurde beibehalten und das Ergebnis als Kopie gespeichert",
diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json
index b816284de..a6ddb9bc4 100644
--- a/src/Filee.App/Assets/i18n/en.json
+++ b/src/Filee.App/Assets/i18n/en.json
@@ -127,6 +127,10 @@
"error.fit_not_activity": "This FIT file isn't an activity (only activities are converted)",
"error.fit_no_positions": "The activity has no recorded positions: convert it to CSV, XLSX or JSON",
"error.fit_no_records": "The activity has no recorded data",
+ "error.fon_vector": "This is a vector font; only bitmap .fon fonts can be converted",
+ "error.fon_unsupported": "This kind of .fon font isn't supported",
+ "error.fon_too_large": "The .fon font is too large to convert",
+ "error.fon_damaged": "The .fon file is damaged or cut short",
"notice.pdf_not_smaller": "Couldn't make it smaller, so it was copied as it is",
"notice.cad_newer_left_out": "Objects the chosen AutoCAD version doesn't have (multileaders, meshes …) were left out",
"notice.original_kept": "The original was kept, the result was saved as a copy",
diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json
index f6a3f8156..24fd4a80e 100644
--- a/src/Filee.App/Assets/i18n/es.json
+++ b/src/Filee.App/Assets/i18n/es.json
@@ -121,6 +121,10 @@
"error.fit_not_activity": "Este archivo FIT no es una actividad (solo se convierten actividades)",
"error.fit_no_positions": "La actividad no tiene posiciones registradas: conviértela a CSV, XLSX o JSON",
"error.fit_no_records": "La actividad no tiene datos registrados",
+ "error.fon_vector": "Esta es una fuente vectorial; solo se pueden convertir fuentes de mapa de bits .fon",
+ "error.fon_unsupported": "Este tipo de fuente .fon no es compatible",
+ "error.fon_too_large": "La fuente .fon es demasiado grande para convertirla",
+ "error.fon_damaged": "El archivo .fon está dañado o incompleto",
"notice.pdf_not_smaller": "No se pudo reducir su tamaño, por lo que se copió tal cual",
"notice.cad_newer_left_out": "Se omitieron objetos que la versión de AutoCAD elegida no tiene (directrices múltiples, mallas…)",
"notice.original_kept": "Se conservó el original, el resultado se guardó como copia",
diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json
index cfa24007a..684fa42d4 100644
--- a/src/Filee.App/Assets/i18n/fr.json
+++ b/src/Filee.App/Assets/i18n/fr.json
@@ -127,6 +127,10 @@
"error.fit_not_activity": "Ce fichier FIT n'est pas une activité (seules les activités sont converties)",
"error.fit_no_positions": "L'activité ne contient aucune position enregistrée : convertissez-la en CSV, XLSX ou JSON",
"error.fit_no_records": "L'activité ne contient aucune donnée enregistrée",
+ "error.fon_vector": "Cette police est vectorielle ; seules les polices bitmap .fon peuvent être converties",
+ "error.fon_unsupported": "Ce type de police .fon n'est pas pris en charge",
+ "error.fon_too_large": "La police .fon est trop grande pour être convertie",
+ "error.fon_damaged": "Le fichier .fon est endommagé ou incomplet",
"notice.pdf_not_smaller": "Impossible de réduire sa taille : le fichier a été copié tel quel",
"notice.cad_newer_left_out": "Des objets que la version d'AutoCAD choisie n'a pas (multilignes de repère, maillages…) ont été omis",
"notice.original_kept": "L'original a été conservé, le résultat a été enregistré en copie",
diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json
index 093937a49..6d3c38194 100644
--- a/src/Filee.App/Assets/i18n/hi.json
+++ b/src/Filee.App/Assets/i18n/hi.json
@@ -121,6 +121,10 @@
"error.fit_not_activity": "यह FIT फ़ाइल गतिविधि नहीं है (सिर्फ़ गतिविधियाँ कन्वर्ट होती हैं)",
"error.fit_no_positions": "गतिविधि में दर्ज स्थान नहीं हैं: इसे CSV, XLSX या JSON में बदलें",
"error.fit_no_records": "गतिविधि में दर्ज डेटा नहीं है",
+ "error.fon_vector": "यह वेक्टर फ़ॉन्ट है; केवल बिटमैप .fon फ़ॉन्ट बदले जा सकते हैं",
+ "error.fon_unsupported": "इस तरह का .fon फ़ॉन्ट समर्थित नहीं है",
+ "error.fon_too_large": ".fon फ़ॉन्ट बदलने के लिए बहुत बड़ा है",
+ "error.fon_damaged": ".fon फ़ाइल खराब है या अधूरी है",
"notice.pdf_not_smaller": "इसे छोटा नहीं किया जा सका, इसलिए जैसा है वैसा ही कॉपी कर दिया गया",
"notice.cad_newer_left_out": "चुने गए AutoCAD संस्करण में न होने वाले ऑब्जेक्ट (मल्टीलीडर, मेश …) छोड़ दिए गए",
"notice.original_kept": "मूल फ़ाइल रखी गई, परिणाम कॉपी के रूप में सहेजा गया",
diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json
index 8da00aee9..1fb8de2f8 100644
--- a/src/Filee.App/Assets/i18n/ja.json
+++ b/src/Filee.App/Assets/i18n/ja.json
@@ -121,6 +121,10 @@
"error.fit_not_activity": "このFITファイルはアクティビティではありません(アクティビティのみ変換できます)",
"error.fit_no_positions": "アクティビティに位置情報が記録されていません。CSV、XLSX、JSONに変換してください",
"error.fit_no_records": "アクティビティに記録データがありません",
+ "error.fon_vector": "これはベクターフォントです。変換できるのはビットマップの.fonフォントだけです",
+ "error.fon_unsupported": "この種類の.fonフォントには対応していません",
+ "error.fon_too_large": ".fonフォントが大きすぎて変換できません",
+ "error.fon_damaged": ".fonファイルが破損しているか、途中で切れています",
"notice.pdf_not_smaller": "サイズを小さくできなかったため、元のままコピーしました",
"notice.cad_newer_left_out": "選んだ AutoCAD のバージョンにないオブジェクト(マルチ引出線、メッシュ …)を省きました",
"notice.original_kept": "元のファイルを残し、結果をコピーとして保存しました",
diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json
index 9abda378a..eef9961e7 100644
--- a/src/Filee.App/Assets/i18n/ko.json
+++ b/src/Filee.App/Assets/i18n/ko.json
@@ -127,6 +127,10 @@
"error.fit_not_activity": "활동 기록이 아닌 FIT 파일이에요 (활동만 변환해요)",
"error.fit_no_positions": "위치 기록이 없는 활동이에요. CSV, XLSX, JSON으로 변환하세요",
"error.fit_no_records": "활동에 기록된 데이터가 없어요",
+ "error.fon_vector": "벡터 글꼴이에요. 비트맵 .fon 글꼴만 변환할 수 있어요",
+ "error.fon_unsupported": "지원하지 않는 종류의 .fon 글꼴이에요",
+ "error.fon_too_large": ".fon 글꼴이 너무 커서 변환할 수 없어요",
+ "error.fon_damaged": ".fon 파일이 손상되었거나 잘렸어요",
"notice.pdf_not_smaller": "더 작게 만들 수 없어서 그대로 복사했어요",
"notice.cad_newer_left_out": "선택한 AutoCAD 버전에 없는 개체(다중 지시선, 메시 …)는 빼고 저장했어요",
"notice.original_kept": "원본은 그대로 두고 결과를 사본으로 저장했어요",
diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json
index 2b79bedc9..9a8a0e9de 100644
--- a/src/Filee.App/Assets/i18n/ne.json
+++ b/src/Filee.App/Assets/i18n/ne.json
@@ -121,6 +121,10 @@
"error.fit_not_activity": "यो FIT फाइल गतिविधि होइन (गतिविधिहरू मात्र रूपान्तरण हुन्छन्)",
"error.fit_no_positions": "गतिविधिमा रेकर्ड गरिएका स्थान छैनन्: यसलाई CSV, XLSX वा JSON मा रूपान्तरण गर्नुहोस्",
"error.fit_no_records": "गतिविधिमा रेकर्ड गरिएको डाटा छैन",
+ "error.fon_vector": "यो भेक्टर फन्ट हो; बिटम्याप .fon फन्टहरू मात्र रूपान्तरण गर्न सकिन्छ",
+ "error.fon_unsupported": "यस प्रकारको .fon फन्ट समर्थित छैन",
+ "error.fon_too_large": ".fon फन्ट रूपान्तरण गर्न धेरै ठूलो छ",
+ "error.fon_damaged": ".fon फाइल बिग्रिएको वा अपूर्ण छ",
"notice.pdf_not_smaller": "यसलाई सानो बनाउन सकिएन, त्यसैले जस्तो छ त्यस्तै प्रतिलिपि गरियो",
"notice.cad_newer_left_out": "छानिएको AutoCAD संस्करणमा नभएका वस्तुहरू (मल्टिलिडर, मेस …) छोडिए",
"notice.original_kept": "मूल फाइल राखियो, नतिजा प्रतिलिपिको रूपमा सुरक्षित भयो",
diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json
index 9b80e10e6..d015f1ee1 100644
--- a/src/Filee.App/Assets/i18n/pl.json
+++ b/src/Filee.App/Assets/i18n/pl.json
@@ -127,6 +127,10 @@
"error.fit_not_activity": "Ten plik FIT nie jest aktywnością (konwertowane są tylko aktywności)",
"error.fit_no_positions": "Aktywność nie zawiera zapisanych położeń: przekonwertuj ją do CSV, XLSX lub JSON",
"error.fit_no_records": "Aktywność nie zawiera zapisanych danych",
+ "error.fon_vector": "To czcionka wektorowa; można konwertować tylko bitmapowe czcionki .fon",
+ "error.fon_unsupported": "Ten rodzaj czcionki .fon nie jest obsługiwany",
+ "error.fon_too_large": "Czcionka .fon jest zbyt duża, aby ją przekonwertować",
+ "error.fon_damaged": "Plik .fon jest uszkodzony lub niekompletny",
"notice.pdf_not_smaller": "Nie udało się zmniejszyć pliku, więc skopiowano go bez zmian",
"notice.cad_newer_left_out": "Pominięto obiekty, których wybrana wersja AutoCAD nie ma (multiodnośniki, siatki…)",
"notice.original_kept": "Oryginał został zachowany, wynik zapisano jako kopię",
diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json
index d5722144b..e4fd0872a 100644
--- a/src/Filee.App/Assets/i18n/pt-BR.json
+++ b/src/Filee.App/Assets/i18n/pt-BR.json
@@ -127,6 +127,10 @@
"error.fit_not_activity": "Este arquivo FIT não é uma atividade (só atividades são convertidas)",
"error.fit_no_positions": "A atividade não tem posições registradas: converta-a para CSV, XLSX ou JSON",
"error.fit_no_records": "A atividade não tem dados registrados",
+ "error.fon_vector": "Esta é uma fonte vetorial; só é possível converter fontes bitmap .fon",
+ "error.fon_unsupported": "Este tipo de fonte .fon não é compatível",
+ "error.fon_too_large": "A fonte .fon é grande demais para converter",
+ "error.fon_damaged": "O arquivo .fon está danificado ou incompleto",
"notice.pdf_not_smaller": "Não deu para deixar menor, então foi copiado como está",
"notice.cad_newer_left_out": "Objetos que a versão do AutoCAD escolhida não tem (multilinhas de chamada, malhas…) ficaram de fora",
"notice.original_kept": "O original foi mantido, o resultado foi salvo como cópia",
diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json
index c92436f46..b218937e8 100644
--- a/src/Filee.App/Assets/i18n/pt-PT.json
+++ b/src/Filee.App/Assets/i18n/pt-PT.json
@@ -127,6 +127,10 @@
"error.fit_not_activity": "Este ficheiro FIT não é uma atividade (só são convertidas atividades)",
"error.fit_no_positions": "A atividade não tem posições registadas: converte-a para CSV, XLSX ou JSON",
"error.fit_no_records": "A atividade não tem dados registados",
+ "error.fon_vector": "Esta é uma fonte vetorial; só é possível converter fontes bitmap .fon",
+ "error.fon_unsupported": "Este tipo de fonte .fon não é suportado",
+ "error.fon_too_large": "A fonte .fon é demasiado grande para converter",
+ "error.fon_damaged": "O ficheiro .fon está danificado ou incompleto",
"notice.pdf_not_smaller": "Não foi possível reduzir o tamanho, pelo que o ficheiro foi copiado sem alterações",
"notice.cad_newer_left_out": "Os objetos que a versão de AutoCAD escolhida não suporta (linhas de chamada múltiplas, malhas…) foram omitidos",
"notice.original_kept": "O original foi mantido e o resultado foi guardado como cópia",
diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json
index 8624ad12e..c95be48e9 100644
--- a/src/Filee.App/Assets/i18n/ru.json
+++ b/src/Filee.App/Assets/i18n/ru.json
@@ -127,6 +127,10 @@
"error.fit_not_activity": "Этот файл FIT не является активностью (конвертируются только активности)",
"error.fit_no_positions": "В активности нет записанных координат: конвертируйте её в CSV, XLSX или JSON",
"error.fit_no_records": "В активности нет записанных данных",
+ "error.fon_vector": "Это векторный шрифт; можно преобразовывать только растровые шрифты .fon",
+ "error.fon_unsupported": "Этот тип шрифта .fon не поддерживается",
+ "error.fon_too_large": "Шрифт .fon слишком велик для преобразования",
+ "error.fon_damaged": "Файл .fon повреждён или обрезан",
"notice.pdf_not_smaller": "Уменьшить не получилось, файл скопирован как есть",
"notice.cad_newer_left_out": "Объекты, которых нет в выбранной версии AutoCAD (мультивыноски, сети…), не сохранены",
"notice.original_kept": "Оригинал оставлен, результат сохранён как копия",
diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json
index ceeee4e34..8086edd0c 100644
--- a/src/Filee.App/Assets/i18n/th.json
+++ b/src/Filee.App/Assets/i18n/th.json
@@ -121,6 +121,10 @@
"error.fit_not_activity": "ไฟล์ FIT นี้ไม่ใช่กิจกรรม (แปลงได้เฉพาะกิจกรรมเท่านั้น)",
"error.fit_no_positions": "กิจกรรมนี้ไม่มีตำแหน่งที่บันทึกไว้ โปรดแปลงเป็น CSV, XLSX หรือ JSON",
"error.fit_no_records": "กิจกรรมนี้ไม่มีข้อมูลที่บันทึกไว้",
+ "error.fon_vector": "นี่คือฟอนต์เวกเตอร์ แปลงได้เฉพาะฟอนต์บิตแมป .fon",
+ "error.fon_unsupported": "ไม่รองรับฟอนต์ .fon ประเภทนี้",
+ "error.fon_too_large": "ฟอนต์ .fon มีขนาดใหญ่เกินกว่าจะแปลงได้",
+ "error.fon_damaged": "ไฟล์ .fon เสียหายหรือไม่สมบูรณ์",
"notice.pdf_not_smaller": "ไม่สามารถบีบอัดให้เล็กลงได้ จึงคัดลอกไฟล์ตามเดิม",
"notice.cad_newer_left_out": "ละวัตถุที่ AutoCAD เวอร์ชันที่เลือกไม่มี (มัลติลีดเดอร์ เมช …) ไว้",
"notice.original_kept": "เก็บไฟล์ต้นฉบับไว้ และบันทึกผลลัพธ์เป็นสำเนา",
diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json
index ead356c04..90d6fe153 100644
--- a/src/Filee.App/Assets/i18n/tr.json
+++ b/src/Filee.App/Assets/i18n/tr.json
@@ -121,6 +121,10 @@
"error.fit_not_activity": "Bu FIT dosyası bir etkinlik değil (yalnızca etkinlikler dönüştürülür)",
"error.fit_no_positions": "Etkinlikte kaydedilmiş konum yok: CSV, XLSX veya JSON'a dönüştürün",
"error.fit_no_records": "Etkinlikte kaydedilmiş veri yok",
+ "error.fon_vector": "Bu bir vektör yazı tipidir; yalnızca bitmap .fon yazı tipleri dönüştürülebilir",
+ "error.fon_unsupported": "Bu tür .fon yazı tipi desteklenmiyor",
+ "error.fon_too_large": ".fon yazı tipi dönüştürmek için çok büyük",
+ "error.fon_damaged": ".fon dosyası hasarlı veya eksik",
"notice.pdf_not_smaller": "Küçültülemediği için olduğu gibi kopyalandı",
"notice.cad_newer_left_out": "Seçilen AutoCAD sürümünün desteklemediği nesneler (çoklu kılavuzlar, ağlar…) çıkarıldı",
"notice.original_kept": "Özgün dosya korundu, sonuç kopya olarak kaydedildi",
diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json
index 77048b17c..5c430a83a 100644
--- a/src/Filee.App/Assets/i18n/zh-CN.json
+++ b/src/Filee.App/Assets/i18n/zh-CN.json
@@ -127,6 +127,10 @@
"error.fit_not_activity": "此 FIT 文件不是活动记录(只能转换活动记录)",
"error.fit_no_positions": "活动没有记录位置信息,请转换为 CSV、XLSX 或 JSON",
"error.fit_no_records": "活动没有记录数据",
+ "error.fon_vector": "这是矢量字体,只能转换位图 .fon 字体",
+ "error.fon_unsupported": "不支持此类 .fon 字体",
+ "error.fon_too_large": ".fon 字体太大,无法转换",
+ "error.fon_damaged": ".fon 文件已损坏或不完整",
"notice.pdf_not_smaller": "无法再缩小,已原样复制",
"notice.cad_newer_left_out": "已省略所选 AutoCAD 版本没有的对象(多重引线、网格……)",
"notice.original_kept": "已保留原文件,结果另存为副本",
diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json
index 2776ef228..ac593fc1e 100644
--- a/src/Filee.App/Assets/i18n/zh-TW.json
+++ b/src/Filee.App/Assets/i18n/zh-TW.json
@@ -127,6 +127,10 @@
"error.fit_not_activity": "此 FIT 檔案不是活動記錄(只能轉換活動記錄)",
"error.fit_no_positions": "活動沒有記錄位置資訊,請轉換為 CSV、XLSX 或 JSON",
"error.fit_no_records": "活動沒有記錄資料",
+ "error.fon_vector": "這是向量字型,只能轉換點陣 .fon 字型",
+ "error.fon_unsupported": "不支援此類 .fon 字型",
+ "error.fon_too_large": ".fon 字型太大,無法轉換",
+ "error.fon_damaged": ".fon 檔案已損壞或不完整",
"notice.pdf_not_smaller": "無法再縮小,已原樣複製",
"notice.cad_newer_left_out": "已略過所選 AutoCAD 版本沒有的物件(複線引線、網格……)",
"notice.original_kept": "已保留原始檔案,結果另存為副本",
diff --git a/src/Filee.Engines/Fonts/FonOutlineBuilder.cs b/src/Filee.Engines/Fonts/FonOutlineBuilder.cs
index 726b880b7..e41218730 100644
--- a/src/Filee.Engines/Fonts/FonOutlineBuilder.cs
+++ b/src/Filee.Engines/Fonts/FonOutlineBuilder.cs
@@ -2,6 +2,7 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
+using Filee.Core.Conversion;
namespace Filee.Engines.Fonts;
@@ -24,13 +25,15 @@ public static SfntFont Build(FonStrike strike, int resourceCount = 1, int sameHe
public static SfntFont Build(FonStrike strike, CancellationToken cancellationToken,
int resourceCount = 1, int sameHeightOrdinal = 1)
{
- if (resourceCount < 1 || sameHeightOrdinal < 1 || strike.Glyphs.Count is < 1 or > 65534)
- throw new InvalidDataException("FON resource or glyph count exceeds TrueType limits.");
+ if (resourceCount < 1 || sameHeightOrdinal < 1 || strike.Glyphs.Count < 1)
+ throw new ConversionRefusedException("error.fon_damaged");
+ if (strike.Glyphs.Count > 65534)
+ throw new ConversionRefusedException("error.fon_too_large", "65535");
var height = strike.PixelHeight;
var scale = Scale(height);
var upem = checked(height * scale);
if (upem is < 16 or > 16384)
- throw new InvalidDataException("FON pixel height exceeds TrueType units-per-em limits.");
+ throw new ConversionRefusedException("error.fon_too_large", "16384");
var ascent = Signed(strike.Ascent * scale);
var descent = Signed(-(height - strike.Ascent) * scale);
var lineGap = Signed(strike.ExternalLeading * scale);
@@ -44,7 +47,7 @@ public static SfntFont Build(FonStrike strike, CancellationToken cancellationTok
cancellationToken.ThrowIfCancellationRequested();
var advance = checked(source.Width * scale);
if (advance > ushort.MaxValue)
- throw new InvalidDataException("FON glyph advance exceeds TrueType limits.");
+ throw new ConversionRefusedException("error.fon_too_large", "65535");
glyphs.Add(Outline(source, strike.Ascent, scale, cancellationToken));
widths.Add(advance);
try
@@ -122,7 +125,7 @@ [.. glyphs.Select(g =>
{
var height = source.Pixels.GetLength(0);
if (source.Pixels.GetLength(1) != source.Width)
- throw new InvalidDataException("FON glyph pixels and width disagree.");
+ throw new ConversionRefusedException("error.fon_damaged");
if (source.Width == 0)
return null;
// Extend identical horizontal runs into maximal rectangles, then cancel each shared unit edge.
@@ -199,18 +202,18 @@ void Add(Corner from, Corner to)
if ((++work & 4095) == 0)
cancellationToken.ThrowIfCancellationRequested();
if (!edges.Remove(edge))
- throw new InvalidDataException("FON outline has an open boundary.");
+ throw new ConversionRefusedException("error.fon_damaged");
loop.Add(edge.From);
if (edge.To == start.From)
break;
if (!outgoing.TryGetValue(edge.To, out var choices))
- throw new InvalidDataException("FON outline has an open boundary.");
+ throw new ConversionRefusedException("error.fon_damaged");
var dx = edge.To.X - edge.From.X;
var dy = edge.To.Y - edge.From.Y;
edge = choices.Where(edges.Contains).OrderByDescending(e =>
dx * (e.To.Y - e.From.Y) - dy * (e.To.X - e.From.X)).FirstOrDefault();
if (edge == default)
- throw new InvalidDataException("FON outline has an open boundary.");
+ throw new ConversionRefusedException("error.fon_damaged");
} while (true);
for (var i = 0; i < loop.Count; i++)
{
@@ -227,7 +230,7 @@ void Add(Corner from, Corner to)
points.Add(new GlyphPoint(Signed(x), Signed(y), true));
}
if (points.Count > ushort.MaxValue || ends.Count == short.MaxValue)
- throw new InvalidDataException("FON outline exceeds TrueType point or contour limits.");
+ throw new ConversionRefusedException("error.fon_too_large", points.Count > ushort.MaxValue ? "65535" : "32767");
ends.Add((ushort)(points.Count - 1));
}
var result = new TrueTypeGlyph
@@ -334,7 +337,7 @@ private static byte[] Os2(FonStrike strike, List widths, SortedDictionary 6,
1257 => 7,
1258 => 8,
- _ => throw new InvalidDataException("FON code page is invalid.")
+ _ => throw new ConversionRefusedException("error.fon_damaged")
};
Put32(b, 78, 1u << cpBit);
return b;
@@ -362,7 +365,7 @@ from pair in names
foreach (var record in records)
{
if (record.Bytes.Length > ushort.MaxValue || offset > ushort.MaxValue - record.Bytes.Length)
- throw new InvalidDataException("FON name strings exceed TrueType limits.");
+ throw new ConversionRefusedException("error.fon_too_large", "65535");
w.U16(record.Platform); w.U16(record.Encoding); w.U16(record.Language);
w.U16(record.Id); w.U16(record.Bytes.Length); w.U16(offset);
offset += record.Bytes.Length;
@@ -421,7 +424,7 @@ private static byte[] Cmap(SortedDictionary map)
private static short Signed(int value) =>
value is >= short.MinValue and <= short.MaxValue
- ? (short)value : throw new InvalidDataException("FON outline or metrics exceed signed TrueType limits.");
+ ? (short)value : throw new ConversionRefusedException("error.fon_too_large", "32767");
private static void Put16(byte[] b, int offset, int value) =>
BinaryPrimitives.WriteUInt16BigEndian(b.AsSpan(offset), checked((ushort)(value & 0xffff)));
diff --git a/src/Filee.Engines/Fonts/FonReader.cs b/src/Filee.Engines/Fonts/FonReader.cs
index 1bc65706c..cc46b9c9e 100644
--- a/src/Filee.Engines/Fonts/FonReader.cs
+++ b/src/Filee.Engines/Fonts/FonReader.cs
@@ -1,5 +1,7 @@
using System.Buffers.Binary;
+using System.Globalization;
using System.Text;
+using Filee.Core.Conversion;
namespace Filee.Engines.Fonts;
@@ -30,23 +32,23 @@ internal static class FonReader
public static IReadOnlyList Read(byte[] data, CancellationToken cancellationToken)
{
if (data.Length < 0x40 || data[0] != 'M' || data[1] != 'Z')
- throw new InvalidDataException("FON requires an MZ executable header.");
+ throw new ConversionRefusedException("error.fon_damaged");
var ne = (long)BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(0x3c));
if (!Fits(data.Length, ne, 0x28))
- throw new InvalidDataException("FON has a truncated NE header.");
+ throw new ConversionRefusedException("error.fon_damaged");
if (data[ne] == 'P' && data[ne + 1] == 'E')
- throw new NotSupportedException("PE FON resources are not supported; an NE font is required.");
+ throw new ConversionRefusedException("error.fon_unsupported", "PE");
if (data[ne] != 'N' || data[ne + 1] != 'E')
- throw new InvalidDataException("FON has no NE executable header.");
+ throw new ConversionRefusedException("error.fon_damaged");
var table = ne + U16(data, ne + 0x24);
var end = ne + U16(data, ne + 0x26);
if (end <= table || !Fits(data.Length, table, 2) || end > data.Length)
- throw new InvalidDataException("FON has an invalid NE resource table.");
+ throw new ConversionRefusedException("error.fon_damaged");
var shift = U16(data, table);
if (shift > 15)
- throw new InvalidDataException("FON has an invalid NE resource alignment shift.");
+ throw new ConversionRefusedException("error.fon_damaged");
var ranges = new List<(long Start, long End)>();
var cursor = table + 2;
@@ -60,11 +62,11 @@ public static IReadOnlyList Read(byte[] data, CancellationToken cance
break;
}
if (!Fits(end, cursor, 8))
- throw new InvalidDataException("FON has a truncated NE resource type.");
+ throw new ConversionRefusedException("error.fon_damaged");
var count = U16(data, cursor + 2);
cursor += 8;
if (!Fits(end, cursor, (long)count * 12))
- throw new InvalidDataException("FON has a truncated NE resource name table.");
+ throw new ConversionRefusedException("error.fon_damaged");
if (type == 0x8008)
{
for (var i = 0; i < count; i++)
@@ -73,20 +75,20 @@ public static IReadOnlyList Read(byte[] data, CancellationToken cance
var start = (long)U16(data, entry) << shift;
var length = (long)U16(data, entry + 2) << shift;
if (length == 0 || start < end || !Fits(data.Length, start, length))
- throw new InvalidDataException("FON has an overlapping or out-of-bounds RT_FONT resource.");
+ throw new ConversionRefusedException("error.fon_damaged");
ranges.Add((start, start + length));
}
}
cursor += (long)count * 12;
}
if (!terminated)
- throw new InvalidDataException("FON has an unterminated NE resource table.");
+ throw new ConversionRefusedException("error.fon_damaged");
if (ranges.Count == 0)
- throw new InvalidDataException("FON contains no RT_FONT resources.");
+ throw new ConversionRefusedException("error.fon_damaged");
var sorted = ranges.OrderBy(r => r.Start).ToArray();
for (var i = 1; i < sorted.Length; i++)
if (sorted[i].Start < sorted[i - 1].End)
- throw new InvalidDataException("FON has an overlapping or out-of-bounds RT_FONT resource.");
+ throw new ConversionRefusedException("error.fon_damaged");
long glyphPixels = 0;
foreach (var (start, stop) in ranges)
ReadFnt(data.AsSpan((int)start, (int)(stop - start)), false, ref glyphPixels, cancellationToken);
@@ -100,25 +102,25 @@ public static IReadOnlyList Read(byte[] data, CancellationToken cance
CancellationToken cancellationToken)
{
if (resource.Length < 6)
- throw new InvalidDataException("RT_FONT has a truncated FNT header.");
+ throw new ConversionRefusedException("error.fon_damaged");
var version = U16(resource, 0);
if (version is not (0x0200 or 0x0300))
- throw new NotSupportedException($"FNT version 0x{version:X4} is not supported (only v2/v3).");
+ throw new ConversionRefusedException("error.fon_unsupported", $"FNT 0x{version:X4}");
var header = version == 0x0200 ? 0x76 : 0x94;
var size = (long)U32(resource, 2);
if (size < header || size > resource.Length)
- throw new InvalidDataException("RT_FONT has an invalid FNT dfSize or truncated header.");
+ throw new ConversionRefusedException("error.fon_damaged");
var fnt = resource[..(int)size];
var type = U16(fnt, 0x42);
if ((type & 1) != 0)
- throw new NotSupportedException("Vector FNT fonts are not supported.");
+ throw new ConversionRefusedException("error.fon_vector");
if ((type & 4) != 0)
- throw new NotSupportedException("FNT bitmap pixels stored outside the file are not supported.");
+ throw new ConversionRefusedException("error.fon_unsupported");
if (version == 0x0300)
{
var flags = U32(fnt, 0x76);
if (flags is not (1 or 2))
- throw new NotSupportedException($"FNT v3 dfFlags 0x{flags:X8} uses an unsupported glyph table or color format.");
+ throw new ConversionRefusedException("error.fon_unsupported", $"dfFlags 0x{flags:X8}");
}
var height = U16(fnt, 0x58);
@@ -126,42 +128,44 @@ public static IReadOnlyList