ucode 0.3.3 → 0.5.0

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.
Files changed (122) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +45 -6
  3. data/config/unicode17_universal_glyph_set.yml +1 -1
  4. data/lib/ucode/cli.rb +201 -32
  5. data/lib/ucode/code_chart/batch_runner.rb +184 -0
  6. data/lib/ucode/code_chart/block_index.rb +89 -0
  7. data/lib/ucode/code_chart/coverage_gap_index.rb +115 -0
  8. data/lib/ucode/code_chart/extractor.rb +65 -13
  9. data/lib/ucode/code_chart/fetcher.rb +112 -0
  10. data/lib/ucode/code_chart/gap_analyzer/block_gap.rb +35 -0
  11. data/lib/ucode/code_chart/gap_analyzer/essenfont_manifest.rb +60 -0
  12. data/lib/ucode/code_chart/gap_analyzer/manifest.rb +39 -0
  13. data/lib/ucode/code_chart/gap_analyzer.rb +83 -0
  14. data/lib/ucode/code_chart/provenance.rb +99 -58
  15. data/lib/ucode/code_chart/sidecar.rb +3 -4
  16. data/lib/ucode/code_chart/verifier/builder.rb +49 -0
  17. data/lib/ucode/code_chart/verifier/mutool_strategy.rb +80 -0
  18. data/lib/ucode/code_chart/verifier/page_render_cache.rb +63 -0
  19. data/lib/ucode/code_chart/verifier/result.rb +27 -0
  20. data/lib/ucode/code_chart/verifier/resvg_strategy.rb +71 -0
  21. data/lib/ucode/code_chart/verifier/strategy.rb +73 -0
  22. data/lib/ucode/code_chart/verifier.rb +152 -0
  23. data/lib/ucode/code_chart/writer.rb +21 -14
  24. data/lib/ucode/code_chart.rb +6 -0
  25. data/lib/ucode/error.rb +16 -1
  26. data/lib/ucode/fetch/http.rb +23 -3
  27. data/lib/ucode/glyphs/embedded_fonts/catalog.rb +66 -3
  28. data/lib/ucode/glyphs/embedded_fonts/codepoint_mapper/correlator_strategy.rb +46 -0
  29. data/lib/ucode/glyphs/embedded_fonts/codepoint_mapper/strategy.rb +54 -0
  30. data/lib/ucode/glyphs/embedded_fonts/codepoint_mapper/tounicode_strategy.rb +41 -0
  31. data/lib/ucode/glyphs/embedded_fonts/codepoint_mapper/trace_strategy.rb +64 -0
  32. data/lib/ucode/glyphs/embedded_fonts/codepoint_mapper.rb +150 -90
  33. data/lib/ucode/glyphs/embedded_fonts/mutool/draw.rb +26 -0
  34. data/lib/ucode/glyphs/embedded_fonts/mutool/info.rb +23 -0
  35. data/lib/ucode/glyphs/embedded_fonts/mutool/show.rb +36 -0
  36. data/lib/ucode/glyphs/embedded_fonts/mutool/trace.rb +26 -0
  37. data/lib/ucode/glyphs/embedded_fonts/mutool.rb +30 -0
  38. data/lib/ucode/glyphs/embedded_fonts/page_trace_cache.rb +112 -0
  39. data/lib/ucode/glyphs/embedded_fonts/pdf_indexer.rb +50 -56
  40. data/lib/ucode/glyphs/embedded_fonts/{pdf_location.rb → pdf_source.rb} +1 -1
  41. data/lib/ucode/glyphs/embedded_fonts/renderer.rb +19 -2
  42. data/lib/ucode/glyphs/embedded_fonts/trace_runner.rb +12 -28
  43. data/lib/ucode/glyphs/embedded_fonts.rb +3 -1
  44. data/lib/ucode/glyphs/real_fonts/coverage_auditor.rb +0 -4
  45. data/lib/ucode/glyphs/real_fonts/font_coverage_report.rb +0 -2
  46. data/lib/ucode/glyphs/real_fonts/writer.rb +0 -2
  47. data/lib/ucode/glyphs/source.rb +11 -1
  48. data/lib/ucode/glyphs/sources/pillar1_embedded_tounicode.rb +11 -6
  49. data/lib/ucode/version.rb +1 -1
  50. data/lib/ucode.rb +2 -0
  51. metadata +28 -74
  52. data/TODO.extract-code-chart/01-pdf-fetch-validation.md +0 -80
  53. data/TODO.extract-code-chart/02-block-name-resolver.md +0 -68
  54. data/TODO.extract-code-chart/03-codechart-namespace.md +0 -82
  55. data/TODO.extract-code-chart/04-codechart-extractor.md +0 -154
  56. data/TODO.extract-code-chart/05-provenance-and-sidecar.md +0 -147
  57. data/TODO.extract-code-chart/06-codechart-writer.md +0 -134
  58. data/TODO.extract-code-chart/07-codechart-cli.md +0 -135
  59. data/TODO.extract-code-chart/08-specs.md +0 -87
  60. data/TODO.full/00-README.md +0 -116
  61. data/TODO.full/01-panglyph-vision.md +0 -112
  62. data/TODO.full/02-panglyph-repo-bootstrap.md +0 -184
  63. data/TODO.full/03-panglyph-font-builder.md +0 -201
  64. data/TODO.full/04-panglyph-publish-pipeline.md +0 -126
  65. data/TODO.full/05-ucode-0-1-1-release.md +0 -139
  66. data/TODO.full/06-fontisan-remove-audit.md +0 -142
  67. data/TODO.full/07-fontisan-remove-ucd.md +0 -125
  68. data/TODO.full/08-archive-private-bin-build.md +0 -143
  69. data/TODO.full/09-archive-public-structure.md +0 -164
  70. data/TODO.full/10-fontist-org-woff-glyphs.md +0 -131
  71. data/TODO.full/11-fontist-org-audit-coverage.md +0 -140
  72. data/TODO.full/12-implementation-order.md +0 -216
  73. data/TODO.full/13-fontisan-font-writer-api.md +0 -189
  74. data/TODO.full/14-fontisan-table-writers.md +0 -66
  75. data/TODO.full/15-panglyph-builder-real.md +0 -82
  76. data/TODO.full/16-archive-public-sync-workflows.md +0 -167
  77. data/TODO.full/17-fontist-org-font-picker.md +0 -73
  78. data/TODO.full/18-comprehensive-spec-coverage.md +0 -64
  79. data/TODO.full/19-ucode-0-1-2-patch.md +0 -32
  80. data/TODO.full/20-fontisan-0-2-23-release.md +0 -52
  81. data/TODO.new/00-README.md +0 -96
  82. data/TODO.new/01-pillar-terminology-alignment.md +0 -69
  83. data/TODO.new/02-audit-schema-design.md +0 -255
  84. data/TODO.new/03-directory-output-spec.md +0 -203
  85. data/TODO.new/04-fontist-org-contract.md +0 -173
  86. data/TODO.new/05-baseline-unicode17-coverage-audit.md +0 -144
  87. data/TODO.new/06-audit-namespace-skeleton.md +0 -105
  88. data/TODO.new/07-audit-models-port.md +0 -132
  89. data/TODO.new/08-extractors-cheap-port.md +0 -113
  90. data/TODO.new/09-extractors-expensive-port.md +0 -99
  91. data/TODO.new/10-aggregations-ucd-rewrite.md +0 -168
  92. data/TODO.new/11-differ-and-library-auditor-port.md +0 -102
  93. data/TODO.new/12-formatters-port.md +0 -115
  94. data/TODO.new/13-directory-emitter.md +0 -147
  95. data/TODO.new/14-html-face-browser.md +0 -144
  96. data/TODO.new/15-html-library-browser.md +0 -102
  97. data/TODO.new/16-cli-audit-subcommands.md +0 -142
  98. data/TODO.new/17-fontisan-cleanup-audit.md +0 -147
  99. data/TODO.new/18-fontisan-cleanup-ucd.md +0 -156
  100. data/TODO.new/19-fontisan-docs-update.md +0 -155
  101. data/TODO.new/20-canonical-resolver-4-tier.md +0 -182
  102. data/TODO.new/21-canonical-unicode17-build.md +0 -148
  103. data/TODO.new/22-implementation-order.md +0 -176
  104. data/TODO.new/23-universal-glyph-set-source-map.md +0 -312
  105. data/TODO.new/24-universal-glyph-set-build.md +0 -189
  106. data/TODO.new/25-font-audit-against-universal-set.md +0 -195
  107. data/TODO.new/26-missing-glyph-reporter.md +0 -189
  108. data/TODO.new/27-fontist-org-consumer-integration.md +0 -200
  109. data/TODO.new/28-implementation-order-update.md +0 -187
  110. data/TODO.new/29-universal-set-curation-uc17.md +0 -312
  111. data/TODO.new/30-tier1-font-acquisition.md +0 -241
  112. data/TODO.new/31-universal-set-production-build.md +0 -205
  113. data/TODO.new/32-uc17-coverage-matrix.md +0 -165
  114. data/TODO.new/33-specialist-font-acquisition-refresh.md +0 -138
  115. data/TODO.new/34-pillar2-content-stream-correlator.md +0 -147
  116. data/TODO.new/35-universal-set-production-run.md +0 -160
  117. data/TODO.new/36-per-font-coverage-audit.md +0 -145
  118. data/TODO.new/37-coverage-highlight-reporter.md +0 -125
  119. data/TODO.new/38-fontist-org-glyph-consumer.md +0 -141
  120. data/TODO.new/39-implementation-order-update-32-38.md +0 -258
  121. data/TODO.new/40-archive-private-uses-ucode-audit.md +0 -124
  122. data/TODO.new/41-ucode-unicode-archive-bridge.md +0 -160
