Skip to content

feat(fonts): read bitmap .fon and convert each size to TTF, OTF, WOFF, WOFF2 and EOT - #561

Merged
KnifeLemon merged 10 commits into
mainfrom
feature/fon-fonts
Oct 26, 2026
Merged

KnifeLemon merged 10 commits into
mainfrom
feature/fon-fonts

Conversation

@SeoJCalive

Copy link
Copy Markdown
Collaborator

What does this change?

Closes #177. As planned in the issue, Filee now reads Windows bitmap fonts (.fon) and converts them to TTF, OTF, WOFF, WOFF2 and EOT, keeping the pixel look.

  • What it reads: the monochrome FNT 2.0 and 3.0 font resources inside an NE .fon file, fixed-width and proportional.
  • How it keeps the pixel look: every lit pixel becomes part of a straight, grid-aligned outline (neighbouring pixels are merged into one shape). There is no smoothing or curve tracing, so each glyph renders exactly like the original bitmap at its size. Advance widths, ascent and baseline come from the font.
  • One file per size: a .fon with one font resource gives Name.ttf with no suffix, as you suggested. Several resources give one file per size, such as Name-13px.ttf and Name-14px.ttf; a repeated height gets -2. The family names inside the fonts also differ per size, so they can be installed side by side. Skip, Rename and Overwrite apply to each output file.
  • Characters: they are mapped to Unicode through the Windows single-byte code page that the font's charset names, not the computer's locale. Undefined bytes and control characters keep their glyph and width but get no text mapping.
  • What it rejects, with a clear message: vector .fon (Modern, Roman, Script), PE-format font files, colour or externally stored bitmaps, standalone .fnt, double-byte or unknown charsets, and truncated or corrupt files. All outputs are built in memory first, so a bad resource fails the whole file before anything is written.
  • .fon is input only; Filee does not write it.

Implementation notes

  • Based on current main (1bf94e71, after 2.1.0); the new European Portuguese language file and README get the Fonts description too.
  • New files: FonReader.cs (NE resource table and FNT parsing with bounds checks), FonOutlineBuilder.cs (outlines and the TrueType tables: head, hhea, maxp, OS/2, name, cmap, post, glyf, loca, hmtx). The result goes through the existing FontConverter writers for OTF, WOFF, WOFF2 and EOT.
  • Changed files: FontConverter.cs (fon → ttf/otf/woff/woff2/eot edges only, existing font routes unchanged), FormatRegistry.cs (fon, not writable), AppSettings.cs and UserDataStore.cs (schema 47), the font engine description in every language file, docs/ENGINES.md and the font row in every README. No shared conversion contracts (IConverter, JobQueue, OutputPathResolver) changed.
  • Existing users (settings schema 47): a saved library learns .fon in its Fonts donut once, the same way .fits/.usm did; a profile of the user's own that already claims .fon keeps it. Read Garmin FIT activities into the geospatial formats (#451) #530 also takes 47 for .fit. Since 47 is not released yet, both can share the one step: whichever PR merges second puts both LearnExtensions calls under if (from < 47). If Read Garmin FIT activities into the geospatial formats (#451) #530 ships first, I'll move this to 48.
  • Test samples: the Fon-*.fon files are synthetic, drawn from pixel art by make-test-fon.py (hashes in FON-FIXTURES.md), so there are no licence questions.

Questions for you

  1. Two sizes with the same height get -2 (Name-13px-2.ttf, family Name 13px-2). Is that naming OK?
  2. Only single-byte Windows charsets are read (Western, Central European, Cyrillic, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai); double-byte fonts (Chinese, Japanese, Korean) are refused with a message. Is that scope OK for now?
  3. Is the new Fonts engine description ("Convert between TTF, OTF, WOFF, WOFF2 and EOT; read-only FON input (NE, monochrome, single-byte).") OK, or would you like it shorter?

Type of change

  • Bug fix
  • New feature or improvement
  • New format or engine
  • Translation
  • Documentation
  • Build, CI or dependencies

