dsr 0.0.0 → 1.0.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.
- checksums.yaml +4 -4
- data/dsr.gemspec +4 -5
- data/lib/dsr/recognizer.rb +147 -0
- data/lib/dsr.rb +38 -8
- metadata +12 -25
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 621fa913c6e55e6c86613100c173069d3dded944de48c3d930b0852ee70733e8
|
|
4
|
+
data.tar.gz: a5086a089ea4da28aa6b5547d55718a87c292e388357b6d1df2943f885ef1347
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 11af653e502854581722067ac9070e4234f20f77969f4ca30aa364e8b717b7aea1a82b5aee9105985ebf7090c8516167e32d147a058f73dab50c1f2373af7c64
|
|
7
|
+
data.tar.gz: 428a9d20a0df7c3310ca70c91fe88283888a5c9460fbed434d6f1ab90fd3f6dbc8e9de1e9327ed3ed4103e665cf78e8c83b24402ff6d0231adaaa42ba987196e
|
data/dsr.gemspec
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
Gem::Specification.new do |spec|
|
|
2
2
|
spec.name = "dsr"
|
|
3
|
-
spec.version = "
|
|
4
|
-
spec.summary = "
|
|
3
|
+
spec.version = "1.0.0"
|
|
4
|
+
spec.summary = "Document Structure Recognizer -- a collection of common routines to build AIs"
|
|
5
5
|
|
|
6
6
|
spec.author = "Victor Maslov aka Nakilon"
|
|
7
7
|
spec.email = "nakilon@gmail.com"
|
|
8
8
|
spec.license = "MIT"
|
|
9
9
|
spec.metadata = {"source_code_uri" => "https://github.com/nakilon/dsr"}
|
|
10
10
|
|
|
11
|
-
spec.add_dependency "
|
|
12
|
-
spec.add_dependency "hexapdf"
|
|
11
|
+
spec.add_dependency "pcbr"
|
|
13
12
|
|
|
14
|
-
spec.files = %w{ LICENSE dsr.gemspec lib/dsr.rb }
|
|
13
|
+
spec.files = %w{ LICENSE dsr.gemspec lib/dsr.rb lib/dsr/recognizer.rb }
|
|
15
14
|
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
using(
|
|
2
|
+
::Module.new do
|
|
3
|
+
AssertOneError = ::Class.new ::RuntimeError
|
|
4
|
+
refine ::Array do
|
|
5
|
+
def mean &block
|
|
6
|
+
sum(&block).fdiv size
|
|
7
|
+
end
|
|
8
|
+
def assert_one
|
|
9
|
+
return at 0 if 1 == size
|
|
10
|
+
raise ::Common::AssertOneError, "size: #{size}"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
module DSR
|
|
17
|
+
module Recognizer
|
|
18
|
+
|
|
19
|
+
# joins two arrays of objects into a Hash of parents and children, sorted by passed block
|
|
20
|
+
# it is possible for parent to have no children
|
|
21
|
+
# examples:
|
|
22
|
+
# Discord search results HTML page -- based on `node.rect.y`, channel names become keys, messages become values
|
|
23
|
+
# @param keys [Array] these become keys
|
|
24
|
+
# @param values [Array] these become values
|
|
25
|
+
# @return [Hash{Object => Array<Object>}
|
|
26
|
+
def self.detect_many_after_many keys, values, &block
|
|
27
|
+
fail if keys.any?(&:nil?) # for 'if key' in the algorithm loop
|
|
28
|
+
# fail if keys.dup.uniq!
|
|
29
|
+
# fail if values.dup.uniq!
|
|
30
|
+
fail if (keys + values).map(&block).uniq!
|
|
31
|
+
key = nil
|
|
32
|
+
h = {}
|
|
33
|
+
vs = []
|
|
34
|
+
for e, from in (
|
|
35
|
+
keys.map{ |_| [_, :from_keys] } +
|
|
36
|
+
values.map{ |_| [_, :from_values] }
|
|
37
|
+
).sort_by{ |e,| block.call e }
|
|
38
|
+
if :from_keys == from
|
|
39
|
+
h[key] = vs if key
|
|
40
|
+
key, vs = e, []
|
|
41
|
+
else
|
|
42
|
+
fail unless key
|
|
43
|
+
vs.push e
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
h[key] = vs if key
|
|
47
|
+
h
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# sorts an array by a block and splits in chunks
|
|
51
|
+
# gauranteed to split into at least two chunks
|
|
52
|
+
# if you pass `ratio` then recursively splits if there is another `gap_size > prev_gap_size * ratio`
|
|
53
|
+
# if you pass `goal` then tries ratios from 0.0 to 1.0 to find the goal number of chunks
|
|
54
|
+
# if you pass nothing then it uses PCBR to chose the ratio
|
|
55
|
+
# for both `goal` and nothing variants it checks the result to be the same for all fitting ratios
|
|
56
|
+
# `ratio: 0` is effectively the same as just Ruby stdlib sort and chunk by block
|
|
57
|
+
# @param _array [Array<Object>]
|
|
58
|
+
# @param ratio [Float]
|
|
59
|
+
# @param goal [Integer]
|
|
60
|
+
# @yieldparam item [Object] an item of `_array`
|
|
61
|
+
# @yieldreturn [Numeric] something that can subtract from each other to measure a gap
|
|
62
|
+
def self.detect_distant_groups _array, goal: nil, ratio: nil
|
|
63
|
+
|
|
64
|
+
array = ::Struct.new(:item, :value, :i, :dist_left).then do |struct|
|
|
65
|
+
_array.map{ |e| struct.new e, yield(e) }.sort_by(&:value)
|
|
66
|
+
end
|
|
67
|
+
array.each_with_index{ |e, i| e.i = i }
|
|
68
|
+
array.each_cons(2){ |e1, e2| e2.dist_left = e2.value - e1.value }
|
|
69
|
+
f = lambda do |from, to, r: ratio, threshold: 0|
|
|
70
|
+
# next [] if from > to
|
|
71
|
+
next [[array[from]]] if from == to
|
|
72
|
+
max = array[from + 1..to].max_by(&:dist_left)
|
|
73
|
+
# next [] unless max
|
|
74
|
+
if threshold * r < max.dist_left
|
|
75
|
+
[*f[from, max.i - 1, r: r, threshold: max.dist_left], *f[max.i, to, r: r, threshold: max.dist_left]]
|
|
76
|
+
else
|
|
77
|
+
[array[from..to]]
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
if ratio
|
|
82
|
+
f[0, array.size - 1].map{ |_| _.map &:item }
|
|
83
|
+
elsif goal
|
|
84
|
+
0.step(1, 0.01).map do |r|
|
|
85
|
+
f[0, array.size - 1, r: r].map{ |_| _.map &:item }
|
|
86
|
+
end.tap do |ratios|
|
|
87
|
+
fail unless ratios.first.size == array.map(&:value).uniq.size
|
|
88
|
+
ratios.each_cons(2){ |a, b| fail if a.size < b.size }
|
|
89
|
+
end.select{ |_| _.size == goal }.tap do |splits|
|
|
90
|
+
fail if 1 < splits.uniq.size
|
|
91
|
+
end.first
|
|
92
|
+
else
|
|
93
|
+
require "pcbr"
|
|
94
|
+
pcbr = ::PCBR.new
|
|
95
|
+
0.step(1, 0.01).map.with_index do |r, i|
|
|
96
|
+
[i, f[0, array.size - 1, r: r].map{ |_| _.map &:item }]
|
|
97
|
+
end.tap do |ratios|
|
|
98
|
+
fail unless ratios.first.last.size == array.map(&:value).uniq.size
|
|
99
|
+
ratios.each_cons(2){ |(_, a), (_, b)| fail if a.size < b.size }
|
|
100
|
+
end.chunk{ |_, split| split.size }.each do |_, splits|
|
|
101
|
+
# v = [splits.first.first, splits.size]
|
|
102
|
+
v = [
|
|
103
|
+
splits.first.first,
|
|
104
|
+
-splits.map(&:last).mean do |split|
|
|
105
|
+
split.map(&:size).then do |sizes|
|
|
106
|
+
sizes.map do |size|
|
|
107
|
+
(sizes.max - sizes.min).then do |_|
|
|
108
|
+
# otherwise it makes Infinity, then NaN
|
|
109
|
+
_.zero? ? size : size.fdiv(_)
|
|
110
|
+
end
|
|
111
|
+
end.then do |sizes|
|
|
112
|
+
# σ²
|
|
113
|
+
sizes.mean{ |_| (_ - sizes.mean) ** 2 }
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
]
|
|
118
|
+
pcbr.store splits.map(&:last), v
|
|
119
|
+
end
|
|
120
|
+
(best, _, score) = pcbr.table.reverse.max_by(&:last)
|
|
121
|
+
fail unless 0 < score
|
|
122
|
+
fail if 1 < best.uniq.size
|
|
123
|
+
best.first
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# https://en.wikipedia.org/wiki/Injective_function
|
|
129
|
+
# picks the closest item from `codomain` for each `domain` element
|
|
130
|
+
# raises exception if picks intersect
|
|
131
|
+
# @param domain [Array]
|
|
132
|
+
# @param codomain [Array]
|
|
133
|
+
# @return [Array<Array(Object, Object)>]
|
|
134
|
+
# @yieldparam domain_item, codomain_item [Object]
|
|
135
|
+
# @yieldreturn [Numeric] distance
|
|
136
|
+
def self.injective_mapping domain, codomain
|
|
137
|
+
domain.map do |a|
|
|
138
|
+
[a, codomain.each_index.group_by{ |i| yield a, codomain[i] }.min.last.assert_one]
|
|
139
|
+
end.tap do |_|
|
|
140
|
+
fail if _.map(&:last).uniq!
|
|
141
|
+
end.map do |a, b|
|
|
142
|
+
[a, codomain[b]]
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
end
|
|
147
|
+
end
|
data/lib/dsr.rb
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
module DSR
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
private_constant :
|
|
3
|
+
StructLinkable = ::Struct.new :ref, :left, :bottom, :right, :top
|
|
4
|
+
private_constant :StructLinkable
|
|
5
|
+
StructWithText = ::Struct.new :text, *StructLinkable.members.drop(1), :width, :height
|
|
6
|
+
private_constant :StructWithText
|
|
5
7
|
|
|
6
8
|
class Texts < Array
|
|
7
|
-
def
|
|
8
|
-
self.class.new select{ |_|
|
|
9
|
+
def find_all text_or_regex
|
|
10
|
+
self.class.new select{ |_| text_or_regex === _.text }
|
|
9
11
|
end
|
|
10
12
|
def select_intersecting_vertically_with item
|
|
11
13
|
self.class.new (self-[item]).select{ |_| _.bottom >= item.top && _.top <= item.bottom }
|
|
@@ -41,7 +43,7 @@ module DSR
|
|
|
41
43
|
}
|
|
42
44
|
}
|
|
43
45
|
end["textAnnotations"].map do |text|
|
|
44
|
-
|
|
46
|
+
StructWithText.new text["description"],
|
|
45
47
|
text["boundingPoly"]["vertices"].map{ |_| _["x"] }.min,
|
|
46
48
|
text["boundingPoly"]["vertices"].map{ |_| _["y"] }.max,
|
|
47
49
|
text["boundingPoly"]["vertices"].map{ |_| _["x"] }.max,
|
|
@@ -51,6 +53,7 @@ module DSR
|
|
|
51
53
|
end )
|
|
52
54
|
end
|
|
53
55
|
|
|
56
|
+
# TODO: refactor or deprecate?
|
|
54
57
|
def self.link headers, array, direction, alignment, *priority
|
|
55
58
|
l, r = case direction
|
|
56
59
|
when :horizontal ; %i{ left right }
|
|
@@ -59,8 +62,8 @@ module DSR
|
|
|
59
62
|
end
|
|
60
63
|
headers = headers.sort_by(&l).map(&:dup)
|
|
61
64
|
headers.each_cons(2){ |a, b| a[r], b[l] = [a[r], b[l]].max, [a[r], b[l]].min }
|
|
62
|
-
headers.first[l] =
|
|
63
|
-
headers.last[r] =
|
|
65
|
+
headers.first[l] = -::Float::INFINITY
|
|
66
|
+
headers.last[r] = +::Float::INFINITY
|
|
64
67
|
headers.unshift headers.delete_at headers.index{ |_| priority.include? _.text } unless priority.empty? # TODO: document/explain this
|
|
65
68
|
array.sort_by(&l).each_with_object([]) do |cell, a|
|
|
66
69
|
i = headers.public_send(alignment){ |_| (_[l].._[r]).include?((cell[l]+cell[r])/2) }
|
|
@@ -68,6 +71,7 @@ module DSR
|
|
|
68
71
|
a[i] << cell
|
|
69
72
|
end
|
|
70
73
|
end
|
|
74
|
+
|
|
71
75
|
def self.pdf2struct object
|
|
72
76
|
require "hexapdf"
|
|
73
77
|
processor = Class.new HexaPDF::Content::Processor do
|
|
@@ -78,7 +82,7 @@ module DSR
|
|
|
78
82
|
end
|
|
79
83
|
def show_text str
|
|
80
84
|
boxes = decode_text_with_positioning str
|
|
81
|
-
@texts.push
|
|
85
|
+
@texts.push StructWithText.new boxes.string,
|
|
82
86
|
boxes.lower_left[0], -boxes.lower_left[1],
|
|
83
87
|
boxes.upper_right[0], -boxes.upper_right[1],
|
|
84
88
|
boxes.upper_right[0] - boxes.lower_left[0],
|
|
@@ -103,4 +107,30 @@ module DSR
|
|
|
103
107
|
end.reject &:empty?
|
|
104
108
|
end
|
|
105
109
|
|
|
110
|
+
def self.nodes2struct nodes
|
|
111
|
+
nodes.map do |node|
|
|
112
|
+
StructLinkable.new(node, *::JSON.load(node.page.evaluate(<<~HEREDOC, node)))
|
|
113
|
+
( function(node) {
|
|
114
|
+
var x = scrollX, y = scrollY;
|
|
115
|
+
var rect = JSON.parse(JSON.stringify(node.getBoundingClientRect()));
|
|
116
|
+
rect.top += scrollY;
|
|
117
|
+
rect.left += scrollX;
|
|
118
|
+
var t = JSON.stringify( [rect.left, rect.bottom, rect.right, rect.top] );
|
|
119
|
+
return t;
|
|
120
|
+
} )(arguments[0])
|
|
121
|
+
HEREDOC
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def self.capybara2struct nodes
|
|
126
|
+
nodes.map do |node|
|
|
127
|
+
rect = node.rect
|
|
128
|
+
StructLinkable.new node,
|
|
129
|
+
rect.x,
|
|
130
|
+
rect.y + rect.height,
|
|
131
|
+
rect.x + rect.width,
|
|
132
|
+
rect.y
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
106
136
|
end
|
metadata
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: dsr
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 1.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Victor Maslov aka Nakilon
|
|
8
|
-
autorequire:
|
|
8
|
+
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-07-30 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
|
-
name:
|
|
14
|
+
name: pcbr
|
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
|
16
16
|
requirements:
|
|
17
17
|
- - ">="
|
|
@@ -24,21 +24,7 @@ dependencies:
|
|
|
24
24
|
- - ">="
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
26
|
version: '0'
|
|
27
|
-
|
|
28
|
-
name: hexapdf
|
|
29
|
-
requirement: !ruby/object:Gem::Requirement
|
|
30
|
-
requirements:
|
|
31
|
-
- - ">="
|
|
32
|
-
- !ruby/object:Gem::Version
|
|
33
|
-
version: '0'
|
|
34
|
-
type: :runtime
|
|
35
|
-
prerelease: false
|
|
36
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
-
requirements:
|
|
38
|
-
- - ">="
|
|
39
|
-
- !ruby/object:Gem::Version
|
|
40
|
-
version: '0'
|
|
41
|
-
description:
|
|
27
|
+
description:
|
|
42
28
|
email: nakilon@gmail.com
|
|
43
29
|
executables: []
|
|
44
30
|
extensions: []
|
|
@@ -47,12 +33,13 @@ files:
|
|
|
47
33
|
- LICENSE
|
|
48
34
|
- dsr.gemspec
|
|
49
35
|
- lib/dsr.rb
|
|
50
|
-
|
|
36
|
+
- lib/dsr/recognizer.rb
|
|
37
|
+
homepage:
|
|
51
38
|
licenses:
|
|
52
39
|
- MIT
|
|
53
40
|
metadata:
|
|
54
41
|
source_code_uri: https://github.com/nakilon/dsr
|
|
55
|
-
post_install_message:
|
|
42
|
+
post_install_message:
|
|
56
43
|
rdoc_options: []
|
|
57
44
|
require_paths:
|
|
58
45
|
- lib
|
|
@@ -67,9 +54,9 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
67
54
|
- !ruby/object:Gem::Version
|
|
68
55
|
version: '0'
|
|
69
56
|
requirements: []
|
|
70
|
-
rubygems_version: 3.
|
|
71
|
-
signing_key:
|
|
57
|
+
rubygems_version: 3.2.0
|
|
58
|
+
signing_key:
|
|
72
59
|
specification_version: 4
|
|
73
|
-
summary:
|
|
74
|
-
|
|
60
|
+
summary: Document Structure Recognizer -- a collection of common routines to build
|
|
61
|
+
AIs
|
|
75
62
|
test_files: []
|