@@ -1,128 +1,188 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "open3"
4
- require "pathname"
5
- require "tempfile"
3
+ require "ucode/glyphs/embedded_fonts/codepoint_mapper/strategy"
4
+ require "ucode/glyphs/embedded_fonts/codepoint_mapper/tounicode_strategy"
5
+ require "ucode/glyphs/embedded_fonts/codepoint_mapper/correlator_strategy"
6
+ require "ucode/glyphs/embedded_fonts/codepoint_mapper/trace_strategy"
7
+ require "ucode/glyphs/embedded_fonts/mutool"
8
+ require "ucode/glyphs/embedded_fonts/page_trace_cache"
9
+ require "ucode/error"
6
10
 
7
11
  module Ucode
8
12
  module Glyphs
9
13
  module EmbeddedFonts
10
- # Resolves codepoint → GID for one Type0 font via a 3-path strategy:
14
+ # Resolves codepoint → GID for one Type0 font via a chain of
15
+ # {Strategy} subclasses.
11
16
  #
12
- # 1. **ToUnicode CMap** the font's `/ToUnicode` stream (Tier 1
13
- # for pillar 1). Parsed by {ToUnicode}.
14
- # 2. **Caller-supplied correlator config** (pillar 2) — render the
15
- # font's pages to SVG and run {ContentStreamCorrelator}.
16
- # 3. **Auto-detect via mutool trace** (pillar 2b) — trace every
17
- # page and run {TraceCorrelator} positionally.
17
+ # The chain is partitioned by {Strategy#positional?}:
18
18
  #
