From 4ac5bd013ad3ac9d93d82b6b29c8fa822687ad12 Mon Sep 17 00:00:00 2001 From: theirix Date: Wed, 25 Mar 2026 21:46:08 +0000 Subject: [PATCH] UTF8StringToArray cache hit skips subsetchars update, causing invisible Unicode characters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UTF8StringToArray maintains an LRU cache (max 8 entries) keyed on the string value. On a cache hit it returned immediately without updating @current_font['subsetchars'] for the active font. When the same string appeared under a second font — the common case in Redmine's PDF export where a Cyrillic project title is written first in a bold heading font and then again in the regular body font — the second font's subsetchars was never populated. At PDF generation time getTrueTypeFontSubset builds each font's embedded glyph subset from its subsetchars array. Codepoints absent from that array are stripped, so those characters render as invisible boxes in the viewer even though the UTF-8 bytes are correctly present in the content stream (which is why copy-paste still worked). The fix adds a subsetchars propagation step inside the cache-hit branch: the cached codepoint array is iterated and every entry is written into the current font's subsetchars before the dup is returned. The assignment is idempotent, so re-doing it on a cache miss is harmless. A regression test is added that calls UTF8StringToArray directly, bypassing the rendering pipeline, to verify that switching fonts between two calls on the same string correctly populates subsetchars for each font. --- lib/rbpdf.rb | 11 +++++ test/rbpdf_subsetchars_cache_test.rb | 71 ++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 test/rbpdf_subsetchars_cache_test.rb diff --git a/lib/rbpdf.rb b/lib/rbpdf.rb index be433d5..571b94f 100755 --- a/lib/rbpdf.rb +++ b/lib/rbpdf.rb @@ -8246,6 +8246,17 @@ def out(s) # def UTF8StringToArray(str) if @cache_utf8_string_to_array[str] + # On a cache hit the byte-parsing loop is skipped, so @current_font['subsetchars'] + # would never learn about these codepoints for the current font. Without this + # update a second font that encounters the same string gets an empty glyph subset + # and those characters are stripped from the embedded font, rendering as invisible + # boxes in the PDF viewer even though the bytes are present in the content stream. + if @is_unicode && @current_font['subsetchars'] + @cache_utf8_string_to_array[str].each do |unichar| + @current_font['subsetchars'][unichar] = true + end + setFontSubBuffer(@current_font['fontkey'], 'subsetchars', @current_font['subsetchars']) + end # return cached value return @cache_utf8_string_to_array[str].dup end diff --git a/test/rbpdf_subsetchars_cache_test.rb b/test/rbpdf_subsetchars_cache_test.rb new file mode 100644 index 0000000..127b336 --- /dev/null +++ b/test/rbpdf_subsetchars_cache_test.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +# Copyright (c) 2011-2024 NAITOH Jun +# Released under the MIT license +# http://www.opensource.org/licenses/MIT + +require "test_helper" + +# Regression test for the UTF8StringToArray cache bug that causes Cyrillic +# characters to be invisible in the PDF when the same string is rendered +# under more than one font. +# +# UTF8StringToArray keeps an LRU cache keyed on the string value. On a cache +# hit it returns immediately, skipping the byte-parsing loop that records each +# codepoint in @current_font['subsetchars']. When the same string is later +# processed under a different font, that font's subsetchars is never updated, +# so getTrueTypeFontSubset strips those glyphs from the embedded font data and +# the characters render as invisible boxes in the PDF viewer. +class RbpdfSubsetcharsCacheTest < Test::Unit::TestCase + class MYPDF < RBPDF + def getFontBuffer(font) + super + end + + def call_utf8_string_to_array(str) + UTF8StringToArray(str) + end + end + + CYRILLIC_TEXT = "Красного строителя" + + # Codepoints above U+00FF must be added explicitly to subsetchars during + # rendering; subsetchars is only pre-populated for indices 0-255. + CYRILLIC_CODEPOINTS = + CYRILLIC_TEXT.each_char + .map(&:ord) + .select { |cp| cp > 255 } + .uniq + .sort + .freeze + + test "UTF8StringToArray cache hit must update subsetchars for the current font" do + pdf = MYPDF.new + pdf.set_font_subsetting(true) + pdf.add_page + + # ASCII-8BIT is the encoding UTF8StringToArray receives in practice. + str = CYRILLIC_TEXT.dup.force_encoding("ASCII-8BIT") + + # Step 1: cache miss under freesansB — full parse runs, subsetchars populated. + pdf.set_font("freesans", "B", 12) + pdf.call_utf8_string_to_array(str) + + bold_subsetchars = pdf.getFontBuffer("freesansB")["subsetchars"] + CYRILLIC_CODEPOINTS.each do |cp| + assert bold_subsetchars[cp], + "freesansB subsetchars missing U+#{cp.to_s(16).upcase.rjust(4, "0")} after cache-miss call" + end + + # Step 2: cache hit under freesans — early return skips the subsetchars + # update, so freesans never learns about these codepoints (the bug). + pdf.set_font("freesans", "", 12) + pdf.call_utf8_string_to_array(str) + + regular_subsetchars = pdf.getFontBuffer("freesans")["subsetchars"] + CYRILLIC_CODEPOINTS.each do |cp| + assert regular_subsetchars[cp], + "freesans subsetchars missing U+#{cp.to_s(16).upcase.rjust(4, "0")} after cache-hit call" + end + end +end