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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e7b3e7c25e9af2d9e3b590493a0b749c1413c8fc4542074a891f0879fb308dd2
4
- data.tar.gz: e88153c370e8f419a810c7014b9b5e15790741811ed40874ac5f3517dba0b8c3
3
+ metadata.gz: f70ea3ee7d06a49e41215d92a1b04db9173cbf357cbeccbd9bb85adc281b2e45
4
+ data.tar.gz: b86bcb7ff7b2f9eab771a2e3419b1ab0cf42a08bb4ae6d4bd906d2cee0ea0c5f
5
5
  SHA512:
6
- metadata.gz: 4200859c9d863f6d18692d9c92d97208cab9b07b17aa9d73f791724917752cc4261709f4fc49216ad2c0a71833d6fec9917c6f0e94972c7287f6f03046888b61
7
- data.tar.gz: 4fb43377d34974ce6f02caaef6a2437e34b3a2d21948776f3ca1ba6d5955250365c576c3e9f7776750c95394c35f0806bea45aaa6449198ebf908d101a48ee75
6
+ metadata.gz: 2d2687f91dbe8ca128de93e975de5e0a25359e8e8f1d7399b6092721b553ac26985eeff2d2963a7112f70bf11f98fdbcfff8f30e4462cb9fc9b3375349e6cf37
7
+ data.tar.gz: e303be1550bd6d5f98635689316e9be8e17d88657e196d614133f84aad0674745db45ffb0f1702dfc22ac3f5798e923113f612151fe98a71bfbce8ba602b0542
@@ -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,133 @@
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, page_separator: "\n\n")
9
+ @bytes = bytes
10
+ @source_ref = source_ref
11
+ @limits = limits
12
+ @page_separator = page_separator
13
+ end
14
+
15
+ def call
16
+ validate_input
17
+ validate_page_separator
18
+ extract_document
19
+ end
20
+
21
+ private
22
+
23
+ def validate_input
24
+ raise ArgumentError, "bytes must be a String" unless @bytes.is_a?(String)
25
+ @limits.check!(:input_bytes, @bytes.bytesize)
26
+ end
27
+
28
+ def validate_page_separator
29
+ raise ArgumentError, "page_separator must be a String" unless @page_separator.is_a?(String)
30
+ @page_separator = @page_separator.encode(Encoding::UTF_8).dup.freeze
31
+ raise ArgumentError, "page_separator must contain valid UTF-8" unless @page_separator.valid_encoding?
32
+ rescue EncodingError => error
33
+ raise ArgumentError, "page_separator must contain valid UTF-8", cause: error
34
+ end
35
+
36
+ def extract_document
37
+ StringIO.open(@bytes) do |io|
38
+ document = HexaPDF::Document.new(io: io, config: {"document.auto_decrypt" => false, "filter.map" => {BrotliDecode: nil}})
39
+ raise Mediums::Pdf::EncryptedPdf, "Encrypted PDFs are not supported" if document.encrypted?
40
+ objects = page_objects(document)
41
+ pages = extract_pages(objects)
42
+ outline = Mediums::Pdf::OutlineFacts.new(document, objects, @limits).call
43
+ publish_content(pages, outline)
44
+ end
45
+ rescue HexaPDF::Error => error
46
+ raise Mediums::Pdf::InvalidText, "PDF Unicode mapping is invalid", cause: error if error.cause.is_a?(EncodingError)
47
+ raise Mediums::Pdf::InvalidPdf, "PDF parsing or page extraction failed", cause: error
48
+ end
49
+
50
+ def page_objects(document)
51
+ root = document.catalog[:Pages]
52
+ raise Mediums::Pdf::InvalidPdf, "PDF page tree is missing" unless root.is_a?(HexaPDF::Dictionary)
53
+ count = root[:Count]
54
+ raise Mediums::Pdf::InvalidPdf, "PDF page count is invalid" unless count.is_a?(Integer) && count >= 0
55
+ @limits.check!(:pages, count)
56
+ pending = [[root, nil]]
57
+ seen = {}
58
+ [].tap do |pages|
59
+ until pending.empty?
60
+ candidate, parent = pending.pop
61
+ node = document.deref(candidate)
62
+ raise Mediums::Pdf::InvalidPdf, "PDF page tree node is invalid" unless node.is_a?(HexaPDF::Dictionary)
63
+ raise Mediums::Pdf::InvalidPdf, "PDF page ancestry is inconsistent" unless node[:Parent] == parent
64
+ identity = node.indirect? ? [node.oid, node.gen] : node.data.object_id
65
+ raise Mediums::Pdf::InvalidPdf, "PDF page tree contains a cycle or repeated node" if seen[identity]
66
+ seen[identity] = true
67
+ if node[:Type] == :Page
68
+ @limits.check!(:pages, pages.size + 1)
69
+ pages << document.wrap(node, type: :Page)
70
+ elsif node[:Type] == :Pages
71
+ kids = node[:Kids]
72
+ raise Mediums::Pdf::InvalidPdf, "PDF page tree children are invalid" unless kids.is_a?(HexaPDF::PDFArray)
73
+ pending.concat(kids.to_a.reverse.map { |child| [child, node] })
74
+ else
75
+ raise Mediums::Pdf::InvalidPdf, "PDF page tree type is invalid"
76
+ end
77
+ end
78
+ end
79
+ end
80
+
81
+ def extract_pages(objects)
82
+ @text = +""
83
+ objects.map.with_index do |page, index|
84
+ @limits.check!(:pages, index + 1)
85
+ text = page.extract_text.encode(Encoding::UTF_8)
86
+ raise Mediums::Pdf::InvalidText, "PDF text contains invalid UTF-8 or NUL" unless text.valid_encoding? && !text.include?("\0")
87
+ append_page_text(text, index)
88
+ Mediums::Pdf::Page.new(number: index + 1, text: text.freeze, crop_box: crop_box(page))
89
+ end.freeze.tap do |pages|
90
+ raise Mediums::Pdf::NoSelectableText, "PDF contains no selectable text" if pages.all? { |page| page.text.match?(/\A[[:space:]]*\z/) }
91
+ end
92
+ rescue EncodingError => error
93
+ raise Mediums::Pdf::InvalidText, "PDF text contains invalid UTF-8", cause: error
94
+ end
95
+
96
+ def append_page_text(text, index)
97
+ separator = index.zero? ? "" : @page_separator
98
+ @limits.check!(:text_bytes, @text.bytesize + separator.bytesize + text.bytesize)
99
+ @text << separator << text
100
+ end
101
+
102
+ def crop_box(page)
103
+ media, crop = page_rectangles(page)
104
+ return unless usable_box?(media) && usable_box?(crop)
105
+ box = Mediums::Pdf::Box.new(left: [media.left, crop.left].max,
106
+ bottom: [media.bottom, crop.bottom].max, right: [media.right, crop.right].min,
107
+ top: [media.top, crop.top].min)
108
+ box if box.right > box.left && box.top > box.bottom
109
+ end
110
+
111
+ def page_rectangles(page)
112
+ media = page[:MediaBox]
113
+ crop = page[:CropBox]
114
+ [media, crop.nil? ? media : crop]
115
+ rescue HexaPDF::Error
116
+ [nil, nil]
117
+ end
118
+
119
+ def usable_box?(rectangle)
120
+ rectangle.is_a?(HexaPDF::Rectangle) &&
121
+ rectangle.to_a.all? { |value| value.is_a?(Numeric) && value.finite? } &&
122
+ rectangle.right > rectangle.left && rectangle.top > rectangle.bottom
123
+ end
124
+
125
+ def publish_content(pages, outline)
126
+ metadata = {extractor: "HexaPDF", extractor_version: HexaPDF::VERSION.dup.freeze,
127
+ input_bytes: @bytes.bytesize, page_count: pages.size,
128
+ text_bytes: @text.bytesize}.freeze
129
+ success(payload: Mediums::Pdf::Content.new(pages: pages, source_ref: @source_ref,
130
+ metadata: metadata, outline: outline, text: @text.freeze))
131
+ end
132
+ end
133
+ 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
+ Box = Data.define(:left, :bottom, :right, :top)
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
+ Chapter = Data.define(:source_ref, :id, :title, :pages)
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
+ Content = Data.define(:pages, :source_ref, :metadata, :outline, :text)
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
+ Destination = Data.define(:kind, :page_number, :name, :fit, :parameters, :remote_target, :reason)
7
+ end
8
+ end
9
+ end