19
- # Each path returns a `{codepoint => gid}` map. First non-empty
20
- # result wins; the strategy stops there.
19
+ # * Intrinsic strategies (ToUnicodeStrategy) read the font's
20
+ # own CMap. Tried in chain order; the first non-empty result
21
+ # wins.
22
+ # * Positional strategies (CorrelatorStrategy, TraceStrategy)
23
+ # attribute glyphs by chart-grid geometry. Expensive — they
24
+ # shell out to mutool per page — so they only run when an
25
+ # intrinsic strategy cannot cover the requested block scope.
21
26
  #
22
- # Pure strategy orchestration does NOT parse the PDF object graph
23
- # (that's {PdfIndexer}'s job). Takes a {RawFontDescriptor} + the
24
- # shared {PdfIndexer} (for page_count + font_appears? queries used
25
- # by the trace fallback).
27
+ # When positional strategies run, their results merge over the
28
+ # intrinsic result with positional precedence: chart geometry is
29
+ # authoritative for in-block specimens, while a font's CMap can
30
+ # be misleading (Enclosed Ideographic Supplement, where the
31
+ # embedded CJKSymbols font's CMap maps its CIDs to the *composing*
32
+ # ideographs rather than the squared characters themselves).
33
+ #
34
+ # Two escape hatches let callers control the gating:
35
+ #
36
+ # * `block_range:` — when set, an intrinsic result with zero
37
+ # in-block intersection is treated as "wrong scope" and
38
+ # dropped, so positional strategies can take over. Auto-detect
39
+ # for the U1F200 class of failure (Option 1).
40
+ # * `force_positional_for_font_ids:` — Type0 font object IDs
41
+ # that always go through positional attribution, regardless
42
+ # of whether the intrinsic strategy succeeded. Escape hatch
43
+ # for partial-overlap cases where the CMap covers some
44
+ # in-block codepoints but positional attribution is still
45
+ # desired for the rest (Option 2).
46
+ #
47
+ # Adding a new strategy = one Strategy subclass + (if positional)
48
+ # a `positional?` override + one entry in the chain. No edit to
49
+ # {#map} (Open/Closed Principle).
26
50
  class CodepointMapper
