substrate 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f70ea3ee7d06a49e41215d92a1b04db9173cbf357cbeccbd9bb85adc281b2e45
4
- data.tar.gz: b86bcb7ff7b2f9eab771a2e3419b1ab0cf42a08bb4ae6d4bd906d2cee0ea0c5f
3
+ metadata.gz: 58f4512e0069be50f46373801d5404c6f0a686d05ae4d1cbb605dab1a301cdeb
4
+ data.tar.gz: 37a9327014aa90f83a81dc57d99a789afb0929e1bfc559c685a7e2e69baff610
5
5
  SHA512:
6
- metadata.gz: 2d2687f91dbe8ca128de93e975de5e0a25359e8e8f1d7399b6092721b553ac26985eeff2d2963a7112f70bf11f98fdbcfff8f30e4462cb9fc9b3375349e6cf37
7
- data.tar.gz: e303be1550bd6d5f98635689316e9be8e17d88657e196d614133f84aad0674745db45ffb0f1702dfc22ac3f5798e923113f612151fe98a71bfbce8ba602b0542
6
+ metadata.gz: e5774d27284b16021e0d61b822f60dabc6bce5cf4de7bd38bbd4b3e606549137a75a0c45bde340628746a114666bca50a758c101ab51ad816728df1469038bd2
7
+ data.tar.gz: c1d796ae557619eb52da2d57838e9f84fcf3a8cd57aaa62ada4a9fb35fcbc7ca736fea662da6b9d42c79adfd986a5381ee5145de8c87455306a69e96baae7e44
@@ -5,7 +5,7 @@ require "stringio"
5
5
 
6
6
  module Substrate
7
7
  class ExtractPdf < Command
8
- def initialize(bytes:, source_ref:, limits: Mediums::Pdf::Limits.new, page_separator: "\n\n")
8
+ def initialize(bytes:, source_ref:, limits: Mediums::Pdf::Limits.new, page_separator: Mediums::Pdf::Content::DEFAULT_PAGE_SEPARATOR)
9
9
  @bytes = bytes
10
10
  @source_ref = source_ref
11
11
  @limits = limits
@@ -125,7 +125,7 @@ module Substrate
125
125
  def publish_content(pages, outline)
126
126
  metadata = {extractor: "HexaPDF", extractor_version: HexaPDF::VERSION.dup.freeze,
127
127
  input_bytes: @bytes.bytesize, page_count: pages.size,
128
- text_bytes: @text.bytesize}.freeze
128
+ text_bytes: @text.bytesize, page_separator: @page_separator}.freeze
129
129
  success(payload: Mediums::Pdf::Content.new(pages: pages, source_ref: @source_ref,
130
130
  metadata: metadata, outline: outline, text: @text.freeze))
131
131
  end
@@ -3,7 +3,13 @@
3
3
  module Substrate
4
4
  module Mediums
5
5
  module Pdf
6
- Content = Data.define(:pages, :source_ref, :metadata, :outline, :text)
6
+ class Content < Data.define(:pages, :source_ref, :metadata, :outline, :text)
7
+ DEFAULT_PAGE_SEPARATOR = "\n\n"
8
+
9
+ def page_separator
10
+ metadata.fetch(:page_separator, DEFAULT_PAGE_SEPARATOR)
11
+ end
12
+ end
7
13
  end
8
14
  end
9
15
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Mediums
5
+ module Pdf
6
+ class HeadingIndex
7
+ MAX_HEADING_LINES = 3
8
+
9
+ def initialize(pages:, entries:)
10
+ @pages = pages
11
+ @entries = entries
12
+ @headings_by_page = {}
13
+ end
14
+
15
+ def find(page, title)
16
+ headings_by_page[page] ||= index_page(page)
17
+ headings_by_page.fetch(page)[normalize(title)]
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :entries, :headings_by_page, :pages
23
+
24
+ def titles_by_page
25
+ @titles_by_page ||= entries.group_by { |entry| entry.destination.page_number }
26
+ .transform_values { |page_entries| page_entries.to_h { |entry| [normalize(entry.title), nil] } }
27
+ end
28
+
29
+ def index_page(page)
30
+ recent_lines = []
31
+ offset = 0
32
+ pages.fetch(page - 1).text.each_line("\n").each_with_object(titles_by_page.fetch(page).dup) do |line, matches|
33
+ normalized = normalize(line)
34
+ recent_lines << [normalized, offset]
35
+ recent_lines.shift if recent_lines.length > MAX_HEADING_LINES
36
+ offset += line.bytesize
37
+ next if normalized.empty?
38
+ match_headings(matches, recent_lines, offset)
39
+ end.select { |_, location| location }
40
+ end
41
+
42
+ def match_headings(matches, recent_lines, end_byte)
43
+ recent_lines.each_index do |index|
44
+ line, start_byte = recent_lines.fetch(index)
45
+ next if line.empty?
46
+ title = recent_lines.drop(index).map(&:first).reject(&:empty?).join(" ")
47
+ next unless matches.key?(title)
48
+ matches[title] = matches[title].nil? ? [start_byte, end_byte] : false
49
+ end
50
+ end
51
+
52
+ def normalize(text)
53
+ text.unicode_normalize(:nfc).gsub(/[[:space:]]+/, " ").strip
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Mediums
5
+ module Pdf
6
+ OutlineLimits = Data.define(:depth, :title_bytes, :text_bytes) do
7
+ def initialize(depth: 16, title_bytes: 4 * 1024, text_bytes: 32 * 1024 * 1024)
8
+ super
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Mediums
5
+ module Pdf
6
+ OutlineSpan = Data.define(:title, :range, :parent_index)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Mediums
5
+ module Pdf
6
+ class PageBoundary
7
+ def initialize(pages)
8
+ @pages = pages
9
+ end
10
+
11
+ def match?(entry)
12
+ return false if entry.title.strip.match?(/\A[[:space:]]*\z/)
13
+ destination = entry.destination
14
+ destination.kind == :local && page_top?(destination)
15
+ end
16
+
17
+ private
18
+
19
+ def page_top?(destination)
20
+ return true if %i[Fit FitB FitV FitBV].include?(destination.fit)
21
+ return true if destination.fit == :XYZ && destination.parameters == [nil, nil, nil]
22
+
23
+ top = vertical_coordinate(destination)
24
+ crop = @pages.fetch(destination.page_number - 1).crop_box
25
+ top && crop && top >= crop.top
26
+ end
27
+
28
+ def vertical_coordinate(destination)
29
+ case destination.fit
30
+ when :XYZ then destination.parameters[1]
31
+ when :FitH, :FitBH then destination.parameters[0]
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Mediums
5
+ module Pdf
6
+ class ResolveOutline < Command
7
+ def initialize(content:, limits: OutlineLimits.new, page_separator: content.page_separator)
8
+ @content = content
9
+ @limits = limits
10
+ @page_separator = page_separator
11
+ end
12
+
13
+ def call
14
+ resolve_spans
15
+ end
16
+
17
+ private
18
+
19
+ Anchor = Data.define(:entry, :start, :heading_end)
20
+ private_constant :Anchor
21
+
22
+ attr_reader :content, :limits, :page_separator, :remaining_subsection_bytes
23
+
24
+ def resolve_spans
25
+ @page_separator = @page_separator.dup.freeze unless @page_separator.frozen?
26
+ roots = root_entries.map { |entry| root_anchor(entry) }
27
+ success(payload: reliable_roots?(roots) ? outline_spans(roots) : nil)
28
+ end
29
+
30
+ def root_entries
31
+ return [] unless content.outline.status == :complete
32
+ entries_by_parent.fetch(nil, [])
33
+ end
34
+
35
+ def reliable_roots?(roots)
36
+ return false if roots.empty? || roots.any?(&:nil?)
37
+ roots.first.start == [1, 0] && ordered?(roots.map(&:start))
38
+ end
39
+
40
+ def outline_spans(roots)
41
+ @remaining_subsection_bytes = limits.text_bytes
42
+ pending = sibling_work(roots, [content.pages.length + 1, 0], nil, 0)
43
+ [].tap do |spans|
44
+ while (work = pending.pop)
45
+ anchor, finish, parent_index, depth = work
46
+ span_index = spans.length
47
+ spans << build_span(anchor, finish, parent_index)
48
+ next if depth >= limits.depth
49
+
50
+ children = admitted_children(anchor, finish)
51
+ pending.concat(sibling_work(children, finish, span_index, depth + 1)) if children
52
+ end
53
+ end.freeze
54
+ end
55
+
56
+ def sibling_work(anchors, parent_end, parent_index, depth)
57
+ anchors.each_index.reverse_each.map do |index|
58
+ [anchors.fetch(index), anchors[index + 1]&.start || parent_end, parent_index, depth]
59
+ end
60
+ end
61
+
62
+ def build_span(anchor, finish, parent_index)
63
+ range = TextRange.new(start: anchor.start, finish: finish, page_separator: page_separator)
64
+ OutlineSpan.new(title: anchor.entry.title.strip.freeze, range: range, parent_index: parent_index)
65
+ end
66
+
67
+ def admitted_children(anchor, finish)
68
+ entries = entries_by_parent.fetch(anchor.entry.id, [])
69
+ return if entries.empty?
70
+ children = entries.map { |entry| child_anchor(entry) }
71
+ return unless reliable_children?(children, anchor.heading_end || anchor.start, finish)
72
+ bytes = represented_bytes(children, finish)
73
+ return if bytes > remaining_subsection_bytes
74
+
75
+ @remaining_subsection_bytes -= bytes
76
+ children
77
+ end
78
+
79
+ def reliable_children?(children, first_allowed_start, parent_end)
80
+ return false if children.any?(&:nil?)
81
+ starts = children.map(&:start)
82
+ (starts.first <=> first_allowed_start) >= 0 && ordered?([*starts, parent_end]) &&
83
+ children.zip([*starts.drop(1), parent_end]).all? { |child, finish| (child.heading_end <=> finish) <= 0 }
84
+ end
85
+
86
+ def ordered?(positions)
87
+ positions.each_cons(2).all? { |left, right| (left <=> right).negative? }
88
+ end
89
+
90
+ def represented_bytes(children, finish)
91
+ boundaries = [*children.drop(1).map(&:start), finish]
92
+ omitted_separators = boundaries.count { |position| position.last.zero? }
93
+ absolute_offset(finish) - absolute_offset(children.first.start) - omitted_separators * page_separator.bytesize
94
+ end
95
+
96
+ def absolute_offset(position)
97
+ page, offset = position
98
+ page_offsets.fetch(page - 1) + offset
99
+ end
100
+
101
+ def page_offsets
102
+ @page_offsets ||= content.pages.each_with_object([0]) do |page, offsets|
103
+ offsets << offsets.last + page.text.bytesize + page_separator.bytesize
104
+ end
105
+ end
106
+
107
+ def entries_by_parent
108
+ @entries_by_parent ||= content.outline.entries.group_by(&:parent_id)
109
+ end
110
+
111
+ def page_boundary
112
+ @page_boundary ||= PageBoundary.new(content.pages)
113
+ end
114
+
115
+ def heading_index
116
+ @heading_index ||= HeadingIndex.new(pages: content.pages, entries: subsection_entries)
117
+ end
118
+
119
+ def subsection_entries
120
+ content.outline.entries.select { |entry| entry.parent_id && subsection_destination?(entry) }
121
+ end
122
+
123
+ def root_anchor(entry)
124
+ return unless page_boundary.match?(entry)
125
+ Anchor.new(entry: entry, start: [entry.destination.page_number, 0], heading_end: nil)
126
+ end
127
+
128
+ def child_anchor(entry)
129
+ return unless subsection_destination?(entry)
130
+ page = entry.destination.page_number
131
+ heading = heading_index.find(page, entry.title)
132
+ return unless heading
133
+ start_byte, end_byte = heading
134
+ Anchor.new(entry: entry, start: [page, start_byte], heading_end: [page, end_byte])
135
+ end
136
+
137
+ def subsection_destination?(entry)
138
+ entry.title.bytesize <= limits.title_bytes && page_boundary.match?(entry)
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Mediums
5
+ module Pdf
6
+ class TextRange < Data.define(:start, :finish, :page_separator)
7
+ Location = Data.define(:page, :start_byte, :end_byte)
8
+ Slice = Data.define(:text, :locations)
9
+
10
+ def initialize(start:, finish:, page_separator: Content::DEFAULT_PAGE_SEPARATOR)
11
+ page_separator = page_separator.dup.freeze unless page_separator.frozen?
12
+ super(start: start.dup.freeze, finish: finish.dup.freeze, page_separator: page_separator)
13
+ end
14
+
15
+ def slice(page_texts:, page_separator: self.page_separator)
16
+ (start.first..last_page).each_with_object(Slice.new(text: +"", locations: [])) do |number, slice|
17
+ append_page(slice, number, page_texts.fetch(number - 1), page_separator)
18
+ end.tap do |slice|
19
+ slice.text.freeze
20
+ slice.locations.freeze
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def append_page(slice, number, page, page_separator)
27
+ from = (number == start.first) ? start.last : 0
28
+ to = (number == finish.first) ? finish.last : page.bytesize
29
+ slice.text << page_separator unless number == start.first
30
+ slice.text << page.byteslice(from...to)
31
+ slice.locations << location(number, from, to, page.bytesize)
32
+ end
33
+
34
+ def last_page
35
+ finish.last.zero? ? finish.first - 1 : finish.first
36
+ end
37
+
38
+ def location(page, from, to, size)
39
+ return Location.new(page: page, start_byte: nil, end_byte: nil) if from.zero? && to == size
40
+ Location.new(page: page, start_byte: from, end_byte: to)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Substrate
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: substrate
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dan Frenette
@@ -106,6 +106,7 @@ files:
106
106
  - lib/substrate/mediums/pdf/document.rb
