Class: Tabula::Ruling

Inherits:
Object
  • Object
show all
Defined in:
lib/tabula/core/ruling.rb

Overview

Represents a ruling line (horizontal or vertical line segment) in a PDF. Used for detecting table cell boundaries in lattice-mode extraction.

Constant Summary collapse

ORIENTATION_TOLERANCE =

Tolerance for considering lines as horizontal/vertical

1.0
INTERSECTION_TOLERANCE =

Tolerance for near-intersection detection

1.0

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(x1, y1, x2, y2) ⇒ Ruling

Returns a new instance of Ruling.



15
16
17
18
19
20
21
# File 'lib/tabula/core/ruling.rb', line 15

def initialize(x1, y1, x2, y2)
  @x1 = x1.to_f
  @y1 = y1.to_f
  @x2 = x2.to_f
  @y2 = y2.to_f
  normalize!
end

Instance Attribute Details

#x1 ⇒ Object

Returns the value of attribute x1.



13
14
15
# File 'lib/tabula/core/ruling.rb', line 13

def x1
  @x1
end

#x2 ⇒ Object

Returns the value of attribute x2.



13
14
15
# File 'lib/tabula/core/ruling.rb', line 13

def x2
  @x2
end

#y1 ⇒ Object

Returns the value of attribute y1.



13
14
15
# File 'lib/tabula/core/ruling.rb', line 13

def y1
  @y1
end

#y2 ⇒ Object

Returns the value of attribute y2.



13
14
15
# File 'lib/tabula/core/ruling.rb', line 13

def y2
  @y2
end

Class Method Details

.collapse_oriented_rulings(rulings, tolerance = 1.0) ⇒ Object

Collapse colinear rulings that are close together



250
251
252
253
254
255
256
257
258
259
260
261
# File 'lib/tabula/core/ruling.rb', line 250

def collapse_oriented_rulings(rulings, tolerance = 1.0)
  return [] if rulings.empty?

  # Separate horizontal and vertical
  horizontal = rulings.select(&:horizontal?).sort_by(&:y1)
  vertical = rulings.select(&:vertical?).sort_by(&:x1)

  collapsed = []
  collapsed.concat(collapse_group(horizontal, tolerance))
  collapsed.concat(collapse_group(vertical, tolerance))
  collapsed
end

.crop_to_area(rulings, rect) ⇒ Object

Crop rulings to a rectangular area



264
265
266
# File 'lib/tabula/core/ruling.rb', line 264

def crop_to_area(rulings, rect)
  rulings.filter_map { |r| r.clip_to(rect) }
end

.find_intersections(horizontal_rulings, vertical_rulings) ⇒ Object

Find all intersection points between horizontal and vertical rulings Uses sweep line algorithm for O(n log n) performance



230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
# File 'lib/tabula/core/ruling.rb', line 230

def find_intersections(horizontal_rulings, vertical_rulings)
  intersections = {}

  horizontal_rulings.each do |h|
    vertical_rulings.each do |v|
      next unless h.intersects?(v)

      point = h.intersection_point(v)
      next unless point

      # Round to avoid floating point issues
      key = [point.x.round(2), point.y.round(2)]
      intersections[key] ||= point
    end
  end

  intersections.values
end

.from_bounds(top, left, width, height) ⇒ Object

Create from top, left, width, height (like Rectangle)



29
30
31
# File 'lib/tabula/core/ruling.rb', line 29

def self.from_bounds(top, left, width, height)
  new(left, top, left + width, top + height)
end

.from_points(p1, p2) ⇒ Object

Create from two points



24
25
26
# File 'lib/tabula/core/ruling.rb', line 24

def self.from_points(p1, p2)
  new(p1.x, p1.y, p2.x, p2.y)
end

Instance Method Details

#==(other) ⇒ Object Also known as: eql?



199
200
201
202
203
# File 'lib/tabula/core/ruling.rb', line 199

def ==(other)
  return false unless other.is_a?(Ruling)

  x1 == other.x1 && y1 == other.y1 && x2 == other.x2 && y2 == other.y2
