substrate 0.1.0 → 0.3.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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/lib/substrate/classify.rb +65 -0
  3. data/lib/substrate/cli/availability.rb +11 -0
  4. data/lib/substrate/cli/error.rb +7 -0
  5. data/lib/substrate/cli/runner.rb +31 -0
  6. data/lib/substrate/command.rb +24 -0
  7. data/lib/substrate/configuration.rb +21 -0
  8. data/lib/substrate/detection/path_resolver.rb +33 -0
  9. data/lib/substrate/distill.rb +31 -0
  10. data/lib/substrate/extract_pdf.rb +133 -0
  11. data/lib/substrate/handlers/article.rb +39 -0
  12. data/lib/substrate/handlers/pdf.rb +29 -0
  13. data/lib/substrate/handlers/raw_text.rb +22 -0
  14. data/lib/substrate/handlers/text_file.rb +26 -0
  15. data/lib/substrate/handlers/transcript.rb +48 -0
  16. data/lib/substrate/mediums/pdf/box.rb +9 -0
  17. data/lib/substrate/mediums/pdf/chapter.rb +9 -0
  18. data/lib/substrate/mediums/pdf/content.rb +9 -0
  19. data/lib/substrate/mediums/pdf/destination.rb +9 -0
  20. data/lib/substrate/mediums/pdf/destination_resolver.rb +131 -0
  21. data/lib/substrate/mediums/pdf/document.rb +46 -0
  22. data/lib/substrate/mediums/pdf/encrypted_pdf.rb +9 -0
  23. data/lib/substrate/mediums/pdf/entry.rb +9 -0
  24. data/lib/substrate/mediums/pdf/invalid_pdf.rb +9 -0
  25. data/lib/substrate/mediums/pdf/invalid_text.rb +9 -0
  26. data/lib/substrate/mediums/pdf/limit_exceeded.rb +17 -0
  27. data/lib/substrate/mediums/pdf/limits.rb +21 -0
  28. data/lib/substrate/mediums/pdf/named_destinations.rb +94 -0
  29. data/lib/substrate/mediums/pdf/no_selectable_text.rb +9 -0
  30. data/lib/substrate/mediums/pdf/outline.rb +9 -0
  31. data/lib/substrate/mediums/pdf/outline_facts.rb +115 -0
  32. data/lib/substrate/mediums/pdf/outline_parser.rb +67 -0
  33. data/lib/substrate/mediums/pdf/page.rb +9 -0
  34. data/lib/substrate/mediums/pdf/page_extractor.rb +28 -0
  35. data/lib/substrate/mediums/pdf/reader.rb +40 -0
  36. data/lib/substrate/mediums/pdf/text_extractor.rb +36 -0
  37. data/lib/substrate/primitives/article_content.rb +10 -0
  38. data/lib/substrate/primitives/extraction.rb +5 -0
  39. data/lib/substrate/primitives/metadata.rb +21 -0
  40. data/lib/substrate/primitives/page_collection.rb +5 -0
  41. data/lib/substrate/primitives/pdf_file.rb +5 -0
  42. data/lib/substrate/primitives/raw_text.rb +5 -0
  43. data/lib/substrate/primitives/source.rb +5 -0
  44. data/lib/substrate/primitives/text_content.rb +5 -0
  45. data/lib/substrate/primitives/text_file.rb +5 -0
  46. data/lib/substrate/primitives/transcripts/content.rb +11 -0
  47. data/lib/substrate/primitives/transcripts/metadata.rb +25 -0
  48. data/lib/substrate/primitives/transcripts/segment.rb +25 -0
  49. data/lib/substrate/primitives/transcripts/word.rb +23 -0
  50. data/lib/substrate/primitives/url_candidate.rb +13 -0
  51. data/lib/substrate/source_ref.rb +5 -0
  52. data/lib/substrate/sources/article.rb +34 -0
  53. data/lib/substrate/sources/audio.rb +57 -0
  54. data/lib/substrate/sources/pdf.rb +35 -0
  55. data/lib/substrate/sources/text.rb +23 -0
  56. data/lib/substrate/sources/url.rb +36 -0
  57. data/lib/substrate/transcribers/deepgram/client.rb +70 -0
  58. data/lib/substrate/transcribers/deepgram/configuration.rb +41 -0
  59. data/lib/substrate/transcribers/deepgram/response_parser.rb +102 -0
  60. data/lib/substrate/transcribers/deepgram.rb +45 -0
  61. data/lib/substrate/transcribers/whisper_cpp/response_parser.rb +66 -0
  62. data/lib/substrate/transcribers/whisper_cpp.rb +87 -0
  63. data/lib/substrate/transcripts/select_time_range.rb +59 -0
  64. data/lib/substrate/url_resolvers/generic.rb +71 -0
  65. data/lib/substrate/url_resolvers/resolver_for.rb +35 -0
  66. data/lib/substrate/version.rb +1 -1
  67. data/lib/substrate.rb +16 -3
  68. metadata +107 -1
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Mediums
5
+ module Pdf
6
+ class DestinationResolver
7
+ FIT_ARITY = {XYZ: 3, Fit: 0, FitH: 1, FitV: 1, FitR: 4, FitB: 0, FitBH: 1, FitBV: 1}.freeze
8
+
9
+ def initialize(document, pages, limits)
10
+ @document = document
11
+ @pages = pages
12
+ @names = NamedDestinations.new(document, limits)
13
+ @limit = limits.outline_entries
14
+ end
15
+
16
+ def resolve(item, prior_reason:)
17
+ return local_destination(item[:Dest], prior_reason) if item[:Dest]
18
+ return fact(kind: :none) unless item[:A]
19
+ action_destination(item[:A], prior_reason)
20
+ end
21
+
22
+ private
23
+
24
+ def action_destination(raw, prior_reason)
25
+ action = @document.wrap(@document.deref(raw))
26
+ return fact(reason: :malformed_action) unless action.value.is_a?(Hash)
27
+ case action[:S]
28
+ when :GoTo then local_destination(action[:D], prior_reason)
29
+ when :GoToR then remote_destination(action)
30
+ else fact(reason: :unsupported_action)
31
+ end
32
+ end
33
+
34
+ def local_destination(raw, prior_reason)
35
+ values, name, reason = resolve_aliases(raw, prior_reason)
36
+ return fact(name: name, reason: reason) if reason
37
+ view = destination_view(values).with(name: name)
38
+ return view if view.reason
39
+ page = @document.deref(values.first)
40
+ index = @pages.index { |candidate| candidate == page }
41
+ index ? view.with(kind: :local, page_number: index + 1) : view.with(reason: :page_not_found)
42
+ end
43
+
44
+ def resolve_aliases(raw, prior_reason)
45
+ seen = {}
46
+ name = nil
47
+ loop do
48
+ value = destination_value(raw)
49
+ return [value, name, nil] unless named?(value)
50
+ text = destination_name(value)
51
+ return [nil, nil, :invalid_destination_name] unless text
52
+ name ||= text
53
+ reason = alias_failure(seen, value, text)
54
+ return [nil, name, reason] if reason
55
+ lookup = @names.lookup(value)
56
+ return [nil, name, prior_reason || lookup.reason || :destination_not_found] unless lookup.value
57
+ raw = lookup.value
58
+ end
59
+ end
60
+
61
+ def alias_failure(seen, value, text)
62
+ key = [value.class, text]
63
+ return :destination_lookup_cycle if seen[key]
64
+ return :destination_lookup_limit if seen.size >= @limit
65
+ seen[key] = true
66
+ nil
67
+ end
68
+
69
+ def destination_view(values)
70
+ return fact(reason: :malformed_destination) unless values.is_a?(Array)
71
+ fit, parameters = values[1], values.drop(2)
72
+ valid_parameters = parameters.all? { |value| value.nil? || (value.is_a?(Numeric) && value.finite?) }
73
+ valid = FIT_ARITY[fit] == parameters.size && valid_parameters
74
+ fact(fit: fit.is_a?(Symbol) ? fit : nil, parameters: valid_parameters ? parameters.freeze : [].freeze,
75
+ reason: valid ? nil : :invalid_destination_view)
76
+ end
77
+
78
+ def remote_destination(action)
79
+ file = remote_file(action[:F])
80
+ target = {action: :GoToR, file: file, page_index: nil, name: nil}.freeze
81
+ destination = remote_location(destination_value(action[:D]), target)
82
+ file ? destination : destination.with(kind: :unresolved, reason: :invalid_remote_target)
83
+ end
84
+
85
+ def destination_value(raw)
86
+ object = @document.wrap(@document.deref(raw))
87
+ object = @document.wrap(@document.deref(object[:D])) if object.value.is_a?(Hash)
88
+ object.value.is_a?(Array) ? object.to_a : object.value
89
+ end
90
+
91
+ def destination_name(value)
92
+ return unless named?(value)
93
+ text = value.to_s.encode(Encoding::UTF_8)
94
+ text.dup.freeze if text.valid_encoding? && !text.include?("\0")
95
+ rescue EncodingError
96
+ nil
97
+ end
98
+
99
+ def named?(value)
100
+ value.is_a?(String) || value.is_a?(Symbol)
101
+ end
102
+
103
+ def remote_file(raw)
104
+ file = @document.wrap(@document.deref(raw))
105
+ value = file.value.is_a?(Hash) ? file[:UF] || file[:F] : file.value
106
+ destination_name(value)
107
+ end
108
+
109
+ def remote_location(value, target)
110
+ return remote_name(value, target) if named?(value)
111
+ view = destination_view(value).with(remote_target: target)
112
+ return view if view.reason
113
+ index = value.first
114
+ return view.with(reason: :invalid_remote_page) unless index.is_a?(Integer) && index >= 0
115
+ view.with(kind: :remote, remote_target: target.merge(page_index: index).freeze)
116
+ end
117
+
118
+ def remote_name(value, target)
119
+ name = destination_name(value)
120
+ return fact(remote_target: target, reason: :invalid_destination_name) unless name
121
+ fact(kind: :remote, name: name, remote_target: target.merge(name: name).freeze)
122
+ end
123
+
124
+ def fact(kind: :unresolved, page_number: nil, name: nil, fit: nil, parameters: [].freeze, remote_target: nil, reason: nil)
125
+ Destination.new(kind: kind, page_number: page_number, name: name, fit: fit,
126
+ parameters: parameters, remote_target: remote_target, reason: reason)
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Mediums
5
+ module Pdf
6
+ class Document
7
+ def initialize(path, source_ref:)
8
+ @path = path
9
+ @source_ref = source_ref
10
+ end
11
+
12
+ def chapters
13
+ outline_parser.chapters
14
+ end
15
+
16
+ def page_count
17
+ reader.page_count
18
+ end
19
+
20
+ def page_text(number)
21
+ page_extractor.text_for_page(number)
22
+ end
23
+
24
+ def pages_text(range)
25
+ page_extractor.text_for_pages(range)
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :path, :source_ref
31
+
32
+ def reader
33
+ @reader ||= Reader.new(path)
34
+ end
35
+
36
+ def outline_parser
37
+ @outline_parser ||= OutlineParser.new(reader, source_ref)
38
+ end
39
+
40
+ def page_extractor
41
+ @page_extractor ||= PageExtractor.new(reader)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Mediums
5
+ module Pdf
6
+ class EncryptedPdf < StandardError; end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Mediums
5
+ module Pdf
6
+ Entry = Data.define(:id, :parent_id, :title, :destination)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Mediums
5
+ module Pdf
6
+ class InvalidPdf < StandardError; end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Mediums
5
+ module Pdf
6
+ class InvalidText < StandardError; end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Mediums
5
+ module Pdf
6
+ class LimitExceeded < StandardError
7
+ attr_reader :dimension, :limit
8
+
9
+ def initialize(dimension, limit)
10
+ @dimension = dimension
11
+ @limit = limit
12
+ super("PDF exceeds #{dimension} limit of #{limit}")
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Mediums
5
+ module Pdf
6
+ Limits = Data.define(:input_bytes, :pages, :text_bytes, :outline_entries) do
7
+ def initialize(input_bytes: 25 * 1024 * 1024, pages: 2000,
8
+ text_bytes: 32 * 1024 * 1024, outline_entries: 2000)
9
+ values = {input_bytes: input_bytes, pages: pages, text_bytes: text_bytes, outline_entries: outline_entries}
10
+ raise ArgumentError, "PDF limits must be positive integers" unless values.values.all? { |value| value.is_a?(Integer) && value.positive? }
11
+ super(**values)
12
+ end
13
+
14
+ def check!(dimension, actual)
15
+ maximum = public_send(dimension)
16
+ raise LimitExceeded.new(dimension, maximum) if actual > maximum
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Mediums
5
+ module Pdf
6
+ class NamedDestinations
7
+ Lookup = Data.define(:value, :reason)
8
+
9
+ def initialize(document, limits)
10
+ @document = document
11
+ @limit = limits.outline_entries
12
+ end
13
+
14
+ def lookup(name)
15
+ Lookup.new(value: named_destination(name), reason: nil)
16
+ rescue LookupFailure => error
17
+ Lookup.new(value: nil, reason: error.reason)
18
+ end
19
+
20
+ private
21
+
22
+ def name_matches?(key, name)
23
+ return false unless key.is_a?(String) || key.is_a?(Symbol)
24
+ text = key.to_s.encode(Encoding::UTF_8)
25
+ text.valid_encoding? && !text.include?("\0") && text == name
26
+ rescue EncodingError
27
+ false
28
+ end
29
+
30
+ class LookupFailure < StandardError
31
+ attr_reader :reason
32
+
33
+ def initialize(reason)
34
+ @reason = reason
35
+ super(reason.to_s)
36
+ end
37
+ end
38
+
39
+ def named_destination(name)
40
+ if name.is_a?(Symbol)
41
+ old = @document.wrap(@document.catalog[:Dests])
42
+ old[name] if old.value.is_a?(Hash)
43
+ else
44
+ names = @document.wrap(@document.catalog[:Names])
45
+ find_name(names[:Dests], name) if names.value.is_a?(Hash) && names[:Dests]
46
+ end
47
+ end
48
+
49
+ def find_name(root, name)
50
+ pending = [root]
51
+ seen = {}
52
+ work = 0
53
+ until pending.empty?
54
+ node = name_tree_node(pending.pop, seen)
55
+ work = count_work(work)
56
+ pairs = name_pairs(node[:Names])
57
+ pairs.each_slice(2) do |key, value|
58
+ work = count_work(work)
59
+ return value if name_matches?(key, name)
60
+ end
61
+ pending.concat(name_children(node[:Kids]).reverse)
62
+ end
63
+ nil
64
+ end
65
+
66
+ def name_tree_node(raw, seen)
67
+ node = @document.wrap(@document.deref(raw))
68
+ raise LookupFailure, :malformed_name_tree unless node.value.is_a?(Hash)
69
+ raise LookupFailure, :destination_lookup_cycle if seen[node.value.object_id]
70
+ seen[node.value.object_id] = true
71
+ node
72
+ end
73
+
74
+ def name_pairs(raw)
75
+ pairs = name_children(raw)
76
+ raise LookupFailure, :malformed_name_tree if pairs.size.odd?
77
+ pairs
78
+ end
79
+
80
+ def name_children(raw)
81
+ return [] unless raw
82
+ array = @document.wrap(@document.deref(raw))
83
+ raise LookupFailure, :malformed_name_tree unless array.value.is_a?(Array)
84
+ array.to_a
85
+ end
86
+
87
+ def count_work(work)
88
+ raise LookupFailure, :destination_lookup_limit if work >= @limit
89
+ work + 1
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Mediums
5
+ module Pdf
6
+ class NoSelectableText < StandardError; end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Mediums
5
+ module Pdf
6
+ Outline = Data.define(:entries, :status, :reason)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Mediums
5
+ module Pdf
6
+ class OutlineFacts
7
+ LOOKUP_FAILURES = [:destination_lookup_cycle, :destination_lookup_limit, :malformed_name_tree].freeze
8
+
9
+ def initialize(document, pages, limits)
10
+ @document = document
11
+ @destinations = DestinationResolver.new(document, pages, limits)
12
+ @limit = limits.outline_entries
13
+ @entries = []
14
+ @reason = nil
15
+ end
16
+
17
+ def call
18
+ read_outline
19
+ publish_outline
20
+ end
21
+
22
+ private
23
+
24
+ def read_outline
25
+ root = @document.catalog[:Outlines]
26
+ return if root.nil?
27
+ root = outline_node(root)
28
+ validate_children(root)
29
+ traverse(root)
30
+ rescue HexaPDF::Error
31
+ degrade(:malformed_outline)
32
+ end
33
+
34
+ def traverse(root)
35
+ pending = [[root[:First], nil, root[:Last]]]
36
+ seen = {}
37
+ until pending.empty?
38
+ raw, parent_id, last = pending.pop
39
+ next if raw.nil?
40
+ item = outline_node(raw)
41
+ return degrade(:cycle_detected) if seen[item.value.object_id]
42
+ return degrade(:outline_entries_limit) if @entries.size >= @limit
43
+ seen[item.value.object_id] = true
44
+ entry = append_entry(item, parent_id)
45
+ schedule_links(pending, item, entry, last)
46
+ end
47
+ end
48
+
49
+ def outline_node(raw)
50
+ node = @document.deref(raw)
51
+ node = @document.wrap(node) unless node.is_a?(HexaPDF::Object)
52
+ raise HexaPDF::Error unless node.value.is_a?(Hash)
53
+ node
54
+ end
55
+
56
+ def append_entry(item, parent_id)
57
+ Entry.new(id: @entries.size, parent_id: parent_id,
58
+ title: outline_title(item), destination: destination(item)).tap { |entry| @entries << entry }
59
+ end
60
+
61
+ def outline_title(item)
62
+ title = item[:Title]
63
+ title = title.to_s if title.is_a?(Symbol)
64
+ raise HexaPDF::Error unless title.is_a?(String)
65
+ title = title.encode(Encoding::UTF_8)
66
+ raise HexaPDF::Error unless title.valid_encoding? && !title.include?("\0")
67
+ title.dup.freeze
68
+ rescue EncodingError
69
+ raise HexaPDF::Error
70
+ end
71
+
72
+ def destination(item)
73
+ fact = @destinations.resolve(item, prior_reason: @reason)
74
+ degrade(fact.reason) if LOOKUP_FAILURES.include?(fact.reason)
75
+ fact
76
+ end
77
+
78
+ def schedule_links(pending, item, entry, last)
79
+ validate_children(item)
80
+ following = item[:Next]
81
+ outline_node(following) unless following.nil?
82
+ validate_last(item, following, last) unless last.nil?
83
+ pending << [following, entry.parent_id, last]
84
+ pending << [item[:First], entry.id, item[:Last]]
85
+ end
86
+
87
+ def validate_children(node)
88
+ first, last, count = node[:First], node[:Last], node[:Count]
89
+ [first, last].each { |link| outline_node(link) unless link.nil? }
90
+ raise HexaPDF::Error unless count.nil? || count.is_a?(Integer)
91
+ raise HexaPDF::Error if first.nil? && (!last.nil? || (count && count != 0))
92
+ end
93
+
94
+ def validate_last(item, following, last)
95
+ is_last = item.value.equal?(outline_node(last).value)
96
+ raise HexaPDF::Error if following.nil? != is_last
97
+ end
98
+
99
+ def publish_outline
100
+ status = if @reason
101
+ @entries.empty? ? :unavailable : :incomplete
102
+ else
103
+ :complete
104
+ end
105
+ Outline.new(entries: @entries.freeze, status: status, reason: @reason)
106
+ end
107
+
108
+ def degrade(reason)
109
+ @reason ||= reason
110
+ nil
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Substrate
6
+ module Mediums
7
+ module Pdf
8
+ class OutlineParser
9
+ def initialize(reader, source_ref)
10
+ @reader = reader
11
+ @source_ref = source_ref
12
+ end
13
+
14
+ def chapters
15
+ items = extract_items
16
+ items.each_with_index.map { |item, index| to_chapter(item, index, items) }
17
+ end
18
+
19
+ private
20
+
21
+ attr_reader :reader, :source_ref
22
+
23
+ def extract_items
24
+ reader.outline_items.each_with_object([]) do |(item, _level), entries|
25
+ entry = parse_item(item)
26
+ entries << entry if entry
27
+ end
28
+ end
29
+
30
+ def parse_item(item)
31
+ title = item[:Title]
32
+ page = resolve_page(item)
33
+ return nil if title.nil? || title.empty? || page.nil?
34
+
35
+ {id: chapter_id(title, page), title: title, page: page}
36
+ end
37
+
38
+ def resolve_page(item)
39
+ dest = item.destination || goto_destination(item)
40
+ return nil unless dest
41
+
42
+ page_obj = dest.first
43
+ reader.page_index(page_obj)&.succ
44
+ end
45
+
46
+ def goto_destination(item)
47
+ action = item[:A]
48
+ return nil unless action
49
+
50
+ action[:D] if action[:S] == :GoTo
51
+ end
52
+
53
+ def chapter_id(title, page)
54
+ Digest::SHA256.hexdigest("#{page}:#{title}")[0, 8]
55
+ end
56
+
57
+ def to_chapter(item, index, all_items)
58
+ start_page = item[:page]
59
+ next_start_page = all_items.dig(index + 1, :page)
60
+ last_page = next_start_page ? [next_start_page.pred, start_page].max : reader.page_count
61
+
62
+ Chapter.new(source_ref: source_ref, id: item[:id], title: item[:title], pages: start_page..last_page)
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Mediums
5
+ module Pdf
6
+ Page = Data.define(:number, :text, :crop_box)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Mediums
5
+ module Pdf
6
+ class PageExtractor
7
+ def initialize(reader)
8
+ @reader = reader
9
+ end
10
+
11
+ def text_for_page(number)
12
+ page = reader.pages[number - 1]
13
+ return "" unless page
14
+
15
+ TextExtractor.extract(page)
16
+ end
17
+
18
+ def text_for_pages(range)
19
+ range.map { |n| text_for_page(n) }.reject(&:empty?).join("\n\n")
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :reader
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hexapdf"
4
+
5
+ module Substrate
6
+ module Mediums
7
+ module Pdf
8
+ class Reader
9
+ def initialize(path, document_class: HexaPDF::Document)
10
+ @path = path
11
+ @document_class = document_class
12
+ end
13
+
14
+ def pages
15
+ document.pages
16
+ end
17
+
18
+ def page_count
19
+ pages.count
20
+ end
21
+
22
+ def outline_items
23
+ document.outline.each_item
24
+ end
25
+
26
+ def page_index(page_object)
27
+ pages.find_index { |page| page == page_object }
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :path, :document_class
33
+
34
+ def document
35
+ @document ||= document_class.open(path, config: {"filter.map" => {BrotliDecode: nil}})
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hexapdf"
4
+
5
+ module Substrate
6
+ module Mediums
7
+ module Pdf
8
+ class TextExtractor < HexaPDF::Content::Processor
9
+ def self.extract(page)
10
+ new(page.resources).tap { |extractor| page.process_contents(extractor) }.text
11
+ end
12
+
13
+ attr_reader :text
14
+
15
+ def initialize(resources)
16
+ super
17
+ @text = +""
18
+ end
19
+
20
+ def show_text(str)
21
+ append(decode_text(str))
22
+ end
23
+
24
+ def show_text_with_positioning(arr)
25
+ append(decode_text(arr))
26
+ end
27
+
28
+ private
29
+
30
+ def append(str)
31
+ text << str << " "
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ ArticleContent = Data.define(
5
+ :source_ref,
6
+ :text,
7
+ :title,
8
+ :metadata
9
+ )
10
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ Extraction = Data.define(:text, :source, :metadata)
5
+ end