Skip to content

feat(engines): PDF backend spike: font subsetter and Skia page writer - #683

Merged
KnifeLemon merged 2 commits into
builtin-enginesfrom
engine/pdf-backend-spike
Oct 28, 2026
Merged

KnifeLemon merged 2 commits into
builtin-enginesfrom
engine/pdf-backend-spike

Conversation

@KnifeLemon

Copy link
Copy Markdown
Owner

What does this change?

Implements the Project 4 item "Layout engine: PDF backend and font subsetting spike" (M0, P0). Both PDF backends were prototyped on one mixed A4 page (Korean, Latin, Arabic, Devanagari). This PR keeps the recommended one, option A: Skia's PDF backend with fonts subset by Filee. Option B, a page writer of Filee's own on PDFsharp's low-level objects, is described in docs/ENGINES.md with its numbers; its prototype code is not in the PR.

The decision is a recommendation for the maintainer to confirm (README → "Decisions to make" #1).

  • Fonts/FontSubsetter.cs (+ Fonts/CmapTable.cs): glyph-ID-preserving TrueType subsets. Composite components and hinting are kept. CFF fonts are converted to TrueType first, only the used glyphs (CffToTrueType gained an only filter). Variable fonts become their default instance. CFF2 and colour fonts are refused with a reason.
  • Rendering/PageContent.cs, PageFont.cs, PdfPageWriter.cs: the minimal page-writer API for the later layout work.
    • A page records shaped glyph runs (glyphs, positions, text, clusters), other Skia drawing, links and destinations.
    • The writer collects the glyphs per font, subsets each font once per document and draws through SKDocument.CreatePdf with SKPositionedTextRunBuffer, so Skia writes ToUnicode and /ActualText.
    • Right-to-left runs are drawn in logical order.
    • PDFsharp adds the outline and exact page sizes. Skia rounds the media box to whole points: A4 becomes 595 × 842.
    • The same PageContent.Draw renders PNG pages.
  • Fix found on the way: CID-keyed CFF fonts whose Top DICT and font dictionary both carry a units-per-em scale (Pretendard) converted to TTF or EOT with glyphs 1000× too small (separate commit, CHANGELOG entry).

Measured (details in docs/ENGINES.md → "Filee renderer: PDF output")

Korean font A (Skia + subsetter) B (PDFsharp writer)
Malgun Gothic (13.5 MB TTF) 72.7 KB 73.4 KB
Noto Sans KR VF (variable) 57.5 KB 57.9 KB
Pretendard (CID-keyed CFF) 57.2 KB 57.7 KB
S-Core Dream (CFF) 56.0 KB 56.2 KB
  • Fonts: every font is embedded as a subset (Type 0 / CIDFontType2, XXXXXX+ tag). Without the subsetter, Skia embeds one line of Malgun Gothic as a 7.45 MB PDF and draws variable and CFF fonts as Type 3.
  • Text: PdfPig reads back the exact source text from A and B with every font (clusters whole, Arabic in logical order).
  • PDFium: the PDFs were rendered at 300 dpi, averaged down and compared with Skia's own picture of the page. SSIM was 0.946–0.954 for A and 0.960–0.971 for B. 300 dpi crops look the same, and all 966 glyphs sit within 0.001 pt in both files. The gap is anti-aliasing.
  • Not checked: Firefox (pdf.js), Acrobat, macOS Preview, Linux/macOS, and the full Noto Sans CJK OTF, which isn't installed here. CID-keyed CFF is covered by FileeTest-CID.otf (a Noto Sans KR OTF subset) and by Pretendard locally. FILEE_RENDER_OUT=<folder> keeps the test PDFs for a manual look.

Open questions

  1. Confirm option A.
  2. Tagged PDF (PDF/UA) isn't possible through SkiaSharp 3.119: Skia's structure tree API isn't bound. Does it matter for the renderer?
  3. Non-default instances of variable fonts need an instancer (gvar/HVAR) before subsetting. Should that go into the font resolver item? CFF2 also needs work there.

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 (Filee.Engines.Tests: 1843 passed, 193 skipped; dotnet build Filee.slnx -c Release clean)
  • Tried it in the app (what you converted or clicked, and on which OS): not wired to a converter yet. The new tests build their inputs in code. The mixed page tests run with Malgun Gothic and Noto Sans KR VF on Windows 11 and skip when the fonts are missing.

Checklist

  • New UI strings are in all src/Filee.App/Assets/i18n/*.json files (none)
  • New third-party code or binaries are listed in THIRD-PARTY-NOTICES.md (none)
  • The PR does one thing (unrelated changes go in a separate PR). The CFF fix is the bug the CFF path of the spike ran into.

🤖 Generated with Claude Code

KnifeLemon and others added 2 commits October 28, 2026 02:42
A CID-keyed CFF font whose Top DICT and font dictionary both carry a
units-per-em scale (Top DICT 1/2048, font dictionary 1/1000, as in
Pretendard) had the two scales multiplied, so OTF -> TTF and EOT drew every
glyph a thousand times too small. The product is now scaled back by the
smaller units-per-em of the two.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…renderer

The PDF backend spike of Project 4 ("Layout engine: PDF backend and font
subsetting spike"):

- Fonts/FontSubsetter: glyph-ID-preserving TrueType subsets (composite
  components, hinting kept; CFF converted first, only the used glyphs;
  variable fonts become their default instance), with Fonts/CmapTable to
  read and write the character map.
- Rendering/PageContent, PageFont, PdfPageWriter: a page record of shaped
  glyph runs (text + clusters), Skia drawing, links and destinations, written
  through SKDocument.CreatePdf with one subset per font and document;
  right-to-left runs in logical order; exact page sizes and the outline
  added with PDFsharp.
- docs/ENGINES.md: measurements of option A (Skia + own subsetter) and
  option B (own page writer on PDFsharp) and the recommendation (A), for
  the maintainer to confirm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added the feature New features label Oct 27, 2026
@KnifeLemon
KnifeLemon changed the base branch from main to builtin-engines October 27, 2026 23:51
@KnifeLemon
KnifeLemon merged commit e26cf64 into builtin-engines Oct 28, 2026
9 checks passed
@KnifeLemon KnifeLemon added the built-in engines Filee: built-in engines (Project 4), on the builtin-engines branch, not released yet label Oct 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

built-in engines Filee: built-in engines (Project 4), on the builtin-engines branch, not released yet feature New features

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant