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.
@@ -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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Breadkit
4
4
  module Render
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
@@ -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
+ }
@@ -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
Binary file