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
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
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [1.0.0] - 2026-10-29
6
+
7
+ - Initial release
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,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bitgram"
5
+
6
+ exit Bitgram::CLI.new.run(ARGV)
@@ -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
@@ -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
@@ -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