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