From 4e6177c8399abd9a19b6cdc1733edb18b2c357e4 Mon Sep 17 00:00:00 2001 From: Diaconu Andrei Date: Tue, 17 Mar 2026 11:22:43 +0200 Subject: [PATCH 1/5] Fix font subset tag generation by converting hex to uppercase letters According to PDF spec section 5.5.3, font subset tags must be 6 uppercase letters. This converts the hex digest (0-9a-f) to uppercase letters: - Digits 0-9 map to letters A-J - Hex a-f map to letters K-P This maintains 1-to-1 deterministic mapping while satisfying the PDF spec requirement for 6 uppercase letters. Fixes both Adobe Acrobat (issue #102) and Adobe Illustrator warnings. Fixes: #102 Related: #107 Made-with: Cursor --- lib/ttfunk/table/name.rb | 14 +++++++++++++- spec/ttfunk/ttf_encoder_spec.rb | 3 ++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/ttfunk/table/name.rb b/lib/ttfunk/table/name.rb index 6f876d7..cd108b6 100644 --- a/lib/ttfunk/table/name.rb +++ b/lib/ttfunk/table/name.rb @@ -185,7 +185,19 @@ def strip_extended # @param key [String] # @return [String] def self.encode(names, key = '') - tag = Digest::SHA1.hexdigest(key)[0, 6] + # Generate a 6-character uppercase tag according to PDF spec section 5.5.3 + # Convert hex digest to uppercase letters: 0-9 -> A-J, a-f -> K-P + # This maintains 1-to-1 mapping while satisfying the "6 uppercase letters" requirement + digest = Digest::SHA1.hexdigest(key)[0, 6] + + tag = digest.chars.map do |c| + case c + when '0'..'9' + ('A'.ord + (c.ord - '0'.ord)).chr # 0->A, 1->B, ..., 9->J + when 'a'..'f' + ('K'.ord + (c.ord - 'a'.ord)).chr # a->K, b->L, ..., f->P + end + end.join postscript_name = NameString.new("#{tag}+#{names.postscript_name}", 1, 0, 0) diff --git a/spec/ttfunk/ttf_encoder_spec.rb b/spec/ttfunk/ttf_encoder_spec.rb index 805b247..06802eb 100644 --- a/spec/ttfunk/ttf_encoder_spec.rb +++ b/spec/ttfunk/ttf_encoder_spec.rb @@ -66,7 +66,8 @@ # verified via the Font-Validator tool at: # https://github.com/HinTak/Font-Validator - expect(checksum).to eq(0xEEB49DA9) + # Updated checksum after hex-to-letters subset tag implementation + expect(checksum).to eq(0xAA92C9B3) end example_group 'maxp regression', issue: 102 do From 42c510bd4050fb96c297d76f0eec443cb9442240 Mon Sep 17 00:00:00 2001 From: Diaconu Andrei Date: Tue, 17 Mar 2026 14:26:59 +0200 Subject: [PATCH 2/5] Updated checksum after adding Windows UTF-16BE PostScript name record --- lib/ttfunk/table/name.rb | 20 +++++++++++++++++--- spec/ttfunk/ttf_encoder_spec.rb | 4 ++-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/lib/ttfunk/table/name.rb b/lib/ttfunk/table/name.rb index cd108b6..119e3a7 100644 --- a/lib/ttfunk/table/name.rb +++ b/lib/ttfunk/table/name.rb @@ -189,7 +189,7 @@ def self.encode(names, key = '') # Convert hex digest to uppercase letters: 0-9 -> A-J, a-f -> K-P # This maintains 1-to-1 mapping while satisfying the "6 uppercase letters" requirement digest = Digest::SHA1.hexdigest(key)[0, 6] - + tag = digest.chars.map do |c| case c when '0'..'9' @@ -199,10 +199,24 @@ def self.encode(names, key = '') end end.join - postscript_name = NameString.new("#{tag}+#{names.postscript_name}", 1, 0, 0) + new_ps_name = "#{tag}+#{names.postscript_name}" + + # platform_id=1, encoding_id=0, language_id=0 (Mac Roman) + # Required by Acrobat and the PDF spec for PostScript name + postscript_name_mac = NameString.new(new_ps_name, 1, 0, 0) + + # platform_id=3, encoding_id=1, language_id=0x0409 (Windows Unicode UTF-16BE, English US) + # Required by Illustrator and other Windows-platform consumers. + # Without this record, apps that only look at platform 3 see no PostScript + # name and fall back to raw glyph indices, producing gobbledygook text. + postscript_name_win = NameString.new( + new_ps_name.encode('UTF-16BE').b, + 3, 1, 0x0409 + ) strings = names.strings.dup - strings[6] = [postscript_name] + strings[6] = [postscript_name_mac, postscript_name_win] + str_count = strings.reduce(0) { |sum, (_, list)| sum + list.length } table = [0, str_count, 6 + (12 * str_count)].pack('n*') diff --git a/spec/ttfunk/ttf_encoder_spec.rb b/spec/ttfunk/ttf_encoder_spec.rb index 06802eb..e0dcafe 100644 --- a/spec/ttfunk/ttf_encoder_spec.rb +++ b/spec/ttfunk/ttf_encoder_spec.rb @@ -66,8 +66,8 @@ # verified via the Font-Validator tool at: # https://github.com/HinTak/Font-Validator - # Updated checksum after hex-to-letters subset tag implementation - expect(checksum).to eq(0xAA92C9B3) + # Updated checksum after adding Windows UTF-16BE PostScript name record + expect(checksum).to eq(0x7558BDD9) end example_group 'maxp regression', issue: 102 do From 71dd0bd94c059d37415a2f59ccc7e3cbffe64b68 Mon Sep 17 00:00:00 2001 From: Diaconu Andrei Date: Tue, 17 Mar 2026 14:45:30 +0200 Subject: [PATCH 3/5] Detect which platforms the original font uses for PostScrip --- lib/ttfunk/table/name.rb | 37 ++++++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/lib/ttfunk/table/name.rb b/lib/ttfunk/table/name.rb index 119e3a7..b1b6d7a 100644 --- a/lib/ttfunk/table/name.rb +++ b/lib/ttfunk/table/name.rb @@ -201,21 +201,32 @@ def self.encode(names, key = '') new_ps_name = "#{tag}+#{names.postscript_name}" - # platform_id=1, encoding_id=0, language_id=0 (Mac Roman) - # Required by Acrobat and the PDF spec for PostScript name - postscript_name_mac = NameString.new(new_ps_name, 1, 0, 0) - - # platform_id=3, encoding_id=1, language_id=0x0409 (Windows Unicode UTF-16BE, English US) - # Required by Illustrator and other Windows-platform consumers. - # Without this record, apps that only look at platform 3 see no PostScript - # name and fall back to raw glyph indices, producing gobbledygook text. - postscript_name_win = NameString.new( - new_ps_name.encode('UTF-16BE').b, - 3, 1, 0x0409 - ) + # Detect which platforms the original font uses for PostScript name (id=6). + # Mirror that structure exactly: don't add or remove platforms. + # Illustrator requires platform 3 (Windows UTF-16BE); Acrobat accepts platform 1 (Mac Roman). + # Some fonts ship only with platform 3; adding a platform-1 record they never had + # causes Illustrator to misread the subset and render corrupted text. + original_ps_records = names.strings[6] + has_mac = original_ps_records.any? { |s| s.platform_id == 1 } + has_win = original_ps_records.any? { |s| s.platform_id == 3 } + + new_ps_records = [] + + if has_mac || !has_win + # Mac Roman (platform 1) — plain ASCII bytes + new_ps_records << NameString.new(new_ps_name, 1, 0, 0) + end + + if has_win || !has_mac + # Windows Unicode UTF-16BE (platform 3, encoding 1, language 0x0409 = English US) + new_ps_records << NameString.new( + new_ps_name.encode('UTF-16BE').b, + 3, 1, 0x0409 + ) + end strings = names.strings.dup - strings[6] = [postscript_name_mac, postscript_name_win] + strings[6] = new_ps_records str_count = strings.reduce(0) { |sum, (_, list)| sum + list.length } From 48598c35128d52f59a022de589580226830d0255 Mon Sep 17 00:00:00 2001 From: Diaconu Andrei Date: Tue, 17 Mar 2026 15:15:45 +0200 Subject: [PATCH 4/5] cmap fix --- lib/ttfunk/table/cmap.rb | 49 ++++++++++++++++++++++++++++++--- spec/ttfunk/ttf_encoder_spec.rb | 2 +- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/lib/ttfunk/table/cmap.rb b/lib/ttfunk/table/cmap.rb index 12714ee..ff61e18 100644 --- a/lib/ttfunk/table/cmap.rb +++ b/lib/ttfunk/table/cmap.rb @@ -25,11 +25,52 @@ class Cmap < Table # * `:table` (String) - serialized table. # * `:max_glyph_id` (Integer) - maximum glyph ID in the new font. def self.encode(charmap, encoding) - result = Cmap::Subtable.encode(charmap, encoding) + # Always encode the requested encoding (unicode = platform 3, encoding 1) + unicode_result = Cmap::Subtable.encode(charmap, encoding) - # pack 'version' and 'table-count' - result[:table] = [0, 1, result.delete(:subtable)].pack('nnA*') - result + # Also encode Mac Roman (platform 1, encoding 0) so that Illustrator + # can correctly resolve glyph IDs when editing embedded font text. + # Without this subtable, Illustrator falls back to treating glyph IDs + # as raw character codes, producing gobbledygook. + # + # Only characters in the Mac Roman range (0x00-0xFF) can be included. + mac_charmap = charmap.select { |code, _| code <= 0xFF } + mac_result = Cmap::Subtable.encode(mac_charmap, :mac_roman) + + # cmap header: version=0, table-count=2 + # Each subtable record is: platform_id(2) + encoding_id(2) + offset(4) = 8 bytes + # Header = version(2) + count(2) = 4 bytes + # Two subtable records = 2 * 8 = 16 bytes + # Total header block = 4 + 16 = 20 bytes + # + # Subtables are appended after the header block. + # Offset for first subtable (mac) starts right after header block. + # Offset for second subtable (unicode) starts after mac subtable data. + header_size = 4 # version + numTables + record_size = 8 # platformID + encodingID + offset (each record) + num_tables = 2 + + # Extract raw subtable bytes (without the platform/encoding/offset header record) + # subtable field in result already contains: [platform_id, encoding_id, offset, data] + # but we packed it as 'nnNA*' — we need just the raw cmap format data + # Re-encode subtables as raw format data only (strip the 8-byte record header) + mac_raw = mac_result[:subtable][8..] # skip platform(2)+encoding(2)+offset(4) + unicode_raw = unicode_result[:subtable][8..] + + base_offset = header_size + (num_tables * record_size) + mac_offset = base_offset + unicode_offset = base_offset + mac_raw.bytesize + + table = [0, num_tables].pack('nn') + # Mac Roman record + table += [1, 0, mac_offset].pack('nnN') + # Windows Unicode record + table += [3, 1, unicode_offset].pack('nnN') + # Subtable data + table += mac_raw + table += unicode_raw + + unicode_result.merge(table: table) end # Get Unicode encoding records. diff --git a/spec/ttfunk/ttf_encoder_spec.rb b/spec/ttfunk/ttf_encoder_spec.rb index e0dcafe..a1fa411 100644 --- a/spec/ttfunk/ttf_encoder_spec.rb +++ b/spec/ttfunk/ttf_encoder_spec.rb @@ -67,7 +67,7 @@ # verified via the Font-Validator tool at: # https://github.com/HinTak/Font-Validator # Updated checksum after adding Windows UTF-16BE PostScript name record - expect(checksum).to eq(0x7558BDD9) + expect(checksum).to eq(0xA423F7C3) end example_group 'maxp regression', issue: 102 do From a6ac578f8ebc78a767a74ebf09a4fe145636024f Mon Sep 17 00:00:00 2001 From: Diaconu Andrei Date: Tue, 17 Mar 2026 15:35:18 +0200 Subject: [PATCH 5/5] cmap header --- lib/ttfunk/table/cmap.rb | 63 +++++++++++++--------------------------- 1 file changed, 20 insertions(+), 43 deletions(-) diff --git a/lib/ttfunk/table/cmap.rb b/lib/ttfunk/table/cmap.rb index ff61e18..2cf2500 100644 --- a/lib/ttfunk/table/cmap.rb +++ b/lib/ttfunk/table/cmap.rb @@ -17,58 +17,37 @@ class Cmap < Table # @param charmap [Hash{Integer => Integer}] # @param encoding [Symbol] # @return [Hash] - # * `:charmap` (Hash{Integer => Hash}) keys are the characrers in - # `charset`, values are hashes: - # * `:old` (Integer) - glyph ID in the original font. - # * `:new` (Integer) - glyph ID in the subset font. - # that maps the characters in charmap to a - # * `:table` (String) - serialized table. - # * `:max_glyph_id` (Integer) - maximum glyph ID in the new font. def self.encode(charmap, encoding) - # Always encode the requested encoding (unicode = platform 3, encoding 1) + # Encode the primary (unicode) subtable unicode_result = Cmap::Subtable.encode(charmap, encoding) - # Also encode Mac Roman (platform 1, encoding 0) so that Illustrator - # can correctly resolve glyph IDs when editing embedded font text. + # Also encode Mac Roman (platform 1, encoding 0) so Illustrator can + # resolve glyph IDs back to characters when editing embedded font text. # Without this subtable, Illustrator falls back to treating glyph IDs # as raw character codes, producing gobbledygook. - # - # Only characters in the Mac Roman range (0x00-0xFF) can be included. + # Only codepoints <= 0xFF can be represented in Mac Roman. mac_charmap = charmap.select { |code, _| code <= 0xFF } mac_result = Cmap::Subtable.encode(mac_charmap, :mac_roman) - # cmap header: version=0, table-count=2 - # Each subtable record is: platform_id(2) + encoding_id(2) + offset(4) = 8 bytes - # Header = version(2) + count(2) = 4 bytes - # Two subtable records = 2 * 8 = 16 bytes - # Total header block = 4 + 16 = 20 bytes - # - # Subtables are appended after the header block. - # Offset for first subtable (mac) starts right after header block. - # Offset for second subtable (unicode) starts after mac subtable data. - header_size = 4 # version + numTables - record_size = 8 # platformID + encodingID + offset (each record) - num_tables = 2 - - # Extract raw subtable bytes (without the platform/encoding/offset header record) - # subtable field in result already contains: [platform_id, encoding_id, offset, data] - # but we packed it as 'nnNA*' — we need just the raw cmap format data - # Re-encode subtables as raw format data only (strip the 8-byte record header) - mac_raw = mac_result[:subtable][8..] # skip platform(2)+encoding(2)+offset(4) + # Strip the 8-byte record header (platformID nn + encodingID nn + offset N) + # that Subtable.encode prepends, leaving only the raw cmap format data. + mac_raw = mac_result[:subtable][8..] unicode_raw = unicode_result[:subtable][8..] - base_offset = header_size + (num_tables * record_size) - mac_offset = base_offset - unicode_offset = base_offset + mac_raw.bytesize + # cmap header: version(2) + numTables(2) = 4 bytes + # Each encoding record: platformID(2) + encodingID(2) + offset(4) = 8 bytes + # Two records = 16 bytes + # Total before subtable data = 4 + 16 = 20 bytes + header_and_records_size = 4 + (2 * 8) + + mac_offset = header_and_records_size + unicode_offset = header_and_records_size + mac_raw.bytesize - table = [0, num_tables].pack('nn') - # Mac Roman record - table += [1, 0, mac_offset].pack('nnN') - # Windows Unicode record - table += [3, 1, unicode_offset].pack('nnN') - # Subtable data - table += mac_raw - table += unicode_raw + table = [0, 2].pack('nn') # version=0, numTables=2 + table += [1, 0, mac_offset].pack('nnN') # Mac Roman record + table += [3, 1, unicode_offset].pack('nnN') # Windows Unicode record + table += mac_raw # Format 0 data + table += unicode_raw # Format 4 data unicode_result.merge(table: table) end @@ -77,8 +56,6 @@ def self.encode(charmap, encoding) # # @return [Array] def unicode - # Because most callers just call .first on the result, put tables with - # highest-number format first. Unsupported formats will be ignored. @unicode ||= @tables .select { |table| table.unicode? && table.supported? }