How was it tested?

  • dotnet format --verify-no-changes

  • dotnet test

  • Tried it in the app (what you converted or clicked, and on which OS): .fon → TTF/OTF/WOFF/WOFF2/EOT from the donut and the CLI on Linux (X11)

  • New tests (39), each checked to fail when its fix is reverted: FonTests (Engines: parsing and rejections, FNT 2.0/3.0 × fixed/proportional, every glyph rendered in Skia and compared pixel by pixel with its bitmap, metrics, names, cmap per code page, build-everything-before-writing on failure and cancellation), FonFormatTests (Core, including the schema 47 step and a custom .fon claim), CLI tests (single resource has no suffix in all five formats, one file per size, conflicts, --to fon refused), EnginesPageTests and the new PresetEditorTargetTests (App).

  • Existing tests: all FontTests pass. Core and Cli all pass on top of 2.1.0. Engines and App fail only the tests that also fail on main here (LibreOffice and Ghostscript installed on the machine).

  • Build and format: the same warnings as 2.1.0 main, dotnet format --verify-no-changes clean.

  • Real CLI runs: two real bitmap fonts (fixed.fon, tektite.fon) and the samples to TTF, OTF, WOFF, WOFF2 and EOT; every mapped glyph of every output rendered in SkiaSharp matches the FNT bitmap (0 differing pixels), and fc-query shows a distinct family per size. 30 broken inputs (truncated, corrupt headers, vector FNT, PE and non-font NE files, unsupported v3 flags) each fail with a clear message, leave no output and keep existing files unchanged.

  • App window (Linux): the donut offers TTF/OTF/WOFF/WOFF2/EOT for a .fon (also mixed with a .ttf, and .FON), the donut and the CLI produce identical files, and the Fonts card reads well in English, Russian, Spanish and the dark theme.

  • Size and speed: fixed.fon (8.8 KB, one 13 px size) → TTF 21.6 KB, WOFF2 3.7 KB; one CLI run takes about 0.66 s, close to an existing TTF → WOFF2 run (0.62 s).

Screenshots (UI changes)

Fonts engine card (new description):

en-engines-font-card

Donut for a multi-size .fon (TTF / OTF / WOFF / WOFF2 / EOT):

en-donut-fon-multi