end

#angle ⇒ Object

Calculate angle in degrees (0 = horizontal, 90 = vertical)



131
132
133
# File 'lib/tabula/core/ruling.rb', line 131

def angle
  Math.atan2(y2 - y1, x2 - x1) * 180.0 / Math::PI
end

#bottom ⇒ Object



96
97
98
# File 'lib/tabula/core/ruling.rb', line 96

def bottom
  [y1, y2].max
end

#bounds ⇒ Object

Get bounding rectangle



117
118
119
# File 'lib/tabula/core/ruling.rb', line 117

def bounds
  Rectangle.new(top, left, width, height)
end

#clip_to(rect) ⇒ Object

Clip ruling to a rectangular area



184
185
186
# File 'lib/tabula/core/ruling.rb', line 184

def clip_to(rect)
  CohenSutherlandClipping.clip(self, rect)
end

#colinear_with?(other, tolerance = 1.0) ⇒ Boolean

Check if this ruling overlaps with another (for collapsing)

Returns:

  • (Boolean)


189
190
191
192
193
194
195
196
197
# File 'lib/tabula/core/ruling.rb', line 189

def colinear_with?(other, tolerance = 1.0)
  return false unless horizontal? == other.horizontal?

  if horizontal?
    (y1 - other.y1).abs < tolerance
  else
    (x1 - other.x1).abs < tolerance
  end
end

#dup ⇒ Object



210
211
212
# File 'lib/tabula/core/ruling.rb', line 210

def dup
  Ruling.new(x1, y1, x2, y2)
end

#end ⇒ Object

End point along the line direction



84
85
86
# File 'lib/tabula/core/ruling.rb', line 84

def end
  horizontal? ? x2 : y2
end

#expand(amount) ⇒ Object

Expand the ruling by extending its endpoints



170
171
172
173
174
175
176
177
178
179
180
181
# File 'lib/tabula/core/ruling.rb', line 170

def expand(amount)
  if horizontal?
    Ruling.new(x1 - amount, y1, x2 + amount, y2)
  elsif vertical?
    Ruling.new(x1, y1 - amount, x2, y2 + amount)
  else
    # For oblique lines, expand in both directions
    dx = (x2 - x1) / length * amount
    dy = (y2 - y1) / length * amount
    Ruling.new(x1 - dx, y1 - dy, x2 + dx, y2 + dy)
  end
end

#hash ⇒ Object



206
207
208
# File 'lib/tabula/core/ruling.rb', line 206

def hash
  [x1, y1, x2, y2].hash
end

#height ⇒ Object



112
113
114
# File 'lib/tabula/core/ruling.rb', line 112

def height
  bottom - top
end

#horizontal? ⇒ Boolean

Returns:

  • (Boolean)


51
52
53
# File 'lib/tabula/core/ruling.rb', line 51

def horizontal?
  (y2 - y1).abs <= ORIENTATION_TOLERANCE
end

#inspect ⇒ Object



223
224
225
# File 'lib/tabula/core/ruling.rb', line 223

def inspect
  to_s
end

#intersection_point(other) ⇒ Object

Find intersection point with another ruling (only for orthogonal lines)



136
137
138
139
140
141
142
143
144
145
# File 'lib/tabula/core/ruling.rb', line 136

def intersection_point(other)
  return nil if horizontal? == other.horizontal?
  return nil if oblique? || other.oblique?

  if horizontal?
    Point.new(other.x1, y1)
  else
    Point.new(x1, other.y1)
  end
end

#intersects?(other, tolerance = INTERSECTION_TOLERANCE) ⇒ Boolean

Check if this ruling intersects another (with tolerance)

Returns:

  • (Boolean)


148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
# File 'lib/tabula/core/ruling.rb', line 148

