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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e7b3e7c25e9af2d9e3b590493a0b749c1413c8fc4542074a891f0879fb308dd2
4
- data.tar.gz: e88153c370e8f419a810c7014b9b5e15790741811ed40874ac5f3517dba0b8c3
3
+ metadata.gz: 20af64cb9a41e61ad9af8a5f678f3485947689193ad3f71d7638bd332452c88b
4
+ data.tar.gz: 34bc75ca3736801c7f35d968a626e32a597d62c36ef6516b7f727c1cd21e53ff
5
5
  SHA512:
6
- metadata.gz: 4200859c9d863f6d18692d9c92d97208cab9b07b17aa9d73f791724917752cc4261709f4fc49216ad2c0a71833d6fec9917c6f0e94972c7287f6f03046888b61
7
- data.tar.gz: 4fb43377d34974ce6f02caaef6a2437e34b3a2d21948776f3ca1ba6d5955250365c576c3e9f7776750c95394c35f0806bea45aaa6449198ebf908d101a48ee75
6
+ metadata.gz: 46c461e08228dcf87eed7d6e6c791619b3a83fc6ee72f1e2804db5e9d81a606d0299afdd3a69c5ce4ba52dc3a3fff1720db1dee30ad1cb810fb7f1bcbcc855d6
7
+ data.tar.gz: 744c7900ef351097160f26a695c440f1197b07ad9aafd38c06d9ce86a5a6714d445d766d048c1cd8ed69cabef792618e4879b19b5383d599441908d3d59c6447
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Substrate
6
+ class Classify < Command
7
+ class UnsupportedSource < StandardError; end
8
+ class ResolutionError < StandardError; end
9
+
10
+ EXTENSION_MAP = {
11
+ ".txt" => :text_file,
12
+ ".md" => :text_file,
13
+ ".rst" => :text_file,
14
+ ".pdf" => :pdf_file,
15
+ ".mp3" => :audio_file
16
+ }.freeze
17
+
18
+ def initialize(input:)
19
+ @input = input
20
+ end
21
+
22
+ def call
23
+ success(payload: classified)
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :input
29
+
30
+ def classified
31
+ return RawText.new(text: input) unless resolved_path || url?
32
+
33
+ return Sources::Url.new(input) if url?
34
+
35
+ case file_kind
36
+ when :pdf_file then Sources::Pdf.new(resolved_path)
37
+ when :text_file then Sources::Text.new(resolved_path)
38
+ when :audio_file then Sources::Audio.new(resolved_path)
39
+ end
40
+ end
41
+
42
+ def url?
43
+ uri = URI.parse(input)
44
+ uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
45
+ rescue URI::InvalidURIError
46
+ false
47
+ end
48
+
49
+ def file_kind
50
+ ext = File.extname(resolved_path).downcase
51
+ EXTENSION_MAP.fetch(ext) do
52
+ raise UnsupportedSource, "Unsupported file type: #{ext}"
53
+ end
54
+ end
55
+
56
+ def resolved_path
57
+ @resolved_path ||= begin
58
+ result = Detection::PathResolver.call(raw: input)
59
+ raise ResolutionError, result.error.message unless result.success?
60
+
61
+ result.payload
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Cli
5
+ class Availability
6
+ def self.call(executable)
7
+ system("which", executable, out: File::NULL, err: File::NULL)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Cli
5
+ class Error < StandardError; end
6
+ end
7
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Substrate
6
+ module Cli
7
+ class Runner < Command
8
+ ExecutionError = Class.new(Error)
9
+
10
+ def initialize(argv:)
11
+ @argv = argv
12
+ end
13
+
14
+ def call
15
+ stdout, stderr, status = Open3.capture3(*argv)
16
+
17
+ raise ExecutionError, stderr unless status.success?
18
+
19
+ success(payload: stdout)
20
+ rescue ExecutionError
21
+ raise
22
+ rescue => e
23
+ raise ExecutionError, e.message
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :argv
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ Result = Data.define(:payload, :error) do
5
+ def success? = error.nil?
6
+ def failure? = !error.nil?
7
+ end
8
+
9
+ class Command
10
+ def self.call(...)
11
+ new(...).call
12
+ rescue => e
13
+ Result.new(payload: nil, error: e)
14
+ end
15
+
16
+ def success(payload:)
17
+ Result.new(payload: payload, error: nil)
18
+ end
19
+
20
+ def failure(error)
21
+ Result.new(payload: nil, error: error)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Configuration
5
+ class << self
6
+ def deepgram_api_key
7
+ @deepgram_api_key ||= ENV.fetch("DEEPGRAM_API_KEY", nil)
8
+ end
9
+
10
+ attr_writer :deepgram_api_key
11
+
12
+ def configure
13
+ yield self
14
+ end
15
+
16
+ def reset!
17
+ @deepgram_api_key = nil
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module Substrate
6
+ module Detection
7
+ class PathResolver < Command
8
+ def initialize(raw:)
9
+ @raw = raw
10
+ end
11
+
12
+ def call
13
+ path = candidates.find { |p| p.file? }
14
+ success(payload: path&.to_s)
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :raw
20
+
21
+ def candidates
22
+ [raw, shell_unescape].compact.uniq.map { |s| Pathname.new(s).expand_path }
23
+ end
24
+
25
+ def shell_unescape
26
+ parts = Shellwords.shellsplit(raw)
27
+ parts.first if parts.length == 1
28
+ rescue ArgumentError
29
+ nil
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ class Distill < Command
5
+ def initialize(content:)
6
+ @content = content
7
+ end
8
+
9
+ def call
10
+ case content
11
+ in RawText(text:)
12
+ Handlers::RawText.call(text: text)
13
+ in TextContent(source_ref:, text:)
14
+ Handlers::TextFile.call(text: text, source_ref: source_ref)
15
+ in ArticleContent(source_ref:, text:, title:, metadata:)
16
+ Handlers::Article.call(content: content)
17
+ in PageCollection(source_ref:, pages:)
18
+ document = Mediums::Pdf::Document.new(source_ref.locator, source_ref: source_ref)
19
+ Handlers::Pdf.call(document: document, pages: pages)
20
+ in Transcripts::Content(source_ref:, segments:, metadata:)
21
+ Handlers::Transcript.call(content: content)
22
+ else
23
+ failure(ArgumentError.new("Unsupported content type: #{content.class}"))
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :content
30
+ end
31
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hexapdf"
4
+ require "stringio"
5
+
6
+ module Substrate
7
+ class ExtractPdf < Command
8
+ def initialize(bytes:, source_ref:, limits: Mediums::Pdf::Limits.new)
9
+ @bytes = bytes
10
+ @source_ref = source_ref
11
+ @limits = limits
12
+ end
13
+
14
+ def call
15
+ validate_input
16
+ extract_document
17
+ end
18
+
19
+ private
20
+
21
+ def validate_input
22
+ raise ArgumentError, "bytes must be a String" unless @bytes.is_a?(String)
23
+ @limits.check!(:input_bytes, @bytes.bytesize)
24
+ end
25
+
26
+ def extract_document
27
+ StringIO.open(@bytes) do |io|
28
+ document = HexaPDF::Document.new(io: io, config: {"document.auto_decrypt" => false, "filter.map" => {BrotliDecode: nil}})
29
+ raise Mediums::Pdf::EncryptedPdf, "Encrypted PDFs are not supported" if document.encrypted?
30
+ objects = page_objects(document)
31
+ pages = extract_pages(objects)
32
+ outline = Mediums::Pdf::OutlineFacts.new(document, objects, @limits).call
33
+ publish_content(pages, outline)
34
+ end
35
+ rescue HexaPDF::Error => error
36
+ raise Mediums::Pdf::InvalidText, "PDF Unicode mapping is invalid", cause: error if error.cause.is_a?(EncodingError)
37
+ raise Mediums::Pdf::InvalidPdf, "PDF parsing or page extraction failed", cause: error
38
+ end
39
+
40
+ def page_objects(document)
41
+ root = document.catalog[:Pages]
42
+ raise Mediums::Pdf::InvalidPdf, "PDF page tree is missing" unless root.is_a?(HexaPDF::Dictionary)
43
+ count = root[:Count]
44
+ raise Mediums::Pdf::InvalidPdf, "PDF page count is invalid" unless count.is_a?(Integer) && count >= 0
45
+ @limits.check!(:pages, count)
46
+ pending = [[root, nil]]
47
+ seen = {}
48
+ [].tap do |pages|
49
+ until pending.empty?
50
+ candidate, parent = pending.pop
51
+ node = document.deref(candidate)
52
+ raise Mediums::Pdf::InvalidPdf, "PDF page tree node is invalid" unless node.is_a?(HexaPDF::Dictionary)
53
+ raise Mediums::Pdf::InvalidPdf, "PDF page ancestry is inconsistent" unless node[:Parent] == parent
54
+ identity = node.indirect? ? [node.oid, node.gen] : node.data.object_id
55
+ raise Mediums::Pdf::InvalidPdf, "PDF page tree contains a cycle or repeated node" if seen[identity]
56
+ seen[identity] = true
57
+ if node[:Type] == :Page
58
+ @limits.check!(:pages, pages.size + 1)
59
+ pages << document.wrap(node, type: :Page)
60
+ elsif node[:Type] == :Pages
61
+ kids = node[:Kids]
62
+ raise Mediums::Pdf::InvalidPdf, "PDF page tree children are invalid" unless kids.is_a?(HexaPDF::PDFArray)
63
+ pending.concat(kids.to_a.reverse.map { |child| [child, node] })
64
+ else
65
+ raise Mediums::Pdf::InvalidPdf, "PDF page tree type is invalid"
66
+ end
67
+ end
68
+ end
69
+ end
70
+
71
+ def extract_pages(objects)
72
+ text_bytes = 0
73
+ objects.map.with_index do |page, index|
74
+ @limits.check!(:pages, index + 1)
75
+ text = page.extract_text.encode(Encoding::UTF_8)
76
+ raise Mediums::Pdf::InvalidText, "PDF text contains invalid UTF-8 or NUL" unless text.valid_encoding? && !text.include?("\0")
77
+ text_bytes += text.bytesize + ((index == 0) ? 0 : 2)
78
+ @limits.check!(:text_bytes, text_bytes)
79
+ Mediums::Pdf::Page.new(number: index + 1, text: text.freeze)
80
+ end.freeze.tap do |pages|
81
+ raise Mediums::Pdf::NoSelectableText, "PDF contains no selectable text" if pages.all? { |page| page.text.match?(/\A[[:space:]]*\z/) }
82
+ end
83
+ rescue EncodingError => error
84
+ raise Mediums::Pdf::InvalidText, "PDF text contains invalid UTF-8", cause: error
85
+ end
86
+
87
+ def publish_content(pages, outline)
88
+ metadata = {extractor: "HexaPDF", extractor_version: HexaPDF::VERSION.dup.freeze,
89
+ input_bytes: @bytes.bytesize, page_count: pages.size,
90
+ text_bytes: pages.sum { |page| page.text.bytesize } + 2 * (pages.size - 1)}.freeze
91
+ success(payload: Mediums::Pdf::Content.new(pages: pages, source_ref: @source_ref,
92
+ metadata: metadata, outline: outline))
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Handlers
5
+ class Article < Command
6
+ def initialize(content:)
7
+ @content = content
8
+ end
9
+
10
+ def call
11
+ success(payload: extraction)
12
+ end
13
+
14
+ private
15
+
16
+ attr_reader :content
17
+
18
+ def extraction
19
+ Extraction.new(
20
+ text: content.text,
21
+ source: source,
22
+ metadata: extraction_metadata
23
+ )
24
+ end
25
+
26
+ def source
27
+ Source.new(
28
+ kind: :article,
29
+ locator: content.source_ref.locator,
30
+ metadata: content.metadata
31
+ )
32
+ end
33
+
34
+ def extraction_metadata
35
+ {title: content.title}.compact
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Handlers
5
+ class Pdf < Command
6
+ def initialize(document:, pages: nil)
7
+ @document = document
8
+ @pages = pages
9
+ end
10
+
11
+ def call
12
+ range = pages || (1..document.page_count)
13
+ text = document.pages_text(range)
14
+ source = Source.new(kind: :pdf_file, locator: nil, metadata: {
15
+ page_count: document.page_count
16
+ })
17
+ extraction = Extraction.new(text: text, source: source, metadata: {
18
+ pages_extracted: range
19
+ })
20
+
21
+ success(payload: extraction)
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :document, :pages
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Handlers
5
+ class RawText < Command
6
+ def initialize(text:)
7
+ @text = text
8
+ end
9
+
10
+ def call
11
+ source = Source.new(kind: :raw_text, locator: nil, metadata: {})
12
+ extraction = Extraction.new(text: text, source: source, metadata: {})
13
+
14
+ success(payload: extraction)
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :text
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Handlers
5
+ class TextFile < Command
6
+ def initialize(text:, source_ref:)
7
+ @text = text
8
+ @source_ref = source_ref
9
+ end
10
+
11
+ def call
12
+ source = Source.new(kind: :text_file, locator: source_ref.locator, metadata: {
13
+ line_count: text.count("\n"),
14
+ file_size: File.size(source_ref.locator)
15
+ })
16
+ extraction = Extraction.new(text: text, source: source, metadata: {})
17
+
18
+ success(payload: extraction)
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :text, :source_ref
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Handlers
5
+ class Transcript < Command
6
+ def initialize(content:)
7
+ @content = content
8
+ end
9
+
10
+ def call
11
+ success(payload: extraction)
12
+ end
13
+
14
+ private
15
+
16
+ attr_reader :content
17
+
18
+ def extraction
19
+ Extraction.new(
20
+ text: full_text,
21
+ source: source,
22
+ metadata: extraction_metadata
23
+ )
24
+ end
25
+
26
+ def full_text
27
+ content.segments.map(&:text).join("\n\n")
28
+ end
29
+
30
+ def source
31
+ Source.new(
32
+ kind: :audio,
33
+ locator: content.source_ref.locator,
34
+ metadata: content.metadata
35
+ )
36
+ end
37
+
38
+ def extraction_metadata
39
+ {
40
+ segment_count: content.segments.count,
41
+ duration_seconds: content.metadata.duration_seconds,
42
+ language: content.metadata.language,
43
+ diarized: content.metadata.diarized
44
+ }.compact
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Mediums
5
+ module Pdf
6
+ Chapter = Data.define(:source_ref, :id, :title, :pages)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Substrate
4
+ module Mediums
5
+ module Pdf
6
+ Content = Data.define(:pages, :source_ref, :metadata, :outline) do
7
+ def text
8
+ pages.map(&:text).join("\n\n").freeze
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
+ Destination = Data.define(:kind, :page_number, :name, :fit, :parameters, :remote_target, :reason)
7
+ end
8
+ end
9
+ end
@@ -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