107
107
  - lib/substrate/mediums/pdf/encrypted_pdf.rb
108
108
  - lib/substrate/mediums/pdf/entry.rb
109
+ - lib/substrate/mediums/pdf/heading_index.rb
109
110
  - lib/substrate/mediums/pdf/invalid_pdf.rb
110
111
  - lib/substrate/mediums/pdf/invalid_text.rb
111
112
  - lib/substrate/mediums/pdf/limit_exceeded.rb
@@ -114,11 +115,16 @@ files:
114
115
  - lib/substrate/mediums/pdf/no_selectable_text.rb
115
116
  - lib/substrate/mediums/pdf/outline.rb
116
117
  - lib/substrate/mediums/pdf/outline_facts.rb
118
+ - lib/substrate/mediums/pdf/outline_limits.rb
117
119
  - lib/substrate/mediums/pdf/outline_parser.rb
120
+ - lib/substrate/mediums/pdf/outline_span.rb
118
121
  - lib/substrate/mediums/pdf/page.rb
122
+ - lib/substrate/mediums/pdf/page_boundary.rb
119
123
  - lib/substrate/mediums/pdf/page_extractor.rb
120
124
  - lib/substrate/mediums/pdf/reader.rb
125
+ - lib/substrate/mediums/pdf/resolve_outline.rb
121
126
  - lib/substrate/mediums/pdf/text_extractor.rb
127
+ - lib/substrate/mediums/pdf/text_range.rb
122
128
  - lib/substrate/primitives/article_content.rb
123
129
  - lib/substrate/primitives/extraction.rb
124
130
  - lib/substrate/primitives/metadata.rb