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.
- checksums.yaml +4 -4
- data/lib/substrate/classify.rb +65 -0
- data/lib/substrate/cli/availability.rb +11 -0
- data/lib/substrate/cli/error.rb +7 -0
- data/lib/substrate/cli/runner.rb +31 -0
- data/lib/substrate/command.rb +24 -0
- data/lib/substrate/configuration.rb +21 -0
- data/lib/substrate/detection/path_resolver.rb +33 -0
- data/lib/substrate/distill.rb +31 -0
- data/lib/substrate/extract_pdf.rb +133 -0
- data/lib/substrate/handlers/article.rb +39 -0
- data/lib/substrate/handlers/pdf.rb +29 -0
- data/lib/substrate/handlers/raw_text.rb +22 -0
- data/lib/substrate/handlers/text_file.rb +26 -0
- data/lib/substrate/handlers/transcript.rb +48 -0
- data/lib/substrate/mediums/pdf/box.rb +9 -0
- data/lib/substrate/mediums/pdf/chapter.rb +9 -0
- data/lib/substrate/mediums/pdf/content.rb +9 -0
- data/lib/substrate/mediums/pdf/destination.rb +9 -0
- data/lib/substrate/mediums/pdf/destination_resolver.rb +131 -0
- data/lib/substrate/mediums/pdf/document.rb +46 -0
- data/lib/substrate/mediums/pdf/encrypted_pdf.rb +9 -0
- data/lib/substrate/mediums/pdf/entry.rb +9 -0
- data/lib/substrate/mediums/pdf/invalid_pdf.rb +9 -0
- data/lib/substrate/mediums/pdf/invalid_text.rb +9 -0
- data/lib/substrate/mediums/pdf/limit_exceeded.rb +17 -0
- data/lib/substrate/mediums/pdf/limits.rb +21 -0
- data/lib/substrate/mediums/pdf/named_destinations.rb +94 -0
- data/lib/substrate/mediums/pdf/no_selectable_text.rb +9 -0
- data/lib/substrate/mediums/pdf/outline.rb +9 -0
- data/lib/substrate/mediums/pdf/outline_facts.rb +115 -0
- data/lib/substrate/mediums/pdf/outline_parser.rb +67 -0
- data/lib/substrate/mediums/pdf/page.rb +9 -0
- data/lib/substrate/mediums/pdf/page_extractor.rb +28 -0
- data/lib/substrate/mediums/pdf/reader.rb +40 -0
- data/lib/substrate/mediums/pdf/text_extractor.rb +36 -0
- data/lib/substrate/primitives/article_content.rb +10 -0
- data/lib/substrate/primitives/extraction.rb +5 -0
- data/lib/substrate/primitives/metadata.rb +21 -0
- data/lib/substrate/primitives/page_collection.rb +5 -0
- data/lib/substrate/primitives/pdf_file.rb +5 -0
- data/lib/substrate/primitives/raw_text.rb +5 -0
- data/lib/substrate/primitives/source.rb +5 -0
- data/lib/substrate/primitives/text_content.rb +5 -0
- data/lib/substrate/primitives/text_file.rb +5 -0
- data/lib/substrate/primitives/transcripts/content.rb +11 -0
- data/lib/substrate/primitives/transcripts/metadata.rb +25 -0
- data/lib/substrate/primitives/transcripts/segment.rb +25 -0
- data/lib/substrate/primitives/transcripts/word.rb +23 -0
- data/lib/substrate/primitives/url_candidate.rb +13 -0
- data/lib/substrate/source_ref.rb +5 -0
- data/lib/substrate/sources/article.rb +34 -0
- data/lib/substrate/sources/audio.rb +57 -0
- data/lib/substrate/sources/pdf.rb +35 -0
- data/lib/substrate/sources/text.rb +23 -0
- data/lib/substrate/sources/url.rb +36 -0
- data/lib/substrate/transcribers/deepgram/client.rb +70 -0
- data/lib/substrate/transcribers/deepgram/configuration.rb +41 -0
- data/lib/substrate/transcribers/deepgram/response_parser.rb +102 -0
- data/lib/substrate/transcribers/deepgram.rb +45 -0
- data/lib/substrate/transcribers/whisper_cpp/response_parser.rb +66 -0
- data/lib/substrate/transcribers/whisper_cpp.rb +87 -0
- data/lib/substrate/transcripts/select_time_range.rb +59 -0
- data/lib/substrate/url_resolvers/generic.rb +71 -0
- data/lib/substrate/url_resolvers/resolver_for.rb +35 -0
- data/lib/substrate/version.rb +1 -1
- data/lib/substrate.rb +16 -3
- 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,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,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,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
|