27
- # @param source [PdfLocation]
28
- # @param correlator_configs [Hash{Integer=>ContentStreamCorrelator::Config}]
29
- # caller-supplied pillar-2 configs, keyed by font_obj_id
30
- # @param indexer [PdfIndexer] for page_count + font_appears? queries
31
- def initialize(source:, correlator_configs:, indexer:)
32
- @source = source
33
- @correlator_configs = correlator_configs
34
- @indexer = indexer
51
+ # @param strategies [Array<Strategy>] chain; partitioned by
52
+ # Strategy#positional? at map time
53
+ # @param block_range [Range<Integer>, nil] codepoint scope the
54
+ # caller is extracting. nil = intrinsic result is always
55
+ # trusted (legacy behavior; the caller doesn't know or care
56
+ # about block scope).
57
+ # @param force_positional_for_font_ids [Set<Integer>] Type0
58
+ # font object IDs that always trigger positional attribution
59
+ def initialize(strategies:, block_range: nil,
60
+ force_positional_for_font_ids: Set.new)
61
+ @strategies = strategies
62
+ @block_range = block_range
63
+ @force_positional_for_font_ids = force_positional_for_font_ids
64
+ end
65
+
66
+ # Convenience builder — wires up the default 3-strategy chain
67
+ # with default Mutool wrappers. Callers that need to inject
68
+ # stubs for tests should construct strategies directly and pass
69
+ # them to +#initialize+.
70
+ #
71
+ # @param trace_cache [PageTraceCache, nil] when provided, the
72
+ # TraceStrategy shares this cache (lets the caller reuse the
73
+ # traced pages for downstream concerns like Catalog's
74
+ # location lookup). nil = construct internally.
75
+ # @return [CodepointMapper]
76
+ def self.build(source:, correlator_configs:, indexer:,
77
+ block_range: nil, force_positional_for_font_ids: Set.new,
78
+ mutool_show: Mutool::Show.new,
79
+ mutool_draw: Mutool::Draw.new,
80
+ mutool_trace: Mutool::Trace.new,
81
+ trace_cache: nil)
82
+ trace_cache ||= PageTraceCache.new(
83
+ pdf: source.pdf_path,
84
+ page_count: indexer.page_count,
85
+ mutool: mutool_trace,
86
+ )
87
+ strategies = [
88
+ ToUnicodeStrategy.new(source: source, mutool_show: mutool_show),
89
+ CorrelatorStrategy.new(source: source,
90
+ correlator_configs: correlator_configs,
91
+ mutool_draw: mutool_draw),
92
+ TraceStrategy.new(cache: trace_cache, indexer: indexer),
93
+ ]
94
+ new(strategies: strategies,
95
+ block_range: block_range,
96
+ force_positional_for_font_ids: force_positional_for_font_ids)
35
97
  end
