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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 96c030724c346e56e13df01f92afcb6be7c1a332cdc539dc31fa23566fafbd07
|
|
4
|
+
data.tar.gz: 675314177dd5e9ecd8a877a56308376d321de8a9cf793fc6bfda32509e48715e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: cfaed2259b457c9a95c0ec4fe883e7d48395c86c5b806447bebcc0af148b499ae9dbfeab204607f2b9c9cfe25a90e6284e493840589ff5116d0a5e6079e841c2
|
|
7
|
+
data.tar.gz: a0492e8a7f993cfe645059811663e3edb9892689c7a9490b0527417431e86d4f991ad3716bb55dfaa01148b6fad2b53a13d4041db07ff57d1f53d85823d58ffd
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Bitgram
|
|
2
|
+
|
|
3
|
+
Generate packet headers, bit fields, and register maps from YAML, JSON, or Ruby. One layout produces RFC-style ASCII, Unicode box drawings, SVG, PNG, and JPEG.
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
0 1 2 3
|
|
7
|
+
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
|
|
8
|
+
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
9
|
+
| Source Port | Destination Port |
|
|
10
|
+
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
11
|
+
| Length | Checksum |
|
|
12
|
+
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
13
|
+
. .
|
|
14
|
+
. Data .
|
|
15
|
+
. .
|
|
16
|
+
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
<img src="examples/tcp.svg" alt="TCP header showing ports, sequence numbers, flags, and variable-length data">
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
Requires Ruby 3.3 or later. ASCII and SVG need only the `unicode-display_width` runtime gem.
|
|
24
|
+
|
|
25
|
+
For a source checkout:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
bundle install
|
|
29
|
+
bundle exec rake install
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Once published on RubyGems:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
gem install bitgram
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
PNG and JPEG need an optional converter. For the preferred vips backend, add `gem "ruby-vips", "~> 2.3"` to your application's Gemfile and install libvips:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
# macOS
|
|
42
|
+
brew install vips librsvg imagemagick
|
|
43
|
+
# Ubuntu
|
|
44
|
+
sudo apt-get install libvips-dev librsvg2-bin imagemagick fonts-noto-cjk
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Any one supported converter suffices. `rsvg-convert` supports PNG; vips and ImageMagick support PNG and JPEG. Automatic selection tries vips, rsvg, then ImageMagick. Run `bitgram doctor` to inspect availability and Japanese fonts.
|
|
48
|
+
|
|
49
|
+
## Quick start
|
|
50
|
+
|
|
51
|
+
Save this as `udp.yml`:
|
|
52
|
+
|
|
53
|
+
```yaml
|
|
54
|
+
version: 1
|
|
55
|
+
title: UDP Header
|
|
56
|
+
fields:
|
|
57
|
+
- { name: Source Port, bits: 16 }
|
|
58
|
+
- { name: Destination Port, bits: 16 }
|
|
59
|
+
- { name: Length, bits: 16 }
|
|
60
|
+
- { name: Checksum, bits: 16 }
|
|
61
|
+
- { name: Data, bits: variable }
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
bitgram render udp.yml
|
|
66
|
+
bitgram render udp.yml -o udp.svg
|
|
67
|
+
bitgram render udp.yml -o udp.png --scale 2
|
|
68
|
+
bitgram render --preset tcp --ascii-style unicode
|
|
69
|
+
bitgram init ipv6 > ipv6.yml
|
|
70
|
+
bitgram validate udp.yml --strict
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Set `layout.row_bits` for a width other than 32 bits, `layout.bit_order: lsb0` for register numbering, and `layout.offsets: hex` for byte offsets. Fields can use `bytes`, a `short` label, `reserved`, `optional`, and `fill`. See the [format reference](docs/format.md) and [CLI reference](docs/cli.md).
|
|
74
|
+
|
|
75
|
+
## Ruby API
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
require "bitgram"
|
|
79
|
+
|
|
80
|
+
diagram = Bitgram.load_file("udp.yml")
|
|
81
|
+
puts diagram.to_ascii(style: :unicode)
|
|
82
|
+
diagram.save("udp.svg", theme: "dark")
|
|
83
|
+
diagram.save("udp.jpg", scale: 2, quality: 85)
|
|
84
|
+
|
|
85
|
+
register = Bitgram.define(title: "CTRL") do
|
|
86
|
+
row_bits 16
|
|
87
|
+
bit_order :lsb0
|
|
88
|
+
field "EN", bits: 1, description: "Enable"
|
|
89
|
+
field "MODE", bits: 3
|
|
90
|
+
reserved 4
|
|
91
|
+
field "DIV", bits: 8
|
|
92
|
+
end
|
|
93
|
+
puts register.to_ascii
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The [Ruby API guide](docs/ruby-api.md) covers diagnostics, overrides, and inspecting the shared layout. [Preset sources](docs/presets.md) document the six bundled protocol definitions.
|
|
97
|
+
|
|
98
|
+
## Rendering notes
|
|
99
|
+
|
|
100
|
+
- ASCII uses display widths for Japanese text. Unicode border characters are East Asian Ambiguous: terminals configured to render them as two columns will misalign the borders. Use RFC style or set your terminal's ambiguous width to one.
|
|
101
|
+
- PNG/JPEG rendering uses installed fonts. Install a CJK font such as Noto Sans CJK JP for Japanese labels. `bitgram doctor` checks fontconfig when available.
|
|
102
|
+
- SVG text measurement estimates font width. Increase `svg.char_width_ratio` or use a monospace font if your chosen font extends beyond a cell.
|
|
103
|
+
- YAML aliases and object tags are rejected. Definitions are limited to 1 MiB, 10,000 fields, and 4,096 logical rows; raster output is limited to 50 million pixels. Definitions never cause network requests.
|
|
104
|
+
|
|
105
|
+
## Development
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
bundle install
|
|
109
|
+
bundle exec rake
|
|
110
|
+
bundle exec rake examples
|
|
111
|
+
bundle exec yard doc
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Enable raster development dependencies with `bundle config set --local with raster`, then run `bundle install`. Raster integration tests skip converters unavailable on the machine; the dedicated CI job installs all three backends. See [release procedures](docs/releasing.md) for publishing setup.
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
[MIT](LICENSE.txt).
|
data/exe/bitgram
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bitgram
|
|
4
|
+
# @api private
|
|
5
|
+
class CLI
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def doctor
|
|
9
|
+
@stdout.puts("Ruby #{RUBY_VERSION}\nbitgram #{VERSION}")
|
|
10
|
+
labels = { vips: "vips", rsvg: "rsvg-convert", imagemagick: "ImageMagick" }
|
|
11
|
+
Rasterizers::Registry.all.each do |name, converter|
|
|
12
|
+
state = converter.available? ? "available (#{converter.version})" : "not found"
|
|
13
|
+
@stdout.puts("#{labels.fetch(name).ljust(14)}#{state}")
|
|
14
|
+
end
|
|
15
|
+
@stdout.puts("CJK font #{cjk_fonts}")
|
|
16
|
+
selections = %i[png jpeg].map do |format|
|
|
17
|
+
"#{format}: #{Rasterizers::Registry.select(:auto, format: format, timeout: 30).name}"
|
|
18
|
+
rescue RasterizerNotFound
|
|
19
|
+
"#{format}: unavailable"
|
|
20
|
+
end
|
|
21
|
+
@stdout.puts("=> #{selections.join(', ')}")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def cjk_fonts
|
|
25
|
+
executable = Util::Command.which("fc-list")
|
|
26
|
+
return "unknown (fc-list not installed)" unless executable
|
|
27
|
+
|
|
28
|
+
fonts = Util::Command.run([executable, ":lang=ja", "family"], stdin_data: "", timeout: 5)
|
|
29
|
+
fonts.strip.empty? ? "not found" : "found (#{fonts.lines.first.strip})"
|
|
30
|
+
rescue RasterizeError
|
|
31
|
+
"unknown (fontconfig failed)"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bitgram
|
|
4
|
+
# @api private
|
|
5
|
+
class CLI
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def parser_for(command, options)
|
|
9
|
+
OptionParser.new do |parser|
|
|
10
|
+
parser.banner = "Usage: bitgram #{command} #{command == 'validate' ? 'INPUT...' : '[options]'}"
|
|
11
|
+
parser.on("-h", "--help", "Show help") { options[:help] = true }
|
|
12
|
+
parser.on("--debug", "Show failure backtraces") { @debug = true }
|
|
13
|
+
next unless %w[render validate].include?(command)
|
|
14
|
+
|
|
15
|
+
parser.on("--strict", "Treat warnings as errors") { options[:strict] = true }
|
|
16
|
+
parser.on("--input-format FORMAT", %w[yaml json], "Input format") do |value|
|
|
17
|
+
options[:input_format] = value.to_sym
|
|
18
|
+
end
|
|
19
|
+
render_options(parser, options) if command == "render"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def render_options(parser, options)
|
|
24
|
+
parser.on("-f", "--format FORMAT", %w[ascii svg png jpeg]) { |value| options[:format] = value }
|
|
25
|
+
parser.on("-o", "--output PATH") { |value| options[:output] = value }
|
|
26
|
+
parser.on("--preset NAME") { |value| options[:preset] = value }
|
|
27
|
+
{
|
|
28
|
+
"--row-bits N" => ["layout.row_bits", Integer], "--bit-order ORDER" => ["layout.bit_order", String],
|
|
29
|
+
"--ascii-style STYLE" => ["ascii.style", String], "--theme NAME_OR_PATH" => ["svg.theme", String],
|
|
30
|
+
"--scale N" => ["raster.scale", Float], "--rasterizer NAME" => ["raster.rasterizer", String]
|
|
31
|
+
}.each do |flag, (key, type)|
|
|
32
|
+
parser.on(flag, type) { |value| assign(options[:overrides], key, value) }
|
|
33
|
+
end
|
|
34
|
+
parser.on("--set KEY=VALUE") do |value|
|
|
35
|
+
key, raw = value.split("=", 2)
|
|
36
|
+
raise OptionParser::InvalidArgument, "--set requires KEY=VALUE" unless raw
|
|
37
|
+
|
|
38
|
+
assign(options[:settings], key, parse_value(raw))
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def assign(target, key, value)
|
|
43
|
+
parts = key.split(".", -1)
|
|
44
|
+
unless parts.all? { |part| /\A[a-z_]+\z/.match?(part) }
|
|
45
|
+
raise OptionParser::InvalidArgument, "invalid setting key: #{key}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
leaf = parts.pop
|
|
49
|
+
parts.each do |part|
|
|
50
|
+
target[part] ||= {}
|
|
51
|
+
raise OptionParser::InvalidArgument, "conflicting setting key: #{key}" unless target[part].is_a?(Hash)
|
|
52
|
+
|
|
53
|
+
target = target[part]
|
|
54
|
+
end
|
|
55
|
+
target[leaf] = value
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def parse_value(value)
|
|
59
|
+
return value == "true" if %w[true false].include?(value)
|
|
60
|
+
return value.to_i if /\A-?\d+\z/.match?(value)
|
|
61
|
+
return value.to_f if /\A-?\d+\.\d+\z/.match?(value)
|
|
62
|
+
|
|
63
|
+
value
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
data/lib/bitgram/cli.rb
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require_relative "rasterizers/registry"
|
|
5
|
+
|
|
6
|
+
module Bitgram
|
|
7
|
+
# @api private
|
|
8
|
+
class CLI
|
|
9
|
+
COMMANDS = %w[render validate presets init doctor version].freeze
|
|
10
|
+
FORMATS = { ".txt" => "ascii", ".svg" => "svg", ".png" => "png", ".jpg" => "jpeg", ".jpeg" => "jpeg" }.freeze
|
|
11
|
+
|
|
12
|
+
def initialize(stdin: $stdin, stdout: $stdout, stderr: $stderr)
|
|
13
|
+
@stdin = stdin
|
|
14
|
+
@stdout = stdout
|
|
15
|
+
@stderr = stderr
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def run(argv)
|
|
19
|
+
args = argv.dup
|
|
20
|
+
@debug = args.delete("--debug")
|
|
21
|
+
return help if %w[-h --help].include?(args.first)
|
|
22
|
+
return version if %w[-v --version].include?(args.first)
|
|
23
|
+
|
|
24
|
+
execute(args)
|
|
25
|
+
rescue ValidationError => e
|
|
26
|
+
report_issues(e.issues, @input_name)
|
|
27
|
+
1
|
|
28
|
+
rescue ParseError, LayoutError, RenderError => e
|
|
29
|
+
failure(e, 1)
|
|
30
|
+
rescue OptionParser::ParseError, ArgumentError => e
|
|
31
|
+
failure(e, 2)
|
|
32
|
+
rescue RasterizeError => e
|
|
33
|
+
failure(e, 3)
|
|
34
|
+
rescue SystemCallError, IOError => e
|
|
35
|
+
failure(e, 4)
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
failure(e, 70)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def execute(args)
|
|
43
|
+
command = args.shift
|
|
44
|
+
raise OptionParser::InvalidArgument, "expected #{COMMANDS.join(', ')}" unless COMMANDS.include?(command)
|
|
45
|
+
|
|
46
|
+
options = { overrides: {}, settings: {} }
|
|
47
|
+
parser = parser_for(command, options)
|
|
48
|
+
parser.parse!(args)
|
|
49
|
+
return @stdout.puts(parser) || 0 if options[:help]
|
|
50
|
+
|
|
51
|
+
dispatch(command, args, options)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def help
|
|
55
|
+
@stdout.puts("Usage: bitgram <command> [options]\n\nCommands: #{COMMANDS.join(', ')}")
|
|
56
|
+
0
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def version
|
|
60
|
+
@stdout.puts(VERSION)
|
|
61
|
+
0
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def dispatch(command, args, options)
|
|
65
|
+
case command
|
|
66
|
+
when "render" then return render(args, options)
|
|
67
|
+
when "validate" then return validate(args, options)
|
|
68
|
+
when "init" then return init(args)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
simple_command(command, args)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def simple_command(command, args)
|
|
75
|
+
raise OptionParser::InvalidArgument, "unexpected arguments: #{args.join(' ')}" unless args.empty?
|
|
76
|
+
|
|
77
|
+
case command
|
|
78
|
+
when "presets" then @stdout.puts(Bitgram.presets)
|
|
79
|
+
when "doctor" then doctor
|
|
80
|
+
when "version" then return version
|
|
81
|
+
end
|
|
82
|
+
0
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def render(args, options)
|
|
86
|
+
diagram = render_input(args, options)
|
|
87
|
+
output = options[:output]
|
|
88
|
+
format = output_format(options)
|
|
89
|
+
rendered = format == "ascii" ? diagram.to_ascii : diagram.to_svg
|
|
90
|
+
report_issues(diagram.issues, @input_name)
|
|
91
|
+
return 1 if options[:strict] && !diagram.issues.empty?
|
|
92
|
+
|
|
93
|
+
write_output(diagram, rendered, output, format)
|
|
94
|
+
0
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def render_input(args, options)
|
|
98
|
+
preset = options[:preset]
|
|
99
|
+
expected = preset ? 0 : 1
|
|
100
|
+
raise OptionParser::InvalidArgument, "provide one INPUT or --preset NAME" unless args.length == expected
|
|
101
|
+
|
|
102
|
+
check_preset(preset) if preset
|
|
103
|
+
output_format(options)
|
|
104
|
+
@input_name = preset ? "preset:#{preset}" : args.first
|
|
105
|
+
overrides = Config.deep_merge(options[:overrides], options[:settings])
|
|
106
|
+
preset ? Bitgram.preset(preset, **overrides) : read_diagram(args.first, options, overrides)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def output_format(options)
|
|
110
|
+
output = options[:output]
|
|
111
|
+
format = options[:format] || FORMATS[File.extname(output.to_s).downcase] || "ascii"
|
|
112
|
+
if %w[png jpeg].include?(format) && [nil, "-"].include?(output)
|
|
113
|
+
raise OptionParser::InvalidArgument, "#{format} requires --output PATH"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
format
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def write_output(diagram, rendered, output, format)
|
|
120
|
+
if [nil, "-"].include?(output)
|
|
121
|
+
@stdout.write(rendered)
|
|
122
|
+
elsif %w[ascii svg].include?(format)
|
|
123
|
+
File.write(output, rendered)
|
|
124
|
+
else
|
|
125
|
+
diagram.save(output, format: format.to_sym)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def read_diagram(path, options, overrides = {})
|
|
130
|
+
if path == "-"
|
|
131
|
+
return Bitgram.load(@stdin.read(Loader::MAX_BYTES + 1), format: options[:input_format], **overrides)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
Bitgram.load_file(path, format: options[:input_format], **overrides)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def validate(args, options)
|
|
138
|
+
raise OptionParser::MissingArgument, "INPUT" if args.empty?
|
|
139
|
+
|
|
140
|
+
codes = args.map do |path|
|
|
141
|
+
issues = if path == "-"
|
|
142
|
+
read_diagram(path, options).issues
|
|
143
|
+
else
|
|
144
|
+
Bitgram.validate_file(path, format: options[:input_format])
|
|
145
|
+
end
|
|
146
|
+
report_issues(issues, path)
|
|
147
|
+
issues.any? { |issue| issue.severity == :error || options[:strict] } ? 1 : 0
|
|
148
|
+
rescue ValidationError => e
|
|
149
|
+
report_issues(e.issues, path)
|
|
150
|
+
1
|
|
151
|
+
rescue ParseError => e
|
|
152
|
+
failure(e, 1)
|
|
153
|
+
rescue SystemCallError, IOError => e
|
|
154
|
+
failure(e, 4)
|
|
155
|
+
end
|
|
156
|
+
codes.max
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def init(args)
|
|
160
|
+
raise OptionParser::InvalidArgument, "init accepts at most one PRESET" if args.length > 1
|
|
161
|
+
|
|
162
|
+
name = args.first || "udp"
|
|
163
|
+
check_preset(name)
|
|
164
|
+
@stdout.write(File.read(File.join(__dir__, "presets", "#{name}.yml"), encoding: "UTF-8"))
|
|
165
|
+
0
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def check_preset(name)
|
|
169
|
+
return if Bitgram.presets.include?(name)
|
|
170
|
+
|
|
171
|
+
raise OptionParser::InvalidArgument, "unknown preset #{name.inspect}; choose #{Bitgram.presets.join(', ')}"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def report_issues(issues, filename)
|
|
175
|
+
issues.each do |issue|
|
|
176
|
+
location = [filename, issue.line].compact.join(":")
|
|
177
|
+
@stderr.puts("#{location}: #{issue.severity} #{issue.code} #{issue.path}: #{issue.message}")
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def failure(error, code)
|
|
182
|
+
@stderr.puts(error.message)
|
|
183
|
+
@stderr.puts(error.backtrace) if @debug
|
|
184
|
+
code
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
require_relative "cli/options"
|
|
190
|
+
require_relative "cli/doctor"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bitgram
|
|
4
|
+
# @api private
|
|
5
|
+
module Config
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def deep_freeze(value)
|
|
9
|
+
case value
|
|
10
|
+
when Hash then value.each do |key, item|
|
|
11
|
+
key.freeze
|
|
12
|
+
deep_freeze(item)
|
|
13
|
+
end
|
|
14
|
+
when Array then value.each { |item| deep_freeze(item) }
|
|
15
|
+
end
|
|
16
|
+
value.freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
DEFAULTS = {
|
|
20
|
+
"layout" => { "row_bits" => 32, "bit_order" => "msb0", "bit_numbers" => true,
|
|
21
|
+
"offsets" => "none", "merge_rows" => true, "split_label" => "range",
|
|
22
|
+
"split_suffix" => " [{from}:{to}]", "allow_partial_row" => false },
|
|
23
|
+
"ascii" => { "style" => "rfc", "indent" => 0, "variable_edge" => "dot", "max_label_lines" => 4,
|
|
24
|
+
"ambiguous_width" => 1, "fit_order" => %w[single wrap short vertical footnote truncate],
|
|
25
|
+
"footnote_marker" => "*{n}", "title" => "none" },
|
|
26
|
+
"svg" => { "theme" => "default", "bit_width" => 20, "row_height" => 36, "margin" => 16,
|
|
27
|
+
"font_family" => "Helvetica, Arial, 'Noto Sans CJK JP', sans-serif", "font_size" => 13,
|
|
28
|
+
"min_font_size" => 9, "char_width_ratio" => 0.6, "bit_number_style" => "full",
|
|
29
|
+
"ticks" => true, "show_title" => true, "inline_styles" => false,
|
|
30
|
+
"fit_order" => %w[single shrink wrap short vertical footnote truncate] },
|
|
31
|
+
"raster" => { "scale" => 2, "quality" => 90, "rasterizer" => "auto", "timeout" => 30 }
|
|
32
|
+
}.then { |defaults| deep_freeze(defaults) }
|
|
33
|
+
|
|
34
|
+
def deep_merge(base, extra)
|
|
35
|
+
base.merge(extra) do |_key, old, new|
|
|
36
|
+
old.is_a?(Hash) && new.is_a?(Hash) ? deep_merge(old, new) : new
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def stringify(value, depth = 0)
|
|
41
|
+
raise ParseError, "input nesting exceeds 20 levels (or contains a cycle)" if depth > 20
|
|
42
|
+
|
|
43
|
+
case value
|
|
44
|
+
when Hash
|
|
45
|
+
value.to_h { |key, item| [key.to_s, stringify(item, depth + 1)] }
|
|
46
|
+
when Array then value.map { |item| stringify(item, depth + 1) }
|
|
47
|
+
when Symbol then value.to_s
|
|
48
|
+
when String then value.dup.force_encoding(Encoding::UTF_8)
|
|
49
|
+
when Numeric, true, false, nil then value
|
|
50
|
+
else raise ParseError, "unsupported input type: #{value.class}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def slug(text)
|
|
55
|
+
text.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bitgram
|
|
4
|
+
# A validated diagram with shared logical layout and format-specific rendering.
|
|
5
|
+
class Diagram
|
|
6
|
+
# @api private
|
|
7
|
+
def initialize(document, issues: [], source: nil)
|
|
8
|
+
@document = document
|
|
9
|
+
@issues = issues.dup
|
|
10
|
+
@source = source
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Logical bit coordinates for custom renderers and inspection.
|
|
14
|
+
# @return [Layout::Result]
|
|
15
|
+
def layout
|
|
16
|
+
@layout ||= Layout::Engine.new.call(@document)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Validation warnings and findings collected during rendering.
|
|
20
|
+
# @return [Array<Issue>]
|
|
21
|
+
def issues
|
|
22
|
+
(@issues + layout.issues).uniq.freeze
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Render RFC or Unicode text. Keywords override the definition's ascii section.
|
|
26
|
+
# @return [String]
|
|
27
|
+
def to_ascii(**options)
|
|
28
|
+
render(:ascii, options).first
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Render SVG. Keywords override the definition's svg section.
|
|
32
|
+
# @return [String]
|
|
33
|
+
def to_svg(**options)
|
|
34
|
+
render(:svg, options).first
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Write a diagram, inferring format from the extension unless specified.
|
|
38
|
+
# @param path [String] output filename
|
|
39
|
+
# @param format [Symbol, nil] ascii, svg, png, or jpeg
|
|
40
|
+
# @return [String] output filename
|
|
41
|
+
def save(path, format: nil, **options)
|
|
42
|
+
format = (format || File.extname(path.to_s).delete_prefix(".")).to_s
|
|
43
|
+
format = { "txt" => "ascii", "jpg" => "jpeg" }.fetch(format, format)
|
|
44
|
+
case format
|
|
45
|
+
when "ascii" then File.write(path, to_ascii(**options))
|
|
46
|
+
when "svg" then File.write(path, to_svg(**options))
|
|
47
|
+
when "png", "jpeg" then save_raster(path, format.to_sym, options)
|
|
48
|
+
else raise ArgumentError, "unknown output format: #{format}"
|
|
49
|
+
end
|
|
50
|
+
path.to_s
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def render(kind, options)
|
|
56
|
+
validate_options(kind, options)
|
|
57
|
+
require_relative "renderers/#{kind}"
|
|
58
|
+
klass = kind == :ascii ? Renderers::Ascii : Renderers::Svg
|
|
59
|
+
renderer = klass.new(layout, **options)
|
|
60
|
+
output = renderer.render
|
|
61
|
+
@issues |= renderer.issues
|
|
62
|
+
[output, renderer]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def validate_options(kind, options)
|
|
66
|
+
raw = Config.deep_merge(@source || { "version" => 1, "fields" => [] }, kind.to_s => Config.stringify(options))
|
|
67
|
+
errors = Validator.new.call(raw).select { |issue| issue.severity == :error }
|
|
68
|
+
raise ValidationError, errors unless errors.empty?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def save_raster(path, format, options)
|
|
72
|
+
svg_keys = Config::DEFAULTS["svg"].keys + ["id_prefix"]
|
|
73
|
+
svg_options, raster_options = options.partition { |key, _| svg_keys.include?(key.to_s) }.map(&:to_h)
|
|
74
|
+
validate_options(:raster, raster_options)
|
|
75
|
+
config = Config.deep_merge(@document.raster, Config.stringify(raster_options))
|
|
76
|
+
background = config.fetch("background", format == :jpeg ? "#ffffff" : "transparent")
|
|
77
|
+
raise RenderError, "JPEG cannot use a transparent background" if format == :jpeg && background == "transparent"
|
|
78
|
+
|
|
79
|
+
svg, renderer = render(:svg, svg_options.merge(inline_styles: true))
|
|
80
|
+
if renderer.width * renderer.height * (config["scale"]**2) > 50_000_000
|
|
81
|
+
raise RenderError, "output exceeds 50 million pixels; lower raster.scale"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
require_relative "rasterizers/registry"
|
|
85
|
+
converter = Rasterizers::Registry.select(config["rasterizer"], format: format, timeout: config["timeout"])
|
|
86
|
+
converter.rasterize(svg, path: path.to_s, format: format, scale: config["scale"],
|
|
87
|
+
background: background, quality: config["quality"])
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
data/lib/bitgram/dsl.rb
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bitgram
|
|
4
|
+
# Builds a definition for the regular validation pipeline.
|
|
5
|
+
# @api private
|
|
6
|
+
class DSL
|
|
7
|
+
def initialize(title: nil)
|
|
8
|
+
@data = { "version" => 1, "title" => title, "layout" => {}, "fields" => [] }.compact
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def row_bits(value)
|
|
12
|
+
@data["layout"]["row_bits"] = value
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def bit_order(value)
|
|
16
|
+
@data["layout"]["bit_order"] = value.to_s
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def field(name, **options)
|
|
20
|
+
@data["fields"] << { "name" => name, **options.transform_keys(&:to_s) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def reserved(bits, name: "Reserved")
|
|
24
|
+
field(name, bits: bits, reserved: true)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def variable(name, **)
|
|
28
|
+
field(name, **, bits: "variable")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def to_h
|
|
32
|
+
@data
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bitgram
|
|
4
|
+
# Base error for definition and rendering failures.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
# Malformed or unsafe serialized input.
|
|
7
|
+
class ParseError < Error; end
|
|
8
|
+
|
|
9
|
+
# Invalid definition; all validation findings are available in #issues.
|
|
10
|
+
class ValidationError < Error
|
|
11
|
+
attr_reader :issues
|
|
12
|
+
|
|
13
|
+
def initialize(issues)
|
|
14
|
+
@issues = issues.freeze
|
|
15
|
+
super(issues.join("\n"))
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The definition cannot be laid out.
|
|
20
|
+
class LayoutError < Error; end
|
|
21
|
+
# The requested output cannot be rendered.
|
|
22
|
+
class RenderError < Error; end
|
|
23
|
+
# Raster conversion failed.
|
|
24
|
+
class RasterizeError < Error; end
|
|
25
|
+
# No installed converter supports the requested format.
|
|
26
|
+
class RasterizerNotFound < RasterizeError; end
|
|
27
|
+
# An external converter exceeded its deadline.
|
|
28
|
+
class RasterizerTimeout < RasterizeError; end
|
|
29
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bitgram
|
|
4
|
+
# A validation or rendering finding, including a JSON path and optional YAML line.
|
|
5
|
+
Issue = Data.define(:severity, :code, :path, :line, :message) do
|
|
6
|
+
# Format the finding for logs and command-line diagnostics.
|
|
7
|
+
def to_s
|
|
8
|
+
[line && "line #{line}:", severity, code, "#{path}:", message].compact.join(" ")
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|