breadkit-render 0.1.0 → 0.2.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 +4 -4
- data/.yamllint +16 -0
- data/CHANGELOG.md +33 -3
- data/Dockerfile +21 -0
- data/Dockerfile.dockerignore +15 -0
- data/README.md +62 -40
- data/docs/REFERENCE.md +115 -0
- data/docs/images/led-netlist.png +0 -0
- data/docs/images/led-schematic.png +0 -0
- data/lib/breadkit/render/apng_encoder.rb +79 -0
- data/lib/breadkit/render/cli.rb +547 -22
- data/lib/breadkit/render/netlist_renderer.rb +138 -0
- data/lib/breadkit/render/orthogonal_router.rb +137 -0
- data/lib/breadkit/render/rasterizer.rb +138 -18
- data/lib/breadkit/render/schematic_renderer.rb +245 -0
- data/lib/breadkit/render/state_selection.rb +23 -0
- data/lib/breadkit/render/svg_renderer.rb +604 -101
- data/lib/breadkit/render/svg_template.rb +84 -0
- data/lib/breadkit/render/theme.rb +46 -0
- data/lib/breadkit/render/version.rb +1 -1
- data/lib/breadkit/render.rb +7 -0
- data/scripts/check-contrast.mjs +28 -0
- data/sig/breadkit/render.rbs +25 -2
- data/site/images/sensor-demo.png +0 -0
- data/site/images/sensor-demo.svg +8 -8
- data/site/index.html +5 -3
- metadata +36 -10
- data/docs/images/sensor-demo.png +0 -0
- data/docs/images/sensor-demo.svg +0 -45
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rexml/document"
|
|
4
|
+
|
|
5
|
+
module Breadkit
|
|
6
|
+
module Render
|
|
7
|
+
class SvgTemplate
|
|
8
|
+
ELEMENTS = %w[g circle ellipse rect line path polygon polyline text title].freeze
|
|
9
|
+
ATTRIBUTES = %w[x y x1 y1 x2 y2 cx cy r rx ry width height points d transform fill stroke stroke-width
|
|
10
|
+
stroke-linecap stroke-linejoin fill-opacity stroke-opacity opacity font-size font-weight text-anchor].freeze
|
|
11
|
+
NUMERIC = %w[x y x1 y1 x2 y2 cx cy r rx ry width height stroke-width fill-opacity stroke-opacity opacity font-size].freeze
|
|
12
|
+
PAINT = %w[fill stroke].freeze
|
|
13
|
+
VARIABLES = %w[ref value fill stroke text_color].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(source)
|
|
16
|
+
raise Error, "part SVG template exceeds 16 KiB" if source.bytesize > 16 * 1024
|
|
17
|
+
raise Error, "part SVG template contains unsupported markup" if source.include?("<!") || source.include?("<?")
|
|
18
|
+
|
|
19
|
+
@source = source
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def render(values)
|
|
23
|
+
expanded = @source.gsub(/\{\{(.*?)\}\}/m) do
|
|
24
|
+
key = Regexp.last_match(1)
|
|
25
|
+
raise Error, "unknown part SVG template variable: #{key}" unless VARIABLES.include?(key)
|
|
26
|
+
|
|
27
|
+
CGI.escapeHTML(values.fetch(key).to_s)
|
|
28
|
+
end
|
|
29
|
+
raise Error, "invalid part SVG template variable" if expanded.include?("{{") || expanded.include?("}}")
|
|
30
|
+
raise Error, "expanded part SVG template exceeds 32 KiB" if expanded.bytesize > 32 * 1024
|
|
31
|
+
|
|
32
|
+
root = REXML::Document.new("<root>#{expanded}</root>").root
|
|
33
|
+
@nodes = 0
|
|
34
|
+
root.children.map { |child| serialize(child, 0) }.join
|
|
35
|
+
rescue REXML::ParseException => e
|
|
36
|
+
raise Error, "invalid part SVG template: #{e.message.lines.first.to_s.strip}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def serialize(node, depth)
|
|
42
|
+
raise Error, "part SVG template is too deeply nested" if depth > 8
|
|
43
|
+
@nodes += 1
|
|
44
|
+
raise Error, "part SVG template has too many nodes" if @nodes > 128
|
|
45
|
+
if node.is_a?(REXML::Text)
|
|
46
|
+
return CGI.escapeHTML(node.value)
|
|
47
|
+
end
|
|
48
|
+
raise Error, "unsupported part SVG template node" unless node.is_a?(REXML::Element)
|
|
49
|
+
raise Error, "unsupported part SVG template element: #{node.name}" unless ELEMENTS.include?(node.name)
|
|
50
|
+
|
|
51
|
+
attrs = node.attributes.map do |key, value|
|
|
52
|
+
raise Error, "unsupported part SVG template attribute: #{key}" unless ATTRIBUTES.include?(key)
|
|
53
|
+
validate_attribute(key, value)
|
|
54
|
+
%(#{key}="#{CGI.escapeHTML(value)}")
|
|
55
|
+
end
|
|
56
|
+
content = node.children.map { |child| serialize(child, depth + 1) }.join
|
|
57
|
+
"<#{node.name}#{attrs.empty? ? '' : ' ' + attrs.join(' ')}>#{content}</#{node.name}>"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def validate_attribute(key, value)
|
|
61
|
+
if PAINT.include?(key)
|
|
62
|
+
raise Error, "invalid part SVG template paint: #{value}" unless value == "none" || Breadkit::Color.valid?(value)
|
|
63
|
+
elsif NUMERIC.include?(key)
|
|
64
|
+
valid = /\A[-+]?(?:\d+(?:\.\d*)?|\.\d+)\z/.match?(value) && value.to_f.abs <= 10_000
|
|
65
|
+
raise Error, "invalid part SVG template number: #{value}" unless valid
|
|
66
|
+
elsif key == "d"
|
|
67
|
+
raise Error, "invalid part SVG template path" unless value.bytesize <= 4096 && /\A[\d\s,\.\-+MmLlHhVvCcSsQqTtAaZz]*\z/.match?(value)
|
|
68
|
+
elsif key == "points"
|
|
69
|
+
raise Error, "invalid part SVG template points" unless /\A[\d\s,\.\-+]*\z/.match?(value)
|
|
70
|
+
elsif key == "transform"
|
|
71
|
+
raise Error, "invalid part SVG template transform" unless value.match?(/\A(?:\s*(?:translate|rotate|scale|matrix)\([\d\s,\.\-+]+\)\s*)+\z/)
|
|
72
|
+
elsif key == "stroke-linecap"
|
|
73
|
+
raise Error, "invalid part SVG template line cap" unless %w[butt round square].include?(value)
|
|
74
|
+
elsif key == "stroke-linejoin"
|
|
75
|
+
raise Error, "invalid part SVG template line join" unless %w[miter round bevel].include?(value)
|
|
76
|
+
elsif key == "text-anchor"
|
|
77
|
+
raise Error, "invalid part SVG template text anchor" unless %w[start middle end].include?(value)
|
|
78
|
+
elsif key == "font-weight"
|
|
79
|
+
raise Error, "invalid part SVG template font weight" unless %w[normal bold 100 200 300 400 500 600 700 800 900].include?(value)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Breadkit
|
|
6
|
+
module Render
|
|
7
|
+
module Theme
|
|
8
|
+
FONT_FORMATS = { ".ttf" => ["font/ttf", "\x00\x01\x00\x00".b], ".otf" => ["font/otf", "OTTO"],
|
|
9
|
+
".woff" => ["font/woff", "wOFF"], ".woff2" => ["font/woff2", "wOF2"] }.freeze
|
|
10
|
+
MAX_FONT_SIZE = 5 * 1024 * 1024
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def load(path)
|
|
15
|
+
data = JSON.parse(File.read(path, encoding: "UTF-8"))
|
|
16
|
+
raise Error, "theme must be a JSON object" unless data.is_a?(Hash)
|
|
17
|
+
unknown = data.keys - %w[base colors]
|
|
18
|
+
raise Error, "unknown theme option: #{unknown.join(', ')}" unless unknown.empty?
|
|
19
|
+
|
|
20
|
+
base = data.fetch("base", "light")
|
|
21
|
+
raise Error, "unknown base theme: #{base}" unless SvgRenderer::COLORS.key?(base)
|
|
22
|
+
|
|
23
|
+
colors = data.fetch("colors", {})
|
|
24
|
+
raise Error, "theme colors must be an object" unless colors.is_a?(Hash)
|
|
25
|
+
colors.each do |key, value|
|
|
26
|
+
raise Error, "unknown theme color: #{key}" unless SvgRenderer::COLORS.fetch(base).key?(key.to_sym)
|
|
27
|
+
raise Error, "invalid theme color for #{key}: use #RRGGBB" unless value.is_a?(String) && value.match?(/\A#[0-9a-fA-F]{6}\z/)
|
|
28
|
+
end
|
|
29
|
+
{ base: base, colors: colors.transform_keys(&:to_sym) }
|
|
30
|
+
rescue JSON::ParserError => e
|
|
31
|
+
raise Error, "invalid theme JSON: #{e.message}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def font(path)
|
|
35
|
+
format = FONT_FORMATS[File.extname(path).downcase]
|
|
36
|
+
raise Error, "unsupported font format: use TTF, OTF, WOFF, or WOFF2" unless format
|
|
37
|
+
raise Error, "font file is too large (limit: 5 MiB)" if File.size(path) > MAX_FONT_SIZE
|
|
38
|
+
|
|
39
|
+
bytes = File.binread(path)
|
|
40
|
+
raise Error, "invalid font signature" unless bytes.start_with?(format.last)
|
|
41
|
+
|
|
42
|
+
"@font-face{font-family:BreadkitEmbedded;src:url(data:#{format.first};base64,#{[bytes].pack('m0')})}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/breadkit/render.rb
CHANGED
|
@@ -5,7 +5,14 @@ require "cgi/escape"
|
|
|
5
5
|
require "open3"
|
|
6
6
|
require_relative "render/version"
|
|
7
7
|
require_relative "render/svg_renderer"
|
|
8
|
+
require_relative "render/state_selection"
|
|
9
|
+
require_relative "render/svg_template"
|
|
10
|
+
require_relative "render/orthogonal_router"
|
|
11
|
+
require_relative "render/theme"
|
|
12
|
+
require_relative "render/netlist_renderer"
|
|
13
|
+
require_relative "render/schematic_renderer"
|
|
8
14
|
require_relative "render/rasterizer"
|
|
15
|
+
require_relative "render/apng_encoder"
|
|
9
16
|
require_relative "render/cli"
|
|
10
17
|
|
|
11
18
|
module Breadkit
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
const css = readFileSync(new URL("../site/styles.css", import.meta.url), "utf8");
|
|
5
|
+
const colors = Object.fromEntries(
|
|
6
|
+
[...css.matchAll(/--color-([a-z]+):\s*(#[0-9a-f]{6})\s*;/gi)].map(([, name, value]) => [name, value])
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
function luminance(hex) {
|
|
10
|
+
return [1, 3, 5].reduce((sum, offset, index) => {
|
|
11
|
+
const channel = Number.parseInt(hex.slice(offset, offset + 2), 16) / 255;
|
|
12
|
+
const linear = channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
|
|
13
|
+
return sum + linear * [0.2126, 0.7152, 0.0722][index];
|
|
14
|
+
}, 0);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
for (const [foreground, background] of [
|
|
18
|
+
["paper", "ink"], ["paper", "board"],
|
|
19
|
+
["muted", "ink"], ["muted", "board"],
|
|
20
|
+
["copper", "ink"], ["copper", "board"],
|
|
21
|
+
["ink", "copper"]
|
|
22
|
+
]) {
|
|
23
|
+
assert.ok(colors[foreground] && colors[background], `Missing ${foreground}/${background} color`);
|
|
24
|
+
const values = [luminance(colors[foreground]), luminance(colors[background])].sort((a, b) => b - a);
|
|
25
|
+
const ratio = (values[0] + 0.05) / (values[1] + 0.05);
|
|
26
|
+
assert.ok(ratio >= 4.5, `${foreground}/${background} contrast ${ratio.toFixed(2)} is below 4.5`);
|
|
27
|
+
console.log(`${foreground}/${background}: ${ratio.toFixed(2)}`);
|
|
28
|
+
}
|
data/sig/breadkit/render.rbs
CHANGED
|
@@ -16,10 +16,28 @@ module Breadkit
|
|
|
16
16
|
?color_by: String,
|
|
17
17
|
?annotations: Array[Hash[String, untyped]],
|
|
18
18
|
?rail_pattern: String?,
|
|
19
|
-
?interactive_layers: bool
|
|
19
|
+
?interactive_layers: bool,
|
|
20
|
+
?state: Breadkit::State?,
|
|
21
|
+
?active_layer: String?,
|
|
22
|
+
?focus: String?,
|
|
23
|
+
?highlight_net: String?,
|
|
24
|
+
?label_density: String,
|
|
25
|
+
?diff_wires: Hash[String, String],
|
|
26
|
+
?theme_colors: Hash[Symbol, String],
|
|
27
|
+
?font_css: String?,
|
|
28
|
+
?wire_routing: String,
|
|
29
|
+
?wire_style: String
|
|
20
30
|
) -> String
|
|
21
31
|
end
|
|
22
32
|
|
|
33
|
+
class NetlistRenderer
|
|
34
|
+
def render: (Breadkit::Circuit circuit, ?theme: String, ?state: Breadkit::State?) -> String
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class SchematicRenderer
|
|
38
|
+
def render: (Breadkit::Circuit circuit, ?theme: String, ?state: Breadkit::State?) -> String
|
|
39
|
+
end
|
|
40
|
+
|
|
23
41
|
class Rasterizer
|
|
24
42
|
def rasterize: (
|
|
25
43
|
String svg,
|
|
@@ -27,10 +45,15 @@ module Breadkit
|
|
|
27
45
|
?scale: Float,
|
|
28
46
|
?background: String,
|
|
29
47
|
?quality: Integer,
|
|
30
|
-
?backend: String
|
|
48
|
+
?backend: String,
|
|
49
|
+
?timeout: Float | Integer
|
|
31
50
|
) -> String
|
|
32
51
|
end
|
|
33
52
|
|
|
53
|
+
class ApngEncoder
|
|
54
|
+
def encode: (Array[String] images, ?delay_ms: Integer) -> String
|
|
55
|
+
end
|
|
56
|
+
|
|
34
57
|
class CLI
|
|
35
58
|
def run: (Array[String] argv) -> Integer
|
|
36
59
|
end
|
data/site/images/sensor-demo.png
CHANGED
|
Binary file
|