Skip to content

Font subsetting is O(glyphs × 65,536), Korean(CJK) documents take seconds to serialize! #182

Description

@captyoungkyu

Which package?

forme-pdf (Rust crate)

Version

0.27.0 (also reproduced on main @ b973bfc)

Warnings

none

Minimal reproduction

import { Document, Page, Text } from '@formepdf/react';
import { renderDocument } from '@formepdf/core';
import { readFileSync } from 'node:fs';

// Noto Sans KR, static Regular (Google Fonts download → static/NotoSansKR-Regular.ttf)
const font = new Uint8Array(readFileSync('NotoSansKR-Regular.ttf'));

// ~600 distinct Hangul syllables
const syllables = Array.from({ length: 1000 }, (_, i) =>
  String.fromCharCode(0xac00 + ((i * 11) % 11172)));
const text = Array.from({ length: 600 }, (_, i) => syllables[i % 1000]).join(' ');

const t = performance.now();
await renderDocument(
  <Document fonts={[{ family: 'KR', src: font }]}>
    <Page size="A4" margin={40}>
      <Text style={{ fontFamily: 'KR', fontSize: 10 }}>{text}</Text>
    </Page>
  </Document>,
);
console.log(performance.now() - t, 'ms'); // ~6.5–7.6s on 0.27.0, Node 22

Expected vs actual

Expected: Korean text renders about as fast as Latin text.

Actual: Serialization time grows with the number of distinct Hangul syllables (native main, FORME_PROFILE=1, 5 pages, Noto Sans KR):

Document layout_ms serialize_ms
English 80 11
Korean, ~100 distinct syllables 44 430
Korean, ~1,000 distinct syllables 46 6,946

Cause

subset.rs L83–L103 scans 0..=0xFFFF per glyph to find its codepoint. Hangul starts at U+AC00, so each syllable costs ~44k lookups; glyphs without a cmap entry scan all 65,536.

Fix

Build a gid → lowest codepoint map once from the cmap subtables. With this, the 1,000-syllable case drops from 6,946ms to 29ms, output PDFs are byte-identical, and the engine tests pass.

Patch (main @ b973bfc)
--- a/engine/src/font/subset.rs
+++ b/engine/src/font/subset.rs
@@ -80,23 +80,40 @@ pub fn subset_ttf(
     // Build minimal cmap (Format 4)
     // We need the original char→gid mapping — invert through the face
     let mut char_to_new_gid: Vec<(u16, u16)> = Vec::new();
+    // Build reverse cmap (gid -> lowest BMP codepoint) in one pass over the
+    // font's Unicode cmap instead of scanning 0..=0xFFFF per glyph.
+    let mut gid_to_code: HashMap<u16, u16> = HashMap::new();
+    if let Some(cmap) = face.tables().cmap {
+        for subtable in cmap.subtables {
+            if !subtable.is_unicode() {
+                continue;
+            }
+            subtable.codepoints(|code| {
+                if code > 0xFFFF {
+                    return;
+                }
+                if let Some(ch) = char::from_u32(code) {
+                    if let Some(gid) = face.glyph_index(ch) {
+                        gid_to_code
+                            .entry(gid.0)
+                            .and_modify(|c| {
+                                if (code as u16) < *c {
+                                    *c = code as u16
+                                }
+                            })
+                            .or_insert(code as u16);
+                    }
+                }
+            });
+        }
+    }
     for &old_gid in &needed_gids {
         if old_gid == 0 {
             continue;
         }
-        // Search for Unicode codepoint that maps to this GID
-        // This is O(n) per glyph but subset sizes are small
-        for code in 0u32..=0xFFFF {
-            if let Some(ch) = char::from_u32(code) {
-                if let Some(gid) = face.glyph_index(ch) {
-                    if gid.0 == old_gid {
-                        if let Some(&new_gid) = gid_remap.get(&old_gid) {
-                            char_to_new_gid.push((code as u16, new_gid));
-                        }
-                        break;
-                    }
-                }
-            }
+        if let (Some(&code), Some(&new_gid)) = (gid_to_code.get(&old_gid), gid_remap.get(&old_gid))
+        {
+            char_to_new_gid.push((code, new_gid));
         }
     }
     let new_cmap = build_cmap_format4(&char_to_new_gid);

Runtime

Node

Environment

No response


Thanks for building such a great library!

Activity

  1. self-assigned this
    on Oct 6, 2026
  2. danmolitor commented on Oct 6, 2026

    @danmolitor
    Owner

    Thanks again for the precise diagnosis and the patch. Fixed in #183 using the same approach (build the glyph-to-codepoint map once from the cmap), now on main and shipping in the next release.

    With Noto Sans KR, a page with 1,000 distinct syllables goes from about 4.7 s to 85 ms, and the output is byte-identical. There's also a test checking the new map against the old full scan for every glyph in six fonts.

    I'll close this once the release is out.

    Also, if you get a chance and wouldn't mind filling this out - https://www.formepdf.com/production - that'd be awesome. Thanks!

  3. danmolitor commented on Oct 6, 2026

    @danmolitor
    Owner

    Shipped in 0.28.0 (now on npm, PyPI and crates.io). See the 0.28.0 entry in CHANGELOG.md.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions