conceptql 0.6.0 → 0.7.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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +176 -0
  3. data/README.md +4 -2
  4. data/assets/diagram/diagram.css +331 -0
  5. data/assets/diagram/fonts/OFL.txt +93 -0
  6. data/assets/diagram/fonts/Roboto-400.ttf +0 -0
  7. data/assets/diagram/fonts/Roboto-700.ttf +0 -0
  8. data/assets/diagram/fonts/Roboto-Italic-400.ttf +0 -0
  9. data/assets/diagram/fonts/Roboto-Italic-Latin.woff2 +0 -0
  10. data/assets/diagram/fonts/Roboto-Normal-Latin.woff2 +0 -0
  11. data/assets/diagram/operators.svg +224 -0
  12. data/config/vocabularies.csv +85 -85
  13. data/docs/spec/README.md +2624 -0
  14. data/docs/spec/examples/after-operator.json +562 -0
  15. data/docs/spec/examples/any-overlap-operator.json +106 -0
  16. data/docs/spec/examples/before-operator.json +437 -0
  17. data/docs/spec/examples/co-reported-operator.json +168 -0
  18. data/docs/spec/examples/contains-operator.json +97 -0
  19. data/docs/spec/examples/date-range-operator.json +275 -0
  20. data/docs/spec/examples/death-operator.json +103 -0
  21. data/docs/spec/examples/during-operator.json +178 -0
  22. data/docs/spec/examples/episode-operator.json +80 -0
  23. data/docs/spec/examples/equal-operator.json +79 -0
  24. data/docs/spec/examples/ethnicity-operator.json +86 -0
  25. data/docs/spec/examples/except-operator.json +536 -0
  26. data/docs/spec/examples/filter-operator.json +218 -0
  27. data/docs/spec/examples/first-operator.json +148 -0
  28. data/docs/spec/examples/gender-operator.json +257 -0
  29. data/docs/spec/examples/hospice-operator.json +40 -0
  30. data/docs/spec/examples/hospitalization-operator.json +131 -0
  31. data/docs/spec/examples/information-periods-operator.json +131 -0
  32. data/docs/spec/examples/last-operator.json +148 -0
  33. data/docs/spec/examples/match-operator.json +186 -0
  34. data/docs/spec/examples/nth-occurrence-operator.json +300 -0
  35. data/docs/spec/examples/numeric-filter-operator.json +77 -0
  36. data/docs/spec/examples/one-in-two-out-operator.json +158 -0
  37. data/docs/spec/examples/person-filter-operator.json +919 -0
  38. data/docs/spec/examples/person-operator.json +274 -0
  39. data/docs/spec/examples/place-of-service-filter-operator.json +57 -0
  40. data/docs/spec/examples/provenance-operator.json +148 -0
  41. data/docs/spec/examples/provider-filter-operator.json +77 -0
  42. data/docs/spec/examples/race-operator.json +131 -0
  43. data/docs/spec/examples/recall-operator.json +289 -0
  44. data/docs/spec/examples/snf-operator.json +40 -0
  45. data/docs/spec/examples/time-window-operator.json +736 -0
  46. data/docs/spec/examples/trim-date-end-operator.json +201 -0
  47. data/docs/spec/examples/trim-date-start-operator.json +201 -0
  48. data/docs/spec/examples/union-operator.json +547 -0
  49. data/docs/spec/examples/vocabulary-operator.json +232 -0
  50. data/lib/conceptql/behaviors/metadatable.rb +68 -2
  51. data/lib/conceptql/behaviors/provenanceable.rb +4 -1
  52. data/lib/conceptql/behaviors/utilizable.rb +3 -0
  53. data/lib/conceptql/code_case_mismatch_error.rb +50 -0
  54. data/lib/conceptql/data_model/gdm.rb +23 -2
  55. data/lib/conceptql/diagram_renderer/app.rb +59 -0
  56. data/lib/conceptql/diagram_renderer/configuration.rb +87 -0
  57. data/lib/conceptql/diagram_renderer/diagram_data.rb +80 -0
  58. data/lib/conceptql/diagram_renderer/drawing.rb +255 -0
  59. data/lib/conceptql/diagram_renderer/geometry.rb +380 -0
  60. data/lib/conceptql/diagram_renderer/input_error.rb +11 -0
  61. data/lib/conceptql/diagram_renderer/js_format.rb +112 -0
  62. data/lib/conceptql/diagram_renderer/resvg.rb +120 -0
  63. data/lib/conceptql/diagram_renderer/sprite.rb +73 -0
  64. data/lib/conceptql/diagram_renderer/static.rb +70 -0
  65. data/lib/conceptql/diagram_renderer/svg.rb +73 -0
  66. data/lib/conceptql/diagram_renderer/tree_layout.rb +287 -0
  67. data/lib/conceptql/diagram_renderer.rb +82 -0
  68. data/lib/conceptql/lexicon/lexicon_strategy.rb +16 -0
  69. data/lib/conceptql/lexicon.rb +63 -11
  70. data/lib/conceptql/nodifier.rb +5 -3
  71. data/lib/conceptql/operators/after.rb +15 -1
  72. data/lib/conceptql/operators/any_overlap.rb +6 -1
  73. data/lib/conceptql/operators/before.rb +11 -1
  74. data/lib/conceptql/operators/binary_operator_operator.rb +12 -2
  75. data/lib/conceptql/operators/co_reported.rb +9 -4
  76. data/lib/conceptql/operators/contains.rb +6 -1
  77. data/lib/conceptql/operators/count.rb +2 -0
  78. data/lib/conceptql/operators/date_range.rb +15 -3
  79. data/lib/conceptql/operators/death.rb +5 -1
  80. data/lib/conceptql/operators/dedup.rb +2 -0
  81. data/lib/conceptql/operators/during.rb +6 -1
  82. data/lib/conceptql/operators/episode.rb +13 -2
  83. data/lib/conceptql/operators/equal.rb +11 -1
  84. data/lib/conceptql/operators/ethnicity.rb +19 -7
  85. data/lib/conceptql/operators/except.rb +10 -1
  86. data/lib/conceptql/operators/filter.rb +6 -1
  87. data/lib/conceptql/operators/first.rb +19 -9
  88. data/lib/conceptql/operators/from.rb +3 -0
  89. data/lib/conceptql/operators/gender.rb +10 -2
  90. data/lib/conceptql/operators/hospice.rb +7 -1
  91. data/lib/conceptql/operators/hospitalization.rb +6 -1
  92. data/lib/conceptql/operators/information_periods.rb +26 -7
  93. data/lib/conceptql/operators/invalid.rb +4 -0
  94. data/lib/conceptql/operators/last.rb +19 -9
  95. data/lib/conceptql/operators/match.rb +6 -1
  96. data/lib/conceptql/operators/multiple_vocabularies.rb +17 -3
  97. data/lib/conceptql/operators/numeric.rb +4 -0
  98. data/lib/conceptql/operators/numeric_filter.rb +13 -3
  99. data/lib/conceptql/operators/occurrence.rb +33 -12
  100. data/lib/conceptql/operators/one_in_two_out.rb +22 -5
  101. data/lib/conceptql/operators/operator.rb +8 -4
  102. data/lib/conceptql/operators/person.rb +5 -1
  103. data/lib/conceptql/operators/person_filter.rb +14 -1
  104. data/lib/conceptql/operators/place_of_service_filter.rb +14 -3
  105. data/lib/conceptql/operators/provenance.rb +17 -8
  106. data/lib/conceptql/operators/provider_filter.rb +18 -6
  107. data/lib/conceptql/operators/race.rb +14 -9
  108. data/lib/conceptql/operators/read.rb +11 -1
  109. data/lib/conceptql/operators/recall.rb +34 -2
  110. data/lib/conceptql/operators/snf.rb +7 -1
  111. data/lib/conceptql/operators/sum.rb +4 -0
  112. data/lib/conceptql/operators/temporal_operator.rb +4 -2
  113. data/lib/conceptql/operators/time_window.rb +27 -4
  114. data/lib/conceptql/operators/trim_date.rb +6 -14
  115. data/lib/conceptql/operators/trim_date_end.rb +9 -3
  116. data/lib/conceptql/operators/trim_date_start.rb +9 -3
  117. data/lib/conceptql/operators/union.rb +13 -1
  118. data/lib/conceptql/operators/vocabulary.rb +80 -6
  119. data/lib/conceptql/paths.rb +9 -1
  120. data/lib/conceptql/version.rb +1 -1
  121. data/lib/conceptql/vocabularies/drg_systems.rb +3 -2
  122. data/lib/conceptql/vocabularies/entry.rb +14 -11
  123. data/lib/conceptql/vocabularies/vocabulary_class_factory.rb +2 -1
  124. data/lib/conceptql.rb +3 -0
  125. metadata +75 -2
  126. data/doc/spec.md +0 -2091
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConceptQL
4
+ module DiagramRenderer
5
+ # The JavaScript rules that the ported renderer code depends on: how a value
6
+ # becomes a string (String(value), template literals, Array#join), which
7
+ # values are falsy, and the order of Object.entries. The JavaScript renderer
8
+ # builds path strings and labels with these rules, so the Ruby port must use
9
+ # them to produce the same strings.
10
+ module JsFormat
11
+ module_function
12
+
13
+ # Number::toString (ECMAScript 6.1.6.1.20) for a finite or non-finite
14
+ # number: 100.0 is "100", 1.0e-7 is "1e-7", -0.0 is "0".
15
+ def number(value)
16
+ return value.to_s if value.is_a?(Integer)
17
+
18
+ value = Float(value)
19
+ return "NaN" if value.nan?
20
+ return value.positive? ? "Infinity" : "-Infinity" if value.infinite?
21
+ return "0" if value.zero?
22
+ return "-#{number(-value)}" if value.negative?
23
+
24
+ digits, n = shortest_digits(value)
25
+ k = digits.length
26
+ if k <= n && n <= 21
27
+ digits + ("0" * (n - k))
28
+ elsif n.positive? && n <= 21
29
+ "#{digits[0, n]}.#{digits[n..]}"
30
+ elsif n > -6 && n <= 0
31
+ "0.#{'0' * -n}#{digits}"
32
+ else
33
+ exponent = n - 1
34
+ sign = exponent.negative? ? "-" : "+"
35
+ mantissa = k == 1 ? digits : "#{digits[0]}.#{digits[1..]}"
36
+ "#{mantissa}e#{sign}#{exponent.abs}"
37
+ end
38
+ end
39
+
40
+ # String(value) for a JSON value.
41
+ def string(value)
42
+ case value
43
+ when nil then "null"
44
+ when String then value
45
+ when Numeric then number(value)
46
+ when Array then join(value, ",")
47
+ when Hash then "[object Object]"
48
+ else value.to_s
49
+ end
50
+ end
51
+
52
+ # Array.prototype.join: null elements become "".
53
+ def join(array, separator)
54
+ array.map { _1.nil? ? "" : string(_1) }.join(separator)
55
+ end
56
+
57
+ # JavaScript truthiness for a JSON value.
58
+ def truthy?(value)
59
+ case value
60
+ when nil, false then false
61
+ when String then !value.empty?
62
+ when Float then !(value.zero? || value.nan?)
63
+ when Integer then !value.zero?
64
+ else true
65
+ end
66
+ end
67
+
68
+ # The length of a value as JavaScript reads `value.length`, for the
69
+ # comparisons in geometry.js: a string's or an array's length, otherwise
70
+ # NaN (JavaScript's undefined, which compares false with every number).
71
+ #
72
+ # A string's length counts characters (code points). JavaScript counts
73
+ # UTF-16 code units, so a character outside the Basic Multilingual Plane
74
+ # (an emoji) counts 1 here and 2 in JavaScript.
75
+ def length(value)
76
+ value.is_a?(String) || value.is_a?(Array) ? value.length : Float::NAN
77
+ end
78
+
79
+ # Object.entries order for a JSON object: integer-like keys first, in
80
+ # numeric order, then the other keys in insertion order.
81
+ def entries(hash)
82
+ index, other = hash.partition { |key, _| array_index?(key) }
83
+ index.sort_by { |key, _| key.to_i } + other
84
+ end
85
+
86
+ # String.prototype.trimEnd: the JavaScript WhiteSpace and LineTerminator
87
+ # characters.
88
+ TRAILING_WHITESPACE = /[\t\n\v\f\r    - 

   ]+\z/
89
+
90
+ def trim_end(text) = text.sub(TRAILING_WHITESPACE, "")
91
+
92
+ # The shortest decimal digits that round-trip to `value` (positive,
93
+ # finite), and the exponent n of ECMAScript's definition: value is
94
+ # 0.<digits> x 10^n. Ruby's Float#to_s prints the same shortest digits.
95
+ def shortest_digits(value)
96
+ mantissa, exponent = value.to_s.split("e")
97
+ whole, fraction = mantissa.split(".")
98
+ digits = whole + fraction.to_s
99
+ n = whole.length + exponent.to_i
100
+ stripped = digits.sub(/\A0+/, "")
101
+ n -= digits.length - stripped.length
102
+ [ stripped.sub(/0+\z/, ""), n ]
103
+ end
104
+
105
+ def array_index?(key)
106
+ key.is_a?(String) && key.match?(/\A(?:0|[1-9]\d*)\z/) && key.to_i < 4_294_967_295
107
+ end
108
+
109
+ private_class_method :shortest_digits, :array_index?
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "tmpdir"
5
+
6
+ module ConceptQL
7
+ module DiagramRenderer
8
+ # The resvg command-line program (https://github.com/linebender/resvg), run
9
+ # as a subprocess: the bounding box of a drawing and PNG output.
10
+ # mise.toml pins the release (VERSION) and says why it is that release.
11
+ # resvg is also the engine of the JavaScript static exporter
12
+ # (@resvg/resvg-js in frontend/lib/conceptql-diagram/cli). Both use the same
13
+ # fonts: the three Roboto files, no system fonts, default family Roboto
14
+ # (resvgOptions() in cli/src/render-static.js).
15
+ #
16
+ # resvg = ConceptQL::DiagramRenderer::Resvg.new(fonts_dir: "assets/diagram/fonts")
17
+ # resvg.bbox(svg) # => [x, y, width, height]
18
+ # resvg.png(svg) # => PNG bytes
19
+ class Resvg
20
+ VERSION = "0.33.0"
21
+ FONT_FILES = %w[Roboto-400.ttf Roboto-700.ttf Roboto-Italic-400.ttf].freeze
22
+ DEFAULT_FONT_FAMILY = "Roboto"
23
+ # The id of the group that bbox wraps around the drawing.
24
+ BBOX_ID = "conceptql-diagram-bbox"
25
+ # The root element's start tag. A quoted attribute value may hold ">".
26
+ ROOT_START = /<svg\b(?:[^>"']|"[^"]*"|'[^']*')*>/
27
+
28
+ # A failed run: the program is missing, exits non-zero, or prints no box.
29
+ class Error < StandardError
30
+ attr_reader :command, :status, :stderr
31
+
32
+ def initialize(command:, status: nil, stderr: "", reason: nil)
33
+ @command = command
34
+ @status = status
35
+ @stderr = stderr
36
+ exit_text = status ? "exit status #{status}" : "no exit status"
37
+ super([ "resvg failed: #{reason || exit_text}", "command: #{command.join(' ')}", "stderr: #{stderr}" ].join("\n"))
38
+ end
39
+ end
40
+
41
+ attr_reader :program, :fonts_dir
42
+
43
+ # fonts_dir holds FONT_FILES. program is the resvg executable (a name on
44
+ # PATH or a path).
45
+ def initialize(fonts_dir:, program: "resvg")
46
+ @fonts_dir = fonts_dir.to_s
47
+ @program = program.to_s
48
+ end
49
+
50
+ # The version that `resvg --version` prints, e.g. "0.33.0".
51
+ def version
52
+ run([ "--version" ]).strip
53
+ end
54
+
55
+ # The bounding box of the whole drawing, in user units, as
56
+ # [x, y, width, height]: what resvg-js getBBox() returns for the same SVG.
57
+ #
58
+ # The CLI has no "box of everything" mode. --query-all prints
59
+ # "id,x,y,width,height" for each element with an id, with text converted
60
+ # to paths and the stroke included, as getBBox() does. The root has no id,
61
+ # so bbox wraps the root's content in one group with BBOX_ID and reads
62
+ # that group's line. In resvg 0.33 the box is in the coordinates of the
63
+ # root's content, so the viewBox and width/height do not change it.
64
+ # --query-all rounds to 3 decimal places. An empty drawing has no box and
65
+ # raises Error.
66
+ def bbox(svg)
67
+ start = svg.match(ROOT_START) or raise ArgumentError, "no <svg> start tag in the input"
68
+ close = svg.rindex("</svg>") or raise ArgumentError, "no </svg> end tag in the input"
69
+ wrapped = "#{svg[0...start.end(0)]}<g id=\"#{BBOX_ID}\">#{svg[start.end(0)...close]}</g>#{svg[close..]}"
70
+ with_input(wrapped) do |input|
71
+ command = [ "--query-all", *font_arguments, input ]
72
+ line = run(command).lines.find { |l| l.start_with?("#{BBOX_ID},") }
73
+ raise Error.new(command: full(command), status: 0, reason: "no bounding box for ##{BBOX_ID}; the drawing may be empty") unless line
74
+
75
+ line.strip.split(",").drop(1).map { |n| Float(n) }
76
+ end
77
+ end
78
+
79
+ # The SVG rasterized to PNG bytes, at the size of its width and height.
80
+ def png(svg)
81
+ with_input(svg) do |input|
82
+ output = File.join(File.dirname(input), "out.png")
83
+ run([ *font_arguments, input, output ])
84
+ File.binread(output)
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ def font_arguments
91
+ FONT_FILES.flat_map { |f| [ "--use-font-file", File.join(fonts_dir, f) ] } +
92
+ [ "--skip-system-fonts", "--font-family", DEFAULT_FONT_FAMILY ]
93
+ end
94
+
95
+ def full(arguments) = [ program, *arguments ]
96
+
97
+ # The SVG goes to the program as a file in a new temporary directory, not
98
+ # on stdin: with stdin input, resvg prints a warning to stdout, which
99
+ # would corrupt PNG output on stdout. The directory is also the
100
+ # program's --resources-dir default, so a relative href finds nothing.
101
+ def with_input(svg)
102
+ Dir.mktmpdir("conceptql-diagram-resvg") do |dir|
103
+ input = File.join(dir, "in.svg")
104
+ File.write(input, svg)
105
+ yield input
106
+ end
107
+ end
108
+
109
+ def run(arguments)
110
+ command = full(arguments)
111
+ stdout, stderr, status = Open3.capture3(*command)
112
+ raise Error.new(command:, status: status.exitstatus, stderr:) unless status.success?
113
+
114
+ stdout
115
+ rescue SystemCallError => e
116
+ raise Error.new(command:, stderr: "", reason: "cannot run #{program}: #{e.message}")
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConceptQL
4
+ module DiagramRenderer
5
+ # The operator icon sprite: markup that holds <symbol id="operator-..">
6
+ # elements, which the drawing's <use> elements refer to. The gem's copy,
7
+ # assets/diagram/operators.svg, is the content of the hidden
8
+ # <div class="operators"> of frontend/index.html. The markup may also be a
9
+ # whole HTML page: only the elements with an id matter.
10
+ #
11
+ # The sprite is read with a small tag scanner, not an XML parser (the
12
+ # library uses the standard library only). It expects well-formed markup
13
+ # with quoted attribute values, as the sprite is. Each element with an id
14
+ # keeps its markup exactly as written, for copying into an SVG.
15
+ class Sprite
16
+ TAG = %r{
17
+ <!--.*?-->
18
+ | <!\[CDATA\[.*?\]\]>
19
+ | <[!?][^>]*>
20
+ | </(?<close>[^\s>]+)\s*>
21
+ | <(?<open>[^\s/>]+)(?<attributes>(?:[^>"']|"[^"]*"|'[^']*')*?)(?<empty>/?)>
22
+ }mx
23
+ ATTRIBUTE = /([^\s=]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/
24
+
25
+ # One element with an id: where its start tag is, its markup, and the ids
26
+ # its descendants refer to (Svg.references of each, in document order).
27
+ Entry = Data.define(:at, :markup, :references)
28
+
29
+ def initialize(markup)
30
+ @entries = {}
31
+ scan(markup)
32
+ end
33
+
34
+ def key?(id) = @entries.key?(id)
35
+
36
+ # The markup of the element with this id.
37
+ def markup(id) = @entries.fetch(id).markup
38
+
39
+ # The ids that the element's descendants refer to.
40
+ def references(id) = @entries.fetch(id).references
41
+
42
+ private
43
+
44
+ # As querySelectorAll("[id]") into a Map: when two elements share an id,
45
+ # the later one (in document order) wins.
46
+ def scan(markup)
47
+ open = []
48
+ tags = []
49
+ markup.scan(TAG) do
50
+ match = Regexp.last_match
51
+ if match[:open]
52
+ attributes = match[:attributes].scan(ATTRIBUTE).to_h { |name, double, single| [ name, double || single ] }
53
+ tag = { at: match.begin(0), id: attributes["id"], references: Svg.references(attributes) }
54
+ tags << tag
55
+ match[:empty].empty? ? open.push([ match[:open], tag ]) : record(markup, tag, tags, match.end(0))
56
+ elsif match[:close]
57
+ index = open.rindex { |name, _| name == match[:close] } or next
58
+ _, tag = open.slice!(index..).first
59
+ record(markup, tag, tags, match.end(0))
60
+ end
61
+ end
62
+ end
63
+
64
+ def record(markup, tag, tags, finish)
65
+ return unless tag[:id]
66
+ return if @entries[tag[:id]] && @entries[tag[:id]].at > tag[:at]
67
+
68
+ inner = tags.select { |t| t[:at] > tag[:at] && t[:at] < finish }.flat_map { _1[:references] }
69
+ @entries[tag[:id]] = Entry.new(at: tag[:at], markup: markup[tag[:at]...finish], references: inner)
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConceptQL
4
+ module DiagramRenderer
5
+ # A standalone static SVG of a diagram: renderStatic in
6
+ # frontend/lib/conceptql-diagram/cli/src/render-static.js. The Drawing,
7
+ # then makeStandalone (inline the sprite symbols the drawing uses, add the
8
+ # <style>) and fitToContent (a viewBox around the drawing, measured by
9
+ # resvg, and a background rect).
10
+ #
11
+ # ConceptQL::DiagramRenderer::Static.new(configuration).render(input) # => SVG text
12
+ class Static
13
+ PADDING = 24
14
+ BACKGROUND = "#f4f2f3"
15
+ SVG_NS = "http://www.w3.org/2000/svg"
16
+ XLINK_NS = "http://www.w3.org/1999/xlink"
17
+ # The canvas that fitToContent measures on: larger than any diagram.
18
+ MEASURE = [ -5000, -5000, 10_000, 10_000 ].freeze
19
+
20
+ def initialize(configuration)
21
+ @configuration = configuration
22
+ end
23
+
24
+ def render(input, show_counts: false, show_options: false)
25
+ drawing = Drawing.new(DiagramData.from(input), show_counts:, show_options:)
26
+ algorithm = drawing.algorithm
27
+ style = Svg.element("style", {}, [ @configuration.standalone_style_text ])
28
+ defs = Svg.element("defs", {}, drawing.gradients + symbols(algorithm))
29
+
30
+ x, y, width, height = @configuration.resvg.bbox(root(MEASURE, [ style, defs, algorithm ]).to_s)
31
+ raise "the diagram rendered nothing measurable" unless width.positive? && height.positive?
32
+
33
+ view_box = [ (x - PADDING).floor, (y - PADDING).floor, (width + (2 * PADDING)).ceil, (height + (2 * PADDING)).ceil ]
34
+ background = Svg.element("rect", { "x" => view_box[0], "y" => view_box[1], "width" => view_box[2], "height" => view_box[3], "fill" => BACKGROUND })
35
+ root(view_box, [ style, defs, background, algorithm ]).to_s
36
+ end
37
+
38
+ private
39
+
40
+ def root(view_box, children)
41
+ Svg.element("svg", {
42
+ "xmlns" => SVG_NS,
43
+ "xmlns:xlink" => XLINK_NS,
44
+ "class" => "vis is-new",
45
+ "viewBox" => view_box.join(" "),
46
+ "width" => view_box[2],
47
+ "height" => view_box[3]
48
+ }, children)
49
+ end
50
+
51
+ # The sprite elements the drawing refers to, and those they refer to, in
52
+ # the order makeStandalone finds them: document order, each one's own
53
+ # references right after it.
54
+ def symbols(algorithm)
55
+ sprite = @configuration.sprite
56
+ needed = []
57
+ visit = lambda do |refs|
58
+ refs.each do |id|
59
+ next if needed.include?(id) || !sprite.key?(id)
60
+
61
+ needed << id
62
+ visit.call(sprite.references(id))
63
+ end
64
+ end
65
+ visit.call(algorithm.descendants.flat_map { Svg.references(_1.attributes) })
66
+ needed.map { Svg::Raw.new(sprite.markup(_1)) }
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConceptQL
4
+ module DiagramRenderer
5
+ # SVG markup built with plain strings: an element tree, its serialization
6
+ # with XML escaping, and the references (#id) that its elements make.
7
+ #
8
+ # Attribute values and text are written as the DOM writes the values that
9
+ # renderer.js sets: a number as JavaScript's Number toString writes it
10
+ # (JsFormat), and a nil attribute is left out (d3's attr(name, null)
11
+ # removes the attribute). Every attribute value and text is escaped, because
12
+ # operator names, values and options come from users.
13
+ module Svg
14
+ # An element: a name, attributes in the order they are written, and
15
+ # children (Element, Raw or String text).
16
+ Element = Data.define(:name, :attributes, :children) do
17
+ def initialize(name:, attributes: {}, children: [])
18
+ super(name:, attributes: attributes.reject { |_, value| value.nil? }.transform_values { JsFormat.string(_1) }, children:)
19
+ end
20
+
21
+ def to_s
22
+ attrs = attributes.map { |key, value| %( #{key}="#{Svg.escape_attribute(value)}") }.join
23
+ return "<#{name}#{attrs}/>" if children.empty?
24
+
25
+ "<#{name}#{attrs}>#{children.map { _1.is_a?(String) ? Svg.escape_text(_1) : _1.to_s }.join}</#{name}>"
26
+ end
27
+
28
+ # The element's descendants in document order (querySelectorAll("*")),
29
+ # without the element itself.
30
+ def descendants
31
+ children.grep(Element).flat_map { [ _1, *_1.descendants ] }
32
+ end
33
+ end
34
+
35
+ # Markup copied as it is, such as an icon <symbol> from the sprite.
36
+ Raw = Data.define(:markup) do
37
+ def to_s = markup
38
+ end
39
+
40
+ # The attributes that makeStandalone (cli/src/render-static.js) reads a
41
+ # url(#id) reference from, in the order it reads them.
42
+ URL_ATTRIBUTES = %w[fill stroke filter clip-path mask].freeze
43
+ URL_REFERENCE = /url\(#([^)]+)\)/
44
+
45
+ module_function
46
+
47
+ def element(name, attributes = {}, children = []) = Element.new(name:, attributes:, children:)
48
+
49
+ # The ids that one element's attributes refer to, in the order
50
+ # makeStandalone finds them: the href (xlink:href, else href), then a
51
+ # url(#id) in each of URL_ATTRIBUTES. attributes is a hash of qualified
52
+ # names to values.
53
+ def references(attributes)
54
+ href = [ attributes["xlink:href"], attributes["href"] ].find { _1 && !_1.empty? }
55
+ refs = href&.start_with?("#") ? [ href[1..] ] : []
56
+ refs + URL_ATTRIBUTES.filter_map { attributes[_1]&.match(URL_REFERENCE)&.[](1) }
57
+ end
58
+
59
+ # An attribute value as XMLSerializer escapes it. The tab, newline and
60
+ # carriage return are character references, so a parser reads them back
61
+ # unchanged (it would read a raw one as a space).
62
+ def escape_attribute(value)
63
+ value.gsub(/[&<>"\t\n\r]/, "&" => "&amp;", "<" => "&lt;", ">" => "&gt;", '"' => "&quot;", "\t" => "&#x9;", "\n" => "&#xA;", "\r" => "&#xD;")
64
+ end
65
+
66
+ # Text as XMLSerializer escapes it. A carriage return is a character
67
+ # reference, so a parser does not turn it into a newline.
68
+ def escape_text(text)
69
+ text.gsub(/[&<>\r]/, "&" => "&amp;", "<" => "&lt;", ">" => "&gt;", "\r" => "&#xD;")
70
+ end
71
+ end
72
+ end
73
+ end