36
98
 
37
99
  # @param descriptor [RawFontDescriptor]
38
- # @return [Hash{Integer=>Integer}] codepoint => gid; empty when
39
- # no strategy produces a map
100
+ # @return [Hash{Integer=>Integer}] codepoint => gid; empty
101
+ # when no strategy produces a mapping
40
102
  def map(descriptor)
41
103
  return {} unless descriptor.cid_map_kind == :identity
42
104
 
43
- from_tounicode = map_from_tounicode(descriptor.tounicode_ref)
44
- return from_tounicode unless from_tounicode.empty?
45
-
46
- from_correlator = map_from_correlator(descriptor.font_obj_id)
47
- return from_correlator unless from_correlator.empty?
105
+ positional, intrinsic = partition_strategies
106
+ intrinsic_result = run_intrinsic(descriptor, intrinsic)
107
+ return intrinsic_result if positional.empty?
108
+ return intrinsic_result unless needs_positional?(descriptor,
109
+ intrinsic_result)
48
110
 
49
- map_from_trace(descriptor.base_font)
111
+ positional_result = run_positional(descriptor, positional)
112
+ merge_with_positional_precedence(intrinsic_result, positional_result)
50
113
  end
51
114
 
52
115
  private
53
116
 
54
- # ---- Strategy 1: /ToUnicode CMap --------------------------------
55
-
56
- def map_from_tounicode(tu_ref)
57
- return {} unless tu_ref
58
-
59
- cmap_text = fetch_tounicode(tu_ref)
60
- cid_to_cp = ToUnicode.parse(cmap_text)
61
- build_codepoint_map(cid_to_cp)
117
+ # `Enumerable#partition` returns `[truthy_group, falsy_group]`,
118
+ # so the first element is positional strategies and the second
119
+ # is intrinsic. Naming them explicitly here avoids the
120
+ # destructure-order footgun.
121
+ def partition_strategies
122
+ positional, intrinsic = @strategies.partition(&:positional?)
123
+ [positional, intrinsic]
62
124
  end
63
125
 
64
- def build_codepoint_map(cid_to_cp)
65
- cid_to_cp.each_with_object({}) do |(cid, cp), h|
66
- h[cp] = cid
67
- end
68
- end
126
+ # Intrinsic chain: first non-empty result wins. preserves the
127
+ # pre-partition semantics so the legacy ToUnicode-only flow
128
+ # behaves identically when no positional strategies exist.
129
+ def run_intrinsic(descriptor, strategies)
130
+ strategies.each do |s|
131
+ next unless s.supports?(descriptor)
69
132
 
70
- def fetch_tounicode(obj_id)
71
- Tempfile.create("ucode-tounicode") do |tmp|
72
- tmp.close
73
- ok = system("mutool", "show", "-o", tmp.path, "-b",
74
- @source.pdf_to_s, obj_id.to_s,
75
- out: File::NULL, err: File::NULL)
76
- unless ok
77
- raise Ucode::EmbeddedFontsMissingError,
78
- "mutool show failed for ToUnicode obj=#{obj_id}"
79
- end
80
-
81
- File.binread(tmp.path).force_encoding("UTF-8")
133
+ result = s.map(descriptor)
134
+ return result unless result.empty?
82
135
  end
