diff --git a/README.ar.md b/README.ar.md index 539c31977..d4ddf7c2e 100644 --- a/README.ar.md +++ b/README.ar.md @@ -58,7 +58,7 @@ تظهر الصيغ حيث أنت، ولا يظهر منها إلا ما يناسب الملفات التي تسحبها. - 265 صيغة دون برامج أخرى
+ 266 صيغة دون برامج أخرى
الصور وPDF وWord وExcel وPowerPoint وHWP والكتب الإلكترونية والأرشيفات والخطوط وCAD والنماذج ثلاثية الأبعاد والبيانات الجغرافية، كلها تعمل دون Office أو Hancom Office أو LibreOffice. @@ -167,7 +167,7 @@ Set-ProcessMitigation -Name filee-cli.exe -Disable UserShadowStack | **الترجمة**: SRT, ASS/SSA, WebVTT، SAMI (SMI)، LRC, TTML, MicroDVD, SCC | فيما بينها بترميز UTF-8 (تُقرأ ملفات CP949 الكورية بشكل صحيح) | | **الأرشيفات**: ZIP، وANSYS Workbench (WBPZ)، و7Z, RAR, TAR, TAR.GZ, TAR.BZ2, TAR.XZ, GZ, ISO, CAB, DMG, ALZ, EGG وغيرها | الاستخراج، ZIP, 7Z, TAR, TAR.GZ؛
وجمع أي ملفات في ZIP واحد | | **CAD**: DWG, DXF | PDF, SVG, PNG, DWG ↔ DXF | -| **الخطوط**: TTF, OTF, WOFF, WOFF2, EOT | فيما بينها | +| **الخطوط**: TTF, OTF, WOFF, WOFF2, EOT؛
FON (خطوط نقطية من Windows) للقراءة | تحويل فيما بينها؛
FON → ملف خط واحد لكل حجم | | **البيانات**: JSON, JSON Lines, YAML, TOML, XML
vCard, iCalendar | فيما بينها، وإلى CSV وXLSX (للجداول)؛
vCard, iCalendar ↔ CSV | | **النماذج ثلاثية الأبعاد**: GLB, glTF, STL, OBJ (+ MTL), 3MF, PLY؛
وFBX للقراءة | فيما بينها، معاينة PNG | | **البيانات الجغرافية المتجهية**: Shapefile, GeoJSON, KML, KMZ, GPX
ملفات CSV فيها إحداثيات؛
وGarmin FIT للقراءة | فيما بينها، وإلى CSV / XLSX / JSON؛
وتُحوَّل البيانات المُسقَطة (UTM, Web Mercator, Lambert, Albers) إلى خطوط الطول والعرض في WGS 84 | diff --git a/README.es.md b/README.es.md index 3f4bf5040..fb39aa7f6 100644 --- a/README.es.md +++ b/README.es.md @@ -56,7 +56,7 @@ Los formatos aparecen donde ya estás, y solo los que tienen sentido para los archivos que arrastras. - 265 formatos, sin otros programas
+ 266 formatos, sin otros programas
Imágenes, PDF, Word, Excel, PowerPoint, HWP, libros electrónicos, archivos comprimidos, tipografías, CAD, modelos 3D y datos geoespaciales funcionan sin Office, Hancom Office ni LibreOffice. @@ -161,7 +161,7 @@ Para deshacerlo, quita esas entradas en Seguridad de Windows → Control de apli | **Subtítulos**: SRT, ASS/SSA, WebVTT, SAMI (SMI), LRC, TTML, MicroDVD, SCC | entre sí, en UTF-8 (los archivos coreanos en CP949 se leen correctamente) | | **Archivos comprimidos**: ZIP, ANSYS Workbench (WBPZ), 7Z, RAR, TAR, TAR.GZ, TAR.BZ2, TAR.XZ, GZ, ISO, CAB, DMG, ALZ, EGG, … | extraer, ZIP, 7Z, TAR, TAR.GZ;
cualquier conjunto de archivos → un ZIP | | **CAD**: DWG, DXF | PDF, SVG, PNG, DWG ↔ DXF | -| **Tipografías**: TTF, OTF, WOFF, WOFF2, EOT | entre sí | +| **Tipografías**: TTF, OTF, WOFF, WOFF2, EOT;
FON (fuentes de mapa de bits de Windows) solo lectura | conversión entre sí;
FON → un archivo por tamaño | | **Datos**: JSON, JSON Lines, YAML, TOML, XML
vCard, iCalendar | entre sí, CSV y XLSX (tablas);
vCard, iCalendar ↔ CSV | | **Modelos 3D**: GLB, glTF, STL, OBJ (+ MTL), 3MF, PLY;
FBX solo lectura | entre sí, vista previa en PNG | | **Datos geoespaciales vectoriales**: Shapefile, GeoJSON, KML, KMZ, GPX
CSV con coordenadas;
Garmin FIT solo lectura | entre sí y a CSV / XLSX / JSON;
los datos proyectados (UTM, Web Mercator, Lambert, Albers) salen como longitud / latitud WGS 84 | diff --git a/README.fr.md b/README.fr.md index 4bc466500..f82ce86f0 100644 --- a/README.fr.md +++ b/README.fr.md @@ -56,7 +56,7 @@ Les formats apparaissent là où vous êtes déjà, et seulement ceux qui ont un sens pour les fichiers que vous faites glisser. - 265 formats, sans autre logiciel
+ 266 formats, sans autre logiciel
Images, PDF, Word, Excel, PowerPoint, HWP, livres numériques, archives, polices, CAO, modèles 3D et données géospatiales fonctionnent sans Office, Hancom Office ni LibreOffice. @@ -161,7 +161,7 @@ Pour annuler, supprimez ces entrées dans Sécurité Windows → Contrôle des a | **Sous-titres** : SRT, ASS/SSA, WebVTT, SAMI (SMI), LRC, TTML, MicroDVD, SCC | entre eux, en UTF-8 (les fichiers coréens en CP949 sont lus correctement) | | **Archives** : ZIP, ANSYS Workbench (WBPZ), 7Z, RAR, TAR, TAR.GZ, TAR.BZ2, TAR.XZ, GZ, ISO, CAB, DMG, ALZ, EGG, … | extraire, ZIP, 7Z, TAR, TAR.GZ ;
n'importe quels fichiers → un seul ZIP | | **CAO** : DWG, DXF | PDF, SVG, PNG, DWG ↔ DXF | -| **Polices** : TTF, OTF, WOFF, WOFF2, EOT | entre elles | +| **Polices** : TTF, OTF, WOFF, WOFF2, EOT ;
FON (polices bitmap Windows) en lecture | conversion entre elles ;
FON → un fichier par taille | | **Données** : JSON, JSON Lines, YAML, TOML, XML
vCard, iCalendar | entre eux, CSV et XLSX (tableaux) ;
vCard, iCalendar ↔ CSV | | **Modèles 3D** : GLB, glTF, STL, OBJ (+ MTL), 3MF, PLY ;
FBX en lecture | entre eux, aperçu PNG | | **Données géospatiales vectorielles** : Shapefile, GeoJSON, KML, KMZ, GPX
CSV avec coordonnées ;
Garmin FIT en lecture | entre eux et CSV / XLSX / JSON ;
les données projetées (UTM, Web Mercator, Lambert, Albers) sortent en longitude / latitude WGS 84 | diff --git a/README.hi.md b/README.hi.md index 23a3cdeab..e05973088 100644 --- a/README.hi.md +++ b/README.hi.md @@ -56,7 +56,7 @@ फ़ॉर्मैट वहीं दिखते हैं जहाँ आप पहले से हैं, और सिर्फ़ वही जो आपकी खींची जा रही फ़ाइलों के लिए सही हों। - 265 फ़ॉर्मैट, कोई और सॉफ़्टवेयर नहीं
+ 266 फ़ॉर्मैट, कोई और सॉफ़्टवेयर नहीं
इमेज, PDF, Word, Excel, PowerPoint, HWP, ई-बुक, आर्काइव, फ़ॉन्ट, CAD, 3D मॉडल और भू-स्थानिक डेटा Office, Hancom Office या LibreOffice के बिना काम करते हैं। @@ -161,7 +161,7 @@ Set-ProcessMitigation -Name filee-cli.exe -Disable UserShadowStack | **सबटाइटल**: SRT, ASS/SSA, WebVTT, SAMI (SMI), LRC, TTML, MicroDVD, SCC | आपस में, UTF-8 में (कोरियाई CP949 फ़ाइलें भी सही पढ़ी जाती हैं) | | **आर्काइव**: ZIP, ANSYS Workbench (WBPZ), 7Z, RAR, TAR, TAR.GZ, TAR.BZ2, TAR.XZ, GZ, ISO, CAB, DMG, ALZ, EGG, … | निकालना, ZIP, 7Z, TAR, TAR.GZ;
कोई भी फ़ाइलें → एक ZIP | | **CAD**: DWG, DXF | PDF, SVG, PNG, DWG ↔ DXF | -| **फ़ॉन्ट**: TTF, OTF, WOFF, WOFF2, EOT | आपस में | +| **फ़ॉन्ट**: TTF, OTF, WOFF, WOFF2, EOT;
FON (Windows बिटमैप फ़ॉन्ट) केवल पढ़ने के लिए | आपस में रूपांतरण;
FON → हर आकार के लिए एक फ़ॉन्ट | | **डेटा**: JSON, JSON Lines, YAML, TOML, XML
vCard, iCalendar | आपस में, CSV और XLSX (टेबल);
vCard, iCalendar ↔ CSV | | **3D मॉडल**: GLB, glTF, STL, OBJ (+ MTL), 3MF, PLY;
FBX सिर्फ़ पढ़ने के लिए | आपस में, PNG प्रीव्यू | | **भू-स्थानिक वेक्टर**: Shapefile, GeoJSON, KML, KMZ, GPX
निर्देशांक वाली CSV;
Garmin FIT सिर्फ़ पढ़ने के लिए | आपस में और CSV / XLSX / JSON;
प्रोजेक्टेड डेटा (UTM, Web Mercator, Lambert, Albers) WGS 84 देशांतर / अक्षांश में बदलकर आता है | diff --git a/README.ja.md b/README.ja.md index 5fe2ae965..ea211204a 100644 --- a/README.ja.md +++ b/README.ja.md @@ -56,7 +56,7 @@ いま作業している場所に、ドラッグ中のファイルに合う形式だけが表示されます。 - 265形式、ほかのソフトは不要
+ 266形式、ほかのソフトは不要
画像、PDF、Word、Excel、PowerPoint、HWP、電子書籍、アーカイブ、フォント、CAD、3Dモデル、地理空間データを、Office、Hancom Office、LibreOfficeなしで変換できます。 @@ -164,7 +164,7 @@ Set-ProcessMitigation -Name filee-cli.exe -Disable UserShadowStack | **字幕**:SRT、ASS/SSA、WebVTT、SAMI(SMI)、LRC、TTML、MicroDVD、SCC | 相互変換、UTF-8で保存(CP949の韓国語ファイルも正しく読み込み) | | **アーカイブ**:ZIP、ANSYS Workbench(WBPZ)、7Z、RAR、TAR、TAR.GZ、TAR.BZ2、TAR.XZ、GZ、ISO、CAB、DMG、ALZ、EGG など | 展開、ZIP、7Z、TAR、TAR.GZ。
複数のファイル → 1つのZIP | | **CAD**:DWG、DXF | PDF、SVG、PNG、DWG ↔ DXF | -| **フォント**:TTF、OTF、WOFF、WOFF2、EOT | 相互変換 | +| **フォント**:TTF、OTF、WOFF、WOFF2、EOT;
FON(Windowsビットマップフォント)は読み込みのみ | 相互変換;
FON → サイズごとに1ファイル | | **データ**:JSON、JSON Lines、YAML、TOML、XML
vCard、iCalendar | 相互変換、CSVとXLSX(表形式のデータ)。
vCard、iCalendar ↔ CSV | | **3Dモデル**:GLB、glTF、STL、OBJ(+ MTL)、3MF、PLY。
FBXは読み込みのみ | 相互変換、PNGプレビュー | | **地理空間ベクターデータ**:Shapefile、GeoJSON、KML、KMZ、GPX
座標付きのCSV。
Garmin FITは読み込みのみ | 相互変換、CSV/XLSX/JSON。
投影座標系(UTM、Webメルカトル、ランベルト、アルベルス)のデータはWGS 84の経度/緯度で出力 | diff --git a/README.ko.md b/README.ko.md index be26be8ec..01740b605 100644 --- a/README.ko.md +++ b/README.ko.md @@ -57,7 +57,7 @@ 끌고 있는 파일에 맞는 형식만 지금 있는 자리에 나타나요. - 265가지 형식, 다른 프로그램 없이
+ 266가지 형식, 다른 프로그램 없이
이미지, PDF, 워드, 엑셀, 파워포인트, HWP, 전자책, 압축 파일, 글꼴, CAD, 3D 모델, 지리 정보 데이터를 오피스·한컴오피스·LibreOffice 없이 변환해요. @@ -162,7 +162,7 @@ Set-ProcessMitigation -Name filee-cli.exe -Disable UserShadowStack | **자막**: SRT, ASS/SSA, WebVTT, SAMI(SMI), LRC, TTML, MicroDVD, SCC | 서로 변환, UTF-8로 저장(CP949 한글 자막도 깨지지 않아요) | | **압축 파일**: ZIP, ANSYS Workbench(WBPZ), 7Z, RAR, TAR, TAR.GZ, TAR.BZ2, TAR.XZ, GZ, ISO, CAB, DMG, ALZ, EGG 등 | 압축 풀기, ZIP, 7Z, TAR, TAR.GZ;
여러 파일 → ZIP 하나 | | **CAD**: DWG, DXF | PDF, SVG, PNG, DWG ↔ DXF | -| **글꼴**: TTF, OTF, WOFF, WOFF2, EOT | 서로 변환 | +| **글꼴**: TTF, OTF, WOFF, WOFF2, EOT;
FON(Windows 비트맵 글꼴)은 읽기 | 서로 변환;
FON → 크기마다 글꼴 하나 | | **데이터**: JSON, JSON Lines, YAML, TOML, XML
vCard, iCalendar | 서로 변환, CSV·XLSX(표 모양 데이터);
vCard, iCalendar ↔ CSV | | **3D 모델**: GLB, glTF, STL, OBJ (+ MTL), 3MF, PLY;
FBX는 읽기 | 서로 변환, PNG 미리보기 | | **지리 정보(벡터)**: Shapefile, GeoJSON, KML, KMZ, GPX
좌표가 있는 CSV;
Garmin FIT는 읽기 | 서로 변환, CSV·XLSX·JSON;
투영 좌표계(UTM, 웹 메르카토르, 람베르트, 알버스)는 WGS 84 경위도로 변환 | diff --git a/README.md b/README.md index c7c19a616..29a6c995e 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ The formats appear where you already are, only the ones that make sense for the files you drag. - 265 formats, no other software
+ 266 formats, no other software
Images, PDF, Word, Excel, PowerPoint, HWP, e-books, archives, fonts, CAD, 3D models and geospatial data work without Office, Hancom Office or LibreOffice. @@ -161,7 +161,7 @@ To undo it, remove the entries in Windows Security → App & browser control → | **Subtitles**: SRT, ASS/SSA, WebVTT, SAMI (SMI), LRC, TTML, MicroDVD, SCC | each other as UTF-8 (Korean CP949 files read correctly) | | **Archives**: ZIP, ANSYS Workbench (WBPZ), 7Z, RAR, TAR, TAR.GZ, TAR.BZ2, TAR.XZ, GZ, ISO, CAB, DMG, ALZ, EGG, … | extract, ZIP, 7Z, TAR, TAR.GZ;
any files → one ZIP | | **CAD**: DWG, DXF | PDF, SVG, PNG, DWG ↔ DXF | -| **Fonts**: TTF, OTF, WOFF, WOFF2, EOT | each other | +| **Fonts**: TTF, OTF, WOFF, WOFF2, EOT;
FON (Windows bitmap fonts) read | each other;
FON → one font per size | | **Data**: JSON, JSON Lines, YAML, TOML, XML
vCard, iCalendar | each other, CSV and XLSX (tables);
vCard, iCalendar ↔ CSV | | **3D models**: GLB, glTF, STL, OBJ (+ MTL), 3MF, PLY;
FBX read | each other, PNG preview | | **Geospatial vectors**: Shapefile, GeoJSON, KML, KMZ, GPX
CSV with coordinates; Garmin FIT read | each other and CSV / XLSX / JSON;
projected data (UTM, Web Mercator, Lambert, Albers) comes out as WGS 84 longitude / latitude | diff --git a/README.pl.md b/README.pl.md index 5e01b47e6..35bfc3a11 100644 --- a/README.pl.md +++ b/README.pl.md @@ -56,7 +56,7 @@ Formaty pojawiają się tam, gdzie już jesteś, i tylko te, które pasują do przeciąganych plików. - 265 formatów bez dodatkowych programów
+ 266 formatów bez dodatkowych programów
Obrazy, PDF, Word, Excel, PowerPoint, HWP, e-booki, archiwa, czcionki, CAD, modele 3D i dane geoprzestrzenne działają bez pakietu Office, Hancom Office i LibreOffice. @@ -161,7 +161,7 @@ Aby to cofnąć, usuń te wpisy w Zabezpieczenia Windows → Kontrola aplikacji | **Napisy**: SRT, ASS/SSA, WebVTT, SAMI (SMI), LRC, TTML, MicroDVD, SCC | między sobą, w UTF-8 (koreańskie pliki CP949 są odczytywane poprawnie) | | **Archiwa**: ZIP, ANSYS Workbench (WBPZ), 7Z, RAR, TAR, TAR.GZ, TAR.BZ2, TAR.XZ, GZ, ISO, CAB, DMG, ALZ, EGG, … | rozpakowanie, ZIP, 7Z, TAR, TAR.GZ;
dowolne pliki → jeden ZIP | | **CAD**: DWG, DXF | PDF, SVG, PNG, DWG ↔ DXF | -| **Czcionki**: TTF, OTF, WOFF, WOFF2, EOT | między sobą | +| **Czcionki**: TTF, OTF, WOFF, WOFF2, EOT;
FON (bitmapowe czcionki Windows) tylko odczyt | konwersja między sobą;
FON → plik na rozmiar | | **Dane**: JSON, JSON Lines, YAML, TOML, XML
vCard, iCalendar | między sobą, CSV i XLSX (tabele);
vCard, iCalendar ↔ CSV | | **Modele 3D**: GLB, glTF, STL, OBJ (+ MTL), 3MF, PLY;
FBX tylko odczyt | między sobą, podgląd PNG | | **Dane geoprzestrzenne (wektorowe)**: Shapefile, GeoJSON, KML, KMZ, GPX
CSV ze współrzędnymi;
Garmin FIT tylko odczyt | między sobą oraz CSV / XLSX / JSON;
dane w odwzorowaniach (UTM, Web Mercator, Lambert, Albers) wychodzą jako długość / szerokość geograficzna WGS 84 | diff --git a/README.pt-BR.md b/README.pt-BR.md index 8433fe6d2..91fc978ae 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -56,7 +56,7 @@ Os formatos aparecem onde você já está, só os que fazem sentido para os arquivos que você arrasta. - 265 formatos, sem outro programa
+ 266 formatos, sem outro programa
Imagens, PDF, Word, Excel, PowerPoint, HWP, e-books, arquivos compactados, fontes, CAD, modelos 3D e dados geoespaciais funcionam sem Office, Hancom Office ou LibreOffice. @@ -161,7 +161,7 @@ Para desfazer, remova essas entradas em Segurança do Windows → Controle de ap | **Legendas**: SRT, ASS/SSA, WebVTT, SAMI (SMI), LRC, TTML, MicroDVD, SCC | entre si, em UTF-8 (arquivos coreanos em CP949 são lidos corretamente) | | **Arquivos compactados**: ZIP, ANSYS Workbench (WBPZ), 7Z, RAR, TAR, TAR.GZ, TAR.BZ2, TAR.XZ, GZ, ISO, CAB, DMG, ALZ, EGG, … | extrair, ZIP, 7Z, TAR, TAR.GZ;
quaisquer arquivos → um ZIP | | **CAD**: DWG, DXF | PDF, SVG, PNG, DWG ↔ DXF | -| **Fontes**: TTF, OTF, WOFF, WOFF2, EOT | entre si | +| **Fontes**: TTF, OTF, WOFF, WOFF2, EOT;
FON (fontes bitmap do Windows) somente leitura | conversão entre si;
FON → um arquivo por tamanho | | **Dados**: JSON, JSON Lines, YAML, TOML, XML
vCard, iCalendar | entre si, CSV e XLSX (tabelas);
vCard, iCalendar ↔ CSV | | **Modelos 3D**: GLB, glTF, STL, OBJ (+ MTL), 3MF, PLY;
leitura de FBX | entre si, prévia em PNG | | **Vetores geoespaciais**: Shapefile, GeoJSON, KML, KMZ, GPX
CSV com coordenadas;
leitura de Garmin FIT | entre si e CSV / XLSX / JSON;
dados projetados (UTM, Web Mercator, Lambert, Albers) saem como longitude / latitude em WGS 84 | diff --git a/README.pt-PT.md b/README.pt-PT.md index baa4de59b..23d027e4f 100644 --- a/README.pt-PT.md +++ b/README.pt-PT.md @@ -56,7 +56,7 @@ Os formatos aparecem onde estás, apenas os que fazem sentido para os ficheiros que arrastas. - 265 formatos, sem outro software
+ 266 formatos, sem outro software
Imagens, PDF, Word, Excel, PowerPoint, HWP, livros digitais, arquivos, tipos de letra, CAD, modelos 3D e dados geoespaciais funcionam sem Office, Hancom Office ou LibreOffice. @@ -161,7 +161,7 @@ Para reverter a alteração, remove as entradas em Segurança do Windows → Con | **Legendas**: SRT, ASS/SSA, WebVTT, SAMI (SMI), LRC, TTML, MicroDVD, SCC | conversão entre estes formatos em UTF-8 (leitura correta de ficheiros coreanos em CP949) | | **Arquivos**: ZIP, ANSYS Workbench (WBPZ), 7Z, RAR, TAR, TAR.GZ, TAR.BZ2, TAR.XZ, GZ, ISO, CAB, DMG, ALZ, EGG, … | extrair, ZIP, 7Z, TAR, TAR.GZ;
quaisquer ficheiros → um ZIP | | **CAD**: DWG, DXF | PDF, SVG, PNG, DWG ↔ DXF | -| **Tipos de letra**: TTF, OTF, WOFF, WOFF2, EOT | conversão entre estes formatos | +| **Tipos de letra**: TTF, OTF, WOFF, WOFF2, EOT;
FON (tipos de letra bitmap do Windows) apenas leitura | conversão entre si;
FON → um ficheiro por tamanho | | **Dados**: JSON, JSON Lines, YAML, TOML, XML
vCard, iCalendar | conversão entre estes formatos, CSV e XLSX (tabelas);
vCard, iCalendar ↔ CSV | | **Modelos 3D**: GLB, glTF, STL, OBJ (+ MTL), 3MF, PLY;
leitura de FBX | conversão entre estes formatos, pré-visualização em PNG | | **Vetores geoespaciais**: Shapefile, GeoJSON, KML, KMZ, GPX
CSV com coordenadas | conversão entre estes formatos e CSV / XLSX / JSON;
os dados projetados (UTM, Web Mercator, Lambert, Albers) são convertidos em longitude / latitude WGS 84 | diff --git a/README.ru.md b/README.ru.md index c1446d21b..0f62d18d3 100644 --- a/README.ru.md +++ b/README.ru.md @@ -56,7 +56,7 @@ Форматы появляются там, где вы уже работаете, и только те, что подходят перетаскиваемым файлам. - 265 форматов без лишних программ
+ 266 форматов без лишних программ
Изображения, PDF, Word, Excel, PowerPoint, HWP, электронные книги, архивы, шрифты, чертежи CAD, 3D-модели и геоданные работают без Office, Hancom Office и LibreOffice. @@ -161,7 +161,7 @@ Set-ProcessMitigation -Name filee-cli.exe -Disable UserShadowStack | **Субтитры**: SRT, ASS/SSA, WebVTT, SAMI (SMI), LRC, TTML, MicroDVD, SCC | друг в друга, в UTF-8 (корейские файлы в CP949 читаются правильно) | | **Архивы**: ZIP, ANSYS Workbench (WBPZ), 7Z, RAR, TAR, TAR.GZ, TAR.BZ2, TAR.XZ, GZ, ISO, CAB, DMG, ALZ, EGG, … | распаковка, ZIP, 7Z, TAR, TAR.GZ;
любые файлы → один ZIP | | **CAD**: DWG, DXF | PDF, SVG, PNG, DWG ↔ DXF | -| **Шрифты**: TTF, OTF, WOFF, WOFF2, EOT | друг в друга | +| **Шрифты**: TTF, OTF, WOFF, WOFF2, EOT;
FON (растровые шрифты Windows) только чтение | преобразование друг в друга;
FON → один файл на размер | | **Данные**: JSON, JSON Lines, YAML, TOML, XML
vCard, iCalendar | друг в друга, CSV и XLSX (таблицы);
vCard, iCalendar ↔ CSV | | **3D-модели**: GLB, glTF, STL, OBJ (+ MTL), 3MF, PLY;
FBX только чтение | друг в друга, превью в PNG | | **Геоданные (векторные)**: Shapefile, GeoJSON, KML, KMZ, GPX
CSV с координатами;
Garmin FIT только чтение | друг в друга и CSV / XLSX / JSON;
данные в проекциях (UTM, Web Mercator, Ламберта, Альберса) переводятся в долготу / широту WGS 84 | diff --git a/README.th.md b/README.th.md index 296a1ef21..2c4c456f9 100644 --- a/README.th.md +++ b/README.th.md @@ -56,7 +56,7 @@ ฟอร์แมตที่เหมาะสมจะแสดงขึ้นตรงจุดที่คุณทำงานอยู่ โดยแสดงเฉพาะฟอร์แมตที่ใช้ได้กับไฟล์ที่คุณลากเท่านั้น - 265 ฟอร์แมต โดยไม่ต้องลงโปรแกรมอื่นเพิ่ม
+ 266 ฟอร์แมต โดยไม่ต้องลงโปรแกรมอื่นเพิ่ม
รูปภาพ, PDF, Word, Excel, PowerPoint, HWP, อีบุ๊ก, ไฟล์บีบอัด, ฟอนต์, CAD, โมเดล 3D และข้อมูลเชิงพื้นที่ ทำงานได้โดยไม่ต้องมี Microsoft Office, Hancom Office หรือ LibreOffice @@ -161,7 +161,7 @@ Set-ProcessMitigation -Name filee-cli.exe -Disable UserShadowStack | **คำบรรยาย**: SRT, ASS/SSA, WebVTT, SAMI (SMI), LRC, TTML, MicroDVD, SCC | แปลงสลับกันเป็น UTF-8 (อ่านไฟล์ SMI/SRT เกาหลีรหัส CP949 ได้ถูกต้อง) | | **ไฟล์บีบอัด**: ZIP, ANSYS Workbench (WBPZ), 7Z, RAR, TAR, TAR.GZ, TAR.BZ2, TAR.XZ, GZ, ISO, CAB, DMG, ALZ, EGG, … | แตกไฟล์, บีบอัดเป็น ZIP, 7Z, TAR, TAR.GZ;
รวมทุกไฟล์เป็นหนึ่ง ZIP | | **CAD**: DWG, DXF | PDF, SVG, PNG, DWG ↔ DXF | -| **ฟอนต์**: TTF, OTF, WOFF, WOFF2, EOT | แปลงสลับกันได้ | +| **ฟอนต์**: TTF, OTF, WOFF, WOFF2, EOT;
FON (ฟอนต์บิตแมปของ Windows) อ่านอย่างเดียว | แปลงระหว่างกัน;
FON → หนึ่งไฟล์ต่อขนาด | | **ข้อมูล**: JSON, JSON Lines, YAML, TOML, XML
vCard, iCalendar | แปลงสลับกันได้, CSV และ XLSX (ตาราง);
vCard, iCalendar ↔ CSV | | **โมเดล 3D**: GLB, glTF, STL, OBJ (+ MTL), 3MF, PLY;
อ่าน FBX | แปลงสลับกันได้, แสดงภาพตัวอย่าง PNG | | **ข้อมูลเวกเตอร์เชิงพื้นที่**: Shapefile, GeoJSON, KML, KMZ, GPX
CSV พร้อมพิกัด;
อ่าน Garmin FIT | แปลงสลับกันได้ และเป็น CSV / XLSX / JSON
โดยพิกัดโปรเจกชัน (UTM, Web Mercator, Lambert, Albers) จะถูกแปลงเป็น WGS 84 ลองจิจูด/ละติจูด | diff --git a/README.zh-CN.md b/README.zh-CN.md index 59ef8d07b..149de6a97 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -56,7 +56,7 @@ 只显示适合当前拖动文件的格式,就在你所在的位置。 - 265 种格式,无需其他软件
+ 266 种格式,无需其他软件
图片、PDF、Word、Excel、PowerPoint、HWP、电子书、压缩包、字体、CAD、3D 模型和地理信息数据,无需 Office、韩文办公软件或 LibreOffice。 @@ -161,7 +161,7 @@ Set-ProcessMitigation -Name filee-cli.exe -Disable UserShadowStack | **字幕** SRT、ASS/SSA、WebVTT、SAMI(SMI)、LRC、TTML、MicroDVD、SCC | 互相转换,保存为 UTF-8(也能读取 CP949 韩文字幕) | | **压缩包** ZIP、ANSYS Workbench(WBPZ)、7Z、RAR、TAR、TAR.GZ、TAR.BZ2、TAR.XZ、GZ、ISO、CAB、DMG、ALZ、EGG 等 | 解压、ZIP、7Z、TAR、TAR.GZ,
多个文件 → 一个 ZIP | | **CAD** DWG、DXF | PDF、SVG、PNG、DWG ↔ DXF | -| **字体** TTF、OTF、WOFF、WOFF2、EOT | 互相转换 | +| **字体**:TTF、OTF、WOFF、WOFF2、EOT;
FON(Windows 位图字体)仅读取 | 相互转换;
FON → 每个尺寸一个文件 | | **数据** JSON、JSON Lines、YAML、TOML、XML
vCard、iCalendar | 互相转换、CSV 和 XLSX(表格数据)、
vCard、iCalendar ↔ CSV | | **3D 模型** GLB、glTF、STL、OBJ(+ MTL)、3MF、PLY,
FBX 仅读取 | 互相转换、PNG 预览 | | **地理信息(矢量)** Shapefile、GeoJSON、KML、KMZ、GPX
带坐标的 CSV,
Garmin FIT 仅读取 | 互相转换、CSV / XLSX / JSON,
投影坐标系(UTM、Web 墨卡托、兰伯特、阿尔伯斯)会转换为 WGS 84 经纬度 | diff --git a/README.zh-TW.md b/README.zh-TW.md index df83ecada..bda85f7d4 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -56,7 +56,7 @@ 只顯示適合當前拖曳檔案的格式,就在你所在的位置。 - 265 種格式,無需其他軟體
+ 266 種格式,無需其他軟體
圖片、PDF、Word、Excel、PowerPoint、HWP、電子書、壓縮檔、字型、CAD、3D 模型和地理資訊資料,無需 Office、韓文文書軟體或 LibreOffice。 @@ -161,7 +161,7 @@ Set-ProcessMitigation -Name filee-cli.exe -Disable UserShadowStack | **字幕** SRT、ASS/SSA、WebVTT、SAMI(SMI)、LRC、TTML、MicroDVD、SCC | 互相轉換,儲存為 UTF-8(也能讀取 CP949 韓文字幕) | | **壓縮檔** ZIP、ANSYS Workbench(WBPZ)、7Z、RAR、TAR、TAR.GZ、TAR.BZ2、TAR.XZ、GZ、ISO、CAB、DMG、ALZ、EGG 等 | 解壓縮、ZIP、7Z、TAR、TAR.GZ,
多個檔案 → 一個 ZIP | | **CAD** DWG、DXF | PDF、SVG、PNG、DWG ↔ DXF | -| **字型** TTF、OTF、WOFF、WOFF2、EOT | 互相轉換 | +| **字型**:TTF、OTF、WOFF、WOFF2、EOT;
FON(Windows 點陣字型)僅讀取 | 相互轉換;
FON → 每個尺寸一個檔案 | | **資料** JSON、JSON Lines、YAML、TOML、XML
vCard、iCalendar | 互相轉換、CSV 和 XLSX(表格資料)、
vCard、iCalendar ↔ CSV | | **3D 模型** GLB、glTF、STL、OBJ(+ MTL)、3MF、PLY,
FBX 僅讀取 | 互相轉換、PNG 預覽 | | **地理資訊(向量)** Shapefile、GeoJSON、KML、KMZ、GPX
帶座標的 CSV,
Garmin FIT 僅讀取 | 互相轉換、CSV / XLSX / JSON,
投影座標系統(UTM、Web 麥卡托、蘭伯特、阿爾伯斯)會轉換為 WGS 84 經緯度 | diff --git a/docs/ENGINES.md b/docs/ENGINES.md index 3b233a9f3..c2049f34a 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -15,7 +15,7 @@ Filee chooses engines automatically. Settings → *Engines* shows their status, | Text & Markdown (Markdig) | built in (library) | **Markdown → HTML, TXT**; **TXT, LOG, PY, PAC, BAT, COMMAND, LUA, CSS, PS1 → UTF-8 TXT**, preserving source text without running scripts (and → PDF / DOCX through the HWPX writer, rhwp or LibreOffice) | | Spreadsheets (ExcelDataReader for XLS) | built in (library) | **XLSX, XLS, ODS, CSV, TSV → XLSX, ODS, CSV, TSV** (one CSV / TSV per sheet) | | Office Open XML | built in | **DOCM / DOTX / DOTM ↔ DOCX, XLSM / XLTX ↔ XLSX, PPTM / POTX / POTM / PPSX ↔ PPTX** (macros removed for macro-free types) | -| Fonts | built in | **TTF, OTF, WOFF, WOFF2, EOT ↔ each other**; CFF (PostScript) outlines become TrueType for TTF and EOT | +| Fonts | built in | **TTF, OTF, WOFF, WOFF2, EOT ↔ each other**; **FON (NE monochrome FNT v2/v3, supported single-byte charsets) → TTF, OTF, WOFF, WOFF2, EOT only**; CFF (PostScript) outlines become TrueType for TTF and EOT | | **HWPX writer** | built in | **DOCX (+ DOCM/DOTX/DOTM), XLSX (+ XLSM/XLTX), XLS, ODS, CSV, TSV, PPTX (+ PPTM/POTX/POTM/PPSX), PDF, OFD, HTML, MHTML, EPUB, MOBI/AZW3, FB2, HWPX, TXT, Markdown → HWPX** (and with rhwp → PDF and images); ODT, RTF, reStructuredText, LaTeX → HWPX with Pandoc | | **DOCX writer** | built in | **HWPX (and HWP through rhwp), PDF, OFD, HTML, MHTML, EPUB, MOBI/AZW3, FB2, Markdown, TXT, XLSX, XLS, ODS, CSV, PPTX (+ variants) → DOCX** | | PDF text | built in (PdfPig) | **PDF → TXT, Markdown** (reading order, also two columns; Markdown keeps headings, emphasis, links and the pictures; no OCR) | @@ -461,7 +461,8 @@ built-in XLSX route reads the workbook into Filee's document model. ## Fonts Fonts are converted in-process (`Filee.Engines/Fonts`); zlib and Brotli come with .NET. The input format is -recognised by content, so a mislabelled file still converts. +recognised by content, so a mislabelled file still converts. FON is the exception: it is chosen by its `.fon` +extension, and the file must then have an NE header with FNT resources. - **WOFF 1.0**: every table zlib-compressed (stored as-is when that isn't smaller). Lossless both ways. - **WOFF2**: one Brotli stream at the highest quality with the glyf/loca transform and, where it applies, the hmtx @@ -481,12 +482,28 @@ recognised by content, so a mislabelled file still converts. CID fonts) are built; head, hhea, hmtx and, from VORG, vmtx are updated; CFF, VORG and DSIG are dropped; every other table (cmap, name, OS/2, GSUB, GPOS, kern, ...) is kept. PostScript hints are not converted: the font gets a gasp table (smoothing at every size) and a prep that switches on dropout control instead. +- **FON → TTF, OTF, WOFF, WOFF2, EOT** (input only): Filee reads the monochrome FNT v2/v3 bitmap resources of an NE + executable and turns each lit pixel into a straight, grid-aligned TrueType outline. The result retains the + original pixel shape, per-glyph advance, ascent and baseline; there is no smoothing or curve tracing. A FON with a + single font resource produces `Name.ttf` (or the corresponding OTF/WOFF/WOFF2/EOT extension). Several resources + produce one file per strike, such as `Name-13px.ttf`, `Name-13px-Bold.ttf`, `Name-13px-2.ttf` and + `Name-14px.ttf`; the font family names distinguish heights, group different styles at the same height, and number + only repeated styles at the same height. Existing output files follow the usual Skip, Rename and Overwrite + policies independently for each strike. Invalid resources fail the whole input before output files are allocated. + Character mapping uses the Windows single-byte code page indicated by the resource charset, not the computer's + locale. Undefined bytes (including those that Windows decodes to Private Use code points in the Greek, Hebrew, + Baltic and Thai code pages), non-round-tripping bytes and Unicode control characters retain their glyphs and + spacing but receive no text mapping; charset hints cannot recover the meaning of custom glyphs. PE resources, vector or + externally stored bitmaps, color FNT, standalone `.fnt` files and double-byte or unrecognized charsets are not + supported. FON cannot be an output format. - Not supported: CFF2 (variable) outlines to TTF or EOT (to WOFF, WOFF2 and OTF they convert), writing font collections, and WOFF/WOFF2 extended metadata (dropped). `tests/Filee.Engines.Tests/FontTests.cs` checks every conversion with small OFL fonts in `tests/Filee.Engines.Tests/Fonts` (see `OFL.txt` there): round trips keep every table byte for byte, a WOFF2 made by Google's encoder decodes to the source font, and converted CFF glyphs are compared with the originals in Skia. +`tests/Filee.Engines.Tests/FonTests.cs` builds synthetic FON samples in code (`FonBuilder`) and compares every +rendered glyph with its source bitmap. ## DOCX writer diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index e6a4f8725..3eacdea4a 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -122,6 +122,10 @@ "error.fit_not_activity": "ملف FIT هذا ليس نشاطًا (يتم تحويل الأنشطة فقط)", "error.fit_no_positions": "لا يحتوي النشاط على مواقع مسجلة: حوّله إلى CSV أو XLSX أو JSON", "error.fit_no_records": "لا يحتوي النشاط على بيانات مسجلة", + "error.fon_vector": "هذا خط متجهي؛ يمكن تحويل خطوط ‎.fon النقطية فقط", + "error.fon_unsupported": "هذا النوع من خطوط ‎.fon غير مدعوم", + "error.fon_too_large": "خط ‎.fon كبير جدًا بحيث لا يمكن تحويله", + "error.fon_damaged": "ملف ‎.fon تالف أو غير مكتمل", "error.m4s_needs_init": "ملف ‎.m4s هذا جزء من فيديو متدفّق، ولا يمكن قراءته دون مقطع البداية (init) المنفصل الخاص به", "notice.pdf_not_smaller": "تعذّر تقليل الحجم، لذا نُسخ الملف كما هو", "notice.cad_newer_left_out": "حُذفت كائنات لا يدعمها إصدار AutoCAD المختار (أسهم توضيحية متعددة، شبكات …)", @@ -182,7 +186,7 @@ "engine.icns.name": "ICNS (built-in)", "engine.icns.description": "تحويل أيقونات تطبيقات macOS ‏(ICNS) من الصور وإليها.", "engine.font.name": "Fonts (built-in)", - "engine.font.description": "التحويل بين الخطوط: TTF وOTF وWOFF وWOFF2 وEOT.", + "engine.font.description": "تحويل TTF وOTF وWOFF وWOFF2 وEOT فيما بينها، وتحويل خطوط Windows النقطية (FON) إلى هذه الصيغ أيضًا.", "engine.cad.name": "CAD (built-in)", "engine.cad.description": "رسومات AutoCAD: التحويل بين DWG وDXF، وتحويل كليهما إلى PDF وSVG وPNG.", "engine.model3d.name": "3D models (built-in)", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index 3f98a8616..022a6164c 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -122,6 +122,10 @@ "error.fit_not_activity": "Diese FIT-Datei ist keine Aktivität (nur Aktivitäten werden konvertiert)", "error.fit_no_positions": "Die Aktivität enthält keine aufgezeichneten Positionen. Konvertiere sie in CSV, XLSX oder JSON", "error.fit_no_records": "Die Aktivität enthält keine aufgezeichneten Daten", + "error.fon_vector": "Dies ist eine Vektorschrift; nur Bitmap-Schriften im .fon-Format können konvertiert werden", + "error.fon_unsupported": "Diese Art von .fon-Schrift wird nicht unterstützt", + "error.fon_too_large": "Die .fon-Schrift ist zu groß zum Konvertieren", + "error.fon_damaged": "Die .fon-Datei ist beschädigt oder unvollständig", "error.m4s_needs_init": "Diese .m4s-Datei ist ein Stück eines gestreamten Videos und lässt sich ohne ihr separates Startsegment (init) nicht lesen", "notice.pdf_not_smaller": "Die Datei ließ sich nicht verkleinern und wurde unverändert kopiert", "notice.cad_newer_left_out": "Objekte, die die gewählte AutoCAD-Version nicht unterstützt (Multiführungslinien, Netze …), wurden weggelassen", @@ -182,7 +186,7 @@ "engine.icns.name": "ICNS (integriert)", "engine.icns.description": "macOS-App-Symbole (ICNS) aus Bildern und in Bilder umwandeln.", "engine.font.name": "Schriftarten (integriert)", - "engine.font.description": "Schriftarten: Konvertiert TTF, OTF, WOFF, WOFF2 und EOT untereinander.", + "engine.font.description": "TTF, OTF, WOFF, WOFF2 und EOT untereinander konvertieren; Windows-Bitmap-Schriftarten (FON) in diese Formate umwandeln.", "engine.cad.name": "CAD (integriert)", "engine.cad.description": "AutoCAD-Zeichnungen: DWG ↔ DXF und beide in PDF, SVG und PNG.", "engine.model3d.name": "3D-Modelle (integriert)", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index e2a8a88a9..6ece06382 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -128,6 +128,10 @@ "error.fit_not_activity": "This FIT file isn't an activity (only activities are converted)", "error.fit_no_positions": "The activity has no recorded positions: convert it to CSV, XLSX or JSON", "error.fit_no_records": "The activity has no recorded data", + "error.fon_vector": "This is a vector font; only bitmap .fon fonts can be converted", + "error.fon_unsupported": "This kind of .fon font isn't supported", + "error.fon_too_large": "The .fon font is too large to convert", + "error.fon_damaged": "The .fon file is damaged or cut short", "error.m4s_needs_init": "This .m4s is one piece of a streamed video: it can't be read without its separate start (init) segment", "notice.pdf_not_smaller": "Couldn't make it smaller, so it was copied as it is", "notice.cad_newer_left_out": "Objects the chosen AutoCAD version doesn't have (multileaders, meshes …) were left out", @@ -189,7 +193,7 @@ "engine.icns.name": "ICNS (built-in)", "engine.icns.description": "macOS app icons (ICNS) from and to images.", "engine.font.name": "Fonts (built-in)", - "engine.font.description": "Fonts: TTF, OTF, WOFF, WOFF2 and EOT into each other.", + "engine.font.description": "Fonts: TTF, OTF, WOFF, WOFF2 and EOT into each other; Windows bitmap fonts (FON) into them.", "engine.cad.name": "CAD (built-in)", "engine.cad.description": "AutoCAD drawings: DWG ↔ DXF, and both to PDF, SVG and PNG.", "engine.model3d.name": "3D models (built-in)", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index 1fc63a675..8d34dfcfe 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -122,6 +122,10 @@ "error.fit_not_activity": "Este archivo FIT no es una actividad (solo se convierten actividades)", "error.fit_no_positions": "La actividad no tiene posiciones registradas: conviértela a CSV, XLSX o JSON", "error.fit_no_records": "La actividad no tiene datos registrados", + "error.fon_vector": "Esta es una fuente vectorial; solo se pueden convertir fuentes de mapa de bits .fon", + "error.fon_unsupported": "Este tipo de fuente .fon no es compatible", + "error.fon_too_large": "La fuente .fon es demasiado grande para convertirla", + "error.fon_damaged": "El archivo .fon está dañado o incompleto", "error.m4s_needs_init": "Este .m4s es un fragmento de un vídeo en streaming y no se puede leer sin su segmento de inicio (init) aparte", "notice.pdf_not_smaller": "No se pudo reducir su tamaño, por lo que se copió tal cual", "notice.cad_newer_left_out": "Se omitieron objetos que la versión de AutoCAD elegida no tiene (directrices múltiples, mallas…)", @@ -182,7 +186,7 @@ "engine.icns.name": "ICNS (built-in)", "engine.icns.description": "Iconos de apps de macOS (ICNS) desde y hacia imágenes.", "engine.font.name": "Fonts (built-in)", - "engine.font.description": "Tipografías: TTF, OTF, WOFF, WOFF2 y EOT entre sí.", + "engine.font.description": "Convierte TTF, OTF, WOFF, WOFF2 y EOT entre sí; convierte fuentes de mapa de bits de Windows (FON) a estos formatos.", "engine.cad.name": "CAD (built-in)", "engine.cad.description": "Planos de AutoCAD: DWG ↔ DXF, y ambos a PDF, SVG y PNG.", "engine.model3d.name": "3D models (built-in)", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index e8f6fb2a5..9d89e5bf1 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -128,6 +128,10 @@ "error.fit_not_activity": "Ce fichier FIT n'est pas une activité (seules les activités sont converties)", "error.fit_no_positions": "L'activité ne contient aucune position enregistrée : convertissez-la en CSV, XLSX ou JSON", "error.fit_no_records": "L'activité ne contient aucune donnée enregistrée", + "error.fon_vector": "Cette police est vectorielle ; seules les polices bitmap .fon peuvent être converties", + "error.fon_unsupported": "Ce type de police .fon n'est pas pris en charge", + "error.fon_too_large": "La police .fon est trop grande pour être convertie", + "error.fon_damaged": "Le fichier .fon est endommagé ou incomplet", "error.m4s_needs_init": "Ce .m4s est un morceau d'une vidéo diffusée en streaming : il ne peut pas être lu sans son segment de départ (init) séparé", "notice.pdf_not_smaller": "Impossible de réduire sa taille : le fichier a été copié tel quel", "notice.cad_newer_left_out": "Des objets que la version d'AutoCAD choisie n'a pas (multilignes de repère, maillages…) ont été omis", @@ -189,7 +193,7 @@ "engine.icns.name": "ICNS (built-in)", "engine.icns.description": "Icônes d'apps macOS (ICNS) depuis et vers des images.", "engine.font.name": "Fonts (built-in)", - "engine.font.description": "Polices : TTF, OTF, WOFF, WOFF2 et EOT entre eux.", + "engine.font.description": "Convertit TTF, OTF, WOFF, WOFF2 et EOT entre eux ; convertit les polices bitmap Windows (FON) vers ces formats.", "engine.cad.name": "CAD (built-in)", "engine.cad.description": "Dessins AutoCAD : DWG ↔ DXF, et les deux en PDF, SVG et PNG.", "engine.model3d.name": "3D models (built-in)", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index 51c97fa5f..431d514ae 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -122,6 +122,10 @@ "error.fit_not_activity": "यह FIT फ़ाइल गतिविधि नहीं है (सिर्फ़ गतिविधियाँ कन्वर्ट होती हैं)", "error.fit_no_positions": "गतिविधि में दर्ज स्थान नहीं हैं: इसे CSV, XLSX या JSON में बदलें", "error.fit_no_records": "गतिविधि में दर्ज डेटा नहीं है", + "error.fon_vector": "यह वेक्टर फ़ॉन्ट है; केवल बिटमैप .fon फ़ॉन्ट बदले जा सकते हैं", + "error.fon_unsupported": "इस तरह का .fon फ़ॉन्ट समर्थित नहीं है", + "error.fon_too_large": ".fon फ़ॉन्ट बदलने के लिए बहुत बड़ा है", + "error.fon_damaged": ".fon फ़ाइल खराब है या अधूरी है", "error.m4s_needs_init": "यह .m4s स्ट्रीम किए गए वीडियो का एक हिस्सा है: इसके अलग शुरुआती (init) सेगमेंट के बिना इसे पढ़ा नहीं जा सकता", "notice.pdf_not_smaller": "इसे छोटा नहीं किया जा सका, इसलिए जैसा है वैसा ही कॉपी कर दिया गया", "notice.cad_newer_left_out": "चुने गए AutoCAD संस्करण में न होने वाले ऑब्जेक्ट (मल्टीलीडर, मेश …) छोड़ दिए गए", @@ -182,7 +186,7 @@ "engine.icns.name": "ICNS (built-in)", "engine.icns.description": "इमेज से और इमेज में macOS ऐप आइकन (ICNS)।", "engine.font.name": "Fonts (built-in)", - "engine.font.description": "फ़ॉन्ट: TTF, OTF, WOFF, WOFF2 और EOT एक-दूसरे में।", + "engine.font.description": "TTF, OTF, WOFF, WOFF2 और EOT को आपस में बदलें; Windows बिटमैप फ़ॉन्ट (FON) को भी इन फ़ॉर्मैट में बदलें।", "engine.cad.name": "CAD (built-in)", "engine.cad.description": "AutoCAD ड्रॉइंग: DWG ↔ DXF, और दोनों से PDF, SVG और PNG।", "engine.model3d.name": "3D models (built-in)", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 7b0204732..a34116596 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -122,6 +122,10 @@ "error.fit_not_activity": "このFITファイルはアクティビティではありません(アクティビティのみ変換できます)", "error.fit_no_positions": "アクティビティに位置情報が記録されていません。CSV、XLSX、JSONに変換してください", "error.fit_no_records": "アクティビティに記録データがありません", + "error.fon_vector": "これはベクターフォントです。変換できるのはビットマップの.fonフォントだけです", + "error.fon_unsupported": "この種類の.fonフォントには対応していません", + "error.fon_too_large": ".fonフォントが大きすぎて変換できません", + "error.fon_damaged": ".fonファイルが破損しているか、途中で切れています", "error.m4s_needs_init": "この .m4s はストリーミング動画の一部で、別にある開始(init)セグメントがないと読み込めません", "notice.pdf_not_smaller": "サイズを小さくできなかったため、元のままコピーしました", "notice.cad_newer_left_out": "選んだ AutoCAD のバージョンにないオブジェクト(マルチ引出線、メッシュ …)を省きました", @@ -182,7 +186,7 @@ "engine.icns.name": "ICNS (built-in)", "engine.icns.description": "macOSアプリのアイコン(ICNS)を画像と相互変換します。", "engine.font.name": "Fonts (built-in)", - "engine.font.description": "フォント形式(TTF、OTF、WOFF、WOFF2、EOT)を相互変換します。", + "engine.font.description": "TTF、OTF、WOFF、WOFF2、EOTを相互変換し、Windowsビットマップフォント(FON)もこれらの形式に変換します。", "engine.cad.name": "CAD (built-in)", "engine.cad.description": "AutoCAD図面に対応します。DWGとDXFの相互変換、および両形式からPDF、SVG、PNGへの変換ができます。", "engine.model3d.name": "3D models (built-in)", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index abee550ec..46ccf6cc3 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -128,6 +128,10 @@ "error.fit_not_activity": "활동 기록이 아닌 FIT 파일이에요 (활동만 변환해요)", "error.fit_no_positions": "위치 기록이 없는 활동이에요. CSV, XLSX, JSON으로 변환하세요", "error.fit_no_records": "활동에 기록된 데이터가 없어요", + "error.fon_vector": "벡터 글꼴이에요. 비트맵 .fon 글꼴만 변환할 수 있어요", + "error.fon_unsupported": "지원하지 않는 종류의 .fon 글꼴이에요", + "error.fon_too_large": ".fon 글꼴이 너무 커서 변환할 수 없어요", + "error.fon_damaged": ".fon 파일이 손상되었거나 잘렸어요", "error.m4s_needs_init": "스트리밍 동영상의 한 조각인 .m4s 파일이에요. 따로 있는 시작(init) 세그먼트 없이는 읽을 수 없어요", "notice.pdf_not_smaller": "더 작게 만들 수 없어서 그대로 복사했어요", "notice.cad_newer_left_out": "선택한 AutoCAD 버전에 없는 개체(다중 지시선, 메시 …)는 빼고 저장했어요", @@ -189,7 +193,7 @@ "engine.icns.name": "ICNS (built-in)", "engine.icns.description": "macOS 앱 아이콘(ICNS)을 이미지로, 이미지를 ICNS로.", "engine.font.name": "Fonts (built-in)", - "engine.font.description": "글꼴: TTF, OTF, WOFF, WOFF2, EOT를 서로 변환해요.", + "engine.font.description": "TTF, OTF, WOFF, WOFF2, EOT를 서로 변환하고, Windows 비트맵 글꼴(FON)도 이 형식으로 바꿔요.", "engine.cad.name": "CAD (built-in)", "engine.cad.description": "오토캐드 도면: DWG ↔ DXF, 그리고 둘 다 PDF, SVG, PNG로.", "engine.model3d.name": "3D models (built-in)", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index 132a4c135..a891cba32 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -122,6 +122,10 @@ "error.fit_not_activity": "यो FIT फाइल गतिविधि होइन (गतिविधिहरू मात्र रूपान्तरण हुन्छन्)", "error.fit_no_positions": "गतिविधिमा रेकर्ड गरिएका स्थान छैनन्: यसलाई CSV, XLSX वा JSON मा रूपान्तरण गर्नुहोस्", "error.fit_no_records": "गतिविधिमा रेकर्ड गरिएको डाटा छैन", + "error.fon_vector": "यो भेक्टर फन्ट हो; बिटम्याप .fon फन्टहरू मात्र रूपान्तरण गर्न सकिन्छ", + "error.fon_unsupported": "यस प्रकारको .fon फन्ट समर्थित छैन", + "error.fon_too_large": ".fon फन्ट रूपान्तरण गर्न धेरै ठूलो छ", + "error.fon_damaged": ".fon फाइल बिग्रिएको वा अपूर्ण छ", "error.m4s_needs_init": "यो .m4s स्ट्रिम गरिएको भिडियोको एउटा टुक्रा हो: छुट्टै सुरुवाती (init) खण्डबिना यसलाई पढ्न सकिँदैन", "notice.pdf_not_smaller": "यसलाई सानो बनाउन सकिएन, त्यसैले जस्तो छ त्यस्तै प्रतिलिपि गरियो", "notice.cad_newer_left_out": "छानिएको AutoCAD संस्करणमा नभएका वस्तुहरू (मल्टिलिडर, मेस …) छोडिए", @@ -182,7 +186,7 @@ "engine.icns.name": "ICNS (built-in)", "engine.icns.description": "छविहरूबाट र छविहरूमा macOS एप आइकन (ICNS)।", "engine.font.name": "Fonts (built-in)", - "engine.font.description": "TTF, OTF, WOFF, WOFF2 फन्टहरू एकअर्कामा रूपान्तरण गर्दछ।", + "engine.font.description": "TTF, OTF, WOFF, WOFF2 र EOT बीच रूपान्तरण; Windows बिटम्याप फन्ट (FON) लाई पनि यी ढाँचामा रूपान्तरण।", "engine.cad.name": "CAD (built-in)", "engine.cad.description": "AutoCAD रेखाचित्रहरू: DWG ↔ DXF, र दुवैबाट PDF, SVG र PNG।", "engine.model3d.name": "3D models (built-in)", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 251695169..e350005d2 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -128,6 +128,10 @@ "error.fit_not_activity": "Ten plik FIT nie jest aktywnością (konwertowane są tylko aktywności)", "error.fit_no_positions": "Aktywność nie zawiera zapisanych położeń: przekonwertuj ją do CSV, XLSX lub JSON", "error.fit_no_records": "Aktywność nie zawiera zapisanych danych", + "error.fon_vector": "To czcionka wektorowa; można konwertować tylko bitmapowe czcionki .fon", + "error.fon_unsupported": "Ten rodzaj czcionki .fon nie jest obsługiwany", + "error.fon_too_large": "Czcionka .fon jest zbyt duża, aby ją przekonwertować", + "error.fon_damaged": "Plik .fon jest uszkodzony lub niekompletny", "error.m4s_needs_init": "Ten plik .m4s to fragment wideo strumieniowego i nie da się go odczytać bez osobnego segmentu początkowego (init)", "notice.pdf_not_smaller": "Nie udało się zmniejszyć pliku, więc skopiowano go bez zmian", "notice.cad_newer_left_out": "Pominięto obiekty, których wybrana wersja AutoCAD nie ma (multiodnośniki, siatki…)", @@ -189,7 +193,7 @@ "engine.icns.name": "ICNS (built-in)", "engine.icns.description": "Ikony aplikacji macOS (ICNS) z obrazów i do obrazów.", "engine.font.name": "Fonts (built-in)", - "engine.font.description": "Czcionki: TTF, OTF, WOFF, WOFF2 i EOT między sobą.", + "engine.font.description": "Konwertuje między TTF, OTF, WOFF, WOFF2 i EOT; konwertuje bitmapowe czcionki Windows (FON) do tych formatów.", "engine.cad.name": "CAD (built-in)", "engine.cad.description": "Rysunki AutoCAD: DWG ↔ DXF, oba także do PDF, SVG i PNG.", "engine.model3d.name": "3D models (built-in)", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index a59792d2e..c5a198fff 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -128,6 +128,10 @@ "error.fit_not_activity": "Este arquivo FIT não é uma atividade (só atividades são convertidas)", "error.fit_no_positions": "A atividade não tem posições registradas: converta-a para CSV, XLSX ou JSON", "error.fit_no_records": "A atividade não tem dados registrados", + "error.fon_vector": "Esta é uma fonte vetorial; só é possível converter fontes bitmap .fon", + "error.fon_unsupported": "Este tipo de fonte .fon não é compatível", + "error.fon_too_large": "A fonte .fon é grande demais para converter", + "error.fon_damaged": "O arquivo .fon está danificado ou incompleto", "error.m4s_needs_init": "Este .m4s é um pedaço de um vídeo transmitido por streaming e não pode ser lido sem o seu segmento inicial (init) separado", "notice.pdf_not_smaller": "Não deu para deixar menor, então foi copiado como está", "notice.cad_newer_left_out": "Objetos que a versão do AutoCAD escolhida não tem (multilinhas de chamada, malhas…) ficaram de fora", @@ -189,7 +193,7 @@ "engine.icns.name": "ICNS (mecanismo integrado)", "engine.icns.description": "Ícones de apps do macOS (ICNS) a partir de imagens e para imagens.", "engine.font.name": "Fontes (mecanismo integrado)", - "engine.font.description": "Fontes: TTF, OTF, WOFF, WOFF2 e EOT entre si.", + "engine.font.description": "Converte TTF, OTF, WOFF, WOFF2 e EOT entre si; converte fontes bitmap do Windows (FON) para esses formatos.", "engine.cad.name": "CAD (mecanismo integrado)", "engine.cad.description": "Desenhos do AutoCAD: DWG ↔ DXF, e os dois para PDF, SVG e PNG.", "engine.model3d.name": "Modelos 3D (mecanismo integrado)", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index c9c14c315..9752ee852 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -128,6 +128,10 @@ "error.fit_not_activity": "Este ficheiro FIT não é uma atividade (só são convertidas atividades)", "error.fit_no_positions": "A atividade não tem posições registadas: converte-a para CSV, XLSX ou JSON", "error.fit_no_records": "A atividade não tem dados registados", + "error.fon_vector": "Esta é uma fonte vetorial; só é possível converter fontes bitmap .fon", + "error.fon_unsupported": "Este tipo de fonte .fon não é suportado", + "error.fon_too_large": "A fonte .fon é demasiado grande para converter", + "error.fon_damaged": "O ficheiro .fon está danificado ou incompleto", "error.m4s_needs_init": "Este .m4s é um pedaço de um vídeo em streaming e não pode ser lido sem o respetivo segmento inicial (init) separado", "notice.pdf_not_smaller": "Não foi possível reduzir o tamanho, pelo que o ficheiro foi copiado sem alterações", "notice.cad_newer_left_out": "Os objetos que a versão de AutoCAD escolhida não suporta (linhas de chamada múltiplas, malhas…) foram omitidos", @@ -189,7 +193,7 @@ "engine.icns.name": "ICNS (motor integrado)", "engine.icns.description": "Ícones de aplicações macOS (ICNS) para imagens e vice-versa.", "engine.font.name": "Tipos de letra (motor integrado)", - "engine.font.description": "Converte tipos de letra entre TTF, OTF, WOFF, WOFF2 e EOT.", + "engine.font.description": "Converte tipos de letra entre TTF, OTF, WOFF, WOFF2 e EOT; converte tipos de letra bitmap do Windows (FON) para estes formatos.", "engine.cad.name": "CAD (motor integrado)", "engine.cad.description": "Desenhos AutoCAD: DWG ↔ DXF e ambos para PDF, SVG e PNG.", "engine.model3d.name": "Modelos 3D (motor integrado)", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 6f980e200..8f9feddc7 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -128,6 +128,10 @@ "error.fit_not_activity": "Этот файл FIT не является активностью (конвертируются только активности)", "error.fit_no_positions": "В активности нет записанных координат: конвертируйте её в CSV, XLSX или JSON", "error.fit_no_records": "В активности нет записанных данных", + "error.fon_vector": "Это векторный шрифт; можно преобразовывать только растровые шрифты .fon", + "error.fon_unsupported": "Этот тип шрифта .fon не поддерживается", + "error.fon_too_large": "Шрифт .fon слишком велик для преобразования", + "error.fon_damaged": "Файл .fon повреждён или обрезан", "error.m4s_needs_init": "Этот .m4s — фрагмент потокового видео: без отдельного начального сегмента (init) его не прочитать", "notice.pdf_not_smaller": "Уменьшить не получилось, файл скопирован как есть", "notice.cad_newer_left_out": "Объекты, которых нет в выбранной версии AutoCAD (мультивыноски, сети…), не сохранены", @@ -189,7 +193,7 @@ "engine.icns.name": "ICNS (built-in)", "engine.icns.description": "Значки приложений macOS (ICNS) из картинок и в картинки.", "engine.font.name": "Fonts (built-in)", - "engine.font.description": "Шрифты: TTF, OTF, WOFF, WOFF2 и EOT друг в друга.", + "engine.font.description": "Преобразует TTF, OTF, WOFF, WOFF2 и EOT друг в друга; преобразует растровые шрифты Windows (FON) в эти форматы.", "engine.cad.name": "CAD (built-in)", "engine.cad.description": "Чертежи AutoCAD: DWG ↔ DXF, и оба — в PDF, SVG и PNG.", "engine.model3d.name": "3D models (built-in)", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index d6862252e..c186c2b0c 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -122,6 +122,10 @@ "error.fit_not_activity": "ไฟล์ FIT นี้ไม่ใช่กิจกรรม (แปลงได้เฉพาะกิจกรรมเท่านั้น)", "error.fit_no_positions": "กิจกรรมนี้ไม่มีตำแหน่งที่บันทึกไว้ โปรดแปลงเป็น CSV, XLSX หรือ JSON", "error.fit_no_records": "กิจกรรมนี้ไม่มีข้อมูลที่บันทึกไว้", + "error.fon_vector": "นี่คือฟอนต์เวกเตอร์ แปลงได้เฉพาะฟอนต์บิตแมป .fon", + "error.fon_unsupported": "ไม่รองรับฟอนต์ .fon ประเภทนี้", + "error.fon_too_large": "ฟอนต์ .fon มีขนาดใหญ่เกินกว่าจะแปลงได้", + "error.fon_damaged": "ไฟล์ .fon เสียหายหรือไม่สมบูรณ์", "error.m4s_needs_init": "ไฟล์ .m4s นี้เป็นชิ้นส่วนหนึ่งของวิดีโอสตรีมมิง และอ่านไม่ได้หากไม่มีเซกเมนต์เริ่มต้น (init) ที่แยกไว้", "notice.pdf_not_smaller": "ไม่สามารถบีบอัดให้เล็กลงได้ จึงคัดลอกไฟล์ตามเดิม", "notice.cad_newer_left_out": "ละวัตถุที่ AutoCAD เวอร์ชันที่เลือกไม่มี (มัลติลีดเดอร์ เมช …) ไว้", @@ -182,7 +186,7 @@ "engine.icns.name": "ICNS (built-in)", "engine.icns.description": "ไอคอนแอป macOS (ICNS) จากและเป็นรูปภาพ", "engine.font.name": "Fonts (built-in)", - "engine.font.description": "ฟอนต์: TTF, OTF, WOFF, WOFF2 และ EOT สลับกันได้", + "engine.font.description": "แปลง TTF, OTF, WOFF, WOFF2 และ EOT ระหว่างกัน; แปลงฟอนต์บิตแมปของ Windows (FON) เป็นรูปแบบเหล่านี้ด้วย", "engine.cad.name": "CAD (built-in)", "engine.cad.description": "แบบแปลน AutoCAD: DWG ↔ DXF และแปลงทั้งสองเป็น PDF, SVG และ PNG", "engine.model3d.name": "3D models (built-in)", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index 990080cde..5d85ce3fd 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -122,6 +122,10 @@ "error.fit_not_activity": "Bu FIT dosyası bir etkinlik değil (yalnızca etkinlikler dönüştürülür)", "error.fit_no_positions": "Etkinlikte kaydedilmiş konum yok: CSV, XLSX veya JSON'a dönüştürün", "error.fit_no_records": "Etkinlikte kaydedilmiş veri yok", + "error.fon_vector": "Bu bir vektör yazı tipidir; yalnızca bitmap .fon yazı tipleri dönüştürülebilir", + "error.fon_unsupported": "Bu tür .fon yazı tipi desteklenmiyor", + "error.fon_too_large": ".fon yazı tipi dönüştürmek için çok büyük", + "error.fon_damaged": ".fon dosyası hasarlı veya eksik", "error.m4s_needs_init": "Bu .m4s, akışla gelen bir videonun bir parçası: ayrı başlangıç (init) segmenti olmadan okunamaz", "notice.pdf_not_smaller": "Küçültülemediği için olduğu gibi kopyalandı", "notice.cad_newer_left_out": "Seçilen AutoCAD sürümünün desteklemediği nesneler (çoklu kılavuzlar, ağlar…) çıkarıldı", @@ -182,7 +186,7 @@ "engine.icns.name": "ICNS (built-in)", "engine.icns.description": "macOS uygulama simgeleri (ICNS) ile görseller arasında dönüştürme.", "engine.font.name": "Fonts (built-in)", - "engine.font.description": "Yazı tipleri: TTF, OTF, WOFF, WOFF2 ve EOT arasında dönüştürme.", + "engine.font.description": "TTF, OTF, WOFF, WOFF2 ve EOT arasında dönüştürür; Windows bitmap yazı tiplerini (FON) bu biçimlere dönüştürür.", "engine.cad.name": "CAD (built-in)", "engine.cad.description": "AutoCAD çizimleri: DWG ↔ DXF ve her ikisinden PDF, SVG ve PNG.", "engine.model3d.name": "3D models (built-in)", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 7cf22820b..45f8b7d47 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -128,6 +128,10 @@ "error.fit_not_activity": "此 FIT 文件不是活动记录(只能转换活动记录)", "error.fit_no_positions": "活动没有记录位置信息,请转换为 CSV、XLSX 或 JSON", "error.fit_no_records": "活动没有记录数据", + "error.fon_vector": "这是矢量字体,只能转换位图 .fon 字体", + "error.fon_unsupported": "不支持此类 .fon 字体", + "error.fon_too_large": ".fon 字体太大,无法转换", + "error.fon_damaged": ".fon 文件已损坏或不完整", "error.m4s_needs_init": "此 .m4s 是流媒体视频的一个片段,没有单独的起始(init)分段就无法读取", "notice.pdf_not_smaller": "无法再缩小,已原样复制", "notice.cad_newer_left_out": "已省略所选 AutoCAD 版本没有的对象(多重引线、网格……)", @@ -189,7 +193,7 @@ "engine.icns.name": "ICNS (built-in)", "engine.icns.description": "macOS 应用图标(ICNS)与图片互转。", "engine.font.name": "Fonts (built-in)", - "engine.font.description": "字体:TTF、OTF、WOFF、WOFF2 和 EOT 互相转换。", + "engine.font.description": "TTF、OTF、WOFF、WOFF2 和 EOT 相互转换;将 Windows 位图字体(FON)转换为这些格式。", "engine.cad.name": "CAD (built-in)", "engine.cad.description": "AutoCAD 图纸:DWG ↔ DXF,并可转为 PDF、SVG 和 PNG。", "engine.model3d.name": "3D models (built-in)", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index 5ff7cd600..78743df19 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -128,6 +128,10 @@ "error.fit_not_activity": "此 FIT 檔案不是活動記錄(只能轉換活動記錄)", "error.fit_no_positions": "活動沒有記錄位置資訊,請轉換為 CSV、XLSX 或 JSON", "error.fit_no_records": "活動沒有記錄資料", + "error.fon_vector": "這是向量字型,只能轉換點陣 .fon 字型", + "error.fon_unsupported": "不支援此類 .fon 字型", + "error.fon_too_large": ".fon 字型太大,無法轉換", + "error.fon_damaged": ".fon 檔案已損壞或不完整", "error.m4s_needs_init": "此 .m4s 是串流影片的一個片段,沒有另外的起始(init)區段就無法讀取", "notice.pdf_not_smaller": "無法再縮小,已原樣複製", "notice.cad_newer_left_out": "已略過所選 AutoCAD 版本沒有的物件(複線引線、網格……)", @@ -189,7 +193,7 @@ "engine.icns.name": "ICNS (built-in)", "engine.icns.description": "macOS 應用程式圖示(ICNS)與圖片互轉。", "engine.font.name": "Fonts (built-in)", - "engine.font.description": "字型:TTF、OTF、WOFF、WOFF2 和 EOT 互相轉換。", + "engine.font.description": "TTF、OTF、WOFF、WOFF2 和 EOT 相互轉換;將 Windows 點陣字型(FON)轉換為這些格式。", "engine.cad.name": "CAD (built-in)", "engine.cad.description": "AutoCAD 圖紙:DWG ↔ DXF,並可轉為 PDF、SVG 和 PNG。", "engine.model3d.name": "3D models (built-in)", diff --git a/src/Filee.Core/Formats/FormatRegistry.cs b/src/Filee.Core/Formats/FormatRegistry.cs index 41ea7c2db..5ae68803d 100644 --- a/src/Filee.Core/Formats/FormatRegistry.cs +++ b/src/Filee.Core/Formats/FormatRegistry.cs @@ -307,6 +307,7 @@ public static class FormatRegistry new("woff", "WOFF", FormatCategory.Font, ["woff"]), new("woff2", "WOFF2", FormatCategory.Font, ["woff2"]), new("eot", "EOT", FormatCategory.Font, ["eot"]), + new("fon", "FON", FormatCategory.Font, ["fon"], Writable: false), // Subtitles (text) new("srt", "SRT", FormatCategory.Subtitle, ["srt"]), diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs index 10363960f..f9c47a370 100644 --- a/src/Filee.Core/Settings/UserDataStore.cs +++ b/src/Filee.Core/Settings/UserDataStore.cs @@ -883,9 +883,9 @@ public static bool ApplyToLibrary(int from, List presets, ListTurns a validated monochrome FNT strike into an unhinted TrueType outline font. +internal static class FonOutlineBuilder +{ + private readonly record struct Corner(int X, int Y); + private readonly record struct Edge(Corner From, Corner To); + + /// Scales the strike's pixel height into TrueType font units. + internal static int Scale(int height) => height <= 1024 ? 1024 / height : 1; + + public static SfntFont Build(FonStrike strike, int resourceCount = 1, int sameHeightOrdinal = 1) => + Build(strike, CancellationToken.None, resourceCount, sameHeightOrdinal); + + /// + /// Builds a validated strike, observing for each glyph and raster row, + /// and at bounded intervals during edge expansion, grouping, contour walking and point compaction. + /// + public static SfntFont Build(FonStrike strike, CancellationToken cancellationToken, + int resourceCount = 1, int sameHeightOrdinal = 1) + { + if (resourceCount < 1 || sameHeightOrdinal < 1 || strike.Glyphs.Count < 1) + throw new ConversionRefusedException("error.fon_damaged"); + if (strike.Glyphs.Count > 65534) + throw new ConversionRefusedException("error.fon_too_large", "65535"); + var height = strike.PixelHeight; + var scale = Scale(height); + var upem = checked(height * scale); + if (upem is < 16 or > 16384) + throw new ConversionRefusedException("error.fon_too_large", "16384"); + var ascent = Signed(strike.Ascent * scale); + var descent = Signed(-(height - strike.Ascent) * scale); + var lineGap = Signed(strike.ExternalLeading * scale); + + var glyphs = new List { null }; + var widths = new List { 0 }; + var cmap = new SortedDictionary(); + var outlines = new Dictionary<(long Offset, int Width), TrueTypeGlyph?>(); + var encoding = FonReader.CharSetEncoding(strike.CharSet); + foreach (var source in strike.Glyphs) + { + cancellationToken.ThrowIfCancellationRequested(); + var advance = checked(source.Width * scale); + if (advance > ushort.MaxValue) + throw new ConversionRefusedException("error.fon_too_large", "65535"); + var key = (source.BitmapOffset, source.Width); + if (!outlines.TryGetValue(key, out var outline)) + { + outline = Outline(source, strike.Ascent, scale, cancellationToken); + outlines.Add(key, outline); + } + glyphs.Add(outline); + widths.Add(advance); + try + { + var text = encoding.GetString([source.ByteValue]); + if (text.EnumerateRunes().Count() != 1) + continue; + var scalar = Rune.GetRuneAt(text, 0); + // Windows best-fit tables decode undefined bytes of CP1253/1255/1257/874 to Private Use code points. + if (scalar.Value > ushort.MaxValue || + Rune.GetUnicodeCategory(scalar) is UnicodeCategory.Control or UnicodeCategory.PrivateUse || + !encoding.GetBytes(text).AsSpan().SequenceEqual([source.ByteValue])) + continue; + cmap.TryAdd(scalar.Value, glyphs.Count - 1); + } + catch (DecoderFallbackException) { } + catch (EncoderFallbackException) { } + } + glyphs[0] = glyphs[strike.DefaultChar + 1]; + widths[0] = widths[strike.DefaultChar + 1]; + var serialized = new Dictionary(ReferenceEqualityComparer.Instance); + var (glyf, loca, longLoca) = GlyfTable.Build( + [.. glyphs.Select(g => + { + cancellationToken.ThrowIfCancellationRequested(); + if (g is null) + return []; + if (!serialized.TryGetValue(g, out var bytes)) + serialized.Add(g, bytes = g.Serialize()); + return bytes; + })], longFormat: false, alignment: 2); + cancellationToken.ThrowIfCancellationRequested(); + var leftBearings = glyphs.Select(g => g?.XMin ?? (short)0).ToArray(); + var covered = glyphs.Where(g => g is not null).Cast().ToArray(); + var xMin = covered.Length == 0 ? 0 : covered.Min(g => g.XMin); + var xMax = covered.Length == 0 ? 0 : covered.Max(g => g.XMax); + var yMin = covered.Length == 0 ? 0 : covered.Min(g => g.YMin); + var yMax = covered.Length == 0 ? 0 : covered.Max(g => g.YMax); + var minRight = covered.Length == 0 ? 0 : glyphs.Select((g, i) => (g, i)) + .Where(p => p.g is not null).Min(p => widths[p.i] - p.g!.XMax); + var maxPoints = covered.Length == 0 ? 0 : covered.Max(g => g.Points.Length); + var maxContours = covered.Length == 0 ? 0 : covered.Max(g => g.NumberOfContours); + + var weight = strike.Weight; + var bold = weight >= 700; + var baseName = resourceCount == 1 ? strike.Face : + $"{strike.Face} {height}px{(sameHeightOrdinal > 1 ? $"-{sameHeightOrdinal}" : "")}"; + var family = weight is 400 or 700 ? baseName : $"{baseName} Weight{weight}"; + var nameBold = weight == 700; + var style = (nameBold, strike.Italic) switch + { + (true, true) => "Bold Italic", + (true, false) => "Bold", + (false, true) => "Italic", + _ => "Regular" + }; + var full = $"{family} {style}"; + var psName = PostScriptName(full); + var font = new SfntFont(SfntFont.TrueTypeFlavor); + font.Tables["glyf"] = glyf; + font.Tables["loca"] = loca; + font.Tables["hmtx"] = Metrics(widths, leftBearings); + font.Tables["head"] = Head(upem, xMin, yMin, xMax, yMax, longLoca, + (bold ? 1 : 0) | (strike.Italic ? 2 : 0)); + font.Tables["hhea"] = Hhea(ascent, descent, lineGap, widths.Max(), + covered.Length == 0 ? 0 : covered.Min(g => g.XMin), minRight, + covered.Length == 0 ? 0 : covered.Max(g => g.XMax), glyphs.Count); + font.Tables["maxp"] = Maxp(glyphs.Count, maxPoints, maxContours); + font.Tables["OS/2"] = Os2(strike, widths, cmap, encoding.CodePage, weight, bold, + ascent, descent, lineGap, yMin, yMax); + font.Tables["name"] = Name(family, style, full, psName); + font.Tables["cmap"] = Cmap(cmap); + var post = new byte[32]; + Put32(post, 0, 0x00030000); + Put32(post, 12, strike.Glyphs.Select(g => g.Width).Distinct().Count() == 1 ? 1u : 0u); + font.Tables["post"] = post; + return font; + } + + private static TrueTypeGlyph? Outline(FonGlyph source, int ascent, int scale, CancellationToken cancellationToken) + { + var height = source.Pixels.GetLength(0); + if (source.Pixels.GetLength(1) != source.Width) + throw new ConversionRefusedException("error.fon_damaged"); + if (source.Width == 0) + return null; + if (CountPoints(source.Pixels, source.Width, ushort.MaxValue, cancellationToken) > ushort.MaxValue) + throw new ConversionRefusedException("error.fon_too_large", "65535"); + // Extend identical horizontal runs into maximal rectangles, then cancel each shared unit edge. + var active = new Dictionary<(int, int), (int Start, int End)>(); + var rectangles = new List<(int X0, int X1, int Y0, int Y1)>(); + for (var y = 0; y <= height; y++) + { + cancellationToken.ThrowIfCancellationRequested(); + var runs = new HashSet<(int, int)>(); + if (y < height) + for (var x = 0; x < source.Width;) + { + if (!source.Pixels[y, x]) { x++; continue; } + var start = x; + while (x < source.Width && source.Pixels[y, x]) x++; + runs.Add((start, x)); + } + foreach (var (run, interval) in active.ToArray()) + if (!runs.Contains(run)) + { + rectangles.Add((run.Item1, run.Item2, interval.Start, y)); + active.Remove(run); + } + foreach (var run in runs) + if (!active.ContainsKey(run)) + active[run] = (y, y); + } + if (rectangles.Count == 0) + return null; + var edges = new HashSet(); + var order = new List(); + void Add(Corner from, Corner to) + { + if (!edges.Remove(new Edge(to, from))) + { + var edge = new Edge(from, to); + edges.Add(edge); + order.Add(edge); + } + } + var work = 0; + foreach (var (x0, x1, y0, y1) in rectangles) + { + cancellationToken.ThrowIfCancellationRequested(); + for (var x = x0; x < x1; x++) + { + if ((++work & 4095) == 0) + cancellationToken.ThrowIfCancellationRequested(); + Add(new(x, y0), new(x + 1, y0)); + Add(new(x + 1, y1), new(x, y1)); + } + for (var y = y0; y < y1; y++) + { + if ((++work & 4095) == 0) + cancellationToken.ThrowIfCancellationRequested(); + Add(new(x1, y), new(x1, y + 1)); + Add(new(x0, y + 1), new(x0, y)); + } + } + var outgoing = new Dictionary>(); + foreach (var edge in edges) + { + if ((++work & 4095) == 0) + cancellationToken.ThrowIfCancellationRequested(); + if (!outgoing.TryGetValue(edge.From, out var choices)) + outgoing.Add(edge.From, choices = []); + choices.Add(edge); + } + var points = new List(); + var ends = new List(); + var cursor = 0; + while (edges.Count != 0) + { + cancellationToken.ThrowIfCancellationRequested(); + while (!edges.Contains(order[cursor])) + cursor++; + var start = order[cursor++]; + var edge = start; + var loop = new List(); + do + { + if ((++work & 4095) == 0) + cancellationToken.ThrowIfCancellationRequested(); + if (!edges.Remove(edge)) + throw new ConversionRefusedException("error.fon_damaged"); + loop.Add(edge.From); + if (edge.To == start.From) + break; + if (!outgoing.TryGetValue(edge.To, out var choices)) + throw new ConversionRefusedException("error.fon_damaged"); + var dx = edge.To.X - edge.From.X; + var dy = edge.To.Y - edge.From.Y; + edge = choices.Where(edges.Contains).OrderByDescending(e => + dx * (e.To.Y - e.From.Y) - dy * (e.To.X - e.From.X)).FirstOrDefault(); + if (edge == default) + throw new ConversionRefusedException("error.fon_damaged"); + } while (true); + for (var i = 0; i < loop.Count; i++) + { + if ((++work & 4095) == 0) + cancellationToken.ThrowIfCancellationRequested(); + var prev = loop[(i + loop.Count - 1) % loop.Count]; + var curr = loop[i]; + var next = loop[(i + 1) % loop.Count]; + if ((curr.X - prev.X) * (next.Y - curr.Y) == + (curr.Y - prev.Y) * (next.X - curr.X)) + continue; + var x = checked(curr.X * scale); + var y = checked((ascent - curr.Y) * scale); + points.Add(new GlyphPoint(Signed(x), Signed(y), true)); + } + if (points.Count > ushort.MaxValue || ends.Count == short.MaxValue) + throw new ConversionRefusedException("error.fon_too_large", points.Count > ushort.MaxValue ? "65535" : "32767"); + ends.Add((ushort)(points.Count - 1)); + } + var result = new TrueTypeGlyph + { + NumberOfContours = (short)ends.Count, + Points = [.. points], + EndPoints = [.. ends] + }; + (result.XMin, result.YMin, result.XMax, result.YMax) = result.ComputeBounds(); + return result; + } + + internal static int CountPoints(bool[,] pixels, int width, int limit, CancellationToken cancellationToken) + { + var height = pixels.GetLength(0); + var points = 0; + for (var y = 0; y <= height; y++) + { + cancellationToken.ThrowIfCancellationRequested(); + for (var x = 0; x <= width; x++) + { + var a = x > 0 && y > 0 && pixels[y - 1, x - 1]; + var b = x < width && y > 0 && pixels[y - 1, x]; + var c = x > 0 && y < height && pixels[y, x - 1]; + var d = x < width && y < height && pixels[y, x]; + var on = (a ? 1 : 0) + (b ? 1 : 0) + (c ? 1 : 0) + (d ? 1 : 0); + if (on is 1 or 3) + points++; + else if (on == 2 && ((a && d) || (b && c))) + points += 2; + if (points > limit) + return points; + } + } + return points; + } + + private static byte[] Head(int upem, int xMin, int yMin, int xMax, int yMax, bool longLoca, int style) + { + var b = new byte[54]; + Put32(b, 0, 0x00010000); Put32(b, 4, 0x00010000); + Put32(b, 12, 0x5F0F3CF5); Put16(b, 16, 3); Put16(b, 18, upem); + Put16(b, 36, xMin); Put16(b, 38, yMin); Put16(b, 40, xMax); Put16(b, 42, yMax); + Put16(b, 44, style); Put16(b, 46, 8); Put16(b, 48, 2); Put16(b, 50, longLoca ? 1 : 0); + return b; + } + + private static byte[] Hhea(int asc, int desc, int gap, int maxAdvance, + int minLsb, int minRsb, int extent, int count) + { + var b = new byte[36]; + Put32(b, 0, 0x00010000); + Put16(b, 4, asc); Put16(b, 6, desc); Put16(b, 8, gap); + Put16(b, 10, maxAdvance); Put16(b, 12, Signed(minLsb)); + Put16(b, 14, Signed(minRsb)); Put16(b, 16, Signed(extent)); + Put16(b, 18, 1); Put16(b, 34, count); + return b; + } + + private static byte[] Maxp(int count, int points, int contours) + { + var b = new byte[32]; + Put32(b, 0, 0x00010000); + Put16(b, 4, count); Put16(b, 6, points); Put16(b, 8, contours); + Put16(b, 14, 1); // maxZones: no twilight zone + return b; + } + + private static byte[] Metrics(List widths, short[] bearings) + { + var b = new byte[widths.Count * 4]; + for (var i = 0; i < widths.Count; i++) + { + Put16(b, i * 4, widths[i]); + Put16(b, i * 4 + 2, bearings[i]); + } + return b; + } + + private static byte[] Os2(FonStrike strike, List widths, SortedDictionary cmap, + int cp, int weight, bool bold, int asc, int desc, int gap, int yMin, int yMax) + { + var b = new byte[86]; + Put16(b, 0, 1); + Put16(b, 2, Signed((int)Math.Round(widths.Skip(1).Average()))); + Put16(b, 4, weight); Put16(b, 6, 5); + // The PANOSE field is left zero (no invented classification). + foreach (var scalar in cmap.Keys) + { + var bit = scalar switch + { + <= 0x007F => 0, + <= 0x00FF => 1, + <= 0x017F => 2, + <= 0x024F => 3, + <= 0x02AF => 4, + <= 0x02FF => 5, + >= 0x0300 and <= 0x036F => 6, + >= 0x0370 and <= 0x03FF => 7, + >= 0x0400 and <= 0x052F => 9, + >= 0x0590 and <= 0x05FF => 11, + >= 0x0600 and <= 0x06FF => 13, + >= 0x0E00 and <= 0x0E7F => 24, + >= 0x1E00 and <= 0x1EFF => 29, + >= 0x2000 and <= 0x206F => 31, + >= 0x20A0 and <= 0x20CF => 33, + >= 0x2100 and <= 0x214F => 35, + _ => -1 + }; + if (bit >= 0) + Put32(b, 42 + bit / 32 * 4, Get32(b, 42 + bit / 32 * 4) | (1u << (bit % 32))); + } + b[58] = (byte)'F'; b[59] = (byte)'I'; b[60] = (byte)'L'; b[61] = (byte)'E'; + Put16(b, 62, (strike.Italic ? 1 : 0) | (bold ? 0x20 : 0) | + (!strike.Italic && !bold ? 0x40 : 0)); + Put16(b, 64, cmap.Count == 0 ? 0 : cmap.Keys.First()); + Put16(b, 66, cmap.Count == 0 ? 0 : cmap.Keys.Last()); + Put16(b, 68, asc); Put16(b, 70, desc); Put16(b, 72, gap); + Put16(b, 74, Math.Max(0, Math.Max(asc, yMax))); + Put16(b, 76, Math.Max(0, -Math.Min(desc, yMin))); + var cpBit = cp == 874 ? 16 : cp switch + { + 1252 => 0, + 1250 => 1, + 1251 => 2, + 1253 => 3, + 1254 => 4, + 1255 => 5, + 1256 => 6, + 1257 => 7, + 1258 => 8, + _ => throw new ConversionRefusedException("error.fon_damaged") + }; + Put32(b, 78, 1u << cpBit); + return b; + } + + private static byte[] Name(string family, string style, string full, string psName) + { + var names = new SortedDictionary + { + [1] = family, + [2] = style, + [3] = psName, + [4] = full, + [5] = "Version 1.0", + [6] = psName + }; + var records = (from platform in new[] { 0, 3 } + from pair in names + select (Platform: platform, Encoding: platform == 0 ? 3 : 1, + Language: platform == 0 ? 0 : 0x409, Id: pair.Key, + Bytes: Encoding.BigEndianUnicode.GetBytes(pair.Value))).ToArray(); + var w = new BigEndianWriter(); + w.U16(0); w.U16(records.Length); w.U16(6 + records.Length * 12); + var offset = 0; + foreach (var record in records) + { + if (record.Bytes.Length > ushort.MaxValue || offset > ushort.MaxValue - record.Bytes.Length) + throw new ConversionRefusedException("error.fon_too_large", "65535"); + w.U16(record.Platform); w.U16(record.Encoding); w.U16(record.Language); + w.U16(record.Id); w.U16(record.Bytes.Length); w.U16(offset); + offset += record.Bytes.Length; + } + foreach (var record in records) w.Bytes(record.Bytes); + return w.ToArray(); + } + + private static string PostScriptName(string full) + { + var bytes = Encoding.UTF8.GetBytes(full); + var tokens = bytes.Select(b => + b is >= (byte)'A' and <= (byte)'Z' or >= (byte)'a' and <= (byte)'z' or + >= (byte)'0' and <= (byte)'9' or (byte)'-' + ? ((char)b).ToString() : $"_{b:X2}").ToArray(); + var name = string.Concat(tokens); + if (name.Length <= 63) return name; + var prefix = new StringBuilder(); + foreach (var token in tokens) + { + if (prefix.Length + token.Length > 50) break; + prefix.Append(token); + } + return $"{prefix}-{Convert.ToHexString(SHA256.HashData(bytes).AsSpan(0, 6))}"; + } + + private static byte[] Cmap(SortedDictionary map) + { + var count = map.Count + 1; + var length = 16 + 8 * count; + var sub = new byte[length]; + Put16(sub, 0, 4); Put16(sub, 2, length); + Put16(sub, 6, count * 2); + var pow = 1; + var selector = 0; + while (pow * 2 <= count) { pow *= 2; selector++; } + Put16(sub, 8, pow * 2); Put16(sub, 10, selector); Put16(sub, 12, count * 2 - pow * 2); + var i = 0; + foreach (var (scalar, gid) in map) + { + Put16(sub, 14 + i * 2, scalar); + Put16(sub, 16 + count * 2 + i * 2, scalar); + Put16(sub, 16 + count * 4 + i * 2, (gid - scalar) & 0xffff); + i++; + } + Put16(sub, 14 + i * 2, 0xffff); + Put16(sub, 16 + count * 2 + i * 2, 0xffff); + Put16(sub, 16 + count * 4 + i * 2, 1); + var w = new BigEndianWriter(); + w.U16(0); w.U16(2); + w.U16(0); w.U16(3); w.U32(20); + w.U16(3); w.U16(1); w.U32(20); + w.Bytes(sub); + return w.ToArray(); + } + + private static short Signed(int value) => + value is >= short.MinValue and <= short.MaxValue + ? (short)value : throw new ConversionRefusedException("error.fon_too_large", "32767"); + + private static void Put16(byte[] b, int offset, int value) => + BinaryPrimitives.WriteUInt16BigEndian(b.AsSpan(offset), checked((ushort)(value & 0xffff))); + + private static void Put32(byte[] b, int offset, uint value) => + BinaryPrimitives.WriteUInt32BigEndian(b.AsSpan(offset), value); + + private static uint Get32(byte[] b, int offset) => + BinaryPrimitives.ReadUInt32BigEndian(b.AsSpan(offset)); +} diff --git a/src/Filee.Engines/Fonts/FonReader.cs b/src/Filee.Engines/Fonts/FonReader.cs new file mode 100644 index 000000000..5dbf4cf60 --- /dev/null +++ b/src/Filee.Engines/Fonts/FonReader.cs @@ -0,0 +1,230 @@ +using System.Buffers.Binary; +using System.Globalization; +using System.Text; +using Filee.Core.Conversion; + +namespace Filee.Engines.Fonts; + +/// A validated raster glyph; pixels are indexed by row, then column. +internal sealed record FonGlyph(byte ByteValue, int Width, bool[,] Pixels, long BitmapOffset); + +/// One RT_FONT resource, independent of its NE container. +internal sealed record FonStrike( + int Version, int PixelWidth, int PixelHeight, int Ascent, int ExternalLeading, + bool Italic, int Weight, byte CharSet, string Face, int DefaultChar, int BreakChar, + IReadOnlyList Glyphs); + +/// Reads the monochrome FNT v2/v3 resources of a 16-bit NE font library. +internal static class FonReader +{ + /// + /// Maximum sum of max(width, 1) * height for every glyph entry across all RT_FONT resources; + /// zero-width rows and entries aliasing the same bitmap bytes each count separately. + /// + internal const int MaxGlyphPixels = 4_194_304; + + public static IReadOnlyList Read(byte[] data) => Read(data, CancellationToken.None); + + /// + /// Validates every glyph entry's max(width, 1) * height across all RT_FONT resources before decoding + /// (aliases count per entry), observing at each glyph during preflight and decoding. + /// + public static IReadOnlyList Read(byte[] data, CancellationToken cancellationToken) + { + if (data.Length < 0x40 || data[0] != 'M' || data[1] != 'Z') + throw new ConversionRefusedException("error.fon_damaged"); + + var ne = (long)BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(0x3c)); + if (!Fits(data.Length, ne, 0x28)) + throw new ConversionRefusedException("error.fon_damaged"); + if (data[ne] == 'P' && data[ne + 1] == 'E') + throw new ConversionRefusedException("error.fon_unsupported", "PE"); + if (data[ne] != 'N' || data[ne + 1] != 'E') + throw new ConversionRefusedException("error.fon_damaged"); + + var table = ne + U16(data, ne + 0x24); + var end = ne + U16(data, ne + 0x26); + if (end <= table || !Fits(data.Length, table, 2) || end > data.Length) + throw new ConversionRefusedException("error.fon_damaged"); + var shift = U16(data, table); + if (shift > 15) + throw new ConversionRefusedException("error.fon_damaged"); + + var ranges = new List<(long Start, long End)>(); + var cursor = table + 2; + var terminated = false; + while (Fits(end, cursor, 2)) + { + var type = U16(data, cursor); + if (type == 0) + { + terminated = true; + break; + } + if (!Fits(end, cursor, 8)) + throw new ConversionRefusedException("error.fon_damaged"); + var count = U16(data, cursor + 2); + cursor += 8; + if (!Fits(end, cursor, (long)count * 12)) + throw new ConversionRefusedException("error.fon_damaged"); + if (type == 0x8008) + { + for (var i = 0; i < count; i++) + { + var entry = cursor + (long)i * 12; + var start = (long)U16(data, entry) << shift; + var length = (long)U16(data, entry + 2) << shift; + if (length == 0 || start < end || !Fits(data.Length, start, length)) + throw new ConversionRefusedException("error.fon_damaged"); + ranges.Add((start, start + length)); + } + } + cursor += (long)count * 12; + } + if (!terminated) + throw new ConversionRefusedException("error.fon_damaged"); + if (ranges.Count == 0) + throw new ConversionRefusedException("error.fon_damaged"); + var sorted = ranges.OrderBy(r => r.Start).ToArray(); + for (var i = 1; i < sorted.Length; i++) + if (sorted[i].Start < sorted[i - 1].End) + throw new ConversionRefusedException("error.fon_damaged"); + long glyphPixels = 0; + foreach (var (start, stop) in ranges) + ReadFnt(data.AsSpan((int)start, (int)(stop - start)), false, ref glyphPixels, cancellationToken); + var strikes = new List(ranges.Count); + foreach (var (start, stop) in ranges) + strikes.Add(ReadFnt(data.AsSpan((int)start, (int)(stop - start)), true, ref glyphPixels, cancellationToken)!); + return strikes; + } + + private static FonStrike? ReadFnt(ReadOnlySpan resource, bool decode, ref long glyphPixels, + CancellationToken cancellationToken) + { + if (resource.Length < 6) + throw new ConversionRefusedException("error.fon_damaged"); + var version = U16(resource, 0); + if (version is not (0x0200 or 0x0300)) + throw new ConversionRefusedException("error.fon_unsupported", $"FNT 0x{version:X4}"); + var header = version == 0x0200 ? 0x76 : 0x94; + var size = (long)U32(resource, 2); + if (size < header || size > resource.Length) + throw new ConversionRefusedException("error.fon_damaged"); + var fnt = resource[..(int)size]; + var type = U16(fnt, 0x42); + if ((type & 1) != 0) + throw new ConversionRefusedException("error.fon_vector"); + if ((type & 4) != 0) + throw new ConversionRefusedException("error.fon_unsupported"); + if (version == 0x0300) + { + var flags = U32(fnt, 0x76); + if (flags is not (1 or 2)) + throw new ConversionRefusedException("error.fon_unsupported", $"dfFlags 0x{flags:X8}"); + } + + var height = U16(fnt, 0x58); + var ascent = U16(fnt, 0x4a); + var first = fnt[0x5f]; + var last = fnt[0x60]; + var count = last - first + 1; + if (height > 16384) + throw new ConversionRefusedException("error.fon_too_large", "16384"); + if (height == 0 || ascent > height || count <= 0) + throw new ConversionRefusedException("error.fon_damaged"); + var defaultChar = fnt[0x61]; + var breakChar = fnt[0x62]; + if (defaultChar >= count || breakChar >= count) + throw new ConversionRefusedException("error.fon_damaged"); + var weight = U16(fnt, 0x53); + if (weight > 1000) + throw new ConversionRefusedException("error.fon_damaged"); + var charset = fnt[0x55]; + var encoding = CharSetEncoding(charset); + var faceOffset = (long)U32(fnt, 0x69); + if (!Fits(size, faceOffset, 1)) + throw new ConversionRefusedException("error.fon_damaged"); + var faceEnd = fnt[(int)faceOffset..].IndexOf((byte)0); + if (faceEnd < 0) + throw new ConversionRefusedException("error.fon_damaged"); + string face; + try + { + face = encoding.GetString(fnt.Slice((int)faceOffset, faceEnd)).Trim(); + } + catch (DecoderFallbackException) + { + throw new ConversionRefusedException("error.fon_damaged"); + } + if (face.Length == 0) + throw new ConversionRefusedException("error.fon_damaged"); + + var entrySize = version == 0x0200 ? 4 : 6; + var tableEnd = header + (long)(count + 1) * entrySize; + if (tableEnd > size) + throw new ConversionRefusedException("error.fon_damaged"); + var bitsOffset = (long)U32(fnt, 0x71); + if (bitsOffset < tableEnd || bitsOffset > size) + throw new ConversionRefusedException("error.fon_damaged"); + var scale = FonOutlineBuilder.Scale(height); + var glyphs = decode ? new List(count) : null; + for (var i = 0; i < count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + var entry = header + i * entrySize; + var width = U16(fnt, entry); + var offset = version == 0x0200 ? U16(fnt, entry + 2) : U32(fnt, entry + 2); + var bytes = ((long)width + 7) / 8 * height; + if ((long)width * scale > ushort.MaxValue) + throw new ConversionRefusedException("error.fon_too_large", $"{ushort.MaxValue}"); + if (offset < bitsOffset || !Fits(size, offset, bytes)) + throw new ConversionRefusedException("error.fon_damaged"); + if (!decode) + { + glyphPixels += (long)Math.Max((int)width, 1) * height; + if (glyphPixels > MaxGlyphPixels) + throw new ConversionRefusedException("error.fon_too_large", MaxGlyphPixels.ToString("N0", CultureInfo.InvariantCulture)); + continue; + } + var pixels = new bool[height, width]; + for (var y = 0; y < height; y++) + for (var x = 0; x < width; x++) + pixels[y, x] = (fnt[(int)(offset + (long)(x / 8) * height + y)] & (0x80 >> (x % 8))) != 0; + glyphs!.Add(new FonGlyph((byte)(first + i), width, pixels, offset)); + } + if (glyphs is null) + return null; + return new FonStrike(version, U16(fnt, 0x56), height, ascent, U16(fnt, 0x4e), + fnt[0x50] != 0, weight == 0 ? 400 : weight, charset, face, defaultChar, breakChar, glyphs); + } + + /// The strict Windows code page encoding of a supported single-byte FNT charset. + internal static Encoding CharSetEncoding(byte charset) + { + var codePage = charset switch + { + 0 => 1252, + 238 => 1250, + 204 => 1251, + 161 => 1253, + 162 => 1254, + 177 => 1255, + 178 => 1256, + 186 => 1257, + 163 => 1258, + 222 => 874, + _ => throw new ConversionRefusedException("error.fon_unsupported", $"charset {charset}") + }; + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + return Encoding.GetEncoding(codePage, EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback); + } + + private static bool Fits(long limit, long start, long length) => + start >= 0 && length >= 0 && start <= limit && length <= limit - start; + + private static ushort U16(ReadOnlySpan data, long offset) => + BinaryPrimitives.ReadUInt16LittleEndian(data.Slice((int)offset, 2)); + + private static uint U32(ReadOnlySpan data, long offset) => + BinaryPrimitives.ReadUInt32LittleEndian(data.Slice((int)offset, 4)); +} diff --git a/src/Filee.Engines/Fonts/FontConverter.cs b/src/Filee.Engines/Fonts/FontConverter.cs index b41a92064..6ae769d41 100644 --- a/src/Filee.Engines/Fonts/FontConverter.cs +++ b/src/Filee.Engines/Fonts/FontConverter.cs @@ -13,15 +13,22 @@ public sealed class FontConverter : IConverter public string DisplayName => "Fonts (built-in)"; public int MaxParallelism => 0; - /// Every format to every other one, directly. + /// Every format to every other one, directly, plus read-only Windows bitmap FON to each of them. public IReadOnlyList Edges { get; } = - [.. from source in FontFile.Formats from target in FontFile.Formats where source != target select new ConversionEdge(source, target)]; + [ + .. from source in FontFile.Formats from target in FontFile.Formats where source != target select new ConversionEdge(source, target), + .. FontFile.Formats.Select(target => new ConversionEdge("fon", target)), + ]; - public EngineStatus GetStatus() => EngineStatus.Available("TTF, OTF, WOFF, WOFF2, EOT", EngineVersions.BuiltIn); + public EngineStatus GetStatus() => EngineStatus.Available( + "TTF, OTF, WOFF, WOFF2, EOT; FON (Windows bitmap fonts) read", + EngineVersions.BuiltIn); public Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) => Task.Run>(() => { + if (step.From == "fon") + return ConvertFon(step, progress, cancellationToken); var font = FontFile.Load(File.ReadAllBytes(step.InputPath), cancellationToken); progress?.Report(0.2); var outlines = progress is null ? null : new SubProgress(progress, 0.2, 0.6); @@ -36,6 +43,61 @@ public Task> ConvertAsync(ConversionStep step, IProgress + /// One output per RT_FONT resource. Every resource is parsed and every output is built in memory before the first + /// path is allocated, so a failure or cancellation before writing leaves no new file and no existing file changed. + /// + private static IReadOnlyList ConvertFon(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) + { + var strikes = FonReader.Read(File.ReadAllBytes(step.InputPath), cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + progress?.Report(0.1); + + // Same-height, same-style ordinals and the resource count are fixed now, so skipped outputs never rename the others. + var ordinals = new int[strikes.Count]; + var counts = new Dictionary<(int Height, int Weight, bool Italic), int>(); + for (var i = 0; i < strikes.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + var strike = strikes[i]; + var key = (strike.PixelHeight, strike.Weight, strike.Italic); + ordinals[i] = counts[key] = counts.GetValueOrDefault(key) + 1; + } + + var results = new byte[strikes.Count][]; + for (var i = 0; i < strikes.Count; i++) + { + var font = FonOutlineBuilder.Build(strikes[i], cancellationToken, strikes.Count, ordinals[i]); + results[i] = FontFile.Save(font, step.To, cancellationToken); + progress?.Report(i == 0 ? 0.85 : 0.85 + 0.05 * i / strikes.Count); + cancellationToken.ThrowIfCancellationRequested(); + } + cancellationToken.ThrowIfCancellationRequested(); + + var outputs = new List(); + for (var i = 0; i < strikes.Count; i++) + { + var strike = strikes[i]; + var style = (strike.Weight, strike.Italic) switch + { + (400, false) => "", + (700, false) => "-Bold", + (400, true) => "-Italic", + (700, true) => "-BoldItalic", + (_, false) => $"-Weight{strike.Weight}", + (_, true) => $"-Weight{strike.Weight}Italic", + }; + var suffix = strikes.Count == 1 ? null + : $"-{strike.PixelHeight}px{style}{(ordinals[i] > 1 ? $"-{ordinals[i]}" : "")}"; + if (step.Output.Allocate(step.To, suffix) is not { } output) + continue; + File.WriteAllBytes(output, results[i]); + outputs.Add(output); + } + progress?.Report(1); + return outputs; + } + /// Maps 0–1 progress of one phase onto a slice of the step's progress, synchronously. private sealed class SubProgress(IProgress parent, double start, double length) : IProgress { diff --git a/tests/Filee.App.Tests/EnginesPageTests.cs b/tests/Filee.App.Tests/EnginesPageTests.cs index f1c6edec4..eb6f4d660 100644 --- a/tests/Filee.App.Tests/EnginesPageTests.cs +++ b/tests/Filee.App.Tests/EnginesPageTests.cs @@ -85,6 +85,40 @@ public void Dragging_a_card_below_the_next_one_moves_it_there_and_saves_the_orde } } + [AvaloniaFact] + public void Font_card_describes_fon_input_only() + { + TestServices.EnsureInitialized("en"); + var vm = new MainWindowViewModel(AppHost.Services, AppHost.Get(), AppHost.Get()); + vm.Navigate("engines"); + var window = new MainWindow { DataContext = vm, Width = 1080, Height = 820 }; + window.Show(); + Pump(); + try + { + var page = window.GetVisualDescendants().OfType().Single(); + var engines = ((EnginesPageViewModel)page.DataContext!).Engines; + var font = engines.Single(e => e.Converter.Id == "font"); + var card = page.FindControl("EngineList")!.ContainerFromIndex(engines.IndexOf(font))!; + + // The card's tooltip is the engine's own detail, and FON is only ever a source. + var tip = card.GetVisualDescendants().OfType().Select(ToolTip.GetTip).OfType().Single(); + Assert.Equal(font.Converter.GetStatus().Detail, tip); + Assert.Contains("FON", tip); + Assert.Equal(["eot", "otf", "ttf", "woff", "woff2"], font.Converter.Edges.Where(e => e.From == "fon").Select(e => e.To).Order()); + Assert.DoesNotContain(font.Converter.Edges, e => e.To == "fon"); + + var description = AppHost.Get()["engine.font.description"]; + Assert.Equal(description, font.Description); + Assert.Contains(card.GetVisualDescendants().OfType(), t => t.Text == description && t.IsVisible); + Assert.Contains("FON", description); + } + finally + { + window.Close(); + } + } + [AvaloniaFact] public void Only_LibreOffice_has_settings_and_they_change_the_saved_switches() { diff --git a/tests/Filee.App.Tests/PresetEditorTargetTests.cs b/tests/Filee.App.Tests/PresetEditorTargetTests.cs new file mode 100644 index 000000000..0ba2c581b --- /dev/null +++ b/tests/Filee.App.Tests/PresetEditorTargetTests.cs @@ -0,0 +1,45 @@ +// The preset editor's target list offers only formats Filee can write: read-only FON (#177) is a source, never a target. + +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using Avalonia.VisualTree; +using Filee.App.Services; +using Filee.App.ViewModels; +using Filee.App.Views; +using Filee.Core.Localization; +using Filee.Core.Settings; + +namespace Filee.App.Tests; + +public class PresetEditorTargetTests +{ + [AvaloniaFact] + public void Preset_editor_target_box_excludes_fon_and_shows_ttf() + { + TestServices.EnsureInitialized("en"); + var store = AppHost.Get(); + var editor = new PresetEditorViewModel(store.FindPreset("to-ttf")!, AppHost.Get()); + var window = new PresetEditorWindow { DataContext = editor, Height = 900 }; + window.Show(); + for (var i = 0; i < 20; i++) + { + Dispatcher.UIThread.RunJobs(); + AvaloniaHeadlessPlatform.ForceRenderTimerTick(); + } + try + { + var box = window.GetVisualDescendants().OfType().Single(c => c.Name == "TargetBox"); + var targets = box.Items.OfType>().Select(c => c.Value).ToList(); + Assert.NotEmpty(targets); + Assert.DoesNotContain("fon", targets); + Assert.Single(targets, t => t == "ttf"); + Assert.Equal("ttf", ((Choice)box.SelectedItem!).Value); + } + finally + { + window.Close(); + } + } +} diff --git a/tests/Filee.Cli.Tests/CliTests.cs b/tests/Filee.Cli.Tests/CliTests.cs index 88ad21cdd..3e00d5289 100644 --- a/tests/Filee.Cli.Tests/CliTests.cs +++ b/tests/Filee.Cli.Tests/CliTests.cs @@ -4,6 +4,7 @@ using System.Text.Json; using System.Xml.Linq; using Filee.Core.Presets; +using Filee.Engines.Tests; namespace Filee.Cli.Tests; @@ -437,6 +438,135 @@ public async Task Unicode_spaces_and_quotes_stay_in_a_single_input_argument() Assert.True(File.Exists(source)); } + // ──────────── Windows bitmap fonts (#177): in-memory samples shared with the engine tests ──────────── + + private static readonly string[] FontFormats = ["ttf", "otf", "woff", "woff2", "eot"]; + + /// Writes a synthetic sample to Name.fon in this test's folder (or a subfolder). + private string Fon(string sample, string? folder = null) + { + folder = Path.Combine(_folder, folder ?? ""); + Directory.CreateDirectory(folder); + var path = Path.Combine(folder, "Name.fon"); + File.WriteAllBytes(path, FonBuilder.Sample(sample)); + return path; + } + + private static string[] Outputs(string json) + { + using var document = JsonDocument.Parse(json); + return [.. document.RootElement.GetProperty("files")[0].GetProperty("outputs").EnumerateArray().Select(o => o.GetString()!)]; + } + + [Fact] + public void Generated_fon_samples_are_written_to_cli_input() + { + foreach (var sample in new[] { "Fon-v2-fixed.fon", "Fon-multi.fon" }) + { + var bytes = FonBuilder.Sample(sample); + Assert.Equal("MZ"u8.ToArray(), bytes[..2]); + Assert.Equal(bytes, File.ReadAllBytes(Fon(sample, Path.GetFileNameWithoutExtension(sample)))); + } + } + + [Theory] + [InlineData("ttf")] + [InlineData("otf")] + [InlineData("woff")] + [InlineData("woff2")] + [InlineData("eot")] + public async Task Fon_single_resource_omits_size_suffix_in_all_five_formats(string format) + { + var source = Fon("Fon-v2-fixed.fon"); + var json = Path.Combine(_folder, "json"); + var text = Path.Combine(_folder, "text"); + + var (code, output, errors) = await Run("convert", source, "--to", format, "-o", json, "--json"); + Assert.Equal(Cli.Success, code); + Assert.Equal("", errors); + Assert.Equal([Path.Combine(json, "Name." + format)], Outputs(output)); + Assert.Equal(["Name." + format], Directory.EnumerateFiles(json).Select(Path.GetFileName)); + + (code, output, _) = await Run("convert", source, "--to", format, "-o", text); + Assert.Equal(Cli.Success, code); + Assert.Contains("Name." + format, output); + Assert.DoesNotContain("13px", output); + Assert.Equal(["Name." + format], Directory.EnumerateFiles(text).Select(Path.GetFileName)); + } + + [Fact] + public async Task Fon_reports_each_strike_and_obeys_conflicts() + { + var source = Fon("Fon-multi.fon"); + string[] names = ["Name-13px.ttf", "Name-13px-2.ttf", "Name-14px.ttf"]; + + // No conflict, in each format: one file per RT_FONT resource, every path in the text and JSON output. + foreach (var format in FontFormats) + { + var clean = Path.Combine(_folder, "clean-" + format); + var (code, output, _) = await Run("convert", source, "--to", format, "-o", clean, "--json"); + Assert.Equal(Cli.Success, code); + Assert.Equal(names.Select(n => Path.Combine(clean, Path.ChangeExtension(n, format))), Outputs(output)); + } + var seed = Path.Combine(_folder, "clean-ttf"); + var (textCode, text, _) = await Run("convert", source, "--to", "ttf", "-o", Path.Combine(_folder, "text")); + Assert.Equal(Cli.Success, textCode); + Assert.All(names, n => Assert.Contains(n, text)); + + // One size already there: skip writes the other two, rename and overwrite write three. + string Folder(string name) + { + var folder = Path.Combine(_folder, name); + Directory.CreateDirectory(folder); + File.WriteAllText(Path.Combine(folder, "Name-13px.ttf"), "old"); + return folder; + } + + var skip = Folder("skip"); + var (skipCode, skipped, _) = await Run("convert", source, "--to", "ttf", "-o", skip, "--skip", "--json"); + Assert.Equal(Cli.Success, skipCode); + Assert.Equal([Path.Combine(skip, "Name-13px-2.ttf"), Path.Combine(skip, "Name-14px.ttf")], Outputs(skipped)); + Assert.Equal("old", File.ReadAllText(Path.Combine(skip, "Name-13px.ttf"))); + + var rename = Folder("rename"); + var (renameCode, renamed, _) = await Run("convert", source, "--to", "ttf", "-o", rename, "--json"); + Assert.Equal(Cli.Success, renameCode); + Assert.Equal([Path.Combine(rename, "Name-13px (2).ttf"), Path.Combine(rename, "Name-13px-2.ttf"), Path.Combine(rename, "Name-14px.ttf")], + Outputs(renamed)); + Assert.Equal("old", File.ReadAllText(Path.Combine(rename, "Name-13px.ttf"))); + + var overwrite = Folder("overwrite"); + var (overwriteCode, overwritten, _) = await Run("convert", source, "--to", "ttf", "-o", overwrite, "--overwrite", "--json"); + Assert.Equal(Cli.Success, overwriteCode); + Assert.Equal(names.Select(n => Path.Combine(overwrite, n)), Outputs(overwritten)); + Assert.All(names, n => Assert.Equal(File.ReadAllBytes(Path.Combine(seed, n)), File.ReadAllBytes(Path.Combine(overwrite, n)))); + + // Every size there: skipped, with no outputs. + var (allCode, all, _) = await Run("convert", source, "--to", "ttf", "-o", seed, "--skip", "--json"); + Assert.Equal(Cli.Success, allCode); + using var json = JsonDocument.Parse(all); + Assert.Equal("skipped", json.RootElement.GetProperty("files")[0].GetProperty("state").GetString()); + Assert.Equal(0, json.RootElement.GetProperty("files")[0].GetProperty("outputs").GetArrayLength()); + } + + [Fact] + public async Task Fon_formats_are_read_only_and_no_to_fon() + { + var (code, output, _) = await Run("formats"); + Assert.Equal(Cli.Success, code); + Assert.Contains(output.Split('\n'), line => line.TrimStart().StartsWith("fon ", StringComparison.Ordinal) && line.Contains("(read only)")); + + (code, output, _) = await Run("formats", "fon"); + Assert.Equal(Cli.Success, code); + var targets = output.Split('\n')[1].Split(',', StringSplitOptions.TrimEntries); + Assert.Equal(FontFormats.Order(), targets.Order()); + + var (refused, _, errors) = await Run("convert", Fon("Fon-v2-fixed.fon"), "--to", "fon"); + Assert.Equal(Cli.UsageError, refused); + Assert.Contains("can't write", errors); + Assert.Equal(["Name.fon"], Directory.EnumerateFiles(_folder).Select(Path.GetFileName)); + } + [Fact] public async Task Refuses_tool_presets() { diff --git a/tests/Filee.Cli.Tests/Filee.Cli.Tests.csproj b/tests/Filee.Cli.Tests/Filee.Cli.Tests.csproj index 488b0a460..d02ae79ae 100644 --- a/tests/Filee.Cli.Tests/Filee.Cli.Tests.csproj +++ b/tests/Filee.Cli.Tests/Filee.Cli.Tests.csproj @@ -18,4 +18,8 @@ + + + + diff --git a/tests/Filee.Core.Tests/FonFormatTests.cs b/tests/Filee.Core.Tests/FonFormatTests.cs new file mode 100644 index 000000000..69b49b03b --- /dev/null +++ b/tests/Filee.Core.Tests/FonFormatTests.cs @@ -0,0 +1,67 @@ +// Windows bitmap fonts (issue #177): .fon is a read-only font format; new and migrated libraries' fonts donut takes it. + +using Filee.Core.Formats; +using Filee.Core.Presets; +using Filee.Core.Profiles; +using Filee.Core.Settings; + +namespace Filee.Core.Tests; + +public class FonFormatTests +{ + [Theory] + [InlineData("Fixed.fon")] + [InlineData("SERIFE.FON")] + public void Fon_is_input_only(string file) + { + var format = FormatRegistry.Detect(file); + + Assert.Equal("fon", format?.Id); + Assert.Equal(FormatCategory.Font, format!.Category); + Assert.False(format.Writable); + Assert.Null(FormatRegistry.Detect("font.fnt")); + } + + [Fact] + public void Fon_is_read_only_and_new_font_profile_claims_it() + { + var profiles = BuiltInData.CreateProfiles(); + var fonts = ProfileSelector.Select(profiles, ["Fixed.fon"]); + + Assert.Equal("fonts", fonts?.Id); + Assert.Contains("fon", fonts!.Extensions); + var targets = fonts.PresetIds.Select(id => BuiltInData.CreatePresets().Single(p => p.Id == id).TargetFormat).Order(); + Assert.Equal(["eot", "otf", "ttf", "woff", "woff2"], targets); + Assert.All(targets, t => Assert.True(FormatRegistry.Known.Single(f => f.Id == t).Writable)); + } + + [Fact] + public void Version_46_libraries_learn_fon_once() + { + var presets = BuiltInData.CreatePresets(); + var profiles = BuiltInData.CreateProfiles(); + var fonts = profiles.Single(p => p.Id == "fonts"); + fonts.Extensions.Remove("fon"); + + Assert.True(SettingsMigrations.ApplyToLibrary(46, presets, profiles)); + + Assert.Contains("fon", fonts.Extensions); + Assert.Equal("fonts", ProfileSelector.Select(profiles, ["Fixed.fon"])?.Id); + Assert.False(SettingsMigrations.ApplyToLibrary(46, presets, profiles)); + } + + [Fact] + public void Version_46_migration_keeps_a_custom_fon_claim() + { + var presets = BuiltInData.CreatePresets(); + var profiles = BuiltInData.CreateProfiles(); + var fonts = profiles.Single(p => p.Id == "fonts"); + fonts.Extensions.Remove("fon"); + profiles.Insert(0, new ToolbarProfile { Id = "retro", Extensions = ["fon"] }); + + SettingsMigrations.ApplyToLibrary(46, presets, profiles); + + Assert.DoesNotContain("fon", fonts.Extensions); + Assert.Equal("retro", ProfileSelector.Select(profiles, ["Fixed.fon"])?.Id); + } +} diff --git a/tests/Filee.Engines.Tests/FonBuilder.cs b/tests/Filee.Engines.Tests/FonBuilder.cs new file mode 100644 index 000000000..e89925387 --- /dev/null +++ b/tests/Filee.Engines.Tests/FonBuilder.cs @@ -0,0 +1,191 @@ +// Creates FON samples in memory; no font files are checked into the repository. + +using System.Numerics; + +namespace Filee.Engines.Tests; + +internal static class FonBuilder +{ + private static void Put(List data, int offset, ushort value) + { + data[offset] = (byte)value; + data[offset + 1] = (byte)(value >> 8); + } + + private static void Put(List data, int offset, uint value) + { + Put(data, offset, (ushort)value); + Put(data, offset + 2, (ushort)(value >> 16)); + } + + private static byte[] Fnt(int version, int height = 13, bool fixedWidth = true, int first = 32, + int last = 127, int? flags = null, int weight = 400, bool italic = false, string faceName = "Fixed") + { + var count = last - first + 1; + var entrySize = version == 2 ? 4 : flags is null ? 6 : 8 + (BitOperations.Log2((uint)flags.Value) - 2) * 2; + var table = version == 2 ? 0x76 : 0x94; + var face = table + (count + 1) * entrySize; + var bits = face + 6; + var data = new List(new byte[bits]); + Put(data, 0, (ushort)(version << 8)); + Put(data, 0x42, (ushort)0); + Put(data, 0x4A, (ushort)Math.Min(height, 10)); + Put(data, 0x4E, (ushort)2); + Put(data, 0x53, (ushort)weight); + data[0x50] = italic ? (byte)1 : (byte)0; + data[0x55] = 0; + Put(data, 0x56, (ushort)(fixedWidth ? 8 : 0)); + Put(data, 0x58, (ushort)height); + Put(data, 0x5D, (ushort)9); + data[0x5F] = (byte)first; + data[0x60] = (byte)last; + Put(data, 0x69, (uint)face); + Put(data, 0x71, (uint)bits); + if (version == 3) + { + Put(data, 0x76, (uint)(flags ?? (fixedWidth ? 1 : 2))); + if (flags is not null) + { + Put(data, 0x7A, (ushort)(flags + 1)); + Put(data, 0x7C, (ushort)(flags + 2)); + Put(data, 0x7E, (ushort)(flags + 3)); + } + } + for (var i = 0; i < 6; i++) + data[face + i] = i < faceName.Length ? (byte)faceName[i] : (byte)0; + + for (var index = 0; index < count; index++) + { + var code = first + index; + var width = flags is not null ? flags.Value + index % 3 : + fixedWidth ? 8 : code == 32 ? 0 : code % 2 == 1 ? 5 : 9; + var entry = table + index * entrySize; + Put(data, entry, (ushort)width); + if (flags is not null) + { + Put(data, entry + 2, (ushort)(flags + index % 3)); + Put(data, entry + 4, (uint)data.Count); + } + else if (version == 2) + Put(data, entry + 2, (ushort)data.Count); + else + Put(data, entry + 2, (uint)data.Count); + + // Pixel art is independent of the reader: a single pixel, diagonal, and hollow box. + for (var column = 0; column < (width + 7) / 8; column++) + for (var y = 0; y < height; y++) + { + byte value = 0; + for (var bit = 0; bit < 8; bit++) + { + var x = column * 8 + bit; + if (x >= width || code == 32) + continue; + var rows = Math.Min(height, 7); + var lit = code == 33 ? x == 2 && y == 0 : + code == 34 ? x == 2 + y % Math.Max(1, width - 2) : + x >= 2 && y < rows && (x == 2 || x == width - 1 || y == 0 || y == rows - 1); + if (lit) + value |= (byte)(0x80 >> bit); + } + data.Add(value); + } + } + var sentinel = table + count * entrySize; + Put(data, sentinel, (ushort)0); + if (flags is not null) + Put(data, sentinel + 4, (uint)data.Count); + else if (version == 2) + Put(data, sentinel + 2, (ushort)data.Count); + else + Put(data, sentinel + 2, (uint)data.Count); + Put(data, 2, (uint)data.Count); + return [.. data]; + } + + /// Writes NE FONTDIR and RT_FONT entries with an optional resource alignment shift. + public static byte[] Container(byte[][] resources, int alignmentShift = 0) + { + const int ne = 0x40, table = ne + 0x40; + var payload = table + 2 + 8 + 12 + 8 + 12 * resources.Length + 2; + var data = new List(new byte[payload]); + data[0] = (byte)'M'; + data[1] = (byte)'Z'; + Put(data, 0x3C, (uint)ne); + data[ne] = (byte)'N'; + data[ne + 1] = (byte)'E'; + Put(data, ne + 0x24, (ushort)(table - ne)); + Put(data, ne + 0x26, (ushort)(payload - ne)); + Put(data, table, (ushort)alignmentShift); + Put(data, table + 2, (ushort)0x8007); + Put(data, table + 4, (ushort)1); + var unit = 1 << alignmentShift; + // The one-byte FONTDIR, like every resource, starts on an alignment unit. + while (data.Count % unit != 0) + data.Add(0); + Put(data, table + 10, (ushort)(data.Count / unit)); + Put(data, table + 12, (ushort)1); + data.Add(0); + var fontType = table + 22; + Put(data, fontType, (ushort)0x8008); + Put(data, fontType + 2, (ushort)resources.Length); + for (var index = 0; index < resources.Length; index++) + { + while (data.Count % unit != 0) + data.Add(0); + var resource = resources[index]; + var offset = data.Count / unit; + var length = (resource.Length + unit - 1) / unit; + if (offset > ushort.MaxValue || length > ushort.MaxValue) + throw new ArgumentOutOfRangeException(nameof(resources), "NE resource exceeds WORD range."); + var entry = fontType + 8 + index * 12; + Put(data, entry, (ushort)offset); + Put(data, entry + 2, (ushort)length); + Put(data, entry + 6, (ushort)(0x8001 + index)); + data.AddRange(resource); + while (data.Count % unit != 0) + data.Add(0); + } + return [.. data]; + } + + /// Builds the fixed-width FNT v2 sample. + public static byte[] V2Fixed() => Container([Fnt(2)]); + /// Builds the proportional FNT v2 sample with control characters. + public static byte[] V2Proportional() => Container([Fnt(2, fixedWidth: false, first: 0)]); + /// Builds the fixed-width FNT v3 sample. + public static byte[] V3Fixed() => Container([Fnt(3)]); + /// Builds the proportional FNT v3 sample. + public static byte[] V3Proportional() => Container([Fnt(3, fixedWidth: false)]); + /// Builds three strikes, two at the same height. + public static byte[] Multi() => Container([Fnt(2), Fnt(3), Fnt(3, 14)]); + /// Builds mixed styles and duplicate style strikes at two heights. + public static byte[] MixedStyles() => Container([ + Fnt(3, faceName: "Name"), Fnt(3, weight: 700, faceName: "Name"), Fnt(3, italic: true, faceName: "Name"), + Fnt(3, weight: 700, italic: true, faceName: "Name"), Fnt(3, weight: 600, faceName: "Name"), + Fnt(3, weight: 600, italic: true, faceName: "Name"), Fnt(3, faceName: "Name"), + Fnt(3, weight: 700, faceName: "Name"), Fnt(3, 14, faceName: "Name") + ]); + /// Builds the one- and 256-pixel strikes. + public static byte[] Edge() => Container([Fnt(3, 1, first: 32, last: 34), Fnt(3, 256, first: 32, last: 34)]); + /// Builds a v3 resource with the given unsupported glyph table flags. + public static byte[] V3Flags(int flag) => Container([Fnt(3, flags: flag)]); + + /// Returns one of the twelve named synthetic samples. + public static byte[] Sample(string name) => name switch + { + "Fon-v2-fixed.fon" => V2Fixed(), + "Fon-v2-proportional.fon" => V2Proportional(), + "Fon-v3-fixed.fon" => V3Fixed(), + "Fon-v3-proportional.fon" => V3Proportional(), + "Fon-multi.fon" => Multi(), + "Fon-edge.fon" => Edge(), + "Fon-v3-flags-04.fon" => V3Flags(0x04), + "Fon-v3-flags-08.fon" => V3Flags(0x08), + "Fon-v3-flags-10.fon" => V3Flags(0x10), + "Fon-v3-flags-20.fon" => V3Flags(0x20), + "Fon-v3-flags-40.fon" => V3Flags(0x40), + "Fon-v3-flags-80.fon" => V3Flags(0x80), + _ => throw new ArgumentOutOfRangeException(nameof(name)), + }; +} diff --git a/tests/Filee.Engines.Tests/FonTests.cs b/tests/Filee.Engines.Tests/FonTests.cs new file mode 100644 index 000000000..133e17134 --- /dev/null +++ b/tests/Filee.Engines.Tests/FonTests.cs @@ -0,0 +1,1470 @@ +// Windows bitmap FON (NE + FNT v2/v3) to TTF/OTF/WOFF/WOFF2/EOT (issue #177). FonBuilder creates +// synthetic samples in memory; the expected pixels below are independent of the engine's reader. + +using System.Buffers.Binary; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using Filee.Core.Conversion; +using Filee.Core.Presets; +using Filee.Engines.Fonts; +using SkiaSharp; + +namespace Filee.Engines.Tests; + +public class FonTests(EngineFixture fx) : IClassFixture +{ + private static readonly string[] Formats = ["ttf", "otf", "woff", "woff2", "eot"]; + + private static byte[] Data(string name) => FonBuilder.Sample(name); + + private static int FntStart(byte[] data, int resource = 0) + { + var ne = (int)BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(0x3c)); + var table = ne + BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(ne + 0x24)); + var entry = table + 2 + 8 + 12 + 8 + resource * 12; + return BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(entry)); + } + + // ───────────────────────── The generator's art, independent of the reader ───────────────────────── + + /// One synthetic single-resource sample: FNT version, fixed or proportional, first character. + public sealed record Kind(string File, bool Fixed, int First); + + private static readonly Kind[] Kinds = + [ + new("Fon-v2-fixed.fon", true, 32), + new("Fon-v2-proportional.fon", false, 0), + new("Fon-v3-fixed.fon", true, 32), + new("Fon-v3-proportional.fon", false, 32), + ]; + + private static int ArtWidth(int code, bool isFixed) => isFixed ? 8 : code == 32 ? 0 : code % 2 == 1 ? 5 : 9; + + private static bool ArtLit(int code, int x, int y, int width, int height) + { + if (x >= width || code == 32) + return false; + var rows = Math.Min(height, 7); + return code switch + { + 33 => x == 2 && y == 0, + 34 => x == 2 + y % Math.Max(1, width - 2), + _ => x >= 2 && y < rows && (x == 2 || x == width - 1 || y == 0 || y == rows - 1), + }; + } + + private static int ArtAscent(int height) => Math.Min(height, 10); + + private static int Scale(int height) => 1024 / height; + + // ───────────────────────── Samples and queue helpers ───────────────────────── + + [Theory] + [InlineData("Fon-v2-fixed.fon", 0x200, 8)] + [InlineData("Fon-v2-proportional.fon", 0x200, 0)] + [InlineData("Fon-v3-fixed.fon", 0x300, 8)] + [InlineData("Fon-v3-proportional.fon", 0x300, 0)] + [InlineData("Fon-multi.fon", 0x200, 8)] + [InlineData("Fon-edge.fon", 0x300, 8)] + public void Generated_fon_samples_have_expected_resources(string name, int version, int width) + { + var bytes = Data(name); + Assert.Equal("MZ", Encoding.ASCII.GetString(bytes, 0, 2)); + var strikes = FonReader.Read(bytes, TestContext.Current.CancellationToken); + Assert.Equal(name == "Fon-multi.fon" ? 3 : name == "Fon-edge.fon" ? 2 : 1, strikes.Count); + Assert.Equal(version, strikes[0].Version); + Assert.Equal(width, strikes[0].PixelWidth); + Assert.Equal("Fixed", strikes[0].Face); + } + + [Fact] + public void Aligned_ne_container_keeps_resource_lengths_inside_file() + { + var sample = FonBuilder.V2Fixed(); + var bytes = FonBuilder.Container([Resource(sample, 0)], alignmentShift: 9); + // FONTDIR (first entry at 0x8A) and RT_FONT (0x9E) both resolve inside the file in 512-byte units. + foreach (var entry in new[] { 0x80 + 10, 0x80 + 30 }) + { + var offset = BitConverter.ToUInt16(bytes, entry) << 9; + var length = BitConverter.ToUInt16(bytes, entry + 2) << 9; + Assert.InRange(offset, 0x100, bytes.Length - length); + } + var strike = Assert.Single(FonReader.Read(bytes, TestContext.Current.CancellationToken)); + Assert.Equal(0x200, strike.Version); + Assert.Equal(13, strike.PixelHeight); + } + + /// A folder holding as Name.fon. + private string Source(byte[] data, out string folder) + { + folder = fx.NewFolder(); + var path = Path.Combine(folder, "Name.fon"); + File.WriteAllBytes(path, data); + return path; + } + + private static Preset PresetFor(string target, ConflictPolicy conflict = ConflictPolicy.Rename) => + new() { TargetFormat = target, Output = { Location = OutputLocation.SameFolder, Conflict = conflict } }; + + /// Every file below with the SHA-256 of its bytes. + private static Dictionary Snapshot(string folder) => + Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories) + .ToDictionary(p => Path.GetRelativePath(folder, p), p => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(p)))); + + /// SFNT bytes Skia can read on every platform (web containers are decoded by Filee first). + private static byte[] SfntBytes(string path, string format) + { + var bytes = File.ReadAllBytes(path); + return format is "ttf" or "otf" ? bytes : FontFile.Load(bytes).Write(); + } + + // ───────────────────────── Rendering, tables ───────────────────────── + + private const int Pad = 2; + + /// + /// The glyph drawn by Skia at its original pixel size, unhinted and aliased, with the cell's top-left at + /// (, ); the padding shows pixels drawn outside the cell. + /// + private static bool[,] Render(SKTypeface face, int gid, int height, int ascent, int width) + { + using var font = new SKFont(face, height) { Hinting = SKFontHinting.None, Edging = SKFontEdging.Alias, Subpixel = false }; + using var path = font.GetGlyphPath((ushort)gid) ?? new SKPath(); + var (w, h) = (width + Pad * 2, height + Pad * 2); + using var bitmap = new SKBitmap(new SKImageInfo(w, h, SKColorType.Alpha8)); + using (var canvas = new SKCanvas(bitmap)) + using (var paint = new SKPaint { IsAntialias = false, Color = SKColors.Black }) + { + canvas.Clear(SKColors.Transparent); + canvas.Translate(Pad, Pad + ascent); + canvas.DrawPath(path, paint); + } + var bytes = bitmap.Bytes; + var mask = new bool[h, w]; + for (var y = 0; y < h; y++) + for (var x = 0; x < w; x++) + mask[y, x] = bytes[y * bitmap.RowBytes + x] > 127; + return mask; + } + + private static bool[,] ExpectedMask(int code, int width, int height) + { + var mask = new bool[height + Pad * 2, width + Pad * 2]; + for (var y = 0; y < height; y++) + for (var x = 0; x < width; x++) + mask[y + Pad, x + Pad] = ArtLit(code, x, y, width, height); + return mask; + } + + private static void AssertMask(bool[,] expected, bool[,] actual, string what) + { + Assert.Equal(expected.GetLength(0), actual.GetLength(0)); + Assert.Equal(expected.GetLength(1), actual.GetLength(1)); + for (var y = 0; y < expected.GetLength(0); y++) + for (var x = 0; x < expected.GetLength(1); x++) + Assert.True(expected[y, x] == actual[y, x], $"{what}: pixel ({x - Pad}, {y - Pad}) should be {(expected[y, x] ? "on" : "off")}"); + } + + private static int U16(byte[] table, int offset) => BinaryPrimitives.ReadUInt16BigEndian(table.AsSpan(offset)); + + private static int I16(byte[] table, int offset) => BinaryPrimitives.ReadInt16BigEndian(table.AsSpan(offset)); + + private static int Advance(SfntFont font, int gid) => U16(font.Require("hmtx"), gid * 4); + + private static int Lsb(SfntFont font, int gid) => I16(font.Require("hmtx"), gid * 4 + 2); + + /// The set bits of OS/2 ulUnicodeRange1..4. + private static int[] UnicodeRangeBits(SfntFont font) => + [.. Enumerable.Range(0, 128).Where(bit => + (BinaryPrimitives.ReadUInt32BigEndian(font.Require("OS/2").AsSpan(42 + bit / 32 * 4)) & (1u << (bit % 32))) != 0)]; + + /// The Windows Unicode BMP (3, 1) format 4 subtable as code point → glyph id. + private static Dictionary Cmap(SfntFont font) + { + var t = font.Require("cmap"); + var result = new Dictionary(); + for (var i = 0; i < U16(t, 2); i++) + { + if (U16(t, 4 + i * 8) != 3 || U16(t, 6 + i * 8) != 1) + continue; + var at = (int)BinaryPrimitives.ReadUInt32BigEndian(t.AsSpan(8 + i * 8)); + Assert.Equal(4, U16(t, at)); + var segX2 = U16(t, at + 6); + for (var s = 0; s < segX2 / 2; s++) + { + var end = U16(t, at + 14 + s * 2); + var start = U16(t, at + 16 + segX2 + s * 2); + var delta = U16(t, at + 16 + segX2 * 2 + s * 2); + var rangeAt = at + 16 + segX2 * 3 + s * 2; + var range = U16(t, rangeAt); + for (var c = start; c <= end && c != 0xFFFF; c++) + { + var gid = range == 0 ? (c + delta) & 0xFFFF : U16(t, rangeAt + range + (c - start) * 2); + if (range != 0 && gid != 0) + gid = (gid + delta) & 0xFFFF; + if (gid != 0) + result[c] = gid; + } + } + return result; + } + throw new Xunit.Sdk.XunitException("no Windows Unicode BMP cmap"); + } + + private static string? Name(SfntFont font, int id) + { + var table = font.Require("name"); + var count = U16(table, 2); + var strings = U16(table, 4); + for (var i = 0; i < count; i++) + { + var offset = 6 + i * 12; + if (U16(table, offset) != 3 || U16(table, offset + 6) != id) + continue; + return Encoding.BigEndianUnicode.GetString(table, strings + U16(table, offset + 10), U16(table, offset + 8)); + } + return null; + } + + // ───────────────────────── C1: every version and width, every target ───────────────────────── + + public static TheoryData KindsAndFormats() + { + var data = new TheoryData(); + for (var k = 0; k < Kinds.Length; k++) + foreach (var format in Formats) + data.Add(k, format); + return data; + } + + [Theory] + [MemberData(nameof(KindsAndFormats))] + public async Task Fon_v2_v3_each_strike_opens_in_all_five_formats(int kindIndex, string format) + { + var kind = Kinds[kindIndex]; + var source = Source(Data(kind.File), out var folder); + var job = await fx.ConvertAsync([source], PresetFor(format)); + + Assert.True(job.State == JobState.Completed, string.Join("; ", job.Files.Select(f => $"{f.ErrorKey} {f.ErrorDetail}"))); + var output = Assert.Single(job.Outputs); + Assert.Equal(Path.Combine(folder, "Name." + format), output); + var bytes = File.ReadAllBytes(output); + var signature = BinaryPrimitives.ReadUInt32BigEndian(bytes); + switch (format) + { + case "ttf" or "otf": Assert.Equal(0x00010000u, signature); break; + case "woff": Assert.Equal(0x774F4646u, signature); break; + case "woff2": Assert.Equal(0x774F4632u, signature); break; + case "eot": Assert.Equal(bytes.Length, (int)BinaryPrimitives.ReadUInt32LittleEndian(bytes)); break; + } + + var font = FontFile.Load(bytes, TestContext.Current.CancellationToken); + Assert.Equal(SfntFont.TrueTypeFlavor, font.Flavor); + foreach (var tag in new[] { "head", "hhea", "maxp", "OS/2", "name", "cmap", "post", "glyf", "loca", "hmtx" }) + Assert.NotNull(font[tag]); + Assert.Equal(1, U16(font.Require("maxp"), 14)); // maxZones + Assert.Equal(2, I16(font.Require("head"), 48)); // fontDirectionHint + var codes = Enumerable.Range(kind.First, 127 - kind.First + 1).ToArray(); + Assert.Equal(codes.Length + 1, font.GlyphCount); + + using var face = SKTypeface.FromStream(new MemoryStream(SfntBytes(output, format))); + Assert.NotNull(face); + Assert.Equal(codes.Length + 1, face.GlyphCount); + var s = Scale(13); + for (var i = 0; i < codes.Length; i++) + { + var width = ArtWidth(codes[i], kind.Fixed); + Assert.Equal(width * s, Advance(font, i + 1)); + AssertMask(ExpectedMask(codes[i], width, 13), Render(face, i + 1, 13, ArtAscent(13), width), $"{kind.File} {format} code {codes[i]}"); + } + } + + // ───────────────────────── C2: pixel masks ───────────────────────── + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + public void Every_bitmap_mask_matches_unhinted_integer_raster(int kindIndex) + { + var kind = Kinds[kindIndex]; + var font = FonOutlineBuilder.Build(Assert.Single(FonReader.Read(Data(kind.File), TestContext.Current.CancellationToken)), TestContext.Current.CancellationToken); + var glyphs = GlyfTable.ReadAll(font); + using var face = SKTypeface.FromStream(new MemoryStream(font.Write())); + var codes = Enumerable.Range(kind.First, 127 - kind.First + 1).ToArray(); + // Single pixel (33), diagonal (34), a box with a hole (35+), blank space (32), and control glyphs (0..31, 127). + Assert.Contains(33, codes); + Assert.Contains(34, codes); + for (var i = 0; i < codes.Length; i++) + { + var width = ArtWidth(codes[i], kind.Fixed); + if (glyphs[i + 1] is { } glyph) + Assert.All(glyph.Points, p => Assert.True(p.OnCurve, $"code {codes[i]} has an off-curve point")); + AssertMask(ExpectedMask(codes[i], width, 13), Render(face, i + 1, 13, ArtAscent(13), width), $"{kind.File} code {codes[i]}"); + } + // The first lit column is x=2: two blank columns on the left survive. + var box = glyphs[35 - kind.First + 1]!; + Assert.Equal(2 * Scale(13), box.XMin); + Assert.Equal(2, box.NumberOfContours); // the outline and its hole + } + + // ───────────────────────── C3: cmap ───────────────────────── + + /// CP1252 0x80..0x9F; 0 marks the five undefined bytes. + private static readonly int[] Cp1252High = + [ + 0x20AC, 0, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0, 0x017D, 0, + 0, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0, 0x017E, 0x0178, + ]; + + [Fact] + public void Cmap_excludes_controls_and_undefined_bytes_without_losing_glyphs() + { + // 0x00..0x7F from the generated sample: controls 0x00..0x1F and DEL 0x7F keep glyphs but no cmap entry. + var strike = Assert.Single(FonReader.Read(Data("Fon-v2-proportional.fon"), TestContext.Current.CancellationToken)); + var font = FonOutlineBuilder.Build(strike, TestContext.Current.CancellationToken); + var glyphs = GlyfTable.ReadAll(font); + Assert.Equal(128 + 1, font.GlyphCount); + var expected = Enumerable.Range(0x20, 0x7F - 0x20).ToDictionary(c => c, c => c + 1); + Assert.Equal(expected.OrderBy(p => p.Key), Cmap(font).OrderBy(p => p.Key)); + foreach (var code in Enumerable.Range(0, 0x20).Append(0x7F)) + { + Assert.NotNull(glyphs[code + 1]); + Assert.Equal(ArtWidth(code, false) * Scale(13), Advance(font, code + 1)); + } + + // 0x7E..0xA0: the CP1252 high half, its five undefined bytes, and NBSP. + var pixels = new bool[13, 8]; + pixels[0, 2] = true; + var high = strike with + { + Glyphs = [.. Enumerable.Range(0x7E, 0xA0 - 0x7E + 1).Select(b => new FonGlyph((byte)b, 8, pixels, 0))], + DefaultChar = 0, + }; + font = FonOutlineBuilder.Build(high, TestContext.Current.CancellationToken); + glyphs = GlyfTable.ReadAll(font); + Assert.Equal(high.Glyphs.Count + 1, font.GlyphCount); + var map = new Dictionary { [0x7E] = 1, [0xA0] = high.Glyphs.Count }; + for (var b = 0x80; b <= 0x9F; b++) + if (Cp1252High[b - 0x80] != 0) + map[Cp1252High[b - 0x80]] = b - 0x7E + 1; + Assert.Equal(map.OrderBy(p => p.Key), Cmap(font).OrderBy(p => p.Key)); + Assert.Equal(map.Count, Cmap(font).Values.Distinct().Count()); + Assert.All(Enumerable.Range(1, high.Glyphs.Count), gid => + { + Assert.NotNull(glyphs[gid]); + Assert.Equal(8 * Scale(13), Advance(font, gid)); + }); + Assert.DoesNotContain(0xFFFD, Cmap(font).Keys); + // Basic Latin, Latin-1, Latin Extended-A/B, spacing modifiers, punctuation, currency and letterlike symbols. + Assert.Equal([0, 1, 2, 3, 5, 31, 33, 35], UnicodeRangeBits(font)); + } + + [Fact] + public void Supported_charsets_are_locale_independent() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + var charsets = new Dictionary + { + [0] = 1252, + [238] = 1250, + [204] = 1251, + [161] = 1253, + [162] = 1254, + [177] = 1255, + [178] = 1256, + [186] = 1257, + [163] = 1258, + [222] = 874, + }; + var strike = Assert.Single(FonReader.Read(Data("Fon-v2-fixed.fon"), TestContext.Current.CancellationToken)); + var pixels = new bool[13, 8]; + pixels[1, 3] = true; + var glyphs = Enumerable.Range(0xA1, 0xFE - 0xA1 + 1).Select(b => new FonGlyph((byte)b, 8, pixels, 0)).ToList(); + var culture = CultureInfo.CurrentCulture; + try + { + foreach (var locale in new[] { "tr-TR", "ja-JP", "ru-RU" }) + { + CultureInfo.CurrentCulture = CultureInfo.CurrentUICulture = new CultureInfo(locale); + foreach (var (charset, codePage) in charsets) + { + var encoding = Encoding.GetEncoding(codePage, EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback); + var expected = new Dictionary(); + for (var i = 0; i < glyphs.Count; i++) + { + string text; + try { text = encoding.GetString([glyphs[i].ByteValue]); } + catch (DecoderFallbackException) { continue; } + if (text.Length != 1 || char.IsControl(text[0]) || char.IsSurrogate(text[0]) || + encoding.GetBytes(text).Single() != glyphs[i].ByteValue || + UndefinedHigh.GetValueOrDefault(charset, []).Contains(glyphs[i].ByteValue)) + continue; + expected.TryAdd(text[0], i + 1); + } + var cmap = Cmap(FonOutlineBuilder.Build(strike with { CharSet = charset, Glyphs = glyphs, DefaultChar = 0 }, TestContext.Current.CancellationToken)); + Assert.Equal(expected.OrderBy(p => p.Key), cmap.OrderBy(p => p.Key)); + } + } + } + finally + { + CultureInfo.CurrentCulture = CultureInfo.CurrentUICulture = culture; + } + // Spot checks that the tables really differ: 0xC0 is À, А (Cyrillic) or ภ (Thai) depending on the charset. + Assert.Equal(0xC0 - 0xA1 + 1, Cmap(FonOutlineBuilder.Build(strike with { CharSet = 0, Glyphs = glyphs, DefaultChar = 0 }, TestContext.Current.CancellationToken))[0x00C0]); + Assert.Equal(0xC0 - 0xA1 + 1, Cmap(FonOutlineBuilder.Build(strike with { CharSet = 204, Glyphs = glyphs, DefaultChar = 0 }, TestContext.Current.CancellationToken))[0x0410]); + Assert.Equal(0xC0 - 0xA1 + 1, Cmap(FonOutlineBuilder.Build(strike with { CharSet = 222, Glyphs = glyphs, DefaultChar = 0 }, TestContext.Current.CancellationToken))[0x0E20]); + // ulUnicodeRange follows the mapped blocks of 0xA1..0xFE in each code page. + Assert.Equal([1, 2, 5], UnicodeRangeBits(FonOutlineBuilder.Build(strike with { CharSet = 238, Glyphs = glyphs, DefaultChar = 0 }, TestContext.Current.CancellationToken))); + Assert.Equal([1, 2, 3, 6, 33], UnicodeRangeBits(FonOutlineBuilder.Build(strike with { CharSet = 163, Glyphs = glyphs, DefaultChar = 0 }, TestContext.Current.CancellationToken))); + Assert.Equal([24], UnicodeRangeBits(FonOutlineBuilder.Build(strike with { CharSet = 222, Glyphs = glyphs, DefaultChar = 0 }, TestContext.Current.CancellationToken))); + } + + /// Bytes the Unicode MICSFT tables mark UNDEFINED that .NET decodes to Private Use code points, by charset. + private static readonly Dictionary UndefinedHigh = new() + { + [161] = [0xAA, 0xD2, 0xFF], // CP1253 + [177] = [0xD9, 0xDA, 0xDB, 0xDC, 0xDD, 0xDE, 0xDF, 0xFB, 0xFC, 0xFF], // CP1255 + [186] = [0xA1, 0xA5], // CP1257 + [222] = [0xDB, 0xDC, 0xDD, 0xDE, 0xFC, 0xFD, 0xFE, 0xFF], // CP874 + }; + + [Theory] + [InlineData(161)] + [InlineData(177)] + [InlineData(186)] + [InlineData(222)] + public void Undefined_bytes_decoded_to_private_use_keep_glyphs_but_no_cmap(int charset) + { + var strike = Assert.Single(FonReader.Read(Data("Fon-v2-fixed.fon"), TestContext.Current.CancellationToken)); + var pixels = new bool[13, 8]; + pixels[1, 3] = true; + var glyphs = Enumerable.Range(0x80, 0x80).Select(b => new FonGlyph((byte)b, 8, pixels, 0)).ToList(); + var font = FonOutlineBuilder.Build(strike with { CharSet = (byte)charset, Glyphs = glyphs, DefaultChar = 0 }, TestContext.Current.CancellationToken); + var cmap = Cmap(font); + var outlines = GlyfTable.ReadAll(font); + foreach (var b in UndefinedHigh[(byte)charset]) + { + var gid = b - 0x80 + 1; + Assert.DoesNotContain(gid, cmap.Values); + Assert.NotNull(outlines[gid]); + Assert.Equal(8 * Scale(13), Advance(font, gid)); + } + Assert.DoesNotContain(cmap.Keys, c => c is >= 0xE000 and <= 0xF8FF); + Assert.Equal(cmap.Keys.Max(), U16(font.Require("OS/2"), 66)); // usLastCharIndex + Assert.True(U16(font.Require("OS/2"), 66) < 0xE000); + Assert.DoesNotContain(60, UnicodeRangeBits(font)); // Private Use Area + } + + // ───────────────────────── C4: metrics ───────────────────────── + + [Theory] + [InlineData(0)] + [InlineData(1)] + public async Task Metrics_preserve_ascent_baseline_proportional_and_space(int kindIndex) + { + var kind = Kinds[kindIndex]; + var job = await fx.ConvertAsync([Source(Data(kind.File), out _)], PresetFor("ttf")); + var output = Assert.Single(job.Outputs); + var font = FontFile.Load(File.ReadAllBytes(output), TestContext.Current.CancellationToken); + var upem = U16(font.Require("head"), 18); + var s = upem / 13; + Assert.Equal(13 * s, upem); + var (hhea, os2) = (font.Require("hhea"), font.Require("OS/2")); + Assert.Equal((10 * s, -3 * s, 2 * s), (I16(hhea, 4), I16(hhea, 6), I16(hhea, 8))); + Assert.Equal((10 * s, -3 * s, 2 * s), (I16(os2, 68), I16(os2, 70), I16(os2, 72))); + + var glyphs = GlyfTable.ReadAll(font); + using var face = SKTypeface.FromFile(output); + using var skFont = new SKFont(face, 13) { Hinting = SKFontHinting.None, Subpixel = true }; + Assert.Equal(-10f, skFont.Metrics.Ascent, 3); + Assert.Equal(3f, skFont.Metrics.Descent, 3); + var codes = Enumerable.Range(kind.First, 127 - kind.First + 1).ToArray(); + var widths = new float[codes.Length + 1]; + skFont.GetGlyphWidths([.. Enumerable.Range(0, codes.Length + 1).Select(g => (ushort)g)], widths, []); + for (var i = 0; i < codes.Length; i++) + { + var width = ArtWidth(codes[i], kind.Fixed); + Assert.Equal(width * s, Advance(font, i + 1)); + Assert.Equal(width, widths[i + 1], 3); + if (codes[i] == 32) + { + Assert.Null(glyphs[i + 1]); + Assert.Equal(0, Lsb(font, i + 1)); + continue; + } + var firstLit = Enumerable.Range(0, width).First(x => Enumerable.Range(0, 13).Any(y => ArtLit(codes[i], x, y, width, 13))); + Assert.Equal(2, firstLit); + Assert.Equal(firstLit * s, glyphs[i + 1]!.XMin); + Assert.Equal(glyphs[i + 1]!.XMin, Lsb(font, i + 1)); // pp1 = xMin - lsb = 0 + var mask = Render(face, i + 1, 13, 10, width); + var renderedFirst = Enumerable.Range(0, width + Pad * 2).First(x => Enumerable.Range(0, 13 + Pad * 2).Any(y => mask[y, x])) - Pad; + Assert.Equal(2, renderedFirst); + } + // Proportional widths stay different; the fixed sample's blank space keeps its 8 pixels. + Assert.Equal(kind.Fixed ? 1 : 3, codes.Select(c => ArtWidth(c, kind.Fixed)).Distinct().Count()); + Assert.Equal(ArtWidth(32, kind.Fixed) * s, Advance(font, 32 - kind.First + 1)); + } + + [Fact] + public void Hhea_extrema_and_caret_match_contoured_glyphs() + { + var kind = Kinds[1]; // proportional, 0x00..0x7F, default char 0 + var font = FonOutlineBuilder.Build(Assert.Single(FonReader.Read(Data(kind.File), TestContext.Current.CancellationToken)), TestContext.Current.CancellationToken); + var s = Scale(13); + var contoured = new List<(int Advance, int Lsb, int XMin, int XMax)>(); + var advances = new List(); + foreach (var code in new[] { kind.First }.Concat(Enumerable.Range(kind.First, 128))) // .notdef copies code 0 + { + var width = ArtWidth(code, false); + advances.Add(width * s); + var lit = Enumerable.Range(0, width).Where(x => Enumerable.Range(0, 13).Any(y => ArtLit(code, x, y, width, 13))).ToList(); + if (lit.Count > 0) + contoured.Add((width * s, lit.Min() * s, lit.Min() * s, (lit.Max() + 1) * s)); + } + var hhea = font.Require("hhea"); + Assert.Equal(advances.Max(), U16(hhea, 10)); + Assert.Equal(contoured.Min(g => g.Lsb), I16(hhea, 12)); + Assert.Equal(contoured.Min(g => g.Advance - (g.Lsb + g.XMax - g.XMin)), I16(hhea, 14)); + Assert.Equal(contoured.Max(g => g.Lsb + g.XMax - g.XMin), I16(hhea, 16)); + Assert.Equal((1, 0, 0), (I16(hhea, 18), I16(hhea, 20), I16(hhea, 22))); + Assert.Equal([0, 0, 0, 0, 0], new[] { I16(hhea, 24), I16(hhea, 26), I16(hhea, 28), I16(hhea, 30), I16(hhea, 32) }); + Assert.Equal(font.GlyphCount, U16(hhea, 34)); + } + + // ───────────────────────── C5: names ───────────────────────── + + private static string PsEscape(string full) => string.Concat(Encoding.UTF8.GetBytes(full).Select(b => + char.IsAsciiLetterOrDigit((char)b) || b == '-' ? ((char)b).ToString() : $"_{b:X2}")); + + [Fact] + public void Names_and_style_bits_match_each_resource() + { + var single = Assert.Single(FonReader.Read(Data("Fon-v2-fixed.fon"), TestContext.Current.CancellationToken)); + void Check(FonStrike strike, string family, string style, int weight, int fsSelection, int macStyle, int count = 1, int ordinal = 1) + { + var font = FonOutlineBuilder.Build(strike, TestContext.Current.CancellationToken, count, ordinal); + var full = $"{family} {style}"; + Assert.Equal(family, Name(font, 1)); + Assert.Equal(style, Name(font, 2)); + Assert.Equal(full, Name(font, 4)); + var ps = PsEscape(full); + Assert.Equal(ps.Length <= 63 ? ps : Name(font, 6), Name(font, 6)); + Assert.Null(Name(font, 16)); + Assert.Null(Name(font, 17)); + Assert.Equal(weight, U16(font.Require("OS/2"), 4)); + Assert.Equal(fsSelection, U16(font.Require("OS/2"), 62)); + Assert.Equal(macStyle, U16(font.Require("head"), 44)); + } + + Check(single, "Fixed", "Regular", 400, 0x40, 0); + Assert.Equal("Fixed_20Regular", Name(FonOutlineBuilder.Build(single, TestContext.Current.CancellationToken), 6)); + Check(single with { Weight = 700, Italic = true }, "Fixed", "Bold Italic", 700, 0x21, 3); + Check(single with { Weight = 700 }, "Fixed", "Bold", 700, 0x20, 1); + Check(single with { Italic = true }, "Fixed", "Italic", 400, 0x01, 2); + var unweighted = Data("Fon-v2-fixed.fon"); + BinaryPrimitives.WriteUInt16LittleEndian(unweighted.AsSpan(FntStart(unweighted) + 0x53), 0); + Check(Assert.Single(FonReader.Read(unweighted, TestContext.Current.CancellationToken)), "Fixed", "Regular", 400, 0x40, 0); + Check(single with { Weight = 500 }, "Fixed Weight500", "Regular", 500, 0x40, 0); + Check(single, "Fixed 13px", "Regular", 400, 0x40, 0, count: 3); + Check(single, "Fixed 13px-2", "Regular", 400, 0x40, 0, count: 3, ordinal: 2); + Assert.Equal("Fixed_2013px-2_20Regular", Name(FonOutlineBuilder.Build(single, TestContext.Current.CancellationToken, 3, 2), 6)); + + // A long non-ASCII face: nameID 6 is cut at a token boundary (at most 50) and gets a hash of the full name. + var face = string.Concat(Enumerable.Repeat("Ä", 40)); + var font = FonOutlineBuilder.Build(single with { Face = face }, TestContext.Current.CancellationToken); + var full = $"{face} Regular"; + var ps = Name(font, 6)!; + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(full)).AsSpan(0, 6)); + Assert.Equal(PsEscape(full)[..48] + "-" + hash, ps); + Assert.True(ps.Length <= 63); + Assert.Equal(face, Name(font, 1)); + } + + [Theory] + [InlineData("ttf")] + [InlineData("otf")] + [InlineData("woff")] + [InlineData("woff2")] + [InlineData("eot")] + public async Task Multi_resource_outputs_have_distinct_name_ids(string format) + { + var source = Source(Data("Fon-multi.fon"), out var folder); + var job = await fx.ConvertAsync([source], PresetFor(format)); + Assert.True(job.State == JobState.Completed, string.Join("; ", job.Files.Select(f => $"{f.ErrorKey} {f.ErrorDetail}"))); + var expected = new[] + { + ("Name-13px", "Fixed 13px", "Fixed_2013px_20Regular"), + ("Name-13px-2", "Fixed 13px-2", "Fixed_2013px-2_20Regular"), + ("Name-14px", "Fixed 14px", "Fixed_2014px_20Regular"), + }; + Assert.Equal(expected.Select(e => Path.Combine(folder, $"{e.Item1}.{format}")), job.Outputs); + var names = new List<(string?, string?, string?)>(); + foreach (var (file, family, ps) in expected) + { + var font = FontFile.Load(File.ReadAllBytes(Path.Combine(folder, $"{file}.{format}")), TestContext.Current.CancellationToken); + Assert.Equal(family, Name(font, 1)); + Assert.Equal("Regular", Name(font, 2)); + Assert.Equal(family + " Regular", Name(font, 4)); + Assert.Equal(ps, Name(font, 6)); + Assert.Null(Name(font, 16)); + Assert.Null(Name(font, 17)); + names.Add((Name(font, 1), Name(font, 4), Name(font, 6))); + } + Assert.Equal(3, names.Select(n => n.Item1).Distinct().Count()); + Assert.Equal(3, names.Select(n => n.Item2).Distinct().Count()); + Assert.Equal(3, names.Select(n => n.Item3).Distinct().Count()); + } + + [Fact] + public async Task Multi_resource_same_height_styles_have_style_names_and_ordinals() + { + var source = Source(FonBuilder.MixedStyles(), out var folder); + var job = await fx.ConvertAsync([source], PresetFor("ttf")); + Assert.True(job.State == JobState.Completed, string.Join("; ", job.Files.Select(f => $"{f.ErrorKey} {f.ErrorDetail}"))); + var expected = new[] + { + ("Name-13px.ttf", "Name 13px", "Regular"), + ("Name-13px-Bold.ttf", "Name 13px", "Bold"), + ("Name-13px-Italic.ttf", "Name 13px", "Italic"), + ("Name-13px-BoldItalic.ttf", "Name 13px", "Bold Italic"), + ("Name-13px-Weight600.ttf", "Name 13px Weight600", "Regular"), + ("Name-13px-Weight600Italic.ttf", "Name 13px Weight600", "Italic"), + ("Name-13px-2.ttf", "Name 13px-2", "Regular"), + ("Name-13px-Bold-2.ttf", "Name 13px-2", "Bold"), + ("Name-14px.ttf", "Name 14px", "Regular"), + }; + Assert.Equal(expected.Select(e => Path.Combine(folder, e.Item1)), job.Outputs); + foreach (var (file, family, style) in expected) + { + var font = FontFile.Load(File.ReadAllBytes(Path.Combine(folder, file)), TestContext.Current.CancellationToken); + Assert.Equal(family, Name(font, 1)); + Assert.Equal(style, Name(font, 2)); + } + } + + /// Hands out Name{suffix}.{ext} paths in a folder and records each request. + private sealed class RecordingAllocator(string folder) : IOutputAllocator + { + public List Suffixes { get; } = []; + + public string? Allocate(string extension, string? suffix = null) + { + Suffixes.Add(suffix); + return Path.Combine(folder, $"Name{suffix}.{extension}"); + } + } + + private sealed class SyncProgress(Action report) : IProgress + { + public void Report(double value) => report(value); + } + + [Fact] + public async Task Same_height_resources_remain_separate() + { + var data = Data("Fon-multi.fon"); + var second = FntStart(data, 1); + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(second + 0x53), 700); + data[second + 0x50] = 1; + var source = Source(data, out var folder); + var allocator = new RecordingAllocator(folder); + var outputs = await new FontConverter().ConvertAsync( + new ConversionStep(source, "fon", "ttf", PresetFor("ttf"), allocator, fx.NewFolder()), null, TestContext.Current.CancellationToken); + + Assert.Equal(["-13px", "-13px-BoldItalic", "-14px"], allocator.Suffixes); + Assert.Equal(3, outputs.Count); + var fonts = outputs.Select(o => FontFile.Load(File.ReadAllBytes(o))).ToArray(); + Assert.Equal(["Fixed 13px", "Fixed 13px", "Fixed 14px"], fonts.Select(f => Name(f, 1))); + Assert.Equal(["Regular", "Bold Italic", "Regular"], fonts.Select(f => Name(f, 2))); + Assert.Equal(3, outputs.Select(o => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(o)))).Distinct().Count()); + Assert.Equal(fonts[0].Require("glyf"), fonts[1].Require("glyf")); // same pixels, still two fonts + } + + // ───────────────────────── C6/C8: queue post-processing ───────────────────────── + + [Fact] + public async Task Single_strike_subfolder_only_follows_queue_postprocessing() + { + Preset Subfolder(ConflictPolicy conflict) => new() + { + TargetFormat = "ttf", + Output = { Location = OutputLocation.Subfolder, SubfolderOnlyForSeveralFiles = true, Conflict = conflict }, + }; + + // No conflict: the one file goes next to the source, without a size suffix, and no subfolder is left. + var source = Source(Data("Fon-v2-fixed.fon"), out var folder); + var job = await fx.ConvertAsync([source], Subfolder(ConflictPolicy.Rename)); + Assert.Equal([Path.Combine(folder, "Name.ttf")], job.Outputs); + Assert.False(Directory.Exists(Path.Combine(folder, "converted"))); + + // Skip: the existing file next to the source stays, the result stays in the subfolder. + source = Source(Data("Fon-v2-fixed.fon"), out folder); + var existing = Path.Combine(folder, "Name.ttf"); + File.WriteAllText(existing, "old"); + job = await fx.ConvertAsync([source], Subfolder(ConflictPolicy.Skip)); + Assert.Equal(FileState.Done, job.Files[0].State); + Assert.Equal([Path.Combine(folder, "converted", "Name.ttf")], job.Outputs); + Assert.Equal("old", File.ReadAllText(existing)); + + // Overwrite: the result replaces it. + source = Source(Data("Fon-v2-fixed.fon"), out folder); + existing = Path.Combine(folder, "Name.ttf"); + File.WriteAllText(existing, "old"); + job = await fx.ConvertAsync([source], Subfolder(ConflictPolicy.Overwrite)); + Assert.Equal([existing], job.Outputs); + Assert.Equal(0x00010000u, BinaryPrimitives.ReadUInt32BigEndian(File.ReadAllBytes(existing))); + Assert.False(Directory.Exists(Path.Combine(folder, "converted"))); + + // The move fails (a folder holds the name): the queue keeps the result where it was written. + source = Source(Data("Fon-v2-fixed.fon"), out folder); + Directory.CreateDirectory(Path.Combine(folder, "Name.ttf")); + job = await fx.ConvertAsync([source], Subfolder(ConflictPolicy.Overwrite)); + Assert.Equal([Path.Combine(folder, "converted", "Name.ttf")], job.Outputs); + Assert.True(Directory.Exists(Path.Combine(folder, "Name.ttf"))); + } + + // ───────────────────────── C8: nothing written before every output is built ───────────────────────── + + private static byte[] Resource(byte[] fon, int index) + { + var ne = (int)BinaryPrimitives.ReadUInt32LittleEndian(fon.AsSpan(0x3c)); + var table = ne + BinaryPrimitives.ReadUInt16LittleEndian(fon.AsSpan(ne + 0x24)); + var entry = table + 2 + 8 + 12 + 8 + index * 12; + var start = BinaryPrimitives.ReadUInt16LittleEndian(fon.AsSpan(entry)); + return fon[start..(start + BinaryPrimitives.ReadUInt16LittleEndian(fon.AsSpan(entry + 2)))]; + } + + /// MZ/NE with a FONTDIR and the given RT_FONT resources. + private static byte[] Container(params byte[][] resources) + { + const int ne = 0x40, table = ne + 0x40; + var payload = table + 2 + 8 + 12 + 8 + 12 * resources.Length + 2; + var data = new byte[payload + 1 + resources.Sum(r => r.Length)]; + data[0] = (byte)'M'; + data[1] = (byte)'Z'; + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x3c), ne); + data[ne] = (byte)'N'; + data[ne + 1] = (byte)'E'; + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(ne + 0x24), table - ne); + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(ne + 0x26), (ushort)(payload - ne)); + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(table + 2), 0x8007); + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(table + 4), 1); + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(table + 10), (ushort)payload); + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(table + 12), 1); + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(table + 22), 0x8008); + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(table + 24), (ushort)resources.Length); + var at = payload + 1; + for (var i = 0; i < resources.Length; i++) + { + var entry = table + 30 + i * 12; + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(entry), (ushort)at); + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(entry + 2), (ushort)resources[i].Length); + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(entry + 6), (ushort)(0x8001 + i)); + resources[i].CopyTo(data, at); + at += resources[i].Length; + } + return data; + } + + /// A valid FNT v2: 1 pixel high, one glyph 33 pixels wide whose last column (x=32) is lit. + private static byte[] WideOnePixelFnt() + { + const int table = 0x76, face = table + 2 * 4, bits = face + 6; + var data = new byte[bits + 5]; + BinaryPrimitives.WriteUInt16LittleEndian(data, 0x0200); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(2), (uint)data.Length); + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(0x4a), 1); + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(0x53), 400); + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(0x58), 1); + data[0x5f] = data[0x60] = 32; + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x69), face); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x71), bits); + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(table), 33); + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(table + 2), bits); + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(table + 6), (ushort)data.Length); + "Fixed\0"u8.CopyTo(data.AsSpan(face)); + data[bits + 4] = 0x80; + return data; + } + + /// Every glyph aliases one blank bitmap; the decoded pixel count is not bounded by FNT size. + private static byte[] AliasedFnt(int width, int height, int glyphCount = 128) + { + const int table = 0x76; + var face = table + (glyphCount + 1) * 4; + var bits = face + 6; + var data = new byte[bits + ((width + 7) / 8) * height]; + BinaryPrimitives.WriteUInt16LittleEndian(data, 0x0200); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(2), (uint)data.Length); + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(0x4a), (ushort)height); + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(0x53), 400); + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(0x58), (ushort)height); + data[0x60] = (byte)(glyphCount - 1); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x69), (uint)face); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(0x71), (uint)bits); + for (var i = 0; i < glyphCount; i++) + { + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(table + i * 4), (ushort)width); + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(table + i * 4 + 2), (ushort)bits); + } + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(table + glyphCount * 4 + 2), (ushort)data.Length); + "Fixed\0"u8.CopyTo(data.AsSpan(face)); + return data; + } + + private static void AssertRefused(Action action, string key, string? detail = null) + { + var error = Assert.Throws(action); + Assert.Equal(key, error.ErrorKey); + Assert.Equal(detail, error.Detail); + Assert.True(error.Detail is null || Regex.IsMatch(error.Detail, + @"^(PE|FNT 0x[0-9A-F]{4}|dfFlags 0x[0-9A-F]{8}|charset \d+|[0-9][0-9,]*)$")); + } + + [Fact] + public void Refusal_details_are_language_neutral() + { + var data = Data("Fon-v2-fixed.fon"); + var offset = FntStart(data); + var cases = new List<(byte[] Data, string Key, string? Detail)>(); + data[offset + 0x42] = 1; + cases.Add((data, "error.fon_vector", null)); + data = Data("Fon-v2-fixed.fon"); + data[FntStart(data) + 0x55] = 128; + cases.Add((data, "error.fon_unsupported", "charset 128")); + cases.Add((FonBuilder.Container([AliasedFnt(64, 1, 1)]), "error.fon_too_large", "65535")); + cases.Add(([], "error.fon_damaged", null)); + + foreach (var (sample, key, detail) in cases) + AssertRefused(() => FonReader.Read(sample, TestContext.Current.CancellationToken), key, detail); + } + + /// + /// Runs as Name.fon under each conflict policy, with every name it would write already + /// taken, and checks that nothing changed and nothing was reported. + /// + private async Task AssertNothingWrittenAsync(byte[] data, string[] taken, FileState state, + string? errorKey = null, string? detail = null) + { + foreach (var policy in Enum.GetValues()) + { + var source = Source(data, out var folder); + for (var i = 0; i < taken.Length; i++) + File.WriteAllText(Path.Combine(folder, taken[i]), $"old {i}"); + var before = Snapshot(folder); + + var job = await fx.ConvertAsync([source], PresetFor("ttf", policy)); + + var file = Assert.Single(job.Files); + Assert.Equal(state, file.State); + if (errorKey is not null) + { + Assert.Equal(errorKey, file.ErrorKey); + Assert.Equal(detail, file.ErrorDetail); + } + Assert.Empty(job.Outputs); + Assert.Equal(before.OrderBy(p => p.Key), Snapshot(folder).OrderBy(p => p.Key)); + } + } + + [Fact] + public async Task Fon_prewrite_parser_failure_preserves_outputs_under_each_conflict_policy() + { + // The second RT_FONT's first bitmap points outside the resource: the reader fails before any output is built. + var data = Data("Fon-multi.fon"); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(FntStart(data, 1) + 0x94 + 2), 0x7fffffff); + AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken), "error.fon_damaged"); + + await AssertNothingWrittenAsync(data, ["Name-13px.ttf", "Name-13px-2.ttf", "Name-14px.ttf"], FileState.Failed, "error.fon_damaged"); + } + + [Fact] + public async Task Fon_prewrite_builder_failure_preserves_outputs_under_each_conflict_policy() + { + var first = Resource(Data("Fon-multi.fon"), 0); + var data = Container(first, WideOnePixelFnt()); + var strikes = FonReader.Read(data, TestContext.Current.CancellationToken); + Assert.Equal([13, 1], strikes.Select(s => s.PixelHeight)); + // The first resource builds completely on its own; the second passes the reader but not the builder. + Assert.NotEmpty(FontFile.Save(FonOutlineBuilder.Build(strikes[0], TestContext.Current.CancellationToken, 2), "ttf", TestContext.Current.CancellationToken)); + AssertRefused(() => FonOutlineBuilder.Build(strikes[1], TestContext.Current.CancellationToken, 2), "error.fon_too_large", "32767"); + + await AssertNothingWrittenAsync(data, ["Name-13px.ttf", "Name-1px.ttf"], FileState.Failed, "error.fon_too_large", "32767"); + } + + [Fact] + public async Task Fon_prewrite_cancellation_leaves_no_outputs() + { + var source = Source(Data("Fon-multi.fon"), out var folder); + File.WriteAllText(Path.Combine(folder, "Name-13px.ttf"), "old"); + var before = Snapshot(folder); + var allocator = new RecordingAllocator(folder); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + var reports = new List(); + // Synchronous: the report right after the first output was built cancels before anything else happens. + var progress = new SyncProgress(value => + { + reports.Add(value); + if (value == 0.85) + cts.Cancel(); + }); + + var conversion = new FontConverter().ConvertAsync( + new ConversionStep(source, "fon", "ttf", PresetFor("ttf"), allocator, fx.NewFolder()), progress, cts.Token); + + await Assert.ThrowsAnyAsync(() => conversion.WaitAsync(TimeSpan.FromSeconds(60), TestContext.Current.CancellationToken)); + Assert.Contains(0.85, reports); + Assert.Empty(allocator.Suffixes); + Assert.Equal(before.OrderBy(p => p.Key), Snapshot(folder).OrderBy(p => p.Key)); + } + + [Fact] + public async Task Aliased_glyphs_over_filewide_pixel_limit_fail_without_outputs() + { + var data = Container(AliasedFnt(16, 1024), AliasedFnt(17, 1024)); + AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken), + "error.fon_too_large", FonReader.MaxGlyphPixels.ToString("N0", CultureInfo.InvariantCulture)); + + await AssertNothingWrittenAsync(data, ["Name-1024px.ttf", "Name-1024px-2.ttf"], FileState.Failed, + "error.fon_too_large", FonReader.MaxGlyphPixels.ToString("N0", CultureInfo.InvariantCulture)); + } + + [Fact] + public void Glyph_advance_above_65535_is_rejected_before_decoding() + { + var data = FonBuilder.Container([AliasedFnt(64, 1, 1)]); + + AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken), "error.fon_too_large", "65535"); + } + + [Fact] + public void Blank_glyph_with_advance_above_signed_coordinate_limit_converts() + { + var resource = AliasedFnt(33, 1, 2); + BinaryPrimitives.WriteUInt16LittleEndian(resource.AsSpan(0x76 + 4), 0); + var data = FonBuilder.Container([resource]); + var strike = Assert.Single(FonReader.Read(data, TestContext.Current.CancellationToken)); + var font = FontFile.Load(FontFile.Save( + FonOutlineBuilder.Build(strike, TestContext.Current.CancellationToken), "ttf", TestContext.Current.CancellationToken), + TestContext.Current.CancellationToken); + + Assert.Equal(33 * 1024, Advance(font, 1)); + Assert.Null(GlyfTable.ReadAll(font)[1]); + } + + [Fact] + public void Shared_large_bitmap_is_refused_with_less_than_one_megabyte_allocated() + { + var data = FonBuilder.Container([AliasedFnt(4096, 1024, 224)], alignmentShift: 9); + Assert.Single(FonReader.Read(FonBuilder.V2Fixed(), TestContext.Current.CancellationToken)); + + var before = GC.GetAllocatedBytesForCurrentThread(); + AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken), + "error.fon_too_large", FonReader.MaxGlyphPixels.ToString("N0", CultureInfo.InvariantCulture)); + var allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.True(allocated < 1_000_000, $"Reader allocated {allocated:N0} bytes."); + } + + [Fact] + public void Checkerboard_glyph_over_point_limit_is_refused_before_tracing() + { + var pixels = new bool[2048, 2048]; + for (var y = 0; y < 2048; y++) + for (var x = 0; x < 2048; x++) + pixels[y, x] = ((x + y) & 1) == 0; + var strike = Assert.Single(FonReader.Read(Data("Fon-v2-fixed.fon"), TestContext.Current.CancellationToken)) with + { + PixelHeight = 2048, + Ascent = 2048, + Glyphs = [new FonGlyph(32, 2048, pixels, 0)], + DefaultChar = 0 + }; + + var before = GC.GetAllocatedBytesForCurrentThread(); + AssertRefused(() => FonOutlineBuilder.Build(strike, TestContext.Current.CancellationToken), "error.fon_too_large", "65535"); + var allocated = GC.GetAllocatedBytesForCurrentThread() - before; + Assert.True(allocated < 1_000_000, $"Builder allocated {allocated:N0} bytes."); + } + + [Fact] + public void Glyphs_sharing_a_bitmap_build_one_outline() + { + var pixels = new bool[64, 64]; + for (var y = 0; y < 64; y++) + for (var x = 0; x < 64; x++) + pixels[y, x] = ((x + y) & 1) == 0; + var strike = Assert.Single(FonReader.Read(Data("Fon-v2-fixed.fon"), TestContext.Current.CancellationToken)) with + { + PixelHeight = 64, + Ascent = 64, + Glyphs = [new FonGlyph(32, 64, pixels, 100)], + DefaultChar = 0 + }; + var before = GC.GetAllocatedBytesForCurrentThread(); + var single = FonOutlineBuilder.Build(strike, TestContext.Current.CancellationToken); + var singleAllocated = GC.GetAllocatedBytesForCurrentThread() - before; + strike = strike with + { + Glyphs = [.. Enumerable.Range(32, 64).Select(code => new FonGlyph((byte)code, 64, pixels, 100))] + }; + before = GC.GetAllocatedBytesForCurrentThread(); + var shared = FonOutlineBuilder.Build(strike, TestContext.Current.CancellationToken); + var sharedAllocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.True(sharedAllocated < singleAllocated * 4, + $"Shared build allocated {sharedAllocated:N0} bytes versus {singleAllocated:N0} for one glyph."); + var glyphs = GlyfTable.ReadAll(shared); + var expected = GlyfTable.ReadAll(single)[1]!.Serialize(); + Assert.All(glyphs.Skip(1), glyph => Assert.Equal(expected, glyph!.Serialize())); + } + + [Fact] + public void Glyphs_with_one_offset_but_different_widths_keep_their_own_outlines() + { + var narrow = new bool[8, 8]; + var wide = new bool[8, 12]; + narrow[0, 7] = true; + wide[0, 11] = true; + var strike = Assert.Single(FonReader.Read(Data("Fon-v2-fixed.fon"), TestContext.Current.CancellationToken)) with + { + PixelHeight = 8, + Ascent = 8, + DefaultChar = 0, + Glyphs = [new FonGlyph(32, 8, narrow, 100), new FonGlyph(33, 12, wide, 100)] + }; + var glyphs = GlyfTable.ReadAll(FonOutlineBuilder.Build(strike, TestContext.Current.CancellationToken)); + + Assert.Equal(8 * 128, glyphs[1]!.XMax); + Assert.Equal(12 * 128, glyphs[2]!.XMax); + } + + [Fact] + public void Point_count_before_tracing_matches_the_outline() + { + var template = Assert.Single(FonReader.Read(Data("Fon-v2-fixed.fon"), TestContext.Current.CancellationToken)); + var random = new Random(561); + foreach (var (height, width, threshold) in new[] { (8, 8, 2), (13, 17, 4), (32, 32, 7), (32, 32, 10) }) + { + var pixels = new bool[height, width]; + for (var y = 0; y < height; y++) + for (var x = 0; x < width; x++) + pixels[y, x] = random.Next(12) < threshold; + AssertPoints(pixels); + } + var checkerboard = new bool[32, 32]; + for (var y = 0; y < 32; y++) + for (var x = 0; x < 32; x++) + checkerboard[y, x] = ((x + y) & 1) == 0; + AssertPoints(checkerboard); + AssertPoints(new bool[8, 8]); + + void AssertPoints(bool[,] pixels) + { + var height = pixels.GetLength(0); + var width = pixels.GetLength(1); + var strike = template with + { + PixelHeight = height, + Ascent = height, + DefaultChar = 0, + Glyphs = [new FonGlyph(32, width, pixels, 0)] + }; + var counted = FonOutlineBuilder.CountPoints(pixels, width, 65535, TestContext.Current.CancellationToken); + var glyph = GlyfTable.ReadAll(FonOutlineBuilder.Build(strike, TestContext.Current.CancellationToken))[1]; + Assert.Equal(counted, glyph?.Points.Length ?? 0); + if (counted == 0) + Assert.Null(glyph); + } + } + + [Fact] + public void Aliased_glyphs_at_filewide_pixel_limit_are_accepted() + { + var data = Container(AliasedFnt(16, 1024), AliasedFnt(16, 1024)); + var strikes = FonReader.Read(data, TestContext.Current.CancellationToken); + Assert.Equal(FonReader.MaxGlyphPixels, strikes.Sum(s => s.Glyphs.Sum(g => (long)g.Width * s.PixelHeight))); + Assert.All(strikes, s => Assert.All(s.Glyphs, g => Assert.False(g.Pixels[0, 0]))); + } + + [Fact] + public void Zero_width_glyph_rows_count_toward_filewide_budget() + { + var atLimit = Container(AliasedFnt(0, 16384, 256)); + var strikes = FonReader.Read(atLimit, TestContext.Current.CancellationToken); + var strike = Assert.Single(strikes); + Assert.Equal(256, strike.Glyphs.Count); + Assert.Equal(FonReader.MaxGlyphPixels, strike.Glyphs.Sum(g => (long)Math.Max(g.Width, 1) * strike.PixelHeight)); + + var overLimit = Container(AliasedFnt(0, 16384, 256), AliasedFnt(0, 1, 1)); + AssertRefused(() => FonReader.Read(overLimit, TestContext.Current.CancellationToken), + "error.fon_too_large", FonReader.MaxGlyphPixels.ToString("N0", CultureInfo.InvariantCulture)); + } + + [Fact] + public void Already_cancelled_token_stops_reader_on_valid_fon() + { + var data = Data("Fon-v2-fixed.fon"); + Assert.ThrowsAny(() => FonReader.Read(data, new CancellationToken(true))); + } + + [Fact] + public void Already_cancelled_token_stops_builder_on_legal_strike() + { + var strike = Assert.Single(FonReader.Read(Data("Fon-v2-fixed.fon"), TestContext.Current.CancellationToken)); + Assert.ThrowsAny(() => FonOutlineBuilder.Build(strike, new CancellationToken(true))); + } + + /// Cancels its token when the first full pass over the glyphs ends, i.e. after every outline is traced. + private sealed class CancelAfterFirstPass(IReadOnlyList glyphs, CancellationTokenSource cts) : IReadOnlyList + { + public FonGlyph this[int index] => glyphs[index]; + public int Count => glyphs.Count; + public IEnumerator GetEnumerator() + { + foreach (var glyph in glyphs) + yield return glyph; + cts.Cancel(); + } + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } + + [Fact] + public void Cancellation_after_tracing_stops_builder_before_serialization() + { + var strike = Assert.Single(FonReader.Read(Data("Fon-v2-fixed.fon"), TestContext.Current.CancellationToken)); + using var cts = new CancellationTokenSource(); + var watched = strike with { Glyphs = new CancelAfterFirstPass(strike.Glyphs, cts) }; + + Assert.ThrowsAny(() => FonOutlineBuilder.Build(watched, cts.Token)); + Assert.True(cts.IsCancellationRequested); + } + + [Fact] + public void Pixel_height_above_units_per_em_limit_fails_in_reader() + { + var data = Container(AliasedFnt(0, 16385, 1)); + AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken), "error.fon_too_large", "16384"); + } + + [Fact] + public async Task Cancelled_large_legal_strike_stops_builder_and_preserves_outputs() + { + var data = Container(AliasedFnt(128, 128, 256)); + var source = Source(data, out var folder); + File.WriteAllText(Path.Combine(folder, "Name.ttf"), "old"); + var before = Snapshot(folder); + var allocator = new RecordingAllocator(folder); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + var reports = new List(); + var progress = new SyncProgress(value => + { + reports.Add(value); + if (value == 0.1) + cts.Cancel(); + }); + + await Assert.ThrowsAnyAsync(() => new FontConverter().ConvertAsync( + new ConversionStep(source, "fon", "ttf", PresetFor("ttf"), allocator, fx.NewFolder()), progress, cts.Token)); + Assert.Contains(0.1, reports); + Assert.Empty(allocator.Suffixes); + Assert.Equal(before.OrderBy(p => p.Key), Snapshot(folder).OrderBy(p => p.Key)); + } + + [Fact] + public void Later_rt_font_overlapping_nonadjacent_resource_is_rejected() + { + var resource = AliasedFnt(8, 8, 1); + var data = Container(resource, resource, resource); + var ne = (int)BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(0x3c)); + var table = ne + BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(ne + 0x24)); + var thirdEntry = table + 30 + 2 * 12; + var thirdStart = FntStart(data) + 1; + Assert.True(thirdStart + 1 <= FntStart(data, 1)); + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(thirdEntry), (ushort)thirdStart); + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(thirdEntry + 2), 1); + + AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken), "error.fon_damaged"); + } + + [Fact] + public async Task Queue_precancel_preserves_existing_outputs() + { + foreach (var policy in Enum.GetValues()) + { + var source = Source(Data("Fon-multi.fon"), out var folder); + File.WriteAllText(Path.Combine(folder, "Name-13px.ttf"), "old"); + var before = Snapshot(folder); + await using var queue = new JobQueue(fx.Catalog); + var job = new ConversionJob([source], PresetFor("ttf", policy), "test"); + job.Cancel(); + + await queue.RunAsync(job); + + Assert.Equal(JobState.Cancelled, job.State); + Assert.Equal(FileState.Cancelled, Assert.Single(job.Files).State); + Assert.Empty(job.Outputs); + Assert.Equal(before.OrderBy(p => p.Key), Snapshot(folder).OrderBy(p => p.Key)); + } + } + + // ───────────────────────── C9: extremes ───────────────────────── + + [Fact] + public async Task Extreme_heights_preserve_masks_and_overflows_fail() + { + var strikes = FonReader.Read(Data("Fon-edge.fon"), TestContext.Current.CancellationToken); + Assert.Equal([1, 256], strikes.Select(s => s.PixelHeight)); + foreach (var strike in strikes) + { + var height = strike.PixelHeight; + var font = FonOutlineBuilder.Build(strike, TestContext.Current.CancellationToken); + Assert.Equal(1024, U16(font.Require("head"), 18)); + using var face = SKTypeface.FromStream(new MemoryStream(font.Write())); + for (var code = 32; code <= 34; code++) + AssertMask(ExpectedMask(code, 8, height), Render(face, code - 32 + 1, height, ArtAscent(height), 8), $"{height}px code {code}"); + } + + // x=32 at 1024 units per pixel is 32768: past signed 16-bit, an explicit error, never a wrapped coordinate. + var pixels = new bool[1, 33]; + pixels[0, 32] = true; + var overflowing = strikes[0] with { Glyphs = [new FonGlyph(32, 33, pixels, 0)], DefaultChar = 0 }; + AssertRefused(() => FonOutlineBuilder.Build(overflowing, TestContext.Current.CancellationToken), "error.fon_too_large", "32767"); + // 31 pixels wide, x=30 lit: every coordinate and advance ends at 31 * 1024 = 31744 and fits. + pixels = new bool[1, 31]; + pixels[0, 30] = true; + var fits = FonOutlineBuilder.Build(strikes[0] with { Glyphs = [new FonGlyph(32, 31, pixels, 0)], DefaultChar = 0 }, TestContext.Current.CancellationToken); + Assert.Equal(31 * 1024, GlyfTable.ReadAll(fits)[1]!.XMax); + + // A bitmap that ends exactly at the end of the resource is read; one byte further is a clear error. + var data = Data("Fon-v2-fixed.fon"); + var fnt = FntStart(data); + var size = (int)BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(fnt + 2)); + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(fnt + 0x76 + 2), (ushort)(size - 13)); + Assert.Single(FonReader.Read(data, TestContext.Current.CancellationToken)); + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(fnt + 0x76 + 2), (ushort)(size - 12)); + AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken), "error.fon_damaged"); + + // Through the queue: an overflowing resource writes nothing. + var source = Source(Container(WideOnePixelFnt()), out var folder); + var job = await fx.ConvertAsync([source], PresetFor("ttf")); + var failed = Assert.Single(job.Files); + Assert.Equal(FileState.Failed, failed.State); + Assert.Equal("error.fon_too_large", failed.ErrorKey); + Assert.Equal("32767", failed.ErrorDetail); + Assert.Equal(["Name.fon"], Directory.EnumerateFiles(folder).Select(Path.GetFileName)); + } + + // ───────────────────────── C10/C12: routes ───────────────────────── + + [Fact] + public void Fon_edges_only_leave_existing_font_routes_intact() + { + var converter = fx.Converters.OfType().Single(); + Assert.Equal(Formats.Select(t => ("fon", t)).Order(), converter.Edges.Where(e => e.From == "fon").Select(e => (e.From, e.To)).Order()); + Assert.DoesNotContain(fx.Converters.SelectMany(c => c.Edges), e => e.To == "fon"); + foreach (var from in Formats) + foreach (var to in Formats.Where(t => t != from)) + Assert.Contains(new ConversionEdge(from, to), converter.Edges); + Assert.Equal(20 + 5, converter.Edges.Count); + + var planner = fx.Catalog.CreatePlanner(); + foreach (var target in Formats) + { + Assert.Equal("font", Assert.Single(planner.Plan("fon", target)!.Steps).Converter.Id); + Assert.Null(planner.Plan(target, "fon")); + } + } + + // ───────────────────────── Edge cases (problem definition, section 6) ───────────────────────── + + [Fact] + public void Zero_pix_width_uses_each_entry_width() + { + var strike = Assert.Single(FonReader.Read(Data("Fon-v3-proportional.fon"), TestContext.Current.CancellationToken)); + Assert.Equal(0, strike.PixelWidth); + Assert.Equal(0, strike.Glyphs[0].Width); + Assert.Equal(5, strike.Glyphs[1].Width); + Assert.Equal(9, strike.Glyphs[2].Width); + Assert.Equal((13, 5), (strike.Glyphs[1].Pixels.GetLength(0), strike.Glyphs[1].Pixels.GetLength(1))); + Assert.True(strike.Glyphs[1].Pixels[0, 2]); + Assert.False(strike.Glyphs[0].Pixels.Cast().Any()); + } + + [Fact] + public void Control_bytes_and_delete_keep_glyphs() + { + var strike = Assert.Single(FonReader.Read(Data("Fon-v2-proportional.fon"), TestContext.Current.CancellationToken)); + Assert.Equal(128, strike.Glyphs.Count); + Assert.Equal((byte)0, strike.Glyphs[0].ByteValue); + Assert.Equal((byte)31, strike.Glyphs[31].ByteValue); + Assert.Equal((byte)127, strike.Glyphs[127].ByteValue); + Assert.True(strike.Glyphs[127].Pixels[0, 2]); + } + + [Theory] + [InlineData("Fon-v2-fixed.fon", true)] + [InlineData("Fon-v3-proportional.fon", false)] + public void Blank_and_zero_width_glyphs_keep_metrics(string sample, bool isFixed) + { + var strike = Assert.Single(FonReader.Read(Data(sample), TestContext.Current.CancellationToken)); + var font = FonOutlineBuilder.Build(strike, TestContext.Current.CancellationToken); + var glyphs = GlyfTable.ReadAll(font); + Assert.Equal(96 + 1, font.GlyphCount); + Assert.Null(glyphs[1]); // the blank space, 0 or 8 pixels wide + Assert.Equal((isFixed ? 8 : 0) * Scale(13), Advance(font, 1)); + Assert.Equal(0, Lsb(font, 1)); + Assert.Equal(1, Cmap(font)[0x20]); + for (var code = 33; code <= 127; code++) + Assert.Equal(ArtWidth(code, isFixed) * Scale(13), Advance(font, code - 32 + 1)); + } + + [Fact] + public void Default_and_break_are_relative_indices() + { + var data = Data("Fon-v2-fixed.fon"); + var fnt = FntStart(data); + data[fnt + 0x61] = 1; + data[fnt + 0x62] = 2; + var strike = Assert.Single(FonReader.Read(data, TestContext.Current.CancellationToken)); + Assert.Equal((1, 2, (byte)33), (strike.DefaultChar, strike.BreakChar, strike.Glyphs[strike.DefaultChar].ByteValue)); + // .notdef is the default glyph (code 33, the single pixel); the break glyph (34) is not forced onto U+0020. + var font = FonOutlineBuilder.Build(strike, TestContext.Current.CancellationToken); + var glyphs = GlyfTable.ReadAll(font); + Assert.Equal(glyphs[2]!.Serialize(), glyphs[0]!.Serialize()); + Assert.Equal(Advance(font, 2), Advance(font, 0)); + Assert.Equal(1, Cmap(font)[0x20]); + Assert.Equal(3, Cmap(font)[0x22]); + + data[fnt + 0x61] = 96; + AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken), "error.fon_damaged"); + } + + [Theory] + [InlineData(0x04)] + [InlineData(0x08)] + [InlineData(0x10)] + [InlineData(0x20)] + [InlineData(0x40)] + [InlineData(0x80)] + public void V3_non_six_byte_flags_are_rejected_before_glyph_table(int flag) + { + var data = Data($"Fon-v3-flags-{flag:x2}.fon"); + AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken), + "error.fon_unsupported", $"dfFlags 0x{flag:X8}"); + } + + [Theory] + [InlineData(0u)] + [InlineData(3u)] + [InlineData(5u)] + public void V3_zero_or_mixed_flags_are_rejected(uint flags) + { + var data = Data("Fon-v3-fixed.fon"); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(FntStart(data) + 0x76), flags); + AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken), + "error.fon_unsupported", $"dfFlags 0x{flags:X8}"); + } + + [Fact] + public void Unsupported_containers_charsets_and_resources_fail_as_a_whole() + { + var data = Data("Fon-multi.fon"); + var second = FntStart(data, 1); + data[second + 0x42] = 1; + AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken), "error.fon_vector"); + data[second + 0x42] = 4; + AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken), "error.fon_unsupported"); + data[second + 0x42] = 0; + foreach (var charset in new byte[] { 128, 129, 130, 134, 136, 2, 1, 255, 77, 99 }) // DBCS, SYMBOL, DEFAULT, OEM, MAC, unknown + { + data[second + 0x55] = charset; + AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken), + "error.fon_unsupported", $"charset {charset}"); + } + data[second + 0x55] = 0; + data[second] = 0; + data[second + 1] = 1; + AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken), "error.fon_unsupported", "FNT 0x0100"); + + var pe = Data("Fon-v2-fixed.fon"); + pe[0x40] = (byte)'P'; + pe[0x41] = (byte)'E'; + AssertRefused(() => FonReader.Read(pe, TestContext.Current.CancellationToken), "error.fon_unsupported", "PE"); + // A bare FNT and an MZ program without fonts. + var fixedFon = Data("Fon-v2-fixed.fon"); + AssertRefused(() => FonReader.Read(fixedFon[FntStart(fixedFon)..], TestContext.Current.CancellationToken), "error.fon_damaged"); + var program = Data("Fon-v2-fixed.fon"); + var ne = 0x40; + var table = ne + BinaryPrimitives.ReadUInt16LittleEndian(program.AsSpan(ne + 0x24)); + BinaryPrimitives.WriteUInt16LittleEndian(program.AsSpan(table + 22), 0x8006); + AssertRefused(() => FonReader.Read(program, TestContext.Current.CancellationToken), "error.fon_damaged"); + } + + [Fact] + public void Truncated_ne_fnt_and_face_never_produce_outputs() + { + var source = Data("Fon-v2-fixed.fon"); + AssertRefused(() => FonReader.Read(source[..0x40], TestContext.Current.CancellationToken), "error.fon_damaged"); + var data = (byte[])source.Clone(); + var ne = 0x40; + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(ne + 0x26), 0x7fff); + AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken), "error.fon_damaged"); + + data = (byte[])source.Clone(); + var table = ne + BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(ne + 0x24)); + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(table + 30), (ushort)table); + AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken), "error.fon_damaged"); + + data = (byte[])source.Clone(); + var fnt = FntStart(data); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(fnt + 2), 0x7fffffff); + AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken), "error.fon_damaged"); + + data = (byte[])source.Clone(); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(fnt + 0x69), 0x7fffffff); + AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken), "error.fon_damaged"); + + data = (byte[])source.Clone(); + var size = (int)BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(fnt + 2)); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(fnt + 0x69), (uint)(size - 1)); + data[fnt + size - 1] = (byte)'F'; + AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken), "error.fon_damaged"); + + data = (byte[])source.Clone(); + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(fnt + 0x76 + 2), 0xffff); + AssertRefused(() => FonReader.Read(data, TestContext.Current.CancellationToken), "error.fon_damaged"); + } + + [Fact] + public void Builder_preserves_pixel_outlines_metrics_and_control_glyphs() + { + var strike = Assert.Single(FonReader.Read(Data("Fon-v2-proportional.fon"), TestContext.Current.CancellationToken)); + var font = FonOutlineBuilder.Build(strike, TestContext.Current.CancellationToken); + Assert.Equal(strike.Glyphs.Count + 1, font.GlyphCount); + Assert.Equal(13 * (1024 / 13), FontTables.UnitsPerEm(font.Require("head"))); + Assert.Equal(0x00010000u, font.Flavor); + var glyphs = GlyfTable.ReadAll(font); + var scaled = 1024 / 13; + for (var i = 0; i < strike.Glyphs.Count; i++) + { + var original = strike.Glyphs[i]; + Assert.Equal(original.Width * scaled, Advance(font, i + 1)); + if (!original.Pixels.Cast().Any()) + { + Assert.Null(glyphs[i + 1]); + Assert.Equal(0, Lsb(font, i + 1)); + continue; + } + Assert.NotNull(glyphs[i + 1]); + Assert.Equal(glyphs[i + 1]!.XMin, Lsb(font, i + 1)); + } + Assert.Equal(glyphs[strike.DefaultChar + 1]?.Serialize(), glyphs[0]?.Serialize()); + Assert.Equal((ushort)0x40, BinaryPrimitives.ReadUInt16BigEndian(font.Require("OS/2").AsSpan(62))); + } +}