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,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("&", "&").gsub("<", "<").gsub(">", ">")
|
|
12
|
+
.gsub('"', """).gsub("'", "'")
|
|
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
|