ucode 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c7dbe8b2ddc06a807988316b3800fd3aa886a15f3d6b9249f62770728aa3441c
4
- data.tar.gz: 396ac3e45668194220bc26ff3d6e002c4942feb6d6c9a63ce0003a22ccfc1c8e
3
+ metadata.gz: 2d93808cc35d75099bb2028677c0ece5bcac1985cf93b20dbb3ba83d6161bd7a
4
+ data.tar.gz: 230980b63347f44f11b0cf5238836c0328aa0d46711baca05fec78830e5f67f3
5
5
  SHA512:
6
- metadata.gz: 6f28010777955f1a11d03ed10c8edefc83ded96a0a307080b360c1a0918ca01a410e6571644e9cb4f924bbb1a67b676906f4252742b5cdcf1bc25c7df587e730
7
- data.tar.gz: 764bd2ff3296fb44d26eabe2003d7e739a5b7baebafd3df80dc3521000958d5ad535f6d73d1eab63ebc7c039f0f8dda9c06d10851d6a8fda1d1a430ab060ea5c
6
+ metadata.gz: b1e56059471ba2fe204f018c41811f19f20f1cf1569ac9da91004031561f8826c64faba4d7ecd07963d273ca51631ec71589c74860e54521e6e1d30b4ec321f7
7
+ data.tar.gz: 2f30cd92e0ac72619250363c4b71321241042b3ade8a7eaaf20c8bfffa2c3d79be42414e1b872d1f0375c470a4c9b82825e8a518ec328022d6c6a8096b8211a6
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  unicode_version: 17.0.0
3
- ucode_version: 0.5.0
3
+ ucode_version: 0.5.1
4
4
  generated_at: '2026-06-28T00:00:00Z'
5
5
  default_sources:
6
6
  - kind: fontist
@@ -56,11 +56,17 @@ module Ucode
56
56
  # about block scope).
57
57
  # @param force_positional_for_font_ids [Set<Integer>] Type0
58
58
  # font object IDs that always trigger positional attribution
59
+ # @param trace_cache [PageTraceCache, nil] consulted by
60
+ # {#needs_positional?} for the partial-ToUnicode-coverage
61
+ # case (the font has glyphs the CMap doesn't list). nil =
62
+ # skip that check (legacy behavior).
59
63
  def initialize(strategies:, block_range: nil,
60
- force_positional_for_font_ids: Set.new)
64
+ force_positional_for_font_ids: Set.new,
65
+ trace_cache: nil)
61
66
  @strategies = strategies
62
67
  @block_range = block_range
63
68
  @force_positional_for_font_ids = force_positional_for_font_ids
69
+ @trace_cache = trace_cache
64
70
  end
65
71
 
66
72
  # Convenience builder — wires up the default 3-strategy chain
@@ -71,7 +77,9 @@ module Ucode
71
77
  # @param trace_cache [PageTraceCache, nil] when provided, the
72
78
  # TraceStrategy shares this cache (lets the caller reuse the
73
79
  # traced pages for downstream concerns like Catalog's
74
- # location lookup). nil = construct internally.
80
+ # location lookup) AND the orchestrator consults it for
81
+ # gap detection (ToUnicode partial-coverage case). nil =
82
+ # construct internally.
75
83
  # @return [CodepointMapper]
76
84
  def self.build(source:, correlator_configs:, indexer:,
77
85
  block_range: nil, force_positional_for_font_ids: Set.new,
@@ -93,7 +101,8 @@ module Ucode
93
101
  ]
94
102
  new(strategies: strategies,
95
103
  block_range: block_range,
96
- force_positional_for_font_ids: force_positional_for_font_ids)
104
+ force_positional_for_font_ids: force_positional_for_font_ids,
105
+ trace_cache: trace_cache)
97
106
  end
98
107
 
99
108
  # @param descriptor [RawFontDescriptor]
@@ -136,7 +145,7 @@ module Ucode
136
145
  {}
137
146
  end
138
147
 
139
- # Positional strategies are gated behind three conditions, any
148
+ # Positional strategies are gated behind four conditions, any
140
149
  # of which triggers them:
141
150
  #
142
151
  # 1. Caller explicitly listed this font in
@@ -146,10 +155,17 @@ module Ucode
146
155
  # 3. The intrinsic result fell entirely outside the
