Which package?
forme-pdf (Rust crate)
Version
0.27.0 (also reproduced on main @ b973bfc)
Warnings
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!
Which package?
forme-pdf (Rust crate)
Version
0.27.0 (also reproduced on main @ b973bfc)
Warnings
Minimal reproduction
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):Cause
subset.rsL83–L103 scans0..=0xFFFFper 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 codepointmap 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)
Runtime
Node
Environment
No response
Thanks for building such a great library!