Checklist

  • New UI strings are in all src/Filee.App/Assets/i18n/*.json files
  • New third-party code or binaries are listed in THIRD-PARTY-NOTICES.md (none added)
  • The PR does one thing (unrelated changes go in a separate PR)

@SeoJCalive SeoJCalive mentioned this pull request Oct 25, 2026
1 task done
@SeoJCalive SeoJCalive added the feature New features label Oct 25, 2026
@SeoJCalive

Copy link
Copy Markdown
Collaborator Author

@KnifeLemon 님, 리더를 다시 점검하다가 비슷한 문제를 하나 찾아서 지금 고치고 있어요.

글자들이 같은 비트맵을 가리키는 작은 파일의 메모리·시간: 글자 256개가 모두 같은 1024×1024 비트맵을 가리키게 만든 395 KB 파일을 TTF로 바꾸면 메모리를 약 1 GB 쓰고 9분 가까이 걸려요. 글자마다 비트맵을 새로 펼치고, 높이 상한은 그 뒤에야 확인하기 때문이에요. 비트맵을 펼치기 전에 글자 픽셀 총량에 상한을 두고, 높이도 먼저 확인하고, 글자마다 취소를 확인하게 바꾸는 중이에요.

곧 커밋 하나로 올릴게요. 그때까지 머지는 잠시만 기다려 주세요.

@KnifeLemon KnifeLemon left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SeoJCalive 님, 큰 기능을 꼼꼼하게 만들어 주셔서 감사해요. NE 리소스 테이블과 FNT v2/v3 헤더를 오프셋 하나하나 범위 검사하며 읽고 있고, 외곽선도 같은 가로 구간을 직사각형으로 묶은 뒤 겹치는 변을 지워서 바깥은 시계 방향, 구멍은 반시계 방향으로 깔끔하게 나와요. 크기가 한 개면 접미사 없이, 여러 개면 Name-13px.ttf로 나누는 것도 이야기한 대로이고, 쓰기 전에 모든 출력을 메모리에서 먼저 만드는 것도 좋아요.

먼저 질문에 답할게요.

  1. 같은 높이의 -2: 같은 높이에서 굵기나 기울임만 다르면 Name-13px-Bold.ttf처럼 스타일 이름을 붙이고, 패밀리는 Name 13px 그대로 두고 스타일만 Bold로 해 주세요. 그래야 글꼴 메뉴에서 한 패밀리로 묶여요. -2는 높이와 스타일이 모두 같을 때만 쓰면 돼요.
  2. 단일 바이트 문자 집합만: 지금은 그 범위로 충분해요. 2바이트 글꼴은 요청이 오면 따로 볼게요.
  3. 엔진 설명: "NE, monochrome, single-byte"는 사용자가 알기 어려운 말이라 짧게 해 주세요. 예를 들면 en은 "Fonts: TTF, OTF, WOFF, WOFF2 and EOT into each other; Windows bitmap fonts (FON) into them."이에요. README 글꼴 줄도 3D 모델의 "FBX read"처럼 TTF, OTF, WOFF, WOFF2, EOT;<br>FON (Windows bitmap fonts) read | each other;<br>FON → one font per size 정도면 충분해요. FontConverter.GetStatus의 문자열도 같이 줄여 주세요. 자세한 조건은 docs/ENGINES.md에 남아 있으니 괜찮아요.

고칠 점이에요.

  1. FonReader.ReadFnt가 glyph마다 bool[height, width]를 만들어서 작은 파일이 큰 메모리를 써요. 높이와 너비에 상한이 없고, 여러 glyph가 같은 비트맵 오프셋을 가리켜도 막지 않아요. 그래서 비트맵 1바이트가 glyph 하나에 8바이트로, 그것도 glyph 수만큼 불어나요. glyph 224개가 4096×1024 비트맵 하나를 같이 가리키는 514 KB 파일을 CLI로 TTF로 바꿔 보니 최대 메모리가 959 MB, 19.7초였어요. FonOutlineBuilder가 어차피 거절하는 높이(upem 16384 초과)와 너비(좌표 32767 초과)를 할당 전에 리더에서 먼저 거르고, 파일 전체의 픽셀 수(모든 크기의 width×height 합)에도 상한을 걸어 주세요. 아니면 bool[,] 대신 리소스의 바이트를 그대로 들고 Outline에서 비트를 읽어도 돼요. #530의 Huge_declared_data_size…처럼 GC.GetAllocatedBytesForCurrentThread로 할당량을 확인하는 테스트도 하나 넣어 주세요.
  2. 지원하지 않는 파일이 영어 오류로 나와요. 벡터 .fon(Modern, Roman, Script)이 어느 언어에서나 "엔진에서 오류가 났어요 (Vector FNT fonts are not supported.)"처럼 보여요. FIT의 error.fit_*처럼 ConversionRefusedException과 문구 키로 바꿔 주세요. 벡터 글꼴용, 지원하지 않는 종류용(PE, 색상, 2바이트 문자 집합), 손상된 파일용 정도면 충분해요. 새 문구는 16개 언어 파일에 모두 넣어 주세요.
  3. 테스트 샘플 파일을 저장소에 넣지 말아 주세요. tests/Filee.Engines.Tests/Fonts/의 Fon-*.fon 12개와 make-test-fon.py, FON-FIXTURES.md가 커밋돼 있는데, 이 저장소는 테스트 입력을 실행할 때 만들어요. #530의 FitBuilder처럼 C# FonBuilder로 테스트 안에서 .fon을 만들어 주세요. 이 파일들과 두 csproj의 .fon 복사 항목, docs/ENGINES.md의 샘플 설명 문장도 같이 빼 주세요.

스키마 47은 #530과 함께 쓰기로 했으니, #530이 먼저 들어가면 같은 단계로 합치는 건 저희가 할게요.

이 세 가지가 정리되면 머지할 수 있어요.

@SeoJCalive

Copy link
Copy Markdown
Collaborator Author

@KnifeLemon 님, 꼼꼼하게 봐 주셔서 감사해요. 말씀하신 내용을 커밋 다섯 개로 나눠 올렸고, 전에 말씀드린 비트맵 상한 수정도 같이 들어가 있어요.

먼저 질문 답에 맞춘 부분이에요.

  1. 같은 높이의 스타일 이름: 같은 높이에서 굵기나 기울임만 다르면 Name-13px-Bold.ttf, Name-13px-Italic.ttf, Name-13px-BoldItalic.ttf처럼 스타일을 붙이고, 패밀리는 Name 13px로 같게 두고 스타일만 Bold 등으로 넣었어요. 400·700이 아닌 굵기는 -Weight600처럼 붙여요. -2는 높이와 스타일이 모두 같을 때만 붙어요.
  2. 엔진 설명: 16개 언어(지금 17개 파일) 설명을 말씀하신 문장으로 줄였고, README 글꼴 줄은 3D 모델 줄처럼 FON (Windows bitmap fonts) read로, GetStatus도 짧게 바꿨어요.

요청하신 수정 사항은 이렇게 반영했어요.

  1. 비트맵 할당: 비트맵을 펼치기 전에 리더에서 파일 전체 글자의 너비×높이 합(4,194,304)과 높이(16384), 너비(advance가 TrueType 한계 65535를 넘는 경우)를 먼저 거르게 했어요. 좌표 32767 검사는 켜진 픽셀 위치에 따라 달라서(오른쪽이 빈 넓은 글자는 지금도 정상 변환돼요) 외곽선 단계에 그대로 두었는데, 메모리는 앞의 총량 상한에서 이미 막혀요. 말씀하신 모양(글자 224개가 4096×1024 비트맵 하나를 공유)은 이제 0.56초, 최대 메모리 79 MB로 거부되고(CLI 기준), GC.GetAllocatedBytesForCurrentThread로 할당량이 1 MB 미만인지 보는 시험도 넣었어요.
  2. 오류 문구: FIT처럼 ConversionRefusedException과 error.fon_vector, error.fon_unsupported, error.fon_too_large, error.fon_damaged 네 키로 바꾸고 모든 언어 파일에 넣었어요. 덧붙는 정보는 PE, FNT 0x0100, 상한 숫자처럼 언어와 상관없는 값만 넘겨서 영어 문장이 섞이지 않아요.
  3. 테스트 샘플: Fon-*.fon 12개와 make-test-fon.py, FON-FIXTURES.md, 두 csproj의 복사 항목, docs/ENGINES.md의 문장을 뺐고, C# FonBuilder가 시험 안에서 만들어요. 빼기 전에 빌더 출력이 원래 파일 12개와 바이트 단위로 같은지 확인해서 기존 시험의 기대값은 바꾸지 않았어요.

정상 폰트의 출력은 수정 전과 바이트 단위로 같아요. 다시 봐 주세요.

@SeoJCalive
SeoJCalive requested a review from KnifeLemon October 25, 2026 14:42

@KnifeLemon KnifeLemon left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SeoJCalive 님, 다섯 커밋 확인했어요. 스타일 이름, 짧아진 엔진 설명과 README·GetStatus, 리더의 픽셀 상한과 할당 테스트, error.fon_* 네 키, 샘플 파일을 FonBuilder로 바꾼 것까지 모두 말씀드린 대로예요. 하나만 더 봐 주세요.

  1. 비트맵을 같이 쓰는 작은 파일이 외곽선 단계에서 오래 걸려요. 리더 상한으로 할당은 막혔는데, FonOutlineBuilder.Outline이 같은 비트맵을 가리키는 glyph마다 외곽선을 처음부터 다시 만들어요. 또 윤곽선을 하나 시작할 때마다 edges.First()가 지금까지 지운 칸을 처음부터 다시 훑어서, 켜진 픽셀 수의 제곱에 가깝게 느려져요. 128×128 체크무늬 비트맵 하나를 glyph 256개가 가리키는 4.6 KB 파일(픽셀 합이 딱 4,194,304)은 외곽선만 63초 걸렸고, 할당 합계가 4.5 GB, TTF가 23 MB였어요. 2048×2048 체크무늬 glyph 하나(526 KB)는 결국 65535점 한도로 거부되지만, 그 전에 간선을 다 만들어서 최대 메모리가 1 GB였어요.

    • 같은 (비트맵 오프셋, 너비)는 외곽선을 한 번만 만들어서 같이 써 주세요.
    • 윤곽선 시작점은 간선을 넣은 순서대로 둔 목록에서 앞으로만 가며 찾으면 돼요. 이렇게만 바꿔 보니 180×180 체크무늬 glyph 하나가 1.3초에서 0.3초가 됐어요.
    • 점 수는 간선을 만들기 전에 픽셀에서 바로 셀 수 있어요. 각 격자 꼭짓점의 2×2 이웃에서 켜진 칸이 1개나 3개면 점 1개, 대각선으로 2개면 점 2개예요. 이 합이 65535를 넘으면 간선을 만들기 전에 error.fon_too_large로 거부해 주세요.

    리더처럼 할당량을 확인하는 테스트도 하나 넣어 주세요(예: 2048×2048 체크무늬가 할당 전에 거부되는지).

스키마 47은 #530과 같이 쓰기로 했으니, #530이 먼저 들어가면 47 단계를 ["fit", "fon"]으로 합치는 건 저희가 할게요. 이것만 정리되면 머지할 수 있어요.

… EOT

Read monochrome FNT 2.0/3.0 resources from NE .fon files and build a
pixel-exact TrueType font for each size, then reuse the existing font
writers for the other formats. A single resource keeps the plain name;
several resources get one file per size. Characters map through the
code page named by the font charset. Vector, PE, colour, double-byte
and corrupt files are refused before anything is written.

Closes #177
A small crafted .fon could alias one large bitmap from every glyph and force
minutes of work and about 1 GB of memory. The reader now charges
max(width, 1) * height per glyph across the whole file against a 4,194,304-unit
budget and rejects heights above 16384 before allocating any pixels. Resource
overlap is checked after sorting, same-height ordinals are counted in one pass,
and cancellation is observed in the reader, the converter and every outline and
serialization stage. Output for valid fonts is unchanged.
Cache outlines by bitmap offset and width, and serialize shared glyphs once.
Trace contours from a forward-only edge insertion cursor and reject excess points before allocating edges.
Cover early refusal, shared allocation, width-specific outlines, and counted points with tests.
Keep both schema steps: v47 learns .fit and .fon, v48 learns .m4s (#603).
Keep the error.fon_* and error.m4s_needs_init keys in every language file.
README format count 265 -> 266 (the registry now has .fon too).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApPC2ToT2xB1UGEZCUBD4q

KnifeLemon commented Oct 26, 2026 •

Copy link
Copy Markdown
Owner

@SeoJCalive 님, 마지막 커밋 확인했어요. 같은 (비트맵 오프셋, 너비)는 외곽선을 한 번만 만들고, 시작점은 넣은 순서대로 앞으로만 찾고, 점 수는 간선을 만들기 전에 2×2 이웃으로 세서 거르는 것까지 말씀드린 대로예요.

그 사이 #603(.m4s, 스키마 48)이 main에 들어가서 충돌이 났기에, main을 이 브랜치에 머지해 두었어요(ec371d7). 47 단계는 ["fit", "fon"] 그대로, 48 단계 m4s는 그 뒤에 두었고, 언어 파일에는 error.fon_*와 error.m4s_needs_init를 모두 남겼어요. 레지스트리가 266개가 되어 README의 형식 수도 265 → 266으로 바꿨어요. 다시 푸시하실 일이 있으면 먼저 pull 해 주세요.

CI가 초록이면 머지할게요.

@SeoJCalive

SeoJCalive commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator Author

@KnifeLemon 님, 다시 봐 주셔서 감사해요. 말씀하신 세 가지를 커밋 하나로 올렸고, #530이 먼저 들어가서 main으로 리베이스하면서 스키마 47 단계도 ["fit", "fon"] 하나로 합쳤어요.

  1. 같은 비트맵 외곽선 한 번만: 한 크기 안에서 (비트맵 오프셋, 너비)가 같은 글자는 외곽선을 한 번만 만들고 같이 써요. 직렬화한 바이트도 한 번만 만들어요. 128×128 체크무늬를 글자 256개가 가리키는 파일은 이제 0.8초, 최대 메모리 148 MB로 변환돼요(예전 코드는 54초).
  2. 윤곽선 시작점: 간선을 넣은 순서대로 둔 목록에서 커서가 앞으로만 가며 남은 간선을 찾게 바꿨어요. 180×180 체크무늬 글자 하나의 외곽선 단계가 770 ms에서 56 ms가 됐어요(외곽선 단계만 잰 값).
  3. 점 수 먼저 세기: 말씀하신 2×2 규칙(켜진 칸 1개나 3개면 1점, 대각선 2개면 2점, 그림 밖은 꺼진 칸)으로 사각형과 간선을 만들기 전에 세고, 65535를 넘으면 바로 error.fon_too_large로 거부해요. 2048×2048 체크무늬 파일은 0.6초, 83 MB에서 거부되고(예전 코드는 5.9초, 1 GB) 출력은 없어요.

시험은 네 개 넣었어요. 2048×2048 체크무늬가 빌더 할당 1 MB 미만으로 거부되는지, 같은 비트맵을 가리키는 64글자의 할당이 한 글자의 4배 미만인지, 오프셋이 같아도 너비가 다르면 외곽선을 따로 만드는지, 미리 센 점 수가 실제 외곽선의 점 수와 같은지 봐요. 정상 폰트 네 개 중 세 개는 출력이 바이트 단위로 수정 전과 같고, 하나는 윤곽선 시작점이 바뀌어 바이트는 달라졌지만 glyf·loca 밖의 표는 모두 같고 글자마다 외곽선 도형도 같아요.

하나 여쭤볼게요. 외곽선은 한 번만 만들지만 glyf에는 글자마다 그대로 들어가서, 위 256글자 파일의 TTF는 여전히 23 MB예요. 같은 외곽선을 composite glyph로 가리키게 해서 출력도 줄일까요, 아니면 지금처럼 둘까요?

@KnifeLemon KnifeLemon left a comment •

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SeoJCalive 님, 꼼꼼하게 재고 고쳐 주셔서 감사해요. 54초가 0.8초로, 2048×2048 체크무늬가 1 GB에서 83 MB로 줄어든 것, 그리고 미리 센 점 수가 실제 외곽선과 같은지까지 테스트로 묶어 둔 것 모두 좋아요.

질문에 답할게요. composite glyph는 이 PR에서는 하지 않고 지금처럼 둘게요. 리더의 픽셀 상한 덕분에 출력 크기도 그 안에서 묶여 있고, 정상적인 .fon에서 여러 글자가 같은 비트맵을 가리키는 경우는 드물어요. 23 MB는 일부러 만든 파일에서만 나와요. 실제 폰트에서 크기 문제가 보이면 그때 따로 이슈로 볼게요.

macOS CI가 끝나면 머지할게요. 큰 기능 감사해요!

@KnifeLemon
KnifeLemon merged commit 2273964 into main Oct 26, 2026
10 checks passed
@SeoJCalive
SeoJCalive deleted the feature/fon-fonts branch October 26, 2026 10:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New features

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Format request: .fon

3 participants