export_kit 0.1.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.
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ExportKit
4
+ module Renderers
5
+ class TooLarge < StandardError; end
6
+
7
+ class InvalidDocument < StandardError
8
+ attr_reader :error_details
9
+
10
+ def initialize(message = "invalid export data", error_details: {})
11
+ super(message)
12
+ @error_details = Result.failure(:invalid, error_details:).error_details
13
+ end
14
+
15
+ def at(**details)
16
+ self.class.new(error_details: details.merge(error_details))
17
+ end
18
+ end
19
+
20
+ # Shared validation for every renderer: rows must match their declared columns and contain
21
+ # only format-neutral scalar values (or arrays of scalars).
22
+ class Base
23
+ SCALARS = [String, Integer, Float, TrueClass, FalseClass, NilClass].freeze
24
+
25
+ def self.validate_row!(record_set, row)
26
+ column = invalid_column(record_set, row)
27
+ valid = row.is_a?(Hash) && row.keys == record_set.columns && row.values.all? { plain?(it) }
28
+ return if valid
29
+
30
+ raise InvalidDocument.new(error_details: { record_set: record_set.name, column: })
31
+ end
32
+
33
+ def self.invalid_column(record_set, row)
34
+ return unless row.is_a?(Hash)
35
+
36
+ record_set.columns.find { !row.key?(it) || !plain?(row[it]) }
37
+ end
38
+
39
+ def self.plain?(value)
40
+ return value.all? { scalar?(it) } if value.is_a?(Array)
41
+
42
+ scalar?(value)
43
+ end
44
+
45
+ def self.scalar?(value)
46
+ return value.finite? if value.is_a?(Float)
47
+ return valid_string?(value) if value.is_a?(String)
48
+
49
+ SCALARS.any? { value.is_a?(it) }
50
+ end
51
+
52
+ def self.valid_string?(value)
53
+ value.encode(Encoding::UTF_8).valid_encoding?
54
+ rescue EncodingError
55
+ false
56
+ end
57
+
58
+ def render(document:, output:, max_bytes:)
59
+ raise NotImplementedError
60
+ end
61
+
62
+ private
63
+
64
+ def each_row(record_set)
65
+ index = 0
66
+ record_set.each_row do |row|
67
+ index += 1
68
+ self.class.validate_row!(record_set, row)
69
+
70
+ yield row
71
+ end
72
+ rescue InvalidDocument => e
73
+ raise e.at(record_set: record_set.name, row: index)
74
+ rescue JSON::GeneratorError, EncodingError
75
+ raise InvalidDocument.new(error_details: { record_set: record_set.name, row: index })
76
+ end
77
+
78
+ def manifest_entry(section, counts)
79
+ {
80
+ "category" => section.key,
81
+ "label" => section.label,
82
+ "description" => section.description,
83
+ **section.metadata,
84
+ "record_sets" => counts,
85
+ "count" => counts.values.sum
86
+ }
87
+ end
88
+
89
+ def ensure_size!(io, max_bytes)
90
+ raise TooLarge if io.size > max_bytes
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+ require "tempfile"
5
+ require "zip"
6
+
7
+ module ExportKit
8
+ module Renderers
9
+ # A multi-table document cannot be represented faithfully by one rectangular CSV. This
10
+ # renderer writes one UTF-8 CSV per non-empty record set plus a deterministic manifest.csv.
11
+ class CsvArchive < Base
12
+ MANIFEST_COLUMNS = %w(kind section record_set file count metadata).freeze
13
+ FORMULA_PREFIX = /\A[=+\-@]/
14
+
15
+ def self.csv_value(value)
16
+ rendered = value.is_a?(Array) ? JSON.generate(value) : value
17
+ rendered.is_a?(String) && rendered.match?(FORMULA_PREFIX) ? "'#{rendered}" : rendered
18
+ end
19
+
20
+ def render(document:, output:, max_bytes:)
21
+ manifest = nil
22
+ Zip::OutputStream.write_buffer(output) do |archive|
23
+ bounded = Output.new(io: archive, max_bytes:)
24
+ manifest, files = write_record_sets(document, archive, bounded)
25
+ write_manifest(document, manifest, files, archive, bounded)
26
+ end
27
+ ensure_size!(output, max_bytes)
28
+ manifest
29
+ end
30
+
31
+ private
32
+
33
+ def write_record_sets(document, archive, bounded)
34
+ state = { files: {} }
35
+ manifest = document.sections.map { write_section(it, archive, bounded, state) }
36
+ [manifest, state.fetch(:files)]
37
+ end
38
+
39
+ def write_section(section, archive, bounded, state)
40
+ counts = section.record_sets.to_h do |record_set|
41
+ count, filename = write_record_set(section, record_set, archive, bounded)
42
+
43
+ state.fetch(:files)[[section.key, record_set.name]] = filename
44
+ [record_set.name, count]
45
+ end
46
+ manifest_entry(section, counts)
47
+ rescue InvalidDocument => e
48
+ raise e.at(section: section.key)
49
+ end
50
+
51
+ def write_record_set(section, record_set, archive, bounded)
52
+ csv = CSV.new(bounded)
53
+ count = 0
54
+ each_row(record_set) do |row|
55
+ start_record_set(archive, csv, section, record_set) if count.zero?
56
+ csv << record_set.columns.map { self.class.csv_value(row.fetch(it)) }
57
+ count += 1
58
+ end
59
+ [count, record_set_filename(section, record_set, count)]
60
+ end
61
+
62
+ def start_record_set(archive, csv, section, record_set)
63
+ archive.put_next_entry(record_set_filename(section, record_set, 1))
64
+ csv << record_set.headers.map { self.class.csv_value(it) }
65
+ end
66
+
67
+ def record_set_filename(section, record_set, count)
68
+ "data/#{section.key}/#{record_set.name}.csv" if count.positive?
69
+ end
70
+
71
+ def write_manifest(document, manifest, files, archive, bounded)
72
+ archive.put_next_entry("manifest.csv")
73
+ csv = CSV.new(bounded)
74
+ csv << MANIFEST_COLUMNS
75
+ write_document_manifest(csv, document)
76
+ document.sections.zip(manifest).each do |section, entry|
77
+ write_section_manifest(csv, section, entry, files)
78
+ end
79
+ end
80
+
81
+ def write_document_manifest(csv, document)
82
+ metadata = {
83
+ "format" => document.identifier,
84
+ "schema_version" => document.schema_version,
85
+ "title" => document.title, **document.metadata
86
+ }
87
+ metadata.each { |key, value| csv << ["document", nil, nil, nil, nil, JSON.generate(key => value)] }
88
+ end
89
+
90
+ def write_section_manifest(csv, section, entry, files)
91
+ csv << ["section", section.key, nil, nil, entry.fetch("count"), JSON.generate(section.metadata)]
92
+ section.record_sets.each { write_record_set_manifest(csv, section, it, entry, files) }
93
+ end
94
+
95
+ def write_record_set_manifest(csv, section, record_set, entry, files)
96
+ csv << [
97
+ "record_set",
98
+ section.key,
99
+ record_set.name,
100
+ files[[section.key, record_set.name]],
101
+ entry.fetch("record_sets").fetch(record_set.name),
102
+ JSON.generate("columns" => record_set.columns,
103
+ "headers" => record_set.headers.map { self.class.csv_value(it) })
104
+ ]
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+
5
+ module ExportKit
6
+ module Renderers
7
+ # Canonical, structured representation of an export document.
8
+ class Json < Base
9
+ def render(document:, output:, max_bytes:)
10
+ data_file = Tempfile.new(%w(export-kit-data .json), binmode: true)
11
+ manifest = write_data(document, Output.new(io: data_file, max_bytes:), max_bytes)
12
+ assemble(document, manifest, data_file, output, max_bytes)
13
+ manifest
14
+ ensure
15
+ data_file&.close!
16
+ end
17
+
18
+ private
19
+
20
+ def write_data(document, data_file, max_bytes)
21
+ data_file.write("{")
22
+ manifest = document.sections.each_with_index.map do |section, section_index|
23
+ data_file.write(",") if section_index.positive?
24
+ data_file.write("#{JSON.generate(section.key)}:")
25
+ counts = write_section(section, data_file, max_bytes)
26
+ manifest_entry(section, counts)
27
+ end
28
+ data_file.write("}")
29
+ manifest
30
+ end
31
+
32
+ def write_section(section, data_file, max_bytes)
33
+ data_file.write("{")
34
+ counts = section.record_sets.each_with_index.to_h do |record_set, set_index|
35
+ data_file.write(",") if set_index.positive?
36
+ data_file.write("#{JSON.generate(record_set.name)}:")
37
+ [record_set.name, write_rows(record_set, data_file, max_bytes)]
38
+ end
39
+ data_file.write("}")
40
+ counts
41
+ rescue InvalidDocument => e
42
+ raise e.at(section: section.key)
43
+ end
44
+
45
+ def write_rows(record_set, data_file, max_bytes)
46
+ data_file.write("[")
47
+ count = 0
48
+ each_row(record_set) do |row|
49
+ data_file.write(",") if count.positive?
50
+ data_file.write(JSON.generate(row))
51
+ count += 1
52
+ ensure_size!(data_file, max_bytes)
53
+ end
54
+ data_file.write("]")
55
+ count
56
+ end
57
+
58
+ def assemble(document, manifest, data_file, output, max_bytes)
59
+ header = JSON.generate(document.envelope(manifest))
60
+ output.write(header.delete_suffix("}"), ",\"data\":")
61
+ data_file.rewind
62
+ output.copy_from(data_file)
63
+ output.write("}")
64
+ output.flush
65
+ ensure_size!(output, max_bytes)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prawn"
4
+
5
+ module ExportKit
6
+ module Renderers
7
+ # Human-readable presentation of the same logical document. All source values are plain text;
8
+ # Prawn never interprets them as HTML or inline markup.
9
+ class Pdf < Base
10
+ def initialize(fonts: nil)
11
+ super()
12
+ @fonts = fonts
13
+ end
14
+
15
+ def render(document:, output:, max_bytes:)
16
+ manifest = render_pdf(document, output)
17
+ ensure_size!(output, max_bytes)
18
+ manifest
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :fonts
24
+
25
+ def render_pdf(document, output)
26
+ pdf = Prawn::Document.new(page_size: "A4", margin: 42)
27
+ configure_font(pdf)
28
+ render_header(pdf, document)
29
+ manifest = document.sections.map { render_page(pdf, it) }
30
+ render_manifest(pdf, manifest)
31
+ render_page_numbers(pdf)
32
+ pdf.render(output)
33
+ manifest
34
+ end
35
+
36
+ def render_page(pdf, section)
37
+ pdf.start_new_page unless pdf.page_number == 1 && pdf.cursor > 300
38
+ manifest_entry(section, render_section(pdf, section))
39
+ end
40
+
41
+ def configure_font(pdf)
42
+ normal = fonts && fonts[:normal]
43
+ bold = fonts && fonts[:bold]
44
+ raise InvalidDocument, "Unicode PDF font is unavailable" unless normal && File.file?(normal)
45
+
46
+ pdf.font_families.update("ExportSans" => { normal:, bold: bold || normal })
47
+ pdf.font("ExportSans")
48
+ end
49
+
50
+ def render_header(pdf, document)
51
+ pdf.text printable(document.title), size: 20, style: :bold
52
+ pdf.move_down 12
53
+ pdf.text "Format: #{printable(document.identifier)} · schema: #{document.schema_version}", size: 9
54
+ document.metadata.each { |key, value| render_field(pdf, key, value, size: 8) }
55
+ end
56
+
57
+ def render_section(pdf, section)
58
+ render_section_header(pdf, section)
59
+ section.record_sets.to_h do |record_set|
60
+ [record_set.name, render_record_set(pdf, record_set)]
61
+ end
62
+ rescue InvalidDocument => e
63
+ raise e.at(section: section.key)
64
+ end
65
+
66
+ def render_section_header(pdf, section)
67
+ pdf.text printable(section.label), size: 16, style: :bold
68
+ pdf.text printable(section.description), size: 9 unless section.description.empty?
69
+ pdf.move_down 10
70
+ end
71
+
72
+ def render_record_set(pdf, record_set)
73
+ pdf.text printable(record_set.name), size: 12, style: :bold
74
+ count = 0
75
+ each_row(record_set) do |row|
76
+ count += 1
77
+ render_record(pdf, record_set, row, count)
78
+ end
79
+ pdf.text "—", size: 9 if count.zero?
80
+ pdf.move_down 12
81
+ count
82
+ end
83
+
84
+ def render_record(pdf, record_set, row, count)
85
+ pdf.start_new_page if pdf.cursor < record_height(pdf, record_set, row, count)
86
+ pdf.move_down 6
87
+ pdf.text "##{count}", size: 9, style: :bold
88
+ row.each { |key, value| render_field(pdf, record_set.column_labels.fetch(key), value) }
89
+ end
90
+
91
+ def record_height(pdf, record_set, row, count)
92
+ heading_height = pdf.height_of("##{count}", size: 9, style: :bold)
93
+ fields_height = row.sum do |key, value|
94
+ pdf.height_of(field_text(record_set.column_labels.fetch(key), value), size: 8, leading: 1)
95
+ end
96
+ 6 + heading_height + fields_height
97
+ end
98
+
99
+ def render_field(pdf, key, value, size: 8)
100
+ pdf.text field_text(key, value), size:, leading: 1
101
+ end
102
+
103
+ def field_text(key, value)
104
+ rendered = value.is_a?(Array) ? JSON.generate(value) : value.to_s
105
+ "#{printable(key)}: #{printable(rendered)}"
106
+ end
107
+
108
+ def render_manifest(pdf, manifest)
109
+ pdf.start_new_page
110
+ pdf.text "Manifest", size: 16, style: :bold
111
+ pdf.move_down 8
112
+ manifest.each do |entry|
113
+ pdf.text "#{printable(entry.fetch('label'))}: #{entry.fetch('count')}", size: 9
114
+ end
115
+ end
116
+
117
+ def render_page_numbers(pdf)
118
+ pdf.number_pages("<page> / <total>", at: [pdf.bounds.right - 70, -18], width: 70, align: :right, size: 8)
119
+ end
120
+
121
+ def printable(value)
122
+ value.to_s.encode("UTF-8", invalid: :replace, undef: :replace, replace: "�")
123
+ .gsub(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/, " ")
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ExportKit
4
+ class Result
5
+ class ValueError < StandardError; end
6
+
7
+ attr_reader :value, :error, :error_details
8
+
9
+ def self.success(value) = new(value:, error: nil)
10
+
11
+ def self.failure(error, error_details: {})
12
+ raise ArgumentError, "error must be a symbol" unless error.is_a?(Symbol)
13
+
14
+ new(value: nil, error:, error_details:)
15
+ end
16
+
17
+ def initialize(value:, error:, error_details: {})
18
+ raise ArgumentError, "error must be a symbol" unless error.nil? || error.is_a?(Symbol)
19
+
20
+ @value = value
21
+ @error = error
22
+ @error_details = safe_details(error_details)
23
+ freeze
24
+ end
25
+
26
+ def success? = error.nil?
27
+ def failure? = !success?
28
+
29
+ def value!
30
+ raise ValueError, error.to_s if failure?
31
+
32
+ value
33
+ end
34
+
35
+ def map
36
+ success? ? self.class.success(yield(value)) : self
37
+ end
38
+
39
+ def map_error
40
+ failure? ? self.class.failure(yield(error), error_details:) : self
41
+ end
42
+
43
+ def and_then
44
+ success? ? yield(value) : self
45
+ end
46
+
47
+ private
48
+
49
+ def safe_details(details)
50
+ details.each_with_object({}) do |(key, detail), safe|
51
+ value = safe_detail(key, detail)
52
+ safe[key] = value unless value.nil?
53
+ end.freeze
54
+ end
55
+
56
+ def safe_detail(key, detail)
57
+ return safe_row(detail) if key == :row
58
+ return unless %i(section record_set column).include?(key)
59
+ return unless detail.is_a?(String) || detail.is_a?(Symbol)
60
+
61
+ detail.to_s.dup.freeze if detail.to_s.match?(Document::KEY_FORMAT)
62
+ end
63
+
64
+ def safe_row(detail)
65
+ detail if detail.is_a?(Integer) && detail.positive?
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ExportKit
4
+ class Table < Document::RecordSet
5
+ def initialize(columns:, rows:, labels: {})
6
+ source = rows.respond_to?(:call) ? rows : ->(&emit) { rows.each(&emit) }
7
+ super(name: "rows", columns:, column_labels: labels, row_source: source)
8
+ end
9
+
10
+ def each_row
11
+ index = 0
12
+ super do |row|
13
+ index += 1
14
+ yield normalize(row)
15
+ end
16
+ rescue Renderers::InvalidDocument => e
17
+ raise e.at(record_set: name, row: index)
18
+ end
19
+
20
+ private
21
+
22
+ def normalize(row)
23
+ validate_keys!(row)
24
+ validate_row_columns!(row.keys.map(&:to_s))
25
+ normalized = row.transform_keys(&:to_s)
26
+ columns.to_h { [it, normalized.fetch(it)] }
27
+ end
28
+
29
+ def validate_row_columns!(keys)
30
+ return if keys.uniq == keys && keys.sort == columns.sort
31
+
32
+ raise Renderers::InvalidDocument.new(error_details: { column: (columns - keys).first })
33
+ end
34
+
35
+ def validate_keys!(row)
36
+ return if row.is_a?(Hash) && row.keys.all? { it.is_a?(String) || it.is_a?(Symbol) }
37
+
38
+ raise Renderers::InvalidDocument
39
+ end
40
+ end
41
+ end