substrate 0.1.0 → 0.2.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 (67) 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 +95 -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/chapter.rb +9 -0
  17. data/lib/substrate/mediums/pdf/content.rb +13 -0
  18. data/lib/substrate/mediums/pdf/destination.rb +9 -0
  19. data/lib/substrate/mediums/pdf/destination_resolver.rb +131 -0
  20. data/lib/substrate/mediums/pdf/document.rb +46 -0
  21. data/lib/substrate/mediums/pdf/encrypted_pdf.rb +9 -0
  22. data/lib/substrate/mediums/pdf/entry.rb +9 -0
  23. data/lib/substrate/mediums/pdf/invalid_pdf.rb +9 -0
  24. data/lib/substrate/mediums/pdf/invalid_text.rb +9 -0
  25. data/lib/substrate/mediums/pdf/limit_exceeded.rb +17 -0
  26. data/lib/substrate/mediums/pdf/limits.rb +21 -0
  27. data/lib/substrate/mediums/pdf/named_destinations.rb +94 -0
  28. data/lib/substrate/mediums/pdf/no_selectable_text.rb +9 -0
  29. data/lib/substrate/mediums/pdf/outline.rb +9 -0
  30. data/lib/substrate/mediums/pdf/outline_facts.rb +115 -0
  31. data/lib/substrate/mediums/pdf/outline_parser.rb +67 -0
  32. data/lib/substrate/mediums/pdf/page.rb +9 -0
  33. data/lib/substrate/mediums/pdf/page_extractor.rb +28 -0
  34. data/lib/substrate/mediums/pdf/reader.rb +40 -0
  35. data/lib/substrate/mediums/pdf/text_extractor.rb +36 -0
  36. data/lib/substrate/primitives/article_content.rb +10 -0
  37. data/lib/substrate/primitives/extraction.rb +5 -0
  38. data/lib/substrate/primitives/metadata.rb +21 -0
  39. data/lib/substrate/primitives/page_collection.rb +5 -0
  40. data/lib/substrate/primitives/pdf_file.rb +5 -0
  41. data/lib/substrate/primitives/raw_text.rb +5 -0
  42. data/lib/substrate/primitives/source.rb +5 -0
  43. data/lib/substrate/primitives/text_content.rb +5 -0
  44. data/lib/substrate/primitives/text_file.rb +5 -0
  45. data/lib/substrate/primitives/transcripts/content.rb +11 -0
  46. data/lib/substrate/primitives/transcripts/metadata.rb +25 -0
  47. data/lib/substrate/primitives/transcripts/segment.rb +25 -0
  48. data/lib/substrate/primitives/transcripts/word.rb +23 -0
  49. data/lib/substrate/primitives/url_candidate.rb +13 -0
  50. data/lib/substrate/source_ref.rb +5 -0
  51. data/lib/substrate/sources/article.rb +34 -0
  52. data/lib/substrate/sources/audio.rb +57 -0
  53. data/lib/substrate/sources/pdf.rb +35 -0
  54. data/lib/substrate/sources/text.rb +23 -0
  55. data/lib/substrate/sources/url.rb +36 -0
  56. data/lib/substrate/transcribers/deepgram/client.rb +70 -0
  57. data/lib/substrate/transcribers/deepgram/configuration.rb +41 -0
  58. data/lib/substrate/transcribers/deepgram/response_parser.rb +102 -0
  59. data/lib/substrate/transcribers/deepgram.rb +45 -0
  60. data/lib/substrate/transcribers/whisper_cpp/response_parser.rb +66 -0
  61. data/lib/substrate/transcribers/whisper_cpp.rb +87 -0
  62. data/lib/substrate/transcripts/select_time_range.rb +59 -0
  63. data/lib/substrate/url_resolvers/generic.rb +71 -0
  64. data/lib/substrate/url_resolvers/resolver_for.rb +35 -0
  65. data/lib/substrate/version.rb +1 -1
  66. data/lib/substrate.rb +16 -3
  67. metadata +106 -1
@@ -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)
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
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ Metadata = Data.define(
5
+ :requested_url,
6
+ :final_url,
7
+ :redirected,
8
+ :content_type,
9
+ :extractor
10
+ ) do
11
+ def initialize(
12
+ requested_url:,
13
+ final_url: nil,
14
+ redirected: false,
15
+ content_type: nil,
16
+ extractor: nil
17
+ )
18
+ super
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ PageCollection = Data.define(:source_ref, :pages)
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ PdfFile = Data.define(:path)
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ RawText = Data.define(:text)
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ Source = Data.define(:kind, :locator, :metadata)
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ TextContent = Data.define(:source_ref, :text)
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ TextFile = Data.define(:path)
5
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Transcripts
5
+ Content = Data.define(
6
+ :source_ref,
7
+ :segments,
8
+ :metadata
9
+ )
10
+ end
11
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Transcripts
5
+ Metadata = Data.define(
6
+ :provider,
7
+ :model,
8
+ :language,
9
+ :duration_seconds,
10
+ :diarized,
11
+ :word_timestamps
12
+ ) do
13
+ def initialize(
14
+ provider:,
15
+ model:,
16
+ language: nil,
17
+ duration_seconds: nil,
18
+ diarized: false,
19
+ word_timestamps: false
20
+ )
21
+ super
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Transcripts
5
+ Segment = Data.define(
6
+ :text,
7
+ :start,
8
+ :end,
9
+ :confidence,
10
+ :speaker,
11
+ :words
12
+ ) do
13
+ def initialize(
14
+ text:,
15
+ start:,
16
+ end:,
17
+ confidence: nil,
18
+ speaker: nil,
19
+ words: []
20
+ )
21
+ super
22
+ end
23
+ end
24
+ end
25
+ end