136
+ {}
83
137
  end
84
138
 
85
- # ---- Strategy 2: caller-supplied correlator config --------------
139
+ # Positional strategies are gated behind three conditions, any
140
+ # of which triggers them:
141
+ #
142
+ # 1. Caller explicitly listed this font in
143
+ # `force_positional_for_font_ids` (Option 2 escape hatch).
144
+ # 2. No intrinsic strategy produced a mapping (legacy
145
+ # fallback for fonts without ToUnicode).
146
+ # 3. The intrinsic result fell entirely outside the
147
+ # caller's block scope — the font's CMap encoded the
148
+ # wrong codepoints (Option 1 auto-detect, e.g. U1F200).
149
+ def needs_positional?(descriptor, intrinsic_result)
150
+ return true if @force_positional_for_font_ids.include?(descriptor.font_obj_id)
151
+ return true if intrinsic_result.empty?
152
+ return true if intrinsic_out_of_scope?(intrinsic_result)
153
+
154
+ false
155
+ end
86
156
 
87
- def map_from_correlator(font_obj_id)
88
- config = @correlator_configs[font_obj_id]
89
- return {} unless config
157
+ def intrinsic_out_of_scope?(intrinsic_result)
158
+ return false unless @block_range
90
159
 
91
- svg = render_pages(config.page_numbers)
92
- ContentStreamCorrelator.new(config).correlate(svg)
160
+ # block_range is a Range, not an Array; Array#intersect? would
161
+ # force an eager .to_a conversion on potentially huge CJK ranges.
162
+ intrinsic_result.keys.all? { |cp| !@block_range.include?(cp) }
93
163
  end
94
164
 
95
- def render_pages(page_numbers)
96
- return "" if page_numbers.nil? || page_numbers.empty?
165
+ # Positional chain: union of all positional strategies' results.
166
+ # Within positional, earlier strategies win on conflict (chain
167
+ # order expresses caller preference — CorrelatorStrategy
168
+ # before TraceStrategy when both are configured).
169
+ def run_positional(descriptor, strategies)
170
+ merged = {}
171
+ strategies.each do |s|
172
+ next unless s.supports?(descriptor)
97
173
 
98
- out, err, status = Open3.capture3(
99
- "mutool", "draw", "-F", "svg",
100
- @source.pdf_to_s,
101
- *page_numbers.map(&:to_s),
102
- )
103
- unless status.success?
104
- raise Ucode::EmbeddedFontsMissingError,
105
- "mutool draw failed: #{err.strip}"
174
+ s.map(descriptor).each { |cp, gid| merged[cp] ||= gid }
106
175
  end
107
-
108
- out
176
+ merged
109
177
  end
110
178
 
111
- # ---- Strategy 3: auto-detect via mutool trace --------------------
112
-
113
- def map_from_trace(base_font)
114
- return {} unless @indexer.font_appears?(base_font)
115
-
116
- runner = TraceRunner.new(@source.pdf_path)
117
- correlator = TraceCorrelator.new(specimen_font_name: base_font)
118
-
119
- (1..@indexer.page_count).each_with_object({}) do |page, mapping|
120
- glyphs = runner.trace([page])
121
- page_mapping = correlator.correlate(glyphs)
122
- page_mapping.each do |cp, gid|
123
- mapping[cp] ||= gid
124
- end
125
- end
179
+ # Merge intrinsic and positional results. Positional wins on
180
+ # conflict (chart geometry is authoritative for in-block
181
+ # specimens).
182
+ def merge_with_positional_precedence(intrinsic, positional)
183
+ merged = intrinsic.dup
184
+ positional.each { |cp, gid| merged[cp] = gid }
185
+ merged
126
186
  end
127
187
  end
128
188
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ require "ucode/glyphs/embedded_fonts/mutool"
6
+
7
+ module Ucode
8
+ module Glyphs
9
+ module EmbeddedFonts
10
+ module Mutool
11
+ class Draw
12
+ def initialize(runner: SystemRunner.new)
13
+ @runner = runner
14
+ end
15
+
16
+ def svg(pdf, *pages)
17
+ return "" if pages.empty?
18
+
19
+ @runner.run("mutool", "draw", "-F", "svg", pdf.to_s,
20
+ *pages.map(&:to_s))
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ require "ucode/glyphs/embedded_fonts/mutool"
6
+
7
+ module Ucode
8
+ module Glyphs
9
+ module EmbeddedFonts
10
+ module Mutool
11
+ class Info
12
+ def initialize(runner: SystemRunner.new)
13
+ @runner = runner
14
+ end
15
+
16
+ def call(pdf)
17
+ @runner.run("mutool", "info", pdf.to_s)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "tempfile"
5
+
6
+ require "ucode/glyphs/embedded_fonts/mutool"
7
+
8
+ module Ucode
9
+ module Glyphs
10
+ module EmbeddedFonts
11
+ module Mutool
12
+ class Show
13
+ def initialize(runner: SystemRunner.new)
14
+ @runner = runner
15
+ end
16
+
17
+ def grep(pdf, *obj_ids)
18
+ return "" if obj_ids.empty?
19
+
20
+ @runner.run("mutool", "show", "-g", pdf.to_s,
21
+ *obj_ids.map(&:to_s))
22
+ end
23
+
24
+ def stream(pdf, obj_id)
25
+ Tempfile.create("mutool-stream") do |tmp|
26
+ tmp.close
27
+ @runner.run("mutool", "show", "-o", tmp.path, "-b",
28
+ pdf.to_s, obj_id.to_s)
29
+ File.binread(tmp.path).force_encoding("UTF-8")
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ require "ucode/glyphs/embedded_fonts/mutool"
6
+
7
+ module Ucode
8
+ module Glyphs
9
+ module EmbeddedFonts
10
+ module Mutool
11
+ class Trace
12
+ def initialize(runner: SystemRunner.new)
13
+ @runner = runner
14
+ end
15
+
16
+ def call(pdf, *pages)
17
+ return "" if pages.empty?
18
+
19
+ @runner.run("mutool", "trace", pdf.to_s,
20
+ *pages.map(&:to_s))
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ require "ucode/error"
6
+
7
+ module Ucode
8
+ module Glyphs
9
+ module EmbeddedFonts
10
+ module Mutool
11
+ autoload :Info, "ucode/glyphs/embedded_fonts/mutool/info"
12
+ autoload :Show, "ucode/glyphs/embedded_fonts/mutool/show"
13
+ autoload :Draw, "ucode/glyphs/embedded_fonts/mutool/draw"
14
+ autoload :Trace, "ucode/glyphs/embedded_fonts/mutool/trace"
15
+
16
+ class SystemRunner
17
+ def run(*argv)
18
+ out, err, status = Open3.capture3(*argv)
19
+ return out + err if status.success?
20
+
21
+ raise Ucode::MutoolError.new(
22
+ "mutool failed (exit #{status.exitstatus}): #{err.strip}",
23
+ context: { argv: argv },
24
+ )
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ require "ucode/glyphs/embedded_fonts/mutool"
6
+ require "ucode/glyphs/embedded_fonts/trace_parser"
7
+
8
+ module Ucode
9
+ module Glyphs
10
+ module EmbeddedFonts
11
+ # Per-PDF trace cache. Runs `mutool trace` once per page
12
+ # (1..page_count), parses each result, and stores glyphs
13
+ # grouped by page so each TraceStrategy can correlate
14
+ # positionally without re-tracing.
15
+ #
16
+ # Replaces the O(F × P) explosion in the pre-cache TraceStrategy,
17
+ # which spawned mutool trace once per page per CID font. For
18
+ # Code Charts PDFs with F trace-needing fonts and P pages, that
19
+ # was F×P subprocess invocations; this cache makes it exactly P.
20
+ #
21
+ # Why per-page grouping: TraceCorrelator's algorithm clusters
22
+ # label glyphs by Y bucket, and Y positions are page-local.
23
+ #
24
+ # Lazy: glyphs are only fetched when a strategy first asks for
25
+ # them. PDFs where every font has /ToUnicode never trigger the
26
+ # trace at all.
27
+ class PageTraceCache
28
+ # @param pdf [Pathname, String]
29
+ # @param page_count [Integer] total pages in the PDF
30
+ # @param mutool [Mutool::Trace]
31
+ def initialize(pdf:, page_count:, mutool: Mutool::Trace.new)
32
+ @pdf = Pathname.new(pdf)
33
+ @page_count = page_count
34
+ @mutool = mutool
35
+ end
36
+
37
+ # @return [Array<Array<TraceGlyph>>] one Array per page,
38
+ # 1-indexed (index 0 is unused). Each inner array holds
39
+ # every glyph emitted by `mutool trace` on that page.
40
+ def glyphs_by_page
41
+ @glyphs_by_page ||= fetch_glyphs_by_page
42
+ end
43
+
44
+ # @param base_font [String] specimen font BaseFont name
45
+ # @yieldparam page [Integer] 1-based page number
46
+ # @yieldparam glyphs [Array<TraceGlyph>] every glyph on that
47
+ # page (all fonts — TraceCorrelator filters internally)
48
+ # @return [Boolean] true if at least one page references the
49
+ # font; false otherwise
50
+ # @return [Enumerator] if no block given
51
+ def each_page_for(base_font)
52
+ return enum_for(:each_page_for, base_font) unless block_given?
53
+
54
+ present_in_any = false
55
+ glyphs_by_page.each_with_index do |glyphs, idx|
56
+ next if idx.zero?
57
+
58
+ present = glyphs.any? { |g| g.font_name == base_font }
59
+ next unless present
60
+
61
+ present_in_any = true
62
+ yield idx, glyphs
63
+ end
64
+ present_in_any
65
+ end
66
+
67
+ # @param base_font [String]
68
+ # @return [Boolean] true if any page references this font
69
+ def references_font?(base_font)
70
+ glyphs_by_page.any? do |page_glyphs|
71
+ page_glyphs.any? { |g| g.font_name == base_font }
72
+ end
73
+ end
74
+
75
+ # Locate the first occurrence of a specific (font, gid) pair
76
+ # across all traced pages. Returns nil when no match. Used by
77
+ # {Catalog#location_for} to attribute a codepoint's source
78
+ # page + (x, y) without exposing the cache's internal layout
79
+ # to callers.
80
+ #
81
+ # @param base_font [String] specimen font BaseFont name
82
+ # @param gid [Integer] glyph id inside that font
83
+ # @return [Hash{Symbol=>Integer, Float}, nil]
84
+ # `{ page: Integer, x: Float, y: Float }` or nil
85
+ def find_glyph(base_font:, gid:)
86
+ glyphs_by_page.each_with_index do |page_glyphs, idx|
87
+ next if idx.zero?
88
+
89
+ match = page_glyphs.find do |g|
90
+ g.font_name == base_font && g.gid == gid
91
+ end
92
+ return { page: idx, x: match.x, y: match.y } if match
93
+ end
94
+ nil
95
+ end
96
+
97
+ private
98
+
99
+ def fetch_glyphs_by_page
100
+ result = [[]] # index 0 unused (1-based pages)
101
+ return result unless @page_count.positive?
102
+
103
+ (1..@page_count).each do |page|
104
+ xml = @mutool.call(@pdf, page)
105
+ result << TraceParser.parse(xml)
106
+ end
107
+ result
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end