147
156
  # caller's block scope — the font's CMap encoded the
148
157
  # wrong codepoints (Option 1 auto-detect, e.g. U1F200).
158
+ # 4. The intrinsic result covered fewer glyphs than the
159
+ # trace cache observes on the page — the font's CMap is
160
+ # partial (Unicode Consortium ships orphan glyphs whose
161
+ # CIDs the ToUnicode stream forgot to map, e.g. the
162
+ # last glyph of Mongolian Supplement U+1166C and Myanmar
163
+ # Extended-A U+116DA in the v17.0 charts).
149
164
  def needs_positional?(descriptor, intrinsic_result)
150
165
  return true if @force_positional_for_font_ids.include?(descriptor.font_obj_id)
151
166
  return true if intrinsic_result.empty?
152
167
  return true if intrinsic_out_of_scope?(intrinsic_result)
168
+ return true if intrinsic_has_uncovered_gids?(descriptor, intrinsic_result)
153
169
 
154
170
  false
155
171
  end
@@ -162,6 +178,21 @@ module Ucode
162
178
  intrinsic_result.keys.all? { |cp| !@block_range.include?(cp) }
163
179
  end
164
180
 
181
+ # Detects the partial-ToUnicode-coverage case: the font's
182
+ # CMap names N codepoints, but the page actually renders >N
183
+ # glyphs from this font (orphan glyphs whose CIDs the CMap
184
+ # forgot). Positional attribution via the chart's hex labels
185
+ # is the only way to recover them.
186
+ def intrinsic_has_uncovered_gids?(descriptor, intrinsic_result)
187
+ return false unless @trace_cache
188
+ return false if intrinsic_result.empty?
189
+
190
+ covered_gids = intrinsic_result.values.to_set
191
+ @trace_cache.distinct_gids_for(descriptor.base_font).any? do |gid|
192
+ !covered_gids.include?(gid)
193
+ end
194
+ end
195
+
165
196
  # Positional chain: union of all positional strategies' results.
166
197
  # Within positional, earlier strategies win on conflict (chain
167
198
  # order expresses caller preference — CorrelatorStrategy
@@ -4,6 +4,7 @@ require "pathname"
4
4
 
5
5
  require "ucode/glyphs/embedded_fonts/mutool"
6
6
  require "ucode/glyphs/embedded_fonts/trace_parser"
7
+ require "ucode/glyphs/embedded_fonts/trace_glyph"
7
8
 
8
9
  module Ucode
9
10
  module Glyphs
@@ -24,6 +25,15 @@ module Ucode
24
25
  # Lazy: glyphs are only fetched when a strategy first asks for
25
26
  # them. PDFs where every font has /ToUnicode never trigger the
26
27
  # trace at all.
28
+ #
29
+ # ## mutool font-name truncation
30
+ #
31
+ # mutool trace emits font names truncated to 31 chars (PDF
32
+ # base-font-name limit). The BaseFont dict may carry the full
33
+ # original name (e.g. `HBBJCP+Uni11660Mongoliansupplement`).
34
+ # All name comparisons in this class go through
35
+ # {TraceGlyph.name_match?} so the truncation doesn't silently
36
+ # break lookups for long-named fonts.
27
37
  class PageTraceCache
28
38
  # @param pdf [Pathname, String]
29
39
  # @param page_count [Integer] total pages in the PDF
@@ -55,7 +65,7 @@ module Ucode
55
65
  glyphs_by_page.each_with_index do |glyphs, idx|
56
66
  next if idx.zero?
57
67
 
58
- present = glyphs.any? { |g| g.font_name == base_font }
68
+ present = glyphs.any? { |g| TraceGlyph.name_match?(g.font_name, base_font) }
59
69
  next unless present
60
70
 
61
71
  present_in_any = true
@@ -68,8 +78,27 @@ module Ucode
68
78
  # @return [Boolean] true if any page references this font
69
79
  def references_font?(base_font)
70
80
  glyphs_by_page.any? do |page_glyphs|
