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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +118 -0
- data/exe/bitgram +6 -0
- data/lib/bitgram/cli/doctor.rb +34 -0
- data/lib/bitgram/cli/options.rb +66 -0
- data/lib/bitgram/cli.rb +190 -0
- data/lib/bitgram/config.rb +58 -0
- data/lib/bitgram/diagram.rb +90 -0
- data/lib/bitgram/dsl.rb +35 -0
- data/lib/bitgram/errors.rb +29 -0
- data/lib/bitgram/issue.rb +11 -0
- data/lib/bitgram/layout/cell.rb +8 -0
- data/lib/bitgram/layout/engine.rb +74 -0
- data/lib/bitgram/layout/result.rb +35 -0
- data/lib/bitgram/layout/row.rb +9 -0
- data/lib/bitgram/loader.rb +59 -0
- data/lib/bitgram/model/document.rb +8 -0
- data/lib/bitgram/model/field.rb +13 -0
- data/lib/bitgram/normalizer.rb +46 -0
- data/lib/bitgram/presets/arp.yml +13 -0
- data/lib/bitgram/presets/icmp.yml +10 -0
- data/lib/bitgram/presets/ipv4.yml +20 -0
- data/lib/bitgram/presets/ipv6.yml +12 -0
- data/lib/bitgram/presets/tcp.yml +23 -0
- data/lib/bitgram/presets/udp.yml +9 -0
- data/lib/bitgram/rasterizers/base.rb +43 -0
- data/lib/bitgram/rasterizers/image_magick.rb +44 -0
- data/lib/bitgram/rasterizers/registry.rb +33 -0
- data/lib/bitgram/rasterizers/rsvg.rb +35 -0
- data/lib/bitgram/rasterizers/vips.rb +51 -0
- data/lib/bitgram/renderers/ascii/box_chars.rb +44 -0
- data/lib/bitgram/renderers/ascii.rb +130 -0
- data/lib/bitgram/renderers/svg/cells.rb +93 -0
- data/lib/bitgram/renderers/svg/decorations.rb +98 -0
- data/lib/bitgram/renderers/svg/geometry.rb +53 -0
- data/lib/bitgram/renderers/svg/theme.rb +69 -0
- data/lib/bitgram/renderers/svg/xml_builder.rb +33 -0
- data/lib/bitgram/renderers/svg.rb +137 -0
- data/lib/bitgram/text/fitter.rb +132 -0
- data/lib/bitgram/text/measurer.rb +31 -0
- data/lib/bitgram/themes/dark.yml +11 -0
- data/lib/bitgram/themes/default.yml +11 -0
- data/lib/bitgram/themes/mono.yml +9 -0
- data/lib/bitgram/util/command.rb +53 -0
- data/lib/bitgram/validator.rb +174 -0
- data/lib/bitgram/version.rb +6 -0
- data/lib/bitgram.rb +82 -0
- data/schema/bitgram.schema.json +388 -0
- metadata +108 -0
|
@@ -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,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,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,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
|