bitgram 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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +7 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +118 -0
  5. data/exe/bitgram +6 -0
  6. data/lib/bitgram/cli/doctor.rb +34 -0
  7. data/lib/bitgram/cli/options.rb +66 -0
  8. data/lib/bitgram/cli.rb +190 -0
  9. data/lib/bitgram/config.rb +58 -0
  10. data/lib/bitgram/diagram.rb +90 -0
  11. data/lib/bitgram/dsl.rb +35 -0
  12. data/lib/bitgram/errors.rb +29 -0
  13. data/lib/bitgram/issue.rb +11 -0
  14. data/lib/bitgram/layout/cell.rb +8 -0
  15. data/lib/bitgram/layout/engine.rb +74 -0
  16. data/lib/bitgram/layout/result.rb +35 -0
  17. data/lib/bitgram/layout/row.rb +9 -0
  18. data/lib/bitgram/loader.rb +59 -0
  19. data/lib/bitgram/model/document.rb +8 -0
  20. data/lib/bitgram/model/field.rb +13 -0
  21. data/lib/bitgram/normalizer.rb +46 -0
  22. data/lib/bitgram/presets/arp.yml +13 -0
  23. data/lib/bitgram/presets/icmp.yml +10 -0
  24. data/lib/bitgram/presets/ipv4.yml +20 -0
  25. data/lib/bitgram/presets/ipv6.yml +12 -0
  26. data/lib/bitgram/presets/tcp.yml +23 -0
  27. data/lib/bitgram/presets/udp.yml +9 -0
  28. data/lib/bitgram/rasterizers/base.rb +43 -0
  29. data/lib/bitgram/rasterizers/image_magick.rb +44 -0
  30. data/lib/bitgram/rasterizers/registry.rb +33 -0
  31. data/lib/bitgram/rasterizers/rsvg.rb +35 -0
  32. data/lib/bitgram/rasterizers/vips.rb +51 -0
  33. data/lib/bitgram/renderers/ascii/box_chars.rb +44 -0
  34. data/lib/bitgram/renderers/ascii.rb +130 -0
  35. data/lib/bitgram/renderers/svg/cells.rb +93 -0
  36. data/lib/bitgram/renderers/svg/decorations.rb +98 -0
  37. data/lib/bitgram/renderers/svg/geometry.rb +53 -0
  38. data/lib/bitgram/renderers/svg/theme.rb +69 -0
  39. data/lib/bitgram/renderers/svg/xml_builder.rb +33 -0
  40. data/lib/bitgram/renderers/svg.rb +137 -0
  41. data/lib/bitgram/text/fitter.rb +132 -0
  42. data/lib/bitgram/text/measurer.rb +31 -0
  43. data/lib/bitgram/themes/dark.yml +11 -0
  44. data/lib/bitgram/themes/default.yml +11 -0
  45. data/lib/bitgram/themes/mono.yml +9 -0
  46. data/lib/bitgram/util/command.rb +53 -0
  47. data/lib/bitgram/validator.rb +174 -0
  48. data/lib/bitgram/version.rb +6 -0
  49. data/lib/bitgram.rb +82 -0
  50. data/schema/bitgram.schema.json +388 -0
  51. metadata +108 -0
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bitgram
4
+ module Layout
5
+ # A field segment in bit coordinates. Ranges are relative to its field.
6
+ Cell = Data.define(:field, :col, :bits, :part, :range)
7
+ end
8
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cell"
4
+ require_relative "row"
5
+ require_relative "result"
6
+
7
+ module Bitgram
8
+ module Layout
9
+ # @api private
10
+ class Engine
11
+ def call(document)
12
+ @document = document
13
+ @width = document.layout.fetch("row_bits")
14
+ @rows = []
15
+ @pending = []
16
+ @col = @position = @row_position = 0
17
+ document.fields.each { |field| place(field) }
18
+ append_row(:normal) unless @pending.empty?
19
+ Result.new(document, @rows.freeze, [].freeze)
20
+ end
21
+
22
+ private
23
+
24
+ def place(field)
25
+ if @col.zero? && field.variable? && field.display_bits == @width
26
+ @pending << Cell.new(field, 0, @width, :whole, nil)
27
+ append_row(:variable)
28
+ @position = @row_position = nil
29
+ elsif block?(field)
30
+ @pending << Cell.new(field, 0, @width, :whole, 0...field.bits)
31
+ append_row(:block, field.bits / @width)
32
+ @position += field.bits if @position
33
+ @row_position = @position
34
+ else
35
+ place_segments(field)
36
+ end
37
+ end
38
+
39
+ def block?(field)
40
+ @document.layout["merge_rows"] && @col.zero? && !field.variable? &&
41
+ field.bits >= 2 * @width && (field.bits % @width).zero?
42
+ end
43
+
44
+ def place_segments(field)
45
+ consumed = 0
46
+ while consumed < field.width_bits
47
+ take = [field.width_bits - consumed, @width - @col].min
48
+ range = field.variable? ? nil : consumed...(consumed + take)
49
+ @pending << Cell.new(field, @col, take, part_of(consumed, take, field.width_bits), range)
50
+ @col += take
51
+ consumed += take
52
+ next unless @col == @width
53
+
54
+ append_row(:normal)
55
+ @row_position = @position && !field.variable? ? @position + consumed : nil
56
+ end
57
+ @position = field.variable? || @position.nil? ? nil : @position + field.bits
58
+ end
59
+
60
+ def append_row(kind, span = 1)
61
+ @rows << Row.new(@rows.size, kind, @pending.freeze, span, @row_position)
62
+ @pending = []
63
+ @col = 0
64
+ end
65
+
66
+ def part_of(consumed, take, total)
67
+ return :whole if consumed.zero? && take == total
68
+ return :head if consumed.zero?
69
+
70
+ consumed + take == total ? :tail : :middle
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bitgram
4
+ module Layout
5
+ # The shared, format-independent layout used by all renderers.
6
+ Result = Data.define(:document, :rows, :issues) do
7
+ # @param cell [Cell] segment whose display label is requested
8
+ # @return [String] label including a continuation or relative bit range
9
+ def cell_label(cell)
10
+ name = cell.field.name
11
+ return name if cell.part == :whole
12
+
13
+ case document.layout["split_label"]
14
+ when "first" then cell.part == :head ? name : "(cont.)"
15
+ when "repeat" then name
16
+ else
17
+ return name unless cell.range
18
+
19
+ name + document.layout["split_suffix"].gsub("{from}", cell.range.begin.to_s)
20
+ .gsub("{to}", (cell.range.end - 1).to_s)
21
+ end
22
+ end
23
+
24
+ # @param row [Row] row whose starting position is requested
25
+ # @return [String] byte offset, bit offset, or blank if unknown/disabled
26
+ def offset_label(row)
27
+ return "" if row.offset_bits.nil? || document.layout["offsets"] == "none"
28
+ return "#{row.offset_bits}b" unless (row.offset_bits % 8).zero?
29
+
30
+ bytes = row.offset_bits / 8
31
+ document.layout["offsets"] == "hex" ? format("0x%02x", bytes) : bytes.to_s
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bitgram
4
+ # Logical rows and cells shared by all output formats.
5
+ module Layout
6
+ # A logical row; span counts physical rows in a merged block.
7
+ Row = Data.define(:index, :kind, :cells, :span, :offset_bits)
8
+ end
9
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+
6
+ module Bitgram
7
+ # @api private
8
+ class Loader
9
+ MAX_BYTES = 1024 * 1024
10
+ Source = Data.define(:data, :line_map, :filename)
11
+
12
+ def load_file(path, format: nil)
13
+ content = File.open(path, "r:UTF-8") { |file| file.read(MAX_BYTES + 1) }
14
+ format ||= :json if File.extname(path).downcase == ".json"
15
+ load_string(content, format: format, filename: path.to_s)
16
+ end
17
+
18
+ def load_string(str, format: nil, filename: nil)
19
+ raise ParseError, "input too large (> 1 MiB)" if str.bytesize > MAX_BYTES
20
+ raise ParseError, "input must be valid UTF-8" unless str.dup.force_encoding("UTF-8").valid_encoding?
21
+
22
+ str = str.dup.force_encoding("UTF-8")
23
+ format = (format || (str.lstrip.start_with?("{", "[") ? :json : :yaml)).to_sym
24
+ case format
25
+ when :json then Source.new(JSON.parse(str, max_nesting: 20), {}, filename)
26
+ when :yaml, :yml
27
+ map = {}
28
+ ast = Psych.parse_stream(str, filename: filename)
29
+ raise ParseError, "expected one YAML document" if ast.children.length > 1
30
+
31
+ walk(ast.children.first&.root, "$", map, 0)
32
+ data = YAML.safe_load(str, permitted_classes: [], aliases: false, filename: filename)
33
+ Source.new(data, map, filename)
34
+ else raise ArgumentError, "unknown input format: #{format}"
35
+ end
36
+ rescue JSON::ParserError, Psych::Exception, EncodingError => e
37
+ raise ParseError, "#{filename || '<input>'}: #{e.message}"
38
+ end
39
+
40
+ private
41
+
42
+ def walk(node, path, map, depth)
43
+ return unless node
44
+ raise ParseError, "input nesting exceeds 20 levels" if depth > 20
45
+
46
+ map[path] = node.start_line + 1
47
+ case node
48
+ when Psych::Nodes::Mapping
49
+ node.children.each_slice(2) do |key, value|
50
+ raise ParseError, "YAML mapping keys must be scalar strings" unless key.is_a?(Psych::Nodes::Scalar)
51
+
52
+ walk(value, "#{path}.#{key.value}", map, depth + 1)
53
+ end
54
+ when Psych::Nodes::Sequence
55
+ node.children.each_with_index { |child, i| walk(child, "#{path}[#{i}]", map, depth + 1) }
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bitgram
4
+ module Model
5
+ # @api private
6
+ Document = Data.define(:title, :description, :layout, :ascii, :svg, :raster, :fields, :notes)
7
+ end
8
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bitgram
4
+ # @api private
5
+ module Model
6
+ # @api private
7
+ Field = Data.define(:id, :name, :short, :bits, :variable, :display_bits,
8
+ :description, :value, :reserved, :optional, :classes, :fill) do
9
+ def variable? = variable
10
+ def width_bits = variable ? display_bits : bits
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bitgram
4
+ # @api private
5
+ class Normalizer
6
+ def call(raw)
7
+ data = Config.deep_merge(Config::DEFAULTS, Config.stringify(raw))
8
+ used = data.fetch("fields").filter_map { |field| field["id"] }.to_h { |id| [id, true] }
9
+ fields = data["fields"].each_with_index.map do |field, i|
10
+ normalize_field(field, i, used, data["layout"]["row_bits"])
11
+ end
12
+ Config.deep_freeze(data)
13
+ Model::Document.new(title: data["title"], description: data["description"],
14
+ layout: data["layout"], ascii: data["ascii"], svg: data["svg"], raster: data["raster"],
15
+ fields: fields.freeze, notes: data.fetch("notes", []).freeze)
16
+ end
17
+
18
+ private
19
+
20
+ def normalize_field(field, index, used, row_bits)
21
+ id = field["id"] || unique_id(field["name"], index, used)
22
+ variable = field["bits"] == "variable"
23
+ Config.deep_freeze(field)
24
+ Model::Field.new(id: id.freeze, name: field["name"], short: field["short"],
25
+ bits: variable ? nil : field.fetch("bits") { field["bytes"] * 8 }, variable: variable,
26
+ display_bits: field.fetch("display_bits", row_bits), description: field["description"],
27
+ value: field["value"], reserved: field.fetch("reserved", false),
28
+ optional: field.fetch("optional", false),
29
+ classes: Array(field["class"]).flat_map(&:split).map(&:freeze).freeze,
30
+ fill: field["fill"])
31
+ end
32
+
33
+ def unique_id(name, index, used)
34
+ base = Config.slug(name)
35
+ base = "field-#{index + 1}" if base.empty?
36
+ id = base
37
+ suffix = 1
38
+ while used[id]
39
+ suffix += 1
40
+ id = "#{base}-#{suffix}"
41
+ end
42
+ used[id] = true
43
+ id
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,13 @@
1
+ version: 1
2
+ title: ARP Header
3
+ description: RFC 826, Ethernet and IPv4
4
+ fields:
5
+ - { name: Hardware Type, bits: 16 }
6
+ - { name: Protocol Type, bits: 16 }
7
+ - { name: HLEN, bits: 8 }
8
+ - { name: PLEN, bits: 8 }
9
+ - { name: Operation, bits: 16 }
10
+ - { name: Sender Hardware Address, bits: 48 }
11
+ - { name: Sender Protocol Address, bits: 32 }
12
+ - { name: Target Hardware Address, bits: 48 }
13
+ - { name: Target Protocol Address, bits: 32 }
@@ -0,0 +1,10 @@
1
+ version: 1
2
+ title: ICMP Echo Header
3
+ description: RFC 792, Echo or Echo Reply
4
+ fields:
5
+ - { name: Type, bits: 8 }
6
+ - { name: Code, bits: 8 }
7
+ - { name: Checksum, bits: 16 }
8
+ - { name: Identifier, bits: 16 }
9
+ - { name: Sequence Number, bits: 16 }
10
+ - { name: Data, bits: variable }
@@ -0,0 +1,20 @@
1
+ version: 1
2
+ title: IPv4 Header
3
+ description: RFC 791, with DSCP and ECN from RFC 2474 and RFC 3168
4
+ fields:
5
+ - { name: Version, bits: 4 }
6
+ - { name: IHL, bits: 4 }
7
+ - { name: DSCP, bits: 6 }
8
+ - { name: ECN, bits: 2 }
9
+ - { name: Total Length, bits: 16 }
10
+ - { name: Identification, bits: 16 }
11
+ - { name: Flags, bits: 3 }
12
+ - { name: Fragment Offset, bits: 13 }
13
+ - { name: Time to Live, bits: 8 }
14
+ - { name: Protocol, bits: 8 }
15
+ - { name: Header Checksum, bits: 16 }
16
+ - { name: Source Address, bits: 32 }
17
+ - { name: Destination Address, bits: 32 }
18
+ - { name: Options, bits: variable, display_bits: 24 }
19
+ - { name: Padding, bits: variable, display_bits: 8 }
20
+ - { name: Data, bits: variable }
@@ -0,0 +1,12 @@
1
+ version: 1
2
+ title: IPv6 Header
3
+ description: RFC 8200, Section 3
4
+ fields:
5
+ - { name: Version, bits: 4 }
6
+ - { name: Traffic Class, bits: 8 }
7
+ - { name: Flow Label, bits: 20 }
8
+ - { name: Payload Length, bits: 16 }
9
+ - { name: Next Header, bits: 8 }
10
+ - { name: Hop Limit, bits: 8 }
11
+ - { name: Source Address, bits: 128 }
12
+ - { name: Destination Address, bits: 128 }
@@ -0,0 +1,23 @@
1
+ version: 1
2
+ title: TCP Header
3
+ description: RFC 9293, Section 3.1
4
+ fields:
5
+ - { name: Source Port, bits: 16 }
6
+ - { name: Destination Port, bits: 16 }
7
+ - { name: Sequence Number, bits: 32 }
8
+ - { name: Acknowledgment Number, bits: 32 }
9
+ - { name: Data Offset, bits: 4 }
10
+ - { name: Rsrvd, bits: 4, reserved: true }
11
+ - { name: CWR, bits: 1, description: Congestion Window Reduced }
12
+ - { name: ECE, bits: 1, description: ECN-Echo }
13
+ - { name: URG, bits: 1, description: Urgent pointer field is significant }
14
+ - { name: ACK, bits: 1, description: Acknowledgment field is significant }
15
+ - { name: PSH, bits: 1, description: Push function }
16
+ - { name: RST, bits: 1, description: Reset the connection }
17
+ - { name: SYN, bits: 1, description: Synchronize sequence numbers }
18
+ - { name: FIN, bits: 1, description: No more data from sender }
19
+ - { name: Window, bits: 16 }
20
+ - { name: Checksum, bits: 16 }
21
+ - { name: Urgent Pointer, bits: 16 }
22
+ - { name: Options, bits: variable }
23
+ - { name: Data, bits: variable }
@@ -0,0 +1,9 @@
1
+ version: 1
2
+ title: UDP Header
3
+ description: RFC 768
4
+ fields:
5
+ - { name: Source Port, bits: 16 }
6
+ - { name: Destination Port, bits: 16 }
7
+ - { name: Length, bits: 16 }
8
+ - { name: Checksum, bits: 16 }
9
+ - { name: Data, bits: variable }
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bitgram
4
+ # @api private
5
+ module Rasterizers
6
+ # @api private
7
+ class Base
8
+ PROBE = '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>'
9
+
10
+ attr_writer :timeout
11
+
12
+ def initialize(timeout: 30)
13
+ @timeout = timeout
14
+ end
15
+
16
+ def name
17
+ self.class.name.split("::").last.downcase
18
+ end
19
+
20
+ def available?
21
+ raise NotImplementedError
22
+ end
23
+
24
+ def supports?(format)
25
+ %i[png jpeg].include?(format.to_sym)
26
+ end
27
+
28
+ def rasterize(*)
29
+ raise NotImplementedError
30
+ end
31
+
32
+ def version
33
+ "unknown"
34
+ end
35
+
36
+ private
37
+
38
+ def check_format(format)
39
+ raise RasterizeError, "#{name} does not support #{format}" unless supports?(format)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "../util/command"
5
+
6
+ module Bitgram
7
+ module Rasterizers
8
+ # @api private
9
+ class ImageMagick < Base
10
+ def available?
11
+ return @available unless @available.nil?
12
+
13
+ @binary = Util::Command.which("magick") || Util::Command.which("convert")
14
+ @available = false
15
+ return false unless @binary
16
+
17
+ Util::Command.run([@binary, "svg:-", "png:-"], stdin_data: PROBE, timeout: @timeout)
18
+ @available = true
19
+ rescue RasterizeError
20
+ false
21
+ end
22
+
23
+ def rasterize(svg, path:, format:, scale:, background:, quality:)
24
+ check_format(format)
25
+ raise RasterizerNotFound, "ImageMagick with SVG support is unavailable" unless available?
26
+
27
+ color = background == "transparent" ? "none" : background || "none"
28
+ command = [@binary, "-density", (96 * scale).to_s, "-background", color, "svg:-"]
29
+ command << "-flatten" unless color == "none"
30
+ command.push("-quality", quality.to_s, "#{format}:#{File.expand_path(path)}")
31
+ Util::Command.run(command, stdin_data: svg, timeout: @timeout)
32
+ end
33
+
34
+ def version
35
+ if available?
36
+ Util::Command.run([@binary, "-version"], stdin_data: "",
37
+ timeout: @timeout).lines.first.strip
38
+ else
39
+ "unavailable"
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "vips"
4
+ require_relative "rsvg"
5
+ require_relative "image_magick"
6
+
7
+ module Bitgram
8
+ module Rasterizers
9
+ # @api private
10
+ module Registry
11
+ INSTALL = "Install a rasterizer: macOS: brew install vips librsvg imagemagick; " \
12
+ "Ubuntu: sudo apt-get install libvips-dev librsvg2-bin imagemagick. " \
13
+ "For vips, also add gem 'ruby-vips' to your Gemfile."
14
+
15
+ module_function
16
+
17
+ def all
18
+ @all ||= { vips: Vips.new, rsvg: Rsvg.new, imagemagick: ImageMagick.new }
19
+ end
20
+
21
+ def select(name = :auto, format:, timeout: 30)
22
+ candidates = name.to_s == "auto" ? all.values : [all[name.to_sym]].compact
23
+ converter = candidates.find do |item|
24
+ item.timeout = timeout
25
+ item.supports?(format) && item.available?
26
+ end
27
+ raise RasterizerNotFound, "No #{name} rasterizer available for #{format}. #{INSTALL}" unless converter
28
+
29
+ converter.dup.tap { |selected| selected.timeout = timeout }
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "../util/command"
5
+
6
+ module Bitgram
7
+ module Rasterizers
8
+ # @api private
9
+ class Rsvg < Base
10
+ def available?
11
+ return @available unless @available.nil?
12
+
13
+ @binary = Util::Command.which("rsvg-convert")
14
+ @available = !@binary.nil?
15
+ end
16
+
17
+ def supports?(format)
18
+ format.to_sym == :png
19
+ end
20
+
21
+ def rasterize(svg, path:, format:, scale:, background:, **)
22
+ check_format(format)
23
+ raise RasterizerNotFound, "rsvg-convert is unavailable" unless available?
24
+
25
+ command = [@binary, "--format", "png", "--zoom", scale.to_s, "--output", File.expand_path(path)]
26
+ command.push("--background-color", background) if background && background != "transparent"
27
+ Util::Command.run(command, stdin_data: svg, timeout: @timeout)
28
+ end
29
+
30
+ def version
31
+ available? ? Util::Command.run([@binary, "--version"], stdin_data: "", timeout: @timeout).strip : "unavailable"
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Bitgram
6
+ module Rasterizers
7
+ # @api private
8
+ class Vips < Base
9
+ def available?
10
+ return @available unless @available.nil?
11
+
12
+ @available = false
13
+ require "vips"
14
+ ::Vips::Image.svgload_buffer(PROBE)
15
+ @available = true
16
+ rescue LoadError, StandardError
17
+ false
18
+ end
19
+
20
+ def rasterize(svg, path:, format:, scale:, background:, quality:)
21
+ check_format(format)
22
+ raise RasterizerNotFound, "ruby-vips with SVG support is unavailable" unless available?
23
+
24
+ image = ::Vips::Image.svgload_buffer(svg, scale: scale)
25
+ image = image.flatten(background: rgb(background)) if background && background != "transparent"
26
+ if format.to_sym == :png
27
+ image.pngsave(path)
28
+ else
29
+ image.jpegsave(path, Q: quality)
30
+ end
31
+ rescue StandardError => e
32
+ raise if e.is_a?(Bitgram::Error)
33
+
34
+ raise RasterizeError, "vips failed: #{e.message}"
35
+ end
36
+
37
+ def version
38
+ available? ? "libvips #{::Vips.version_string}, svgload: yes" : "unavailable"
39
+ end
40
+
41
+ private
42
+
43
+ def rgb(color)
44
+ # Let the SVG backend interpret the same validated CSS colors as the diagram.
45
+ swatch = '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1">' \
46
+ "<rect width=\"1\" height=\"1\" fill=\"#{color}\"/></svg>"
47
+ ::Vips::Image.svgload_buffer(swatch).getpoint(0, 0).first(3)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bitgram
4
+ module Renderers
5
+ class Ascii
6
+ # @api private
7
+ module BoxChars
8
+ module_function
9
+
10
+ def border(width, above:, below:, unicode:)
11
+ return "#{'+-' * width}+" unless unicode
12
+
13
+ top = above.nil?
14
+ bottom = below.nil?
15
+ left, right = if top
16
+ %w[┌ ┐]
17
+ else
18
+ (bottom ? %w[└ ┘] : %w[├ ┤])
19
+ end
20
+ upper = boundaries(above)
21
+ lower = boundaries(below)
22
+ middle = (1...(2 * width)).map do |column|
23
+ next "─" if column.odd?
24
+
25
+ up = upper.include?(column / 2)
26
+ down = lower.include?(column / 2)
27
+ if up
28
+ down ? "┼" : "┴"
29
+ else
30
+ (down ? "┬" : "─")
31
+ end
32
+ end.join
33
+ left + middle + right
34
+ end
35
+
36
+ def boundaries(row)
37
+ return [] unless row
38
+
39
+ row.cells.flat_map { |cell| [cell.col, cell.col + cell.bits] }
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end