71
- page_glyphs.any? { |g| g.font_name == base_font }
81
+ page_glyphs.any? { |g| TraceGlyph.name_match?(g.font_name, base_font) }
82
+ end
83
+ end
84
+
85
+ # Returns the set of distinct GIDs rendered on any page for
86
+ # the given font. Used by {CodepointMapper#needs_positional?}
87
+ # to detect the partial-ToUnicode-coverage case (font ships
88
+ # more glyphs than its CMap admits).
89
+ #
90
+ # @param base_font [String]
91
+ # @return [Set<Integer>]
92
+ def distinct_gids_for(base_font)
93
+ gids = Set.new
94
+ glyphs_by_page.each do |page_glyphs|
95
+ page_glyphs.each do |g|
96
+ next unless TraceGlyph.name_match?(g.font_name, base_font)
97
+
98
+ gids << g.gid if g.gid
99
+ end
72
100
  end
101
+ gids
73
102
  end
74
103
 
75
104
  # Locate the first occurrence of a specific (font, gid) pair
@@ -87,7 +116,7 @@ module Ucode
87
116
  next if idx.zero?
88
117
 
89
118
  match = page_glyphs.find do |g|
90
- g.font_name == base_font && g.gid == gid
119
+ TraceGlyph.name_match?(g.font_name, base_font) && g.gid == gid
91
120
  end
92
121
  return { page: idx, x: match.x, y: match.y } if match
93
122
  end
@@ -48,7 +48,7 @@ module Ucode
48
48
  private
49
49
 
50
50
  def select_specimens(trace_glyphs)
51
- trace_glyphs.select { |g| g.font_name == @specimen_font_name }
51
+ trace_glyphs.select { |g| TraceGlyph.name_match?(g.font_name, @specimen_font_name) }
52
52
  end
53
53
 
54
54
  def select_labels(trace_glyphs)
@@ -93,7 +93,7 @@ module Ucode
93
93
 
94
94
  def select_hex_candidates(trace_glyphs)
95
95
  trace_glyphs.select do |g|
96
- g.font_name != @specimen_font_name &&
96
+ !TraceGlyph.name_match?(g.font_name, @specimen_font_name) &&
97
97
  g.unicode&.match?(/\A[0-9A-Fa-f]\z/)
98
98
  end
99
99
  end
@@ -3,6 +3,16 @@
3
3
  module Ucode
4
4
  module Glyphs
5
5
  module EmbeddedFonts
6
+ # mutool trace emits font names truncated to 31 characters
7
+ # (PDF base-font-name limit, see PDF 32000-1 §7.9.6). The
8
+ # BaseFont dict, however, may carry the full original name.
9
+ # `HBBJCP+Uni11660Mongoliansupplement` (34 chars) is emitted
10
+ # by mutool trace as `HBBJCP+Uni11660Mongoliansupplem`. The
11
+ # helpers below let callers compare trace-side names against
12
+ # catalog-side names without tripping on the truncation.
13
+ TRACE_NAME_LIMIT = 31
14
+ private_constant :TRACE_NAME_LIMIT
15
+
6
16
  # Value object for one glyph emitted by `mutool trace`.
7
17
  #
8
18
  # Each `<g>` element in the trace XML maps to one TraceGlyph:
@@ -21,7 +31,31 @@ module Ucode
21
31
  :y,
22
32
  :unicode,
23
33
  keyword_init: true,
24
- )
34
+ ) do
35
+ class << self
36
+ # @param name [String, nil] a BaseFont name from `mutool info`
37
+ # or a trace-emitted font name
38
+ # @return [String, nil] the name truncated to the trace limit
39
+ def normalize_name(name)
40
+ return nil if name.nil?
41
+
42
+ name.length <= TRACE_NAME_LIMIT ? name : name[0, TRACE_NAME_LIMIT]
43
+ end
44
+
45
+ # True when `a` and `b` resolve to the same normalized
46
+ # name. Treats `nil` as never matching (avoids accidental
47
+ # collision on missing names).
48
+ #
49
+ # @param a [String, nil]
50
+ # @param b [String, nil]
51
+ # @return [Boolean]
52
+ def name_match?(a, b)
53
+ return false if a.nil? || b.nil?
54
+
55
+ normalize_name(a) == normalize_name(b)
56
+ end
57
+ end
58
+ end
25
59
  end
26
60
  end
27
61
  end
data/lib/ucode/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ucode
4
- VERSION = "0.5.1"
4
+ VERSION = "0.5.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ucode
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.5.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.