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,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../text/fitter"
4
+ require_relative "ascii/box_chars"
5
+
6
+ module Bitgram
7
+ module Renderers
8
+ # @api private
9
+ class Ascii
10
+ attr_reader :issues
11
+
12
+ def initialize(result, **options)
13
+ @result = result
14
+ @document = result.document
15
+ @config = @document.ascii.merge(options.transform_keys(&:to_s))
16
+ @width = @document.layout.fetch("row_bits")
17
+ @unicode = @config["style"].to_s == "unicode"
18
+ @measurer = Text::AsciiMeasurer.new(ambiguous: @config["ambiguous_width"])
19
+ @offset_width = @result.rows.map { |row| @result.offset_label(row).size }.max || 0
20
+ @issues = []
21
+ end
22
+
23
+ def render
24
+ @fitter = Text::Fitter.new(measurer: @measurer, footnote_marker: @config["footnote_marker"])
25
+ lines = []
26
+ lines << @document.title if @document.title && @config["title"].to_s == "top"
27
+ lines.concat(header.map { |line| gutter + line }) if @document.layout["bit_numbers"]
28
+ lines.concat(table_lines)
29
+ lines.concat(@fitter.footnotes).concat(@document.notes)
30
+ lines << @document.title if @document.title && @config["title"].to_s == "bottom"
31
+ @issues = @fitter.issues
32
+ indented = lines.flat_map { |line| line.split("\n") }
33
+ .map { |line| ((" " * @config["indent"]) + line).rstrip }
34
+ "#{indented.join("\n")}\n"
35
+ end
36
+
37
+ private
38
+
39
+ def table_lines
40
+ lines = []
41
+ @result.rows.each_with_index do |row, index|
42
+ lines << (gutter + border(@result.rows[index - 1], row, top: index.zero?))
43
+ row_lines(row).each_with_index do |line, inner_index|
44
+ offset = inner_index.zero? ? @result.offset_label(row) : ""
45
+ lines << (gutter(offset) + line)
46
+ end
47
+ end
48
+ lines << (gutter + border(@result.rows.last, nil))
49
+ lines
50
+ end
51
+
52
+ def gutter(offset = "")
53
+ @offset_width.positive? ? "#{offset.rjust(@offset_width)} " : ""
54
+ end
55
+
56
+ def border(above, below, top: false)
57
+ BoxChars.border(@width, above: top ? nil : above, below: below, unicode: @unicode)
58
+ end
59
+
60
+ def header
61
+ tens = Array.new((2 * @width) + 1, " ")
62
+ ones = Array.new((2 * @width) + 1, " ")
63
+ @width.times do |column|
64
+ number = @document.layout["bit_order"] == "lsb0" ? @width - 1 - column : column
65
+ tens[(2 * column) + 1, (number / 10).to_s.size] = (number / 10).to_s.chars if (number % 10).zero?
66
+ ones[(2 * column) + 1] = (number % 10).to_s
67
+ end
68
+ [tens.join.rstrip, ones.join.rstrip]
69
+ end
70
+
71
+ def row_lines(row)
72
+ fits = row.cells.map { |cell| fit_cell(cell) }
73
+ height = [fits.map { |fit| fit.lines.size }.max, minimum_height(row)].max
74
+ Array.new(height) do |index|
75
+ edge = edge_for(row, index)
76
+ content = row.cells.zip(fits).map do |cell, fit|
77
+ cell_line(cell, fit, index, height) + edge
78
+ end.join
79
+ remaining = @width - row.cells.sum(&:bits)
80
+ content += (" " * ((2 * remaining) - 1)) + edge if remaining.positive?
81
+ edge + content
82
+ end
83
+ end
84
+
85
+ def cell_line(cell, fit, index, height)
86
+ line_index = index - ((height - fit.lines.size) / 2)
87
+ label = line_index >= 0 ? fit.lines[line_index].to_s : ""
88
+ center(label, (2 * cell.bits) - 1)
89
+ end
90
+
91
+ def minimum_height(row)
92
+ return (2 * row.span) - 1 if row.kind == :block
93
+ return @unicode ? 1 : 3 if row.kind == :variable
94
+
95
+ 1
96
+ end
97
+
98
+ def edge_for(row, index)
99
+ if row.kind == :variable
100
+ return "┊" if @unicode
101
+
102
+ return index == 1 ? "\\" : "/" if @config["variable_edge"].to_s == "slash"
103
+
104
+ return @config["variable_edge"].to_s == "colon" ? ":" : "."
105
+ end
106
+ return @unicode ? "│" : "+" if row.kind == :block && index.odd?
107
+
108
+ @unicode ? "│" : "|"
109
+ end
110
+
111
+ def fit_cell(cell)
112
+ width = (2 * cell.bits) - 1
113
+ label = @result.cell_label(cell)
114
+ if cell.part != :whole && @document.layout["split_label"] == "range" && @measurer.width(label) > width
115
+ label = cell.field.name
116
+ end
117
+ label += " (=#{cell.field.value})" unless cell.field.value.nil?
118
+ @fitter.fit(text: label, short: cell.field.short, inner_width: width,
119
+ max_lines: @config["max_label_lines"], order: @config["fit_order"],
120
+ description: cell.field.description || cell.field.name,
121
+ path: "$.fields[#{@document.fields.index(cell.field)}].name")
122
+ end
123
+
124
+ def center(text, width)
125
+ padding = [width - @measurer.width(text), 0].max
126
+ (" " * (padding / 2)) + text + (" " * (padding - (padding / 2)))
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bitgram
4
+ module Renderers
5
+ class Svg
6
+ # @api private
7
+ module Cells
8
+ private
9
+
10
+ def draw_cell(cell, row, y, height, fits)
11
+ field = cell.field
12
+ x = @geometry.x + (cell.col * @options["bit_width"])
13
+ width = cell.bits * @options["bit_width"]
14
+ attributes = { class: (["bg-field"] + field.classes).join(" "), "data-field-id": field.id,
15
+ "data-bits": field.variable? ? "variable" : field.bits, "data-part": cell.part }
16
+ @output << "<g #{XmlBuilder.attributes(attributes)}>"
17
+ label = "#{field.name} (#{field.variable? ? 'variable' : field.bits} bits)"
18
+ label += ": #{field.description}" if field.description
19
+ emit("title", {}, label)
20
+ draw_rectangle(field, row, x: x, y: y, width: width, height: height)
21
+ draw_label(fits, x + (width / 2.0), y + (height / 2.0))
22
+ @output << "</g>"
23
+ end
24
+
25
+ def draw_rectangle(field, row, x:, y:, width:, height:)
26
+ rectangle = { class: "bg-cell", x: x, y: y, width: width, height: height,
27
+ fill: @theme.field_fill(field, @field_indexes.fetch(field.id)),
28
+ stroke: @theme["stroke"], "stroke-width": 1 }
29
+ rectangle["stroke-dasharray"] = "4 3" if field.optional
30
+ if row.kind == :variable
31
+ rectangle[:stroke] = "none"
32
+ rectangle[:class] += " bg-variable-cell"
33
+ end
34
+ emit("rect", rectangle)
35
+ if field.reserved
36
+ emit("rect",
37
+ { class: "bg-hatch-overlay", x: x, y: y, width: width, height: height, fill: "url(##{@prefix}-hatch)",
38
+ "pointer-events": "none" })
39
+ end
40
+ draw_variable_edges(field, x, y, width, height) if row.kind == :variable
41
+ end
42
+
43
+ def draw_variable_edges(field, x, y, width, height)
44
+ emit("line", { class: "bg-edge", x1: x, y1: y, x2: x + width, y2: y, stroke: @theme["stroke"] })
45
+ emit("line",
46
+ { class: "bg-edge", x1: x, y1: y + height, x2: x + width, y2: y + height, stroke: @theme["stroke"] })
47
+ [x, x + width].each do |edge|
48
+ steps = 6
49
+ path = "M #{fmt(edge)} #{fmt(y)}"
50
+ (1..steps).each do |index|
51
+ delta = if index == steps
52
+ 0
53
+ else
54
+ (index.odd? ? 2 : -2)
55
+ end
56
+ path += " L #{fmt(edge + delta)} #{fmt(y + (height * index / steps))}"
57
+ end
58
+ emit("path",
59
+ { class: "bg-edge", d: path, fill: "none", stroke: @theme["stroke"],
60
+ "stroke-dasharray": field.optional ? "4 3" : nil })
61
+ end
62
+ end
63
+
64
+ def draw_label(fits, x, center)
65
+ sizes = fits.each_with_index.map do |fit, index|
66
+ fit && ((fit.font_size || @options["font_size"]) * (index.zero? ? 1 : 0.85))
67
+ end
68
+ total = fits.each_with_index.sum { |fit, index| fit ? fit.lines.length * sizes[index] * 1.2 : 0 }
69
+ top = center - (total / 2.0)
70
+ fits.each_with_index do |fit, index|
71
+ next unless fit
72
+
73
+ size = sizes[index]
74
+ baseline = top + (size * 0.95)
75
+ klass = index.zero? ? "bg-label" : "bg-value"
76
+ draw_label_lines(fit.lines, x, baseline, size, klass)
77
+ top += fit.lines.length * size * 1.2
78
+ end
79
+ end
80
+
81
+ def draw_label_lines(lines, x, baseline, size, klass)
82
+ if lines.length == 1
83
+ emit("text", text_attributes(klass, size).merge(x: x, y: baseline), lines.first)
84
+ else
85
+ @output << "<text #{XmlBuilder.attributes(text_attributes(klass, size))}>"
86
+ lines.each_with_index { |line, index| emit("tspan", { x: x, y: baseline + (index * size * 1.2) }, line) }
87
+ @output << "</text>"
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bitgram
4
+ module Renderers
5
+ class Svg
6
+ # @api private
7
+ module Decorations
8
+ private
9
+
10
+ def draw_definitions
11
+ @output << "<defs>"
12
+ attributes = { id: "#{@prefix}-hatch", width: 6, height: 6,
13
+ patternUnits: "userSpaceOnUse", patternTransform: "rotate(45)" }
14
+ @output << "<pattern #{XmlBuilder.attributes(attributes)}>"
15
+ emit("line", { x1: 0, y1: 0, x2: 0, y2: 6, class: "bg-hatch", stroke: @theme["hatch"], "stroke-width": 1 })
16
+ @output << "</pattern>"
17
+ @output << "</defs>"
18
+ end
19
+
20
+ def draw_styles
21
+ css = theme_css(@theme)
22
+ if @options["theme"] == "auto"
23
+ css += "\n@media (prefers-color-scheme: dark) {\n#{theme_css(Theme.new('dark'))}\n}"
24
+ end
25
+ emit("style", {}, css)
26
+ end
27
+
28
+ def theme_css(theme)
29
+ selector = %([id="#{@prefix}-diagram"])
30
+ rules = ["#{selector} { background: #{theme['background']}; }",
31
+ "#{selector} text { font-family: #{@options['font_family']}; fill: #{theme['text']}; }",
32
+ "#{selector} .bg-cell, #{selector} .bg-edge, #{selector} .bg-tick " \
33
+ "{ stroke: #{theme['stroke']}; stroke-width: 1; }",
34
+ "#{selector} .bg-hatch { stroke: #{theme['hatch']}; }",
35
+ "#{selector} .bg-variable-cell { stroke: none; }",
36
+ "#{selector} .bg-bit-numbers text, #{selector} .bg-offset { fill: #{theme['header_text']}; }"]
37
+ @document.fields.each_with_index do |field, index|
38
+ rules << %(#{selector} [data-field-id="#{field.id}"] .bg-cell { fill: #{theme.field_fill(field, index)}; })
39
+ end
40
+ rules.join("\n")
41
+ end
42
+
43
+ def draw_header
44
+ margin = @options["margin"]
45
+ if @geometry.title_height.positive?
46
+ emit("text", text_attributes("bg-title", @options["font_size"] * 1.2)
47
+ .merge(x: width / 2.0, y: margin + (@options["font_size"] * 1.4)), @document.title)
48
+ end
49
+ return unless @document.layout["bit_numbers"]
50
+
51
+ draw_numbers(margin + @geometry.title_height)
52
+ end
53
+
54
+ def draw_numbers(top)
55
+ @output << '<g class="bg-bit-numbers">'
56
+ @document.layout["row_bits"].times do |col|
57
+ number = @document.layout["bit_order"] == "lsb0" ? @document.layout["row_bits"] - 1 - col : col
58
+ x = @geometry.x + ((col + 0.5) * @options["bit_width"])
59
+ attributes = text_attributes("bg-bit-number", 11).merge(x: x, fill: @theme["header_text"])
60
+ if @options["bit_number_style"] == "rfc"
61
+ emit("text", attributes.merge(y: top + 12), number / 10) if (number % 10).zero?
62
+ emit("text", attributes.merge(y: top + 28), number % 10)
63
+ else
64
+ emit("text", attributes.merge(y: top + 16), number)
65
+ end
66
+ end
67
+ @output << "</g>"
68
+ end
69
+
70
+ def draw_ticks(row, y, height)
71
+ width = @document.layout["row_bits"]
72
+ (1...width).each do |col|
73
+ x = @geometry.x + (col * @options["bit_width"])
74
+ emit("line", { class: "bg-tick", x1: x, y1: y, x2: x, y2: y + 4, stroke: @theme["stroke"] })
75
+ end
76
+ return unless row.kind == :block
77
+
78
+ (1...row.span).each do |index|
79
+ boundary = y + (height * index / row.span)
80
+ [@geometry.x, @geometry.x + (width * @options["bit_width"]) - 6].each do |x|
81
+ emit("line", { class: "bg-tick", x1: x, y1: boundary, x2: x + 6, y2: boundary, stroke: @theme["stroke"] })
82
+ end
83
+ end
84
+ end
85
+
86
+ def draw_offset(row, y, height)
87
+ label = @result.offset_label(row)
88
+ return if label.empty?
89
+
90
+ baseline = y + ([height, @options["row_height"]].min / 2) + (@options["font_size"] * 0.35)
91
+ attributes = text_attributes("bg-offset").merge(x: @geometry.x - 8, y: baseline,
92
+ "text-anchor": "end", fill: @theme["header_text"])
93
+ emit("text", attributes, label)
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bitgram
4
+ module Renderers
5
+ class Svg
6
+ # @api private
7
+ class Geometry
8
+ attr_reader :width, :height, :gutter, :title_height, :header_height, :row_heights, :x, :y
9
+
10
+ def initialize(result, options, fits, footnotes, measurer)
11
+ @result = result
12
+ @options = options
13
+ measure_header(measurer)
14
+ @row_heights = result.rows.each_with_index.map { |row, index| row_height(row, fits[index]) }
15
+ @x = options["margin"] + gutter
16
+ @y = options["margin"] + title_height + header_height
17
+ @width = (options["margin"] * 2) + gutter + (result.document.layout["row_bits"] * options["bit_width"])
18
+ @height = (options["margin"] * 2) + title_height + header_height + row_heights.sum + footer_height(footnotes)
19
+ end
20
+
21
+ private
22
+
23
+ def footer_height(footnotes)
24
+ footnotes.empty? ? 0 : 8 + (footnotes.length * @options["font_size"] * 1.4)
25
+ end
26
+
27
+ def measure_header(measurer)
28
+ result = @result
29
+ options = @options
30
+ @title_height = options["show_title"] && result.document.title ? (options["font_size"] * 2) + 2 : 0
31
+ @header_height = if result.document.layout["bit_numbers"]
32
+ options["bit_number_style"] == "rfc" ? 36 : 24
33
+ else
34
+ 0
35
+ end
36
+ @gutter = if result.document.layout["offsets"] == "none"
37
+ 0
38
+ else
39
+ result.rows.map { |row| measurer.width(result.offset_label(row)) }.max.to_f + 12
40
+ end
41
+ end
42
+
43
+ def row_height(row, fits)
44
+ content_height = fits.map do |label, value|
45
+ [label, value].compact.sum { |fit| fit.lines.length * (fit.font_size || @options["font_size"]) * 1.2 } + 8
46
+ end.max || 0
47
+ base = @options["row_height"] * (row.kind == :variable ? 1.5 : row.span)
48
+ [base, content_height].max
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../loader"
4
+
5
+ module Bitgram
6
+ module Renderers
7
+ class Svg
8
+ # @api private
9
+ class Theme
10
+ COLORS = %w[background stroke text header_text cell_fill reserved_fill hatch].freeze
11
+ COLOR = /\A(?:\#[0-9a-fA-F]{3}|\#[0-9a-fA-F]{6}|\#[0-9a-fA-F]{8}|[a-zA-Z]+|rgba?\([0-9., %]+\))\z/
12
+ DIRECTORY = File.expand_path("../../themes", __dir__)
13
+
14
+ attr_reader :data
15
+
16
+ def initialize(name)
17
+ defaults = read(File.join(DIRECTORY, "default.yml"))
18
+ path = %w[default dark mono].include?(name.to_s) ? File.join(DIRECTORY, "#{name}.yml") : name.to_s
19
+ @data = defaults.merge(read(path))
20
+ validate
21
+ end
22
+
23
+ def [](key)
24
+ data.fetch(key)
25
+ end
26
+
27
+ def field_fill(field, index)
28
+ classes = field.classes.filter_map { |name| self["classes"][name]&.fetch("fill", nil) }
29
+ palette = self["palette"]
30
+ field.fill || classes.last || (palette[index % palette.size] unless palette.empty?) ||
31
+ self[field.reserved ? "reserved_fill" : "cell_fill"]
32
+ end
33
+
34
+ private
35
+
36
+ def read(path)
37
+ result = Loader.new.load_file(path).data
38
+ raise RenderError, "theme must be an object" unless result.is_a?(Hash)
39
+
40
+ result
41
+ rescue ParseError => e
42
+ raise RenderError, "invalid theme: #{e.message}"
43
+ end
44
+
45
+ def validate
46
+ unknown = data.keys - COLORS - %w[palette classes]
47
+ raise RenderError, "unknown theme keys: #{unknown.join(', ')}" unless unknown.empty?
48
+ raise RenderError, "theme palette must be an array" unless self["palette"].is_a?(Array)
49
+ raise RenderError, "theme classes must be an object" unless self["classes"].is_a?(Hash)
50
+
51
+ COLORS.each { |key| validate_color(self[key]) }
52
+ self["palette"].each { |color| validate_color(color) }
53
+ self["classes"].each do |name, rules|
54
+ unless name.is_a?(String) && /\A[a-zA-Z_][a-zA-Z0-9_-]*\z/.match?(name) &&
55
+ rules.is_a?(Hash) && (rules.keys - ["fill"]).empty?
56
+ raise RenderError, "theme class must have a safe name and fill property"
57
+ end
58
+
59
+ rules.each_value { |color| validate_color(color) }
60
+ end
61
+ end
62
+
63
+ def validate_color(color)
64
+ raise RenderError, "invalid theme color: #{color.inspect}" unless color.is_a?(String) && COLOR.match?(color)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bitgram
4
+ module Renderers
5
+ class Svg
6
+ # @api private
7
+ module XmlBuilder
8
+ module_function
9
+
10
+ def escape(value)
11
+ value.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
12
+ .gsub('"', "&quot;").gsub("'", "&apos;")
13
+ end
14
+
15
+ def number(value)
16
+ rounded = value.round(2)
17
+ rounded == rounded.round ? rounded.round.to_s : format("%.2f", rounded).sub(/0+\z/, "")
18
+ end
19
+
20
+ def attributes(values)
21
+ values.compact.map do |key, value|
22
+ %(#{key}="#{escape(value.is_a?(Numeric) ? number(value) : value)}")
23
+ end.join(" ")
24
+ end
25
+
26
+ def element(name, attributes = {}, text = nil)
27
+ attrs = attributes.empty? ? "" : " #{self.attributes(attributes)}"
28
+ text.nil? ? "<#{name}#{attrs}/>" : "<#{name}#{attrs}>#{escape(text)}</#{name}>"
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require_relative "../text/fitter"
6
+ require_relative "svg/xml_builder"
7
+ require_relative "svg/theme"
8
+ require_relative "svg/geometry"
9
+ require_relative "svg/decorations"
10
+ require_relative "svg/cells"
11
+
12
+ module Bitgram
13
+ # @api private
14
+ module Renderers
15
+ # @api private
16
+ class Svg
17
+ include Decorations
18
+ include Cells
19
+
20
+ attr_reader :issues, :width, :height
21
+
22
+ def initialize(result, **options)
23
+ @result = result
24
+ @document = result.document
25
+ @options = @document.svg.merge(options.transform_keys(&:to_s).transform_values do |value|
26
+ value.is_a?(Symbol) ? value.to_s : value
27
+ end)
28
+ @issues = []
29
+ end
30
+
31
+ def render
32
+ prepare
33
+ @output = []
34
+ attributes = { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 #{fmt(width)} #{fmt(height)}",
35
+ width: width, height: height, role: "img", "aria-labelledby": "#{@prefix}-title #{@prefix}-desc",
36
+ class: "bitgram", id: "#{@prefix}-diagram" }
37
+ @output << "<svg #{XmlBuilder.attributes(attributes)}>"
38
+ emit("title", { id: "#{@prefix}-title" }, @document.title || "Bit-field diagram")
39
+ emit("desc", { id: "#{@prefix}-desc" }, @document.description || "Bit-field layout")
40
+ draw_definitions
41
+ draw_styles unless @options["inline_styles"]
42
+ draw_header
43
+ draw_rows
44
+ draw_footer
45
+ @output << "</svg>"
46
+ @issues = @fitter.issues
47
+ "#{@output.join("\n")}\n"
48
+ end
49
+
50
+ private
51
+
52
+ def prepare
53
+ selected_theme = @options["theme"] == "auto" ? "default" : @options["theme"]
54
+ @theme = Theme.new(selected_theme)
55
+ @prefix = @options["id_prefix"] || default_prefix
56
+ @field_indexes = @document.fields.each_with_index.to_h { |field, index| [field.id, index] }
57
+ @measurer = Text::SvgMeasurer.new(font_size: @options["font_size"], ratio: @options["char_width_ratio"])
58
+ @fitter = Text::Fitter.new(measurer: @measurer, font_size: @options["font_size"],
59
+ min_font_size: @options["min_font_size"],
60
+ footnote_marker: @document.ascii["footnote_marker"])
61
+ @fits = @result.rows.map { |row| row.cells.map { |cell| fit_cell(cell) } }
62
+ @footer = @fitter.footnotes + @document.notes
63
+ @geometry = Geometry.new(@result, @options, @fits, @footer, @measurer)
64
+ @width = @geometry.width
65
+ @height = @geometry.height
66
+ end
67
+
68
+ def fit_cell(cell)
69
+ inner_width = [(cell.bits * @options["bit_width"]) - 8, 0].max
70
+ label = @result.cell_label(cell)
71
+ if cell.part != :whole && @document.layout["split_label"] == "range" && @measurer.width(label) > inner_width
72
+ label = cell.field.name
73
+ end
74
+ arguments = { inner_width: inner_width, max_lines: @document.ascii["max_label_lines"],
75
+ order: @options["fit_order"], path: "$.fields[#{@field_indexes.fetch(cell.field.id)}].name" }
76
+ fit = @fitter.fit(text: label, short: cell.field.short, description: cell.field.description, **arguments)
77
+ value = cell.field.value
78
+ value_fit = @fitter.fit(text: "= #{value}", **arguments) unless value.nil?
79
+ [fit, value_fit]
80
+ end
81
+
82
+ def default_prefix
83
+ slug = @document.title.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")
84
+ return slug unless slug.empty?
85
+
86
+ content = @document.to_h.merge(fields: @document.fields.map(&:to_h))
87
+ "bitgram-#{Digest::SHA256.hexdigest(JSON.generate(content))[0, 8]}"
88
+ end
89
+
90
+ def draw_rows
91
+ @output << '<g class="bg-rows">'
92
+ y = @geometry.y
93
+ @result.rows.each_with_index do |row, index|
94
+ row_height = @geometry.row_heights[index]
95
+ attributes = { class: "bg-row", "data-row": row.index,
96
+ "data-offset": row.offset_bits && (row.offset_bits / 8.0) }
97
+ @output << "<g #{XmlBuilder.attributes(attributes)}>"
98
+ row.cells.each_with_index { |cell, col| draw_cell(cell, row, y, row_height, @fits[index][col]) }
99
+ draw_ticks(row, y, row_height) if @options["ticks"]
100
+ draw_offset(row, y, row_height)
101
+ @output << "</g>"
102
+ y += row_height
103
+ end
104
+ @output << "</g>"
105
+ end
106
+
107
+ def draw_footer
108
+ return if @footer.empty?
109
+
110
+ y = @geometry.y + @geometry.row_heights.sum + 8 + @options["font_size"]
111
+ @output << '<g class="bg-footnotes">'
112
+ @footer.each do |line|
113
+ emit("text", text_attributes("bg-footnote").merge(x: @geometry.x, y: y, "text-anchor": "start"), line)
114
+ y += @options["font_size"] * 1.4
115
+ end
116
+ @output << "</g>"
117
+ end
118
+
119
+ def text_attributes(klass, size = @options["font_size"])
120
+ attributes = { class: klass, "font-size": size, "text-anchor": "middle" }
121
+ if @options["inline_styles"]
122
+ attributes[:"font-family"] = @options["font_family"]
123
+ attributes[:fill] = @theme["text"]
124
+ end
125
+ attributes
126
+ end
127
+
128
+ def emit(name, attributes = {}, text = nil)
129
+ @output << XmlBuilder.element(name, attributes, text)
130
+ end
131
+
132
+ def fmt(value)
133
+ XmlBuilder.number(value)
134
+ end
135
+ end
136
+ end
137
+ end