Module: Ucode::Glyphs::EmbeddedFonts::PositionalMatcher
- Defined in:
- lib/ucode/glyphs/embedded_fonts/positional_matcher.rb
Overview
Shared positional matching for Code Charts specimen attribution.
Both ContentStreamCorrelator and TraceCorrelator need the same algorithm: given a set of specimen glyphs and a set of hex codepoint labels with positions, match each specimen to its nearest valid label cluster by Euclidean distance.
This module owns that algorithm. The input is format-agnostic —
callers produce Position structs from their source format
(SVG <use> elements or mutool trace XML) and delegate here.
Handles both Code Charts layouts:
- List layout — label to the LEFT of specimen at the same Y.
- Grid layout — label ABOVE specimen (~12pt higher, same X).
Greedy one-to-one matching: each GID and each codepoint is assigned at most once, so a specimen between two labels only claims the closer one.
Defined Under Namespace
Classes: Position
Class Method Summary collapse
- .build_cluster(cluster) ⇒ Object
-
.build_label_clusters(labels) ⇒ Object
---- Clustering --------------------------------------------------.
-
.build_mapping(specimens, clusters) ⇒ Object
---- Matching ----------------------------------------------------.
- .cluster_by_x_gap(sorted_glyphs) ⇒ Object
- .distance(spec, cluster) ⇒ Object
-
.match(specimens, labels) ⇒ Hash{Integer=>Integer}
Codepoint => gid.
- .pairs_by_distance(specimens, clusters) ⇒ Object
- .quantize(value, bucket_size) ⇒ Object
- .specimen_distances(specimens, clusters, cluster_idx) ⇒ Object
Class Method Details
.build_cluster(cluster) ⇒ Object
96 97 98 99 100 101 102 103 104 105 106 107 108 |
# File 'lib/ucode/glyphs/embedded_fonts/positional_matcher.rb', line 96 def build_cluster(cluster) hex = cluster.map(&:text).join return nil unless hex.match?(/\A[0-9A-Fa-f]{4,6}\z/) cp = hex.to_i(16) return nil unless cp <= UNICODE_MAX { x: cluster.sum(&:x) / cluster.size, y: cluster.first.y, codepoint: cp, } end |
.build_label_clusters(labels) ⇒ Object
---- Clustering --------------------------------------------------
70 71 72 73 74 75 76 77 78 |
# File 'lib/ucode/glyphs/embedded_fonts/positional_matcher.rb', line 70 def build_label_clusters(labels) by_y = labels.group_by { |g| quantize(g.y, DEFAULT_Y_BUCKET) } by_y.flat_map do |(_, glyphs)| cluster_by_x_gap(glyphs.sort_by(&:x)).filter_map do |cluster| build_cluster(cluster) end end end |
.build_mapping(specimens, clusters) ⇒ Object
---- Matching ----------------------------------------------------
112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 |
# File 'lib/ucode/glyphs/embedded_fonts/positional_matcher.rb', line 112 def build_mapping(specimens, clusters) assigned_gids = Set.new assigned_cps = Set.new mapping = {} pairs_by_distance(specimens, clusters).each do |spec_idx, cluster_idx, dist| next if dist > MAX_MATCH_DISTANCE spec = specimens[spec_idx] cluster = clusters[cluster_idx] next if assigned_gids.include?(spec.glyph_id) next if assigned_cps.include?(cluster[:codepoint]) assigned_gids << spec.glyph_id assigned_cps << cluster[:codepoint] mapping[cluster[:codepoint]] = spec.glyph_id end mapping end |
.cluster_by_x_gap(sorted_glyphs) ⇒ Object
80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 |
# File 'lib/ucode/glyphs/embedded_fonts/positional_matcher.rb', line 80 def cluster_by_x_gap(sorted_glyphs) clusters = [] current = [] sorted_glyphs.each do |g| if current.empty? || (g.x - current.last.x).abs < X_GAP_THRESHOLD current << g else clusters << current if current.size > 1 current = [g] end end clusters << current if current.size > 1 clusters end |
.distance(spec, cluster) ⇒ Object
148 149 150 151 152 |
# File 'lib/ucode/glyphs/embedded_fonts/positional_matcher.rb', line 148 def distance(spec, cluster) dx = spec.x - cluster[:x] dy = spec.y - cluster[:y] Math.sqrt(dx * dx + dy * dy) end |
.match(specimens, labels) ⇒ Hash{Integer=>Integer}
Returns codepoint => gid.
59 60 61 62 63 64 65 66 |
# File 'lib/ucode/glyphs/embedded_fonts/positional_matcher.rb', line 59 def match(specimens, labels) return {} if specimens.empty? || labels.empty? clusters = build_label_clusters(labels) return {} if clusters.empty? build_mapping(specimens, clusters) end |
.pairs_by_distance(specimens, clusters) ⇒ Object
133 134 135 136 137 138 139 |
# File 'lib/ucode/glyphs/embedded_fonts/positional_matcher.rb', line 133 def pairs_by_distance(specimens, clusters) candidates = Array.new(clusters.size) do |ci| specimen_distances(specimens, clusters, ci) end candidates.flatten(1).sort_by { |_, _, dist| dist } end |
.quantize(value, bucket_size) ⇒ Object
154 155 156 157 158 |
# File 'lib/ucode/glyphs/embedded_fonts/positional_matcher.rb', line 154 def quantize(value, bucket_size) return nil if value.nil? (value / bucket_size).round * bucket_size end |
.specimen_distances(specimens, clusters, cluster_idx) ⇒ Object
141 142 143 144 145 146 |
# File 'lib/ucode/glyphs/embedded_fonts/positional_matcher.rb', line 141 def specimen_distances(specimens, clusters, cluster_idx) cluster = clusters[cluster_idx] specimens.each_with_index.map do |spec, spec_idx| [spec_idx, cluster_idx, distance(spec, cluster)] end end |