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:

  1. List layout — label to the LEFT of specimen at the same Y.
  2. 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

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.

Parameters:

  • specimens (Array<Position>)

    positioned specimen glyphs

  • labels (Array<Position>)

    positioned label chars

Returns:

  • (Hash{Integer=>Integer})

    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