def intersects?(other, tolerance = INTERSECTION_TOLERANCE)
  point = intersection_point(other)
  return false unless point

  # Check if intersection point lies within both line segments
  if horizontal?
    x_in_self = point.x.between?(left - tolerance, right + tolerance)
    y_in_other = point.y.between?(other.top - tolerance, other.bottom + tolerance)
    x_in_self && y_in_other
  else
    y_in_self = point.y.between?(top - tolerance, bottom + tolerance)
    x_in_other = point.x.between?(other.left - tolerance, other.right + tolerance)
    y_in_self && x_in_other
  end
end

#left ⇒ Object



100
101
102
# File 'lib/tabula/core/ruling.rb', line 100

def left
  [x1, x2].min
end

#length ⇒ Object



88
89
90
# File 'lib/tabula/core/ruling.rb', line 88

def length
  Math.sqrt(((x2 - x1)**2) + ((y2 - y1)**2))
end

#nearly_intersects?(other, tolerance = INTERSECTION_TOLERANCE) ⇒ Boolean

Check if lines nearly intersect (for cell detection)

Returns:

  • (Boolean)


165
166
167
# File 'lib/tabula/core/ruling.rb', line 165

def nearly_intersects?(other, tolerance = INTERSECTION_TOLERANCE)
  intersects?(other, tolerance)
end

#normalize! ⇒ Object

Normalize almost-horizontal and almost-vertical lines



34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# File 'lib/tabula/core/ruling.rb', line 34

def normalize!
  if horizontal?
    avg_y = (y1 + y2) / 2.0
    @y1 = avg_y
    @y2 = avg_y
    # Ensure x1 < x2
    @x1, @x2 = @x2, @x1 if x1 > x2
  elsif vertical?
    avg_x = (x1 + x2) / 2.0
    @x1 = avg_x
    @x2 = avg_x
    # Ensure y1 < y2
    @y1, @y2 = @y2, @y1 if y1 > y2
  end
  self
end

#oblique? ⇒ Boolean

Returns:

  • (Boolean)


59
60
61
# File 'lib/tabula/core/ruling.rb', line 59

def oblique?
  !horizontal? && !vertical?
end

#p1 ⇒ Object

Get start and end points as Point objects



122
123
124
# File 'lib/tabula/core/ruling.rb', line 122

def p1
  Point.new(x1, y1)
end

#p2 ⇒ Object



126
127
128
# File 'lib/tabula/core/ruling.rb', line 126

def p2
  Point.new(x2, y2)
end

#position ⇒ Object

Position perpendicular to the line (y for horizontal, x for vertical)



64
65
66
# File 'lib/tabula/core/ruling.rb', line 64

def position
  horizontal? ? y1 : x1
end

#position=(value) ⇒ Object



68
69
70
71
72
73
74
75
76
# File 'lib/tabula/core/ruling.rb', line 68

def position=(value)
  if horizontal?
    @y1 = value
    @y2 = value
  else
    @x1 = value
    @x2 = value
  end
end

#right ⇒ Object



104
105
106
# File 'lib/tabula/core/ruling.rb', line 104

def right
  [x1, x2].max
end

#start ⇒ Object

Start point along the line direction



79
80
81
# File 'lib/tabula/core/ruling.rb', line 79

def start
  horizontal? ? x1 : y1
end

#to_s ⇒ Object



214
215
216
217
218
219
220
221
# File 'lib/tabula/core/ruling.rb', line 214

def to_s
  orientation = if horizontal?
                  'H'
                else
                  (vertical? ? 'V' : 'O')
                end
  "Ruling[#{orientation}](#{x1}, #{y1}) -> (#{x2}, #{y2})"
end

#top ⇒ Object



92
93
94
# File 'lib/tabula/core/ruling.rb', line 92

def top
  [y1, y2].min
end

#vertical? ⇒ Boolean

Returns:

  • (Boolean)


55
56
57
# File 'lib/tabula/core/ruling.rb', line 55

def vertical?
  (x2 - x1).abs <= ORIENTATION_TOLERANCE
end

#width ⇒ Object



108
109
110
# File 'lib/tabula/core/ruling.rb', line 108

def width
  right - left
end