diff --git a/README.ar.md b/README.ar.md
index 539c31977..d4ddf7c2e 100644
--- a/README.ar.md
+++ b/README.ar.md
@@ -58,7 +58,7 @@
تظهر الصيغ حيث أنت، ولا يظهر منها إلا ما يناسب الملفات التي تسحبها.
- 265 صيغة دون برامج أخرى
+ 266 صيغة دون برامج أخرى
الصور وPDF وWord وExcel وPowerPoint وHWP والكتب الإلكترونية والأرشيفات والخطوط وCAD والنماذج ثلاثية الأبعاد والبيانات الجغرافية، كلها تعمل دون Office أو Hancom Office أو LibreOffice.
|
@@ -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 (خطوط نقطية من Windows) للقراءة | تحويل فيما بينها؛ FON → ملف خط واحد لكل حجم |
| **البيانات**: 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 3f4bf5040..fb39aa7f6 100644
--- a/README.es.md
+++ b/README.es.md
@@ -56,7 +56,7 @@
Los formatos aparecen donde ya estás, y solo los que tienen sentido para los archivos que arrastras.
|
- 265 formatos, sin otros programas
+ 266 formatos, sin otros programas
Imágenes, PDF, Word, Excel, PowerPoint, HWP, libros electrónicos, archivos comprimidos, tipografías, CAD, modelos 3D y datos geoespaciales funcionan sin Office, Hancom Office ni LibreOffice.
|
@@ -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 (fuentes de mapa de bits de Windows) solo lectura | conversión entre sí; FON → un archivo por tamaño |
| **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 4bc466500..f82ce86f0 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -56,7 +56,7 @@
Les formats apparaissent là où vous êtes déjà, et seulement ceux qui ont un sens pour les fichiers que vous faites glisser.
|
- 265 formats, sans autre logiciel
+ 266 formats, sans autre logiciel
Images, PDF, Word, Excel, PowerPoint, HWP, livres numériques, archives, polices, CAO, modèles 3D et données géospatiales fonctionnent sans Office, Hancom Office ni LibreOffice.
|
@@ -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 (polices bitmap Windows) en lecture | conversion entre elles ; FON → un fichier par taille |
| **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 23a3cdeab..e05973088 100644
--- a/README.hi.md
+++ b/README.hi.md
@@ -56,7 +56,7 @@
फ़ॉर्मैट वहीं दिखते हैं जहाँ आप पहले से हैं, और सिर्फ़ वही जो आपकी खींची जा रही फ़ाइलों के लिए सही हों।
|
- 265 फ़ॉर्मैट, कोई और सॉफ़्टवेयर नहीं
+ 266 फ़ॉर्मैट, कोई और सॉफ़्टवेयर नहीं
इमेज, PDF, Word, Excel, PowerPoint, HWP, ई-बुक, आर्काइव, फ़ॉन्ट, CAD, 3D मॉडल और भू-स्थानिक डेटा Office, Hancom Office या LibreOffice के बिना काम करते हैं।
|
@@ -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 (Windows बिटमैप फ़ॉन्ट) केवल पढ़ने के लिए | आपस में रूपांतरण; FON → हर आकार के लिए एक फ़ॉन्ट |
| **डेटा**: 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 5fe2ae965..ea211204a 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -56,7 +56,7 @@
いま作業している場所に、ドラッグ中のファイルに合う形式だけが表示されます。
|
- 265形式、ほかのソフトは不要
+ 266形式、ほかのソフトは不要
画像、PDF、Word、Excel、PowerPoint、HWP、電子書籍、アーカイブ、フォント、CAD、3Dモデル、地理空間データを、Office、Hancom Office、LibreOfficeなしで変換できます。
|
@@ -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(Windowsビットマップフォント)は読み込みのみ | 相互変換; FON → サイズごとに1ファイル |
| **データ**: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 be26be8ec..01740b605 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -57,7 +57,7 @@
끌고 있는 파일에 맞는 형식만 지금 있는 자리에 나타나요.
|
- 265가지 형식, 다른 프로그램 없이
+ 266가지 형식, 다른 프로그램 없이
이미지, PDF, 워드, 엑셀, 파워포인트, HWP, 전자책, 압축 파일, 글꼴, CAD, 3D 모델, 지리 정보 데이터를 오피스·한컴오피스·LibreOffice 없이 변환해요.
|
@@ -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(Windows 비트맵 글꼴)은 읽기 | 서로 변환; FON → 크기마다 글꼴 하나 |
| **데이터**: 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 c7c19a616..29a6c995e 100644
--- a/README.md
+++ b/README.md
@@ -56,7 +56,7 @@
The formats appear where you already are, only the ones that make sense for the files you drag.
|
- 265 formats, no other software
+ 266 formats, no other software
Images, PDF, Word, Excel, PowerPoint, HWP, e-books, archives, fonts, CAD, 3D models and geospatial data work without Office, Hancom Office or LibreOffice.
|
@@ -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 (Windows bitmap fonts) read | each other; FON → one font per size |
| **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 5e01b47e6..35bfc3a11 100644
--- a/README.pl.md
+++ b/README.pl.md
@@ -56,7 +56,7 @@
Formaty pojawiają się tam, gdzie już jesteś, i tylko te, które pasują do przeciąganych plików.
|
- 265 formatów bez dodatkowych programów
+ 266 formatów bez dodatkowych programów
Obrazy, PDF, Word, Excel, PowerPoint, HWP, e-booki, archiwa, czcionki, CAD, modele 3D i dane geoprzestrzenne działają bez pakietu Office, Hancom Office i LibreOffice.
|
@@ -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 (bitmapowe czcionki Windows) tylko odczyt | konwersja między sobą; FON → plik na rozmiar |
| **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 8433fe6d2..91fc978ae 100644
--- a/README.pt-BR.md
+++ b/README.pt-BR.md
@@ -56,7 +56,7 @@
Os formatos aparecem onde você já está, só os que fazem sentido para os arquivos que você arrasta.
|
- 265 formatos, sem outro programa
+ 266 formatos, sem outro programa
Imagens, PDF, Word, Excel, PowerPoint, HWP, e-books, arquivos compactados, fontes, CAD, modelos 3D e dados geoespaciais funcionam sem Office, Hancom Office ou LibreOffice.
|
@@ -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 (fontes bitmap do Windows) somente leitura | conversão entre si; FON → um arquivo por tamanho |
| **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 baa4de59b..23d027e4f 100644
--- a/README.pt-PT.md
+++ b/README.pt-PT.md
@@ -56,7 +56,7 @@
Os formatos aparecem onde estás, apenas os que fazem sentido para os ficheiros que arrastas.
|
- 265 formatos, sem outro software
+ 266 formatos, sem outro software
Imagens, PDF, Word, Excel, PowerPoint, HWP, livros digitais, arquivos, tipos de letra, CAD, modelos 3D e dados geoespaciais funcionam sem Office, Hancom Office ou LibreOffice.
|
@@ -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 (tipos de letra bitmap do Windows) apenas leitura | conversão entre si; FON → um ficheiro por tamanho |
| **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 c1446d21b..0f62d18d3 100644
--- a/README.ru.md
+++ b/README.ru.md
@@ -56,7 +56,7 @@
Форматы появляются там, где вы уже работаете, и только те, что подходят перетаскиваемым файлам.
|
- 265 форматов без лишних программ
+ 266 форматов без лишних программ
Изображения, PDF, Word, Excel, PowerPoint, HWP, электронные книги, архивы, шрифты, чертежи CAD, 3D-модели и геоданные работают без Office, Hancom Office и LibreOffice.
|
@@ -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 (растровые шрифты Windows) только чтение | преобразование друг в друга; FON → один файл на размер |
| **Данные**: 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 296a1ef21..2c4c456f9 100644
--- a/README.th.md
+++ b/README.th.md
@@ -56,7 +56,7 @@
ฟอร์แมตที่เหมาะสมจะแสดงขึ้นตรงจุดที่คุณทำงานอยู่ โดยแสดงเฉพาะฟอร์แมตที่ใช้ได้กับไฟล์ที่คุณลากเท่านั้น
|
- 265 ฟอร์แมต โดยไม่ต้องลงโปรแกรมอื่นเพิ่ม
+ 266 ฟอร์แมต โดยไม่ต้องลงโปรแกรมอื่นเพิ่ม
รูปภาพ, PDF, Word, Excel, PowerPoint, HWP, อีบุ๊ก, ไฟล์บีบอัด, ฟอนต์, CAD, โมเดล 3D และข้อมูลเชิงพื้นที่ ทำงานได้โดยไม่ต้องมี Microsoft Office, Hancom Office หรือ LibreOffice
|
@@ -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 (ฟอนต์บิตแมปของ Windows) อ่านอย่างเดียว | แปลงระหว่างกัน; FON → หนึ่งไฟล์ต่อขนาด |
| **ข้อมูล**: 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 59ef8d07b..149de6a97 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -56,7 +56,7 @@
只显示适合当前拖动文件的格式,就在你所在的位置。
|
- 265 种格式,无需其他软件
+ 266 种格式,无需其他软件
图片、PDF、Word、Excel、PowerPoint、HWP、电子书、压缩包、字体、CAD、3D 模型和地理信息数据,无需 Office、韩文办公软件或 LibreOffice。
|
@@ -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(Windows 位图字体)仅读取 | 相互转换; FON → 每个尺寸一个文件 |
| **数据** 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 df83ecada..bda85f7d4 100644
--- a/README.zh-TW.md
+++ b/README.zh-TW.md
@@ -56,7 +56,7 @@
只顯示適合當前拖曳檔案的格式,就在你所在的位置。
|
- 265 種格式,無需其他軟體
+ 266 種格式,無需其他軟體
圖片、PDF、Word、Excel、PowerPoint、HWP、電子書、壓縮檔、字型、CAD、3D 模型和地理資訊資料,無需 Office、韓文文書軟體或 LibreOffice。
|
@@ -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(Windows 點陣字型)僅讀取 | 相互轉換; FON → 每個尺寸一個檔案 |
| **資料** 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 3b233a9f3..c2049f34a 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,28 @@ 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-Bold.ttf`, `Name-13px-2.ttf` and
+ `Name-14px.ttf`; the font family names distinguish heights, group different styles at the same height, and number
+ only repeated styles at the same height. 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` builds synthetic FON samples in code (`FonBuilder`) 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 e6a4f8725..3eacdea4a 100644
--- a/src/Filee.App/Assets/i18n/ar.json
+++ b/src/Filee.App/Assets/i18n/ar.json
@@ -122,6 +122,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 تالف أو غير مكتمل",
"error.m4s_needs_init": "ملف .m4s هذا جزء من فيديو متدفّق، ولا يمكن قراءته دون مقطع البداية (init) المنفصل الخاص به",
"notice.pdf_not_smaller": "تعذّر تقليل الحجم، لذا نُسخ الملف كما هو",
"notice.cad_newer_left_out": "حُذفت كائنات لا يدعمها إصدار AutoCAD المختار (أسهم توضيحية متعددة، شبكات …)",
@@ -182,7 +186,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 فيما بينها، وتحويل خطوط Windows النقطية (FON) إلى هذه الصيغ أيضًا.",
"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/de.json b/src/Filee.App/Assets/i18n/de.json
index 3f98a8616..022a6164c 100644
--- a/src/Filee.App/Assets/i18n/de.json
+++ b/src/Filee.App/Assets/i18n/de.json
@@ -122,6 +122,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",
"error.m4s_needs_init": "Diese .m4s-Datei ist ein Stück eines gestreamten Videos und lässt sich ohne ihr separates Startsegment (init) nicht lesen",
"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",
@@ -182,7 +186,7 @@
"engine.icns.name": "ICNS (integriert)",
"engine.icns.description": "macOS-App-Symbole (ICNS) aus Bildern und in Bilder umwandeln.",
"engine.font.name": "Schriftarten (integriert)",
- "engine.font.description": "Schriftarten: Konvertiert TTF, OTF, WOFF, WOFF2 und EOT untereinander.",
+ "engine.font.description": "TTF, OTF, WOFF, WOFF2 und EOT untereinander konvertieren; Windows-Bitmap-Schriftarten (FON) in diese Formate umwandeln.",
"engine.cad.name": "CAD (integriert)",
"engine.cad.description": "AutoCAD-Zeichnungen: DWG ↔ DXF und beide in PDF, SVG und PNG.",
"engine.model3d.name": "3D-Modelle (integriert)",
diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json
index e2a8a88a9..6ece06382 100644
--- a/src/Filee.App/Assets/i18n/en.json
+++ b/src/Filee.App/Assets/i18n/en.json
@@ -128,6 +128,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",
"error.m4s_needs_init": "This .m4s is one piece of a streamed video: it can't be read without its separate start (init) segment",
"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",
@@ -189,7 +193,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": "Fonts: TTF, OTF, WOFF, WOFF2 and EOT into each other; Windows bitmap fonts (FON) into them.",
"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 1fc63a675..8d34dfcfe 100644
--- a/src/Filee.App/Assets/i18n/es.json
+++ b/src/Filee.App/Assets/i18n/es.json
@@ -122,6 +122,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",
"error.m4s_needs_init": "Este .m4s es un fragmento de un vídeo en streaming y no se puede leer sin su segmento de inicio (init) aparte",
"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…)",
@@ -182,7 +186,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 TTF, OTF, WOFF, WOFF2 y EOT entre sí; convierte fuentes de mapa de bits de Windows (FON) a estos formatos.",
"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 e8f6fb2a5..9d89e5bf1 100644
--- a/src/Filee.App/Assets/i18n/fr.json
+++ b/src/Filee.App/Assets/i18n/fr.json
@@ -128,6 +128,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",
"error.m4s_needs_init": "Ce .m4s est un morceau d'une vidéo diffusée en streaming : il ne peut pas être lu sans son segment de départ (init) séparé",
"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",
@@ -189,7 +193,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 TTF, OTF, WOFF, WOFF2 et EOT entre eux ; convertit les polices bitmap Windows (FON) vers ces formats.",
"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 51c97fa5f..431d514ae 100644
--- a/src/Filee.App/Assets/i18n/hi.json
+++ b/src/Filee.App/Assets/i18n/hi.json
@@ -122,6 +122,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 फ़ाइल खराब है या अधूरी है",
"error.m4s_needs_init": "यह .m4s स्ट्रीम किए गए वीडियो का एक हिस्सा है: इसके अलग शुरुआती (init) सेगमेंट के बिना इसे पढ़ा नहीं जा सकता",
"notice.pdf_not_smaller": "इसे छोटा नहीं किया जा सका, इसलिए जैसा है वैसा ही कॉपी कर दिया गया",
"notice.cad_newer_left_out": "चुने गए AutoCAD संस्करण में न होने वाले ऑब्जेक्ट (मल्टीलीडर, मेश …) छोड़ दिए गए",
@@ -182,7 +186,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 को आपस में बदलें; Windows बिटमैप फ़ॉन्ट (FON) को भी इन फ़ॉर्मैट में बदलें।",
"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 7b0204732..a34116596 100644
--- a/src/Filee.App/Assets/i18n/ja.json
+++ b/src/Filee.App/Assets/i18n/ja.json
@@ -122,6 +122,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ファイルが破損しているか、途中で切れています",
"error.m4s_needs_init": "この .m4s はストリーミング動画の一部で、別にある開始(init)セグメントがないと読み込めません",
"notice.pdf_not_smaller": "サイズを小さくできなかったため、元のままコピーしました",
"notice.cad_newer_left_out": "選んだ AutoCAD のバージョンにないオブジェクト(マルチ引出線、メッシュ …)を省きました",
@@ -182,7 +186,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を相互変換し、Windowsビットマップフォント(FON)もこれらの形式に変換します。",
"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 abee550ec..46ccf6cc3 100644
--- a/src/Filee.App/Assets/i18n/ko.json
+++ b/src/Filee.App/Assets/i18n/ko.json
@@ -128,6 +128,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 파일이 손상되었거나 잘렸어요",
"error.m4s_needs_init": "스트리밍 동영상의 한 조각인 .m4s 파일이에요. 따로 있는 시작(init) 세그먼트 없이는 읽을 수 없어요",
"notice.pdf_not_smaller": "더 작게 만들 수 없어서 그대로 복사했어요",
"notice.cad_newer_left_out": "선택한 AutoCAD 버전에 없는 개체(다중 지시선, 메시 …)는 빼고 저장했어요",
@@ -189,7 +193,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를 서로 변환하고, Windows 비트맵 글꼴(FON)도 이 형식으로 바꿔요.",
"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 132a4c135..a891cba32 100644
--- a/src/Filee.App/Assets/i18n/ne.json
+++ b/src/Filee.App/Assets/i18n/ne.json
@@ -122,6 +122,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 फाइल बिग्रिएको वा अपूर्ण छ",
"error.m4s_needs_init": "यो .m4s स्ट्रिम गरिएको भिडियोको एउटा टुक्रा हो: छुट्टै सुरुवाती (init) खण्डबिना यसलाई पढ्न सकिँदैन",
"notice.pdf_not_smaller": "यसलाई सानो बनाउन सकिएन, त्यसैले जस्तो छ त्यस्तै प्रतिलिपि गरियो",
"notice.cad_newer_left_out": "छानिएको AutoCAD संस्करणमा नभएका वस्तुहरू (मल्टिलिडर, मेस …) छोडिए",
@@ -182,7 +186,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 बीच रूपान्तरण; Windows बिटम्याप फन्ट (FON) लाई पनि यी ढाँचामा रूपान्तरण।",
"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 251695169..e350005d2 100644
--- a/src/Filee.App/Assets/i18n/pl.json
+++ b/src/Filee.App/Assets/i18n/pl.json
@@ -128,6 +128,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",
"error.m4s_needs_init": "Ten plik .m4s to fragment wideo strumieniowego i nie da się go odczytać bez osobnego segmentu początkowego (init)",
"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…)",
@@ -189,7 +193,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; konwertuje bitmapowe czcionki Windows (FON) do tych formatów.",
"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 a59792d2e..c5a198fff 100644
--- a/src/Filee.App/Assets/i18n/pt-BR.json
+++ b/src/Filee.App/Assets/i18n/pt-BR.json
@@ -128,6 +128,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",
"error.m4s_needs_init": "Este .m4s é um pedaço de um vídeo transmitido por streaming e não pode ser lido sem o seu segmento inicial (init) separado",
"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",
@@ -189,7 +193,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 TTF, OTF, WOFF, WOFF2 e EOT entre si; converte fontes bitmap do Windows (FON) para esses formatos.",
"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 c9c14c315..9752ee852 100644
--- a/src/Filee.App/Assets/i18n/pt-PT.json
+++ b/src/Filee.App/Assets/i18n/pt-PT.json
@@ -128,6 +128,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",
"error.m4s_needs_init": "Este .m4s é um pedaço de um vídeo em streaming e não pode ser lido sem o respetivo segmento inicial (init) separado",
"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",
@@ -189,7 +193,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; converte tipos de letra bitmap do Windows (FON) para estes formatos.",
"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 6f980e200..8f9feddc7 100644
--- a/src/Filee.App/Assets/i18n/ru.json
+++ b/src/Filee.App/Assets/i18n/ru.json
@@ -128,6 +128,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 повреждён или обрезан",
"error.m4s_needs_init": "Этот .m4s — фрагмент потокового видео: без отдельного начального сегмента (init) его не прочитать",
"notice.pdf_not_smaller": "Уменьшить не получилось, файл скопирован как есть",
"notice.cad_newer_left_out": "Объекты, которых нет в выбранной версии AutoCAD (мультивыноски, сети…), не сохранены",
@@ -189,7 +193,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 друг в друга; преобразует растровые шрифты Windows (FON) в эти форматы.",
"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 d6862252e..c186c2b0c 100644
--- a/src/Filee.App/Assets/i18n/th.json
+++ b/src/Filee.App/Assets/i18n/th.json
@@ -122,6 +122,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 เสียหายหรือไม่สมบูรณ์",
"error.m4s_needs_init": "ไฟล์ .m4s นี้เป็นชิ้นส่วนหนึ่งของวิดีโอสตรีมมิง และอ่านไม่ได้หากไม่มีเซกเมนต์เริ่มต้น (init) ที่แยกไว้",
"notice.pdf_not_smaller": "ไม่สามารถบีบอัดให้เล็กลงได้ จึงคัดลอกไฟล์ตามเดิม",
"notice.cad_newer_left_out": "ละวัตถุที่ AutoCAD เวอร์ชันที่เลือกไม่มี (มัลติลีดเดอร์ เมช …) ไว้",
@@ -182,7 +186,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 ระหว่างกัน; แปลงฟอนต์บิตแมปของ Windows (FON) เป็นรูปแบบเหล่านี้ด้วย",
"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 990080cde..5d85ce3fd 100644
--- a/src/Filee.App/Assets/i18n/tr.json
+++ b/src/Filee.App/Assets/i18n/tr.json
@@ -122,6 +122,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",
"error.m4s_needs_init": "Bu .m4s, akışla gelen bir videonun bir parçası: ayrı başlangıç (init) segmenti olmadan okunamaz",
"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ı",
@@ -182,7 +186,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; Windows bitmap yazı tiplerini (FON) bu biçimlere dönüştürür.",
"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 7cf22820b..45f8b7d47 100644
--- a/src/Filee.App/Assets/i18n/zh-CN.json
+++ b/src/Filee.App/Assets/i18n/zh-CN.json
@@ -128,6 +128,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 文件已损坏或不完整",
"error.m4s_needs_init": "此 .m4s 是流媒体视频的一个片段,没有单独的起始(init)分段就无法读取",
"notice.pdf_not_smaller": "无法再缩小,已原样复制",
"notice.cad_newer_left_out": "已省略所选 AutoCAD 版本没有的对象(多重引线、网格……)",
@@ -189,7 +193,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 相互转换;将 Windows 位图字体(FON)转换为这些格式。",
"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 5ff7cd600..78743df19 100644
--- a/src/Filee.App/Assets/i18n/zh-TW.json
+++ b/src/Filee.App/Assets/i18n/zh-TW.json
@@ -128,6 +128,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 檔案已損壞或不完整",
"error.m4s_needs_init": "此 .m4s 是串流影片的一個片段,沒有另外的起始(init)區段就無法讀取",
"notice.pdf_not_smaller": "無法再縮小,已原樣複製",
"notice.cad_newer_left_out": "已略過所選 AutoCAD 版本沒有的物件(複線引線、網格……)",
@@ -189,7 +193,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 相互轉換;將 Windows 點陣字型(FON)轉換為這些格式。",
"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 41ea7c2db..5ae68803d 100644
--- a/src/Filee.Core/Formats/FormatRegistry.cs
+++ b/src/Filee.Core/Formats/FormatRegistry.cs
@@ -307,6 +307,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.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs
index 10363960f..f9c47a370 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, ListTurns 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);
+
+ /// 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);
+
+ ///
+ /// 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 < 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 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);
+
+ var glyphs = new List { null };
+ var widths = new List { 0 };
+ var cmap = new SortedDictionary();
+ var outlines = new Dictionary<(long Offset, int Width), TrueTypeGlyph?>();
+ 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 ConversionRefusedException("error.fon_too_large", "65535");
+ var key = (source.BitmapOffset, source.Width);
+ if (!outlines.TryGetValue(key, out var outline))
+ {
+ outline = Outline(source, strike.Ascent, scale, cancellationToken);
+ outlines.Add(key, outline);
+ }
+ glyphs.Add(outline);
+ 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 serialized = new Dictionary(ReferenceEqualityComparer.Instance);
+ var (glyf, loca, longLoca) = GlyfTable.Build(
+ [.. glyphs.Select(g =>
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (g is null)
+ return [];
+ if (!serialized.TryGetValue(g, out var bytes))
+ serialized.Add(g, bytes = g.Serialize());
+ return bytes;
+ })], 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);
+ 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 nameBold = weight == 700;
+ var style = (nameBold, 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, CancellationToken cancellationToken)
+ {
+ var height = source.Pixels.GetLength(0);
+ if (source.Pixels.GetLength(1) != source.Width)
+ throw new ConversionRefusedException("error.fon_damaged");
+ if (source.Width == 0)
+ return null;
+ if (CountPoints(source.Pixels, source.Width, ushort.MaxValue, cancellationToken) > ushort.MaxValue)
+ throw new ConversionRefusedException("error.fon_too_large", "65535");
+ // 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;)
+ {
+ 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();
+ var order = new List();
+ void Add(Corner from, Corner to)
+ {
+ if (!edges.Remove(new Edge(to, from)))
+ {
+ var edge = new Edge(from, to);
+ edges.Add(edge);
+ order.Add(edge);
+ }
+ }
+ 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 = 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();
+ var cursor = 0;
+ while (edges.Count != 0)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ while (!edges.Contains(order[cursor]))
+ cursor++;
+ var start = order[cursor++];
+ var edge = start;
+ var loop = new List();
+ do
+ {
+ if ((++work & 4095) == 0)
+ cancellationToken.ThrowIfCancellationRequested();
+ if (!edges.Remove(edge))
+ 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 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 ConversionRefusedException("error.fon_damaged");
+ } 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];
+ 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 ConversionRefusedException("error.fon_too_large", points.Count > ushort.MaxValue ? "65535" : "32767");
+ 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;
+ }
+
+ internal static int CountPoints(bool[,] pixels, int width, int limit, CancellationToken cancellationToken)
+ {
+ var height = pixels.GetLength(0);
+ var points = 0;
+ for (var y = 0; y <= height; y++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ for (var x = 0; x <= width; x++)
+ {
+ var a = x > 0 && y > 0 && pixels[y - 1, x - 1];
+ var b = x < width && y > 0 && pixels[y - 1, x];
+ var c = x > 0 && y < height && pixels[y, x - 1];
+ var d = x < width && y < height && pixels[y, x];
+ var on = (a ? 1 : 0) + (b ? 1 : 0) + (c ? 1 : 0) + (d ? 1 : 0);
+ if (on is 1 or 3)
+ points++;
+ else if (on == 2 && ((a && d) || (b && c)))
+ points += 2;
+ if (points > limit)
+ return points;
+ }
+ }
+ return points;
+ }
+
+ 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 ConversionRefusedException("error.fon_damaged")
+ };
+ 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 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;
+ }
+ 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 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)));
+
+ 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..5dbf4cf60
--- /dev/null
+++ b/src/Filee.Engines/Fonts/FonReader.cs
@@ -0,0 +1,230 @@
+using System.Buffers.Binary;
+using System.Globalization;
+using System.Text;
+using Filee.Core.Conversion;
+
+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, long BitmapOffset);
+
+/// 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
+{
+ ///
+ /// 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 ConversionRefusedException("error.fon_damaged");
+
+ var ne = (long)BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(0x3c));
+ if (!Fits(data.Length, ne, 0x28))
+ throw new ConversionRefusedException("error.fon_damaged");
+ if (data[ne] == 'P' && data[ne + 1] == 'E')
+ throw new ConversionRefusedException("error.fon_unsupported", "PE");
+ if (data[ne] != 'N' || data[ne + 1] != 'E')
+ 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 ConversionRefusedException("error.fon_damaged");
+ var shift = U16(data, table);
+ if (shift > 15)
+ throw new ConversionRefusedException("error.fon_damaged");
+
+ 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 ConversionRefusedException("error.fon_damaged");
+ var count = U16(data, cursor + 2);
+ cursor += 8;
+ if (!Fits(end, cursor, (long)count * 12))
+ throw new ConversionRefusedException("error.fon_damaged");
+ 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))
+ throw new ConversionRefusedException("error.fon_damaged");
+ ranges.Add((start, start + length));
+ }
+ }
+ cursor += (long)count * 12;
+ }
+ if (!terminated)
+ throw new ConversionRefusedException("error.fon_damaged");
+ if (ranges.Count == 0)
+ 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 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);
+ 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, bool decode, ref long glyphPixels,
+ CancellationToken cancellationToken)
+ {
+ if (resource.Length < 6)
+ throw new ConversionRefusedException("error.fon_damaged");
+ var version = U16(resource, 0);
+ if (version is not (0x0200 or 0x0300))
+ 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 ConversionRefusedException("error.fon_damaged");
+ var fnt = resource[..(int)size];
+ var type = U16(fnt, 0x42);
+ if ((type & 1) != 0)
+ throw new ConversionRefusedException("error.fon_vector");
+ if ((type & 4) != 0)
+ throw new ConversionRefusedException("error.fon_unsupported");
+ if (version == 0x0300)
+ {
+ var flags = U32(fnt, 0x76);
+ if (flags is not (1 or 2))
+ throw new ConversionRefusedException("error.fon_unsupported", $"dfFlags 0x{flags:X8}");
+ }
+
+ 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 > 16384)
+ throw new ConversionRefusedException("error.fon_too_large", "16384");
+ if (height == 0 || ascent > height || count <= 0)
+ throw new ConversionRefusedException("error.fon_damaged");
+ var defaultChar = fnt[0x61];
+ var breakChar = fnt[0x62];
+ if (defaultChar >= count || breakChar >= count)
+ throw new ConversionRefusedException("error.fon_damaged");
+ var weight = U16(fnt, 0x53);
+ if (weight > 1000)
+ throw new ConversionRefusedException("error.fon_damaged");
+ var charset = fnt[0x55];
+ var encoding = CharSetEncoding(charset);
+ var faceOffset = (long)U32(fnt, 0x69);
+ if (!Fits(size, faceOffset, 1))
+ throw new ConversionRefusedException("error.fon_damaged");
+ var faceEnd = fnt[(int)faceOffset..].IndexOf((byte)0);
+ if (faceEnd < 0)
+ throw new ConversionRefusedException("error.fon_damaged");
+ string face;
+ try
+ {
+ face = encoding.GetString(fnt.Slice((int)faceOffset, faceEnd)).Trim();
+ }
+ catch (DecoderFallbackException)
+ {
+ throw new ConversionRefusedException("error.fon_damaged");
+ }
+ if (face.Length == 0)
+ throw new ConversionRefusedException("error.fon_damaged");
+
+ var entrySize = version == 0x0200 ? 4 : 6;
+ var tableEnd = header + (long)(count + 1) * entrySize;
+ if (tableEnd > size)
+ throw new ConversionRefusedException("error.fon_damaged");
+ var bitsOffset = (long)U32(fnt, 0x71);
+ if (bitsOffset < tableEnd || bitsOffset > size)
+ throw new ConversionRefusedException("error.fon_damaged");
+ var scale = FonOutlineBuilder.Scale(height);
+ 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 ((long)width * scale > ushort.MaxValue)
+ throw new ConversionRefusedException("error.fon_too_large", $"{ushort.MaxValue}");
+ if (offset < bitsOffset || !Fits(size, offset, bytes))
+ throw new ConversionRefusedException("error.fon_damaged");
+ if (!decode)
+ {
+ glyphPixels += (long)Math.Max((int)width, 1) * height;
+ if (glyphPixels > MaxGlyphPixels)
+ throw new ConversionRefusedException("error.fon_too_large", MaxGlyphPixels.ToString("N0", CultureInfo.InvariantCulture));
+ 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, offset));
+ }
+ 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);
+ }
+
+ /// 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 ConversionRefusedException("error.fon_unsupported", $"charset {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..6ae769d41 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 (Windows bitmap fonts) read",
+ 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,61 @@ 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);
+ cancellationToken.ThrowIfCancellationRequested();
+ progress?.Report(0.1);
+
+ // Same-height, same-style 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<(int Height, int Weight, bool Italic), int>();
+ for (var i = 0; i < strikes.Count; i++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var strike = strikes[i];
+ var key = (strike.PixelHeight, strike.Weight, strike.Italic);
+ ordinals[i] = counts[key] = counts.GetValueOrDefault(key) + 1;
+ }
+
+ var results = new byte[strikes.Count][];
+ for (var i = 0; i < strikes.Count; 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();
+ }
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var outputs = new List();
+ for (var i = 0; i < strikes.Count; i++)
+ {
+ var strike = strikes[i];
+ var style = (strike.Weight, strike.Italic) switch
+ {
+ (400, false) => "",
+ (700, false) => "-Bold",
+ (400, true) => "-Italic",
+ (700, true) => "-BoldItalic",
+ (_, false) => $"-Weight{strike.Weight}",
+ (_, true) => $"-Weight{strike.Weight}Italic",
+ };
+ var suffix = strikes.Count == 1 ? null
+ : $"-{strike.PixelHeight}px{style}{(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..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,6 +438,135 @@ public async Task Unicode_spaces_and_quotes_stay_in_a_single_input_argument()
Assert.True(File.Exists(source));
}
+ // ──────────── Windows bitmap fonts (#177): in-memory samples shared with the engine tests ────────────
+
+ private static readonly string[] FontFormats = ["ttf", "otf", "woff", "woff2", "eot"];
+
+ /// 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.WriteAllBytes(path, FonBuilder.Sample(sample));
+ 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 Generated_fon_samples_are_written_to_cli_input()
+ {
+ foreach (var sample in new[] { "Fon-v2-fixed.fon", "Fon-multi.fon" })
+ {
+ var bytes = FonBuilder.Sample(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..d02ae79ae 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..69b49b03b
--- /dev/null
+++ b/tests/Filee.Core.Tests/FonFormatTests.cs
@@ -0,0 +1,67 @@
+// Windows bitmap fonts (issue #177): .fon is a read-only font format; new and migrated libraries' fonts donut takes it.
+
+using Filee.Core.Formats;
+using Filee.Core.Presets;
+using Filee.Core.Profiles;
+using Filee.Core.Settings;
+
+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));
+ }
+
+ [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);
+ }
+}
diff --git a/tests/Filee.Engines.Tests/FonBuilder.cs b/tests/Filee.Engines.Tests/FonBuilder.cs
new file mode 100644
index 000000000..e89925387
--- /dev/null
+++ b/tests/Filee.Engines.Tests/FonBuilder.cs
@@ -0,0 +1,191 @@
+// 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, int weight = 400, bool italic = false, string faceName = "Fixed")
+ {
+ 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)weight);
+ data[0x50] = italic ? (byte)1 : (byte)0;
+ 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] = i < faceName.Length ? (byte)faceName[i] : (byte)0;
+
+ 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 mixed styles and duplicate style strikes at two heights.
+ public static byte[] MixedStyles() => Container([
+ Fnt(3, faceName: "Name"), Fnt(3, weight: 700, faceName: "Name"), Fnt(3, italic: true, faceName: "Name"),
+ Fnt(3, weight: 700, italic: true, faceName: "Name"), Fnt(3, weight: 600, faceName: "Name"),
+ Fnt(3, weight: 600, italic: true, faceName: "Name"), Fnt(3, faceName: "Name"),
+ Fnt(3, weight: 700, faceName: "Name"), Fnt(3, 14, faceName: "Name")
+ ]);
+ /// 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
new file mode 100644
index 000000000..133e17134
--- /dev/null
+++ b/tests/Filee.Engines.Tests/FonTests.cs
@@ -0,0 +1,1470 @@
+// 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;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.RegularExpressions;
+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 byte[] Data(string name) => FonBuilder.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 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 =
+ [
+ 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 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));
+ 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);
+ 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)
+ {
+ 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), 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();
+ // 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 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);
+ 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, 0))],
+ DefaultChar = 0,
+ };
+ 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 };
+ 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"), 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, 0)).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 }, TestContext.Current.CancellationToken));
+ 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 }, 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 }, 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.
+ 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"), 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, 0)).ToList();
+ 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])
+ {
+ 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), 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();
+ 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"), 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, TestContext.Current.CancellationToken, 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, 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, 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, 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 }, 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));
+ 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());
+ }
+
+ [Fact]
+ public async Task Multi_resource_same_height_styles_have_style_names_and_ordinals()
+ {
+ var source = Source(FonBuilder.MixedStyles(), out var folder);
+ var job = await fx.ConvertAsync([source], PresetFor("ttf"));
+ Assert.True(job.State == JobState.Completed, string.Join("; ", job.Files.Select(f => $"{f.ErrorKey} {f.ErrorDetail}")));
+ var expected = new[]
+ {
+ ("Name-13px.ttf", "Name 13px", "Regular"),
+ ("Name-13px-Bold.ttf", "Name 13px", "Bold"),
+ ("Name-13px-Italic.ttf", "Name 13px", "Italic"),
+ ("Name-13px-BoldItalic.ttf", "Name 13px", "Bold Italic"),
+ ("Name-13px-Weight600.ttf", "Name 13px Weight600", "Regular"),
+ ("Name-13px-Weight600Italic.ttf", "Name 13px Weight600", "Italic"),
+ ("Name-13px-2.ttf", "Name 13px-2", "Regular"),
+ ("Name-13px-Bold-2.ttf", "Name 13px-2", "Bold"),
+ ("Name-14px.ttf", "Name 14px", "Regular"),
+ };
+ Assert.Equal(expected.Select(e => Path.Combine(folder, e.Item1)), job.Outputs);
+ foreach (var (file, family, style) in expected)
+ {
+ var font = FontFile.Load(File.ReadAllBytes(Path.Combine(folder, file)), TestContext.Current.CancellationToken);
+ Assert.Equal(family, Name(font, 1));
+ Assert.Equal(style, Name(font, 2));
+ }
+ }
+
+ /// 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-BoldItalic", "-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", "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.
+ 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;
+ }
+
+ /// 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;
+ }
+
+ private static void AssertRefused(Action action, string key, string? detail = null)
+ {
+ var error = Assert.Throws(action);
+ Assert.Equal(key, error.ErrorKey);
+ Assert.Equal(detail, error.Detail);
+ Assert.True(error.Detail is null || Regex.IsMatch(error.Detail,
+ @"^(PE|FNT 0x[0-9A-F]{4}|dfFlags 0x[0-9A-F]{8}|charset \d+|[0-9][0-9,]*)$"));
+ }
+
+ [Fact]
+ public void Refusal_details_are_language_neutral()
+ {
+ var data = Data("Fon-v2-fixed.fon");
+ var offset = FntStart(data);
+ var cases = new List<(byte[] Data, string Key, string? Detail)>();
+ data[offset + 0x42] = 1;
+ cases.Add((data, "error.fon_vector", null));
+ data = Data("Fon-v2-fixed.fon");
+ data[FntStart(data) + 0x55] = 128;
+ cases.Add((data, "error.fon_unsupported", "charset 128"));
+ cases.Add((FonBuilder.Container([AliasedFnt(64, 1, 1)]), "error.fon_too_large", "65535"));
+ cases.Add(([], "error.fon_damaged", null));
+
+ foreach (var (sample, key, detail) in cases)
+ AssertRefused(() => FonReader.Read(sample, TestContext.Current.CancellationToken), key, detail);
+ }
+
+ ///
+ /// 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? errorKey = null, string? detail = 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 (errorKey is not null)
+ {
+ Assert.Equal(errorKey, file.ErrorKey);
+ Assert.Equal(detail, 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);
+ AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken), "error.fon_damaged");
+
+ await AssertNothingWrittenAsync(data, ["Name-13px.ttf", "Name-13px-2.ttf", "Name-14px.ttf"], FileState.Failed, "error.fon_damaged");
+ }
+
+ [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, 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], TestContext.Current.CancellationToken, 2), "ttf", TestContext.Current.CancellationToken));
+ AssertRefused(() => FonOutlineBuilder.Build(strikes[1], TestContext.Current.CancellationToken, 2), "error.fon_too_large", "32767");
+
+ await AssertNothingWrittenAsync(data, ["Name-13px.ttf", "Name-1px.ttf"], FileState.Failed, "error.fon_too_large", "32767");
+ }
+
+ [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 Aliased_glyphs_over_filewide_pixel_limit_fail_without_outputs()
+ {
+ var data = Container(AliasedFnt(16, 1024), AliasedFnt(17, 1024));
+ AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken),
+ "error.fon_too_large", FonReader.MaxGlyphPixels.ToString("N0", CultureInfo.InvariantCulture));
+
+ await AssertNothingWrittenAsync(data, ["Name-1024px.ttf", "Name-1024px-2.ttf"], FileState.Failed,
+ "error.fon_too_large", FonReader.MaxGlyphPixels.ToString("N0", CultureInfo.InvariantCulture));
+ }
+
+ [Fact]
+ public void Glyph_advance_above_65535_is_rejected_before_decoding()
+ {
+ var data = FonBuilder.Container([AliasedFnt(64, 1, 1)]);
+
+ AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken), "error.fon_too_large", "65535");
+ }
+
+ [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();
+ AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken),
+ "error.fon_too_large", FonReader.MaxGlyphPixels.ToString("N0", CultureInfo.InvariantCulture));
+ var allocated = GC.GetAllocatedBytesForCurrentThread() - before;
+
+ Assert.True(allocated < 1_000_000, $"Reader allocated {allocated:N0} bytes.");
+ }
+
+ [Fact]
+ public void Checkerboard_glyph_over_point_limit_is_refused_before_tracing()
+ {
+ var pixels = new bool[2048, 2048];
+ for (var y = 0; y < 2048; y++)
+ for (var x = 0; x < 2048; x++)
+ pixels[y, x] = ((x + y) & 1) == 0;
+ var strike = Assert.Single(FonReader.Read(Data("Fon-v2-fixed.fon"), TestContext.Current.CancellationToken)) with
+ {
+ PixelHeight = 2048,
+ Ascent = 2048,
+ Glyphs = [new FonGlyph(32, 2048, pixels, 0)],
+ DefaultChar = 0
+ };
+
+ var before = GC.GetAllocatedBytesForCurrentThread();
+ AssertRefused(() => FonOutlineBuilder.Build(strike, TestContext.Current.CancellationToken), "error.fon_too_large", "65535");
+ var allocated = GC.GetAllocatedBytesForCurrentThread() - before;
+ Assert.True(allocated < 1_000_000, $"Builder allocated {allocated:N0} bytes.");
+ }
+
+ [Fact]
+ public void Glyphs_sharing_a_bitmap_build_one_outline()
+ {
+ var pixels = new bool[64, 64];
+ for (var y = 0; y < 64; y++)
+ for (var x = 0; x < 64; x++)
+ pixels[y, x] = ((x + y) & 1) == 0;
+ var strike = Assert.Single(FonReader.Read(Data("Fon-v2-fixed.fon"), TestContext.Current.CancellationToken)) with
+ {
+ PixelHeight = 64,
+ Ascent = 64,
+ Glyphs = [new FonGlyph(32, 64, pixels, 100)],
+ DefaultChar = 0
+ };
+ var before = GC.GetAllocatedBytesForCurrentThread();
+ var single = FonOutlineBuilder.Build(strike, TestContext.Current.CancellationToken);
+ var singleAllocated = GC.GetAllocatedBytesForCurrentThread() - before;
+ strike = strike with
+ {
+ Glyphs = [.. Enumerable.Range(32, 64).Select(code => new FonGlyph((byte)code, 64, pixels, 100))]
+ };
+ before = GC.GetAllocatedBytesForCurrentThread();
+ var shared = FonOutlineBuilder.Build(strike, TestContext.Current.CancellationToken);
+ var sharedAllocated = GC.GetAllocatedBytesForCurrentThread() - before;
+
+ Assert.True(sharedAllocated < singleAllocated * 4,
+ $"Shared build allocated {sharedAllocated:N0} bytes versus {singleAllocated:N0} for one glyph.");
+ var glyphs = GlyfTable.ReadAll(shared);
+ var expected = GlyfTable.ReadAll(single)[1]!.Serialize();
+ Assert.All(glyphs.Skip(1), glyph => Assert.Equal(expected, glyph!.Serialize()));
+ }
+
+ [Fact]
+ public void Glyphs_with_one_offset_but_different_widths_keep_their_own_outlines()
+ {
+ var narrow = new bool[8, 8];
+ var wide = new bool[8, 12];
+ narrow[0, 7] = true;
+ wide[0, 11] = true;
+ var strike = Assert.Single(FonReader.Read(Data("Fon-v2-fixed.fon"), TestContext.Current.CancellationToken)) with
+ {
+ PixelHeight = 8,
+ Ascent = 8,
+ DefaultChar = 0,
+ Glyphs = [new FonGlyph(32, 8, narrow, 100), new FonGlyph(33, 12, wide, 100)]
+ };
+ var glyphs = GlyfTable.ReadAll(FonOutlineBuilder.Build(strike, TestContext.Current.CancellationToken));
+
+ Assert.Equal(8 * 128, glyphs[1]!.XMax);
+ Assert.Equal(12 * 128, glyphs[2]!.XMax);
+ }
+
+ [Fact]
+ public void Point_count_before_tracing_matches_the_outline()
+ {
+ var template = Assert.Single(FonReader.Read(Data("Fon-v2-fixed.fon"), TestContext.Current.CancellationToken));
+ var random = new Random(561);
+ foreach (var (height, width, threshold) in new[] { (8, 8, 2), (13, 17, 4), (32, 32, 7), (32, 32, 10) })
+ {
+ var pixels = new bool[height, width];
+ for (var y = 0; y < height; y++)
+ for (var x = 0; x < width; x++)
+ pixels[y, x] = random.Next(12) < threshold;
+ AssertPoints(pixels);
+ }
+ var checkerboard = new bool[32, 32];
+ for (var y = 0; y < 32; y++)
+ for (var x = 0; x < 32; x++)
+ checkerboard[y, x] = ((x + y) & 1) == 0;
+ AssertPoints(checkerboard);
+ AssertPoints(new bool[8, 8]);
+
+ void AssertPoints(bool[,] pixels)
+ {
+ var height = pixels.GetLength(0);
+ var width = pixels.GetLength(1);
+ var strike = template with
+ {
+ PixelHeight = height,
+ Ascent = height,
+ DefaultChar = 0,
+ Glyphs = [new FonGlyph(32, width, pixels, 0)]
+ };
+ var counted = FonOutlineBuilder.CountPoints(pixels, width, 65535, TestContext.Current.CancellationToken);
+ var glyph = GlyfTable.ReadAll(FonOutlineBuilder.Build(strike, TestContext.Current.CancellationToken))[1];
+ Assert.Equal(counted, glyph?.Points.Length ?? 0);
+ if (counted == 0)
+ Assert.Null(glyph);
+ }
+ }
+
+ [Fact]
+ public void Aliased_glyphs_at_filewide_pixel_limit_are_accepted()
+ {
+ 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])));
+ }
+
+ [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));
+ AssertRefused(() => FonReader.Read(overLimit, TestContext.Current.CancellationToken),
+ "error.fon_too_large", FonReader.MaxGlyphPixels.ToString("N0", CultureInfo.InvariantCulture));
+ }
+
+ [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));
+ AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken), "error.fon_too_large", "16384");
+ }
+
+ [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);
+
+ AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken), "error.fon_damaged");
+ }
+
+ [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"), 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, 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++)
+ 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, 0)], DefaultChar = 0 };
+ AssertRefused(() => FonOutlineBuilder.Build(overflowing, TestContext.Current.CancellationToken), "error.fon_too_large", "32767");
+ // 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, 0)], 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.
+ 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, TestContext.Current.CancellationToken));
+ BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(fnt + 0x76 + 2), (ushort)(size - 12));
+ AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken), "error.fon_damaged");
+
+ // Through the queue: an overflowing resource writes nothing.
+ var source = Source(Container(WideOnePixelFnt()), out var folder);
+ var job = await fx.ConvertAsync([source], PresetFor("ttf"));
+ var failed = Assert.Single(job.Files);
+ Assert.Equal(FileState.Failed, failed.State);
+ Assert.Equal("error.fon_too_large", failed.ErrorKey);
+ Assert.Equal("32767", failed.ErrorDetail);
+ 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"), TestContext.Current.CancellationToken));
+ 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"), 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);
+ 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), 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
+ 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, 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, TestContext.Current.CancellationToken);
+ 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;
+ AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken), "error.fon_damaged");
+ }
+
+ [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");
+ AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken),
+ "error.fon_unsupported", $"dfFlags 0x{flag:X8}");
+ }
+
+ [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);
+ AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken),
+ "error.fon_unsupported", $"dfFlags 0x{flags:X8}");
+ }
+
+ [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;
+ AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken), "error.fon_vector");
+ data[second + 0x42] = 4;
+ AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken), "error.fon_unsupported");
+ 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;
+ AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken),
+ "error.fon_unsupported", $"charset {charset}");
+ }
+ data[second + 0x55] = 0;
+ data[second] = 0;
+ data[second + 1] = 1;
+ AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken), "error.fon_unsupported", "FNT 0x0100");
+
+ var pe = Data("Fon-v2-fixed.fon");
+ pe[0x40] = (byte)'P';
+ pe[0x41] = (byte)'E';
+ AssertRefused(() => FonReader.Read(pe, TestContext.Current.CancellationToken), "error.fon_unsupported", "PE");
+ // A bare FNT and an MZ program without fonts.
+ var fixedFon = Data("Fon-v2-fixed.fon");
+ AssertRefused(() => FonReader.Read(fixedFon[FntStart(fixedFon)..], TestContext.Current.CancellationToken), "error.fon_damaged");
+ 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);
+ AssertRefused(() => FonReader.Read(program, TestContext.Current.CancellationToken), "error.fon_damaged");
+ }
+
+ [Fact]
+ public void Truncated_ne_fnt_and_face_never_produce_outputs()
+ {
+ var source = Data("Fon-v2-fixed.fon");
+ AssertRefused(() => FonReader.Read(source[..0x40], TestContext.Current.CancellationToken), "error.fon_damaged");
+ var data = (byte[])source.Clone();
+ var ne = 0x40;
+ BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(ne + 0x26), 0x7fff);
+ AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken), "error.fon_damaged");
+
+ data = (byte[])source.Clone();
+ var table = ne + BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(ne + 0x24));
+ BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(table + 30), (ushort)table);
+ AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken), "error.fon_damaged");
+
+ data = (byte[])source.Clone();
+ var fnt = FntStart(data);
+ BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(fnt + 2), 0x7fffffff);
+ AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken), "error.fon_damaged");
+
+ data = (byte[])source.Clone();
+ BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(fnt + 0x69), 0x7fffffff);
+ AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken), "error.fon_damaged");
+
+ 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';
+ AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken), "error.fon_damaged");
+
+ data = (byte[])source.Clone();
+ BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(fnt + 0x76 + 2), 0xffff);
+ AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken), "error.fon_damaged");
+ }
+
+ [Fact]
+ public void Builder_preserves_pixel_outlines_metrics_and_control_glyphs()
+ {
+ 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);
+ 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)));
+ }
+}
|