sevgi-graphics 0.98.2 → 1.0.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.
@@ -10,8 +10,9 @@ module Sevgi
10
10
  # moved to non-rendering `-id` metadata before the optional block runs, allowing the block to derive replacement
11
11
  # ids without rendering duplicates. A pre-existing `-id` takes precedence over the visible id.
12
12
  # Translation and parent channels are validated before the subtree is copied or the customization block runs.
13
- # @param dx [Numeric, nil] finite x translation; nil omits the axis
14
- # @param dy [Numeric, nil] finite y translation; nil omits the axis
13
+ # The copied subtree has no parent during customization and is attached to the target parent afterward.
14
+ # @param dx [Numeric, nil] finite x translation. Nil omits the axis
15
+ # @param dy [Numeric, nil] finite y translation. Nil omits the axis
15
16
  # @param parent [Sevgi::Graphics::Element, nil] explicit parent, or the source parent when nil
16
17
  # @yield [element] optional customization hook for each copied element
17
18
  # @yieldparam element [Sevgi::Graphics::Element] copied element
@@ -27,7 +28,7 @@ module Sevgi
27
28
  # end
28
29
  def Duplicate(dx: nil, dy: nil, parent: nil, &block)
29
30
  dx, dy, target = Subtree.channels(self, dx, dy, parent)
30
- duplicated = Subtree.copy(self)
31
+ duplicated = dup
31
32
  Subtree.prepare(duplicated, &block)
32
33
  Subtree.translate(duplicated, dx, dy)
33
34
  Subtree.attach(duplicated, target)
@@ -80,19 +81,6 @@ module Sevgi
80
81
  [dx, dy, target]
81
82
  end
82
83
 
83
- # Builds an independent copy of an element subtree.
84
- # @param element [Sevgi::Graphics::Element] source subtree root
85
- # @param parent [Sevgi::Graphics::Element, Object] parent for the copied root
86
- # @return [Sevgi::Graphics::Element] copied subtree root
87
- def self.copy(element, parent = Element.send(:tree_parent, element))
88
- element.dup.tap do |duplicated|
89
- duplicated.send(:parent=, parent)
90
- duplicated.send(:attributes=, element.attributes.dup)
91
- duplicated.send(:contents=, element.contents.map(&:dup))
92
- duplicated.send(:children=, element.children.map { |child| copy(child, duplicated) })
93
- end
94
- end
95
-
96
84
  # Removes copied public ids and applies an optional customization hook.
97
85
  # @api private
98
86
  def self.prepare(element, &block)
@@ -4,6 +4,8 @@ module Sevgi
4
4
  module Graphics
5
5
  module Mixtures
6
6
  # DSL helpers for native SVG export formats.
7
+ # Export CSS is a late adjustment after document checks. Its supported XML endings and cascade limitations are
8
+ # documented in {Sevgi::Sundries::Export}. The source callback receives SVG after CSS insertion.
7
9
  module Export
8
10
  # Validates the closed export option channel before optional components load or rendering starts.
9
11
  # @api private
@@ -6,10 +6,10 @@ module Sevgi
6
6
  # DSL helpers for drawing geometry values and geometry-derived hatch lines.
7
7
  #
8
8
  # `Draw` delegates to each geometry object's drawing protocol. `Hatch`
9
- # first sweeps interior spans through a closed lined element, then draws
10
- # them. Its `angle` describes line direction; `step` is perpendicular
9
+ # first sweeps interior spans through a closed geometry element, then draws
10
+ # them. Its `angle` describes line direction. `step` is perpendicular
11
11
  # spacing. The default initial line passes through `element.position`.
12
- # The built-in `:inkscape` document profile includes this mixture;
12
+ # The built-in `:inkscape` document profile includes this mixture.
13
13
  # `:minimal`, `:default`, and `:html` do not.
14
14
  #
15
15
  # Hatch materializes separate finite SVG path elements. When only a repeated visual fill matters, use an SVG
@@ -52,7 +52,7 @@ module Sevgi
52
52
  # Sevgi::Graphics.SVG(:inkscape) do
53
53
  # Hatch region, initial: [2, 8], angle: 0, step: 3
54
54
  # end
55
- # @param element [Sevgi::Geometry::Element::Lined] lined geometry element to sweep
55
+ # @param element [Sevgi::Geometry::Element] geometry element to sweep
56
56
  # @param angle [Numeric] hatch angle in degrees
57
57
  # @param step [Numeric] distance between hatch lines
58
58
  # @param initial [Sevgi::Geometry::Point, Array<Numeric>, nil] initial sweep point, or nil for element.position
@@ -72,7 +72,7 @@ module Sevgi
72
72
  MissingComponentError.("sevgi/geometry")
73
73
  end
74
74
 
75
- initial = element.position if initial.nil? && element.is_a?(Geometry::Element::Lined)
75
+ initial = element.position if initial.nil? && element.is_a?(Geometry::Element)
76
76
  Draw(Geometry::Operation.sweep!(element, initial:, angle:, step:), **kwargs)
77
77
  end
78
78
  end
@@ -6,7 +6,7 @@ module Sevgi
6
6
  # DSL helpers for collecting and hiding SVG ids.
7
7
  module Identify
8
8
  # Immutable snapshot of every rendered element id under a subtree. Keys are the serialized id values, including
9
- # `"false"` and the empty String. Keys and containers are owned by the index; values retain references to the
9
+ # `"false"` and the empty String. Keys and containers are owned by the index. Values retain references to the
10
10
  # elements present when the snapshot is built. Later tree changes require a new index.
11
11
  class Identifiers
12
12
  # @return [Sevgi::Graphics::Element] indexed root element
@@ -48,7 +48,7 @@ module Sevgi
48
48
  # Sevgi::Graphics.SVG(:inkscape) { Group drawing, attributes: {id: "drawing"} }
49
49
  # @param mod [Module] module extended with {Sevgi::Graphics::Module}
50
50
  # @param args [Array<Object>] callable arguments
51
- # @param attributes [Hash] group attributes; String and Symbol names are normalized and must not collide
51
+ # @param attributes [Hash] group attributes. String and Symbol names are normalized and must not collide
52
52
  # @param kwargs [Hash] callable keyword arguments
53
53
  # @yield forwards customization to the callable module
54
54
  # @yieldreturn [Object] callable customization result
@@ -65,7 +65,7 @@ module Sevgi
65
65
  # Named modules default the layer id to their final constant name. Anonymous modules omit the id unless supplied.
66
66
  # @param mod [Module] module extended with {Sevgi::Graphics::Module}
67
67
  # @param args [Array<Object>] callable arguments
68
- # @param attributes [Hash] layer attributes; String and Symbol names are normalized and must not collide
68
+ # @param attributes [Hash] layer attributes. String and Symbol names are normalized and must not collide
69
69
  # @param kwargs [Hash] callable keyword arguments
70
70
  # @yield forwards customization to the callable module
71
71
  # @yieldreturn [Object] callable customization result
@@ -82,7 +82,7 @@ module Sevgi
82
82
  # Named modules default the layer id to their final constant name. Anonymous modules omit the id unless supplied.
83
83
  # @param mod [Module] module extended with {Sevgi::Graphics::Module}
84
84
  # @param args [Array<Object>] callable arguments
85
- # @param attributes [Hash] layer attributes; String and Symbol names are normalized and must not collide
85
+ # @param attributes [Hash] layer attributes. String and Symbol names are normalized and must not collide
86
86
  # @param kwargs [Hash] callable keyword arguments
87
87
  # @yield forwards customization to the callable module
88
88
  # @yieldreturn [Object] callable customization result
@@ -163,7 +163,7 @@ module Sevgi
163
163
  def channels(namedview, page)
164
164
  ArgumentError.("Namedview attributes must be a Hash") unless namedview.is_a?(::Hash)
165
165
  ArgumentError.("Page attributes must be a Hash") unless page.is_a?(::Hash)
166
- [Attribute.defaults(namedview, id: "namedview"), Attribute.normalize(page)]
166
+ [Attribute.defaults(namedview, id: "namedview"), page_attributes(page)]
167
167
  end
168
168
 
169
169
  # Normalizes one explicit or generated page id.
@@ -181,7 +181,7 @@ module Sevgi
181
181
  # @api private
182
182
  def normalize(attributes, defaults, index)
183
183
  ArgumentError.("Page #{index + 1} must be a Hash") unless attributes.is_a?(::Hash)
184
- attributes = defaults.merge(Attribute.normalize(attributes))
184
+ attributes = defaults.merge(page_attributes(attributes))
185
185
  %i[x y].each { number(attributes, it, index) }
186
186
  %i[width height].each do |field|
187
187
  number(attributes, field, index, positive: true)
@@ -190,6 +190,19 @@ module Sevgi
190
190
  identify(attributes, index)
191
191
  end
192
192
 
193
+ # Coerces numeric page fields before attribute snapshots stringify mutable numeric subclasses.
194
+ def page_attributes(attributes)
195
+ Attribute.normalize(
196
+ attributes.to_h do |key, value|
197
+ if %w[x y width height].include?(key.to_s) && value.is_a?(::Numeric)
198
+ value = Scalar.number(value, context: "page", field: key)
199
+ end
200
+
201
+ [key, value]
202
+ end
203
+ )
204
+ end
205
+
193
206
  # Validates and normalizes page-grid arguments.
194
207
  # @return [Array<(Integer, Float)>] normalized width, height, and gap
195
208
  # @raise [Sevgi::ArgumentError] when a count, dimension, or gap is invalid
@@ -239,9 +252,9 @@ module Sevgi
239
252
  # page: {class: "print"}
240
253
  # )
241
254
  # end
242
- # @param pages [Array<Hash>] page attribute hashes; numeric page fields are normalized to SVG numbers
243
- # @param namedview [Hash] namedview attributes; String and Symbol names are normalized and must not collide
244
- # @param page [Hash] page defaults; normalized page attributes override these defaults by name
255
+ # @param pages [Array<Hash>] page attribute hashes. Numeric page fields are normalized to SVG numbers
256
+ # @param namedview [Hash] namedview attributes. String and Symbol names are normalized and must not collide
257
+ # @param page [Hash] page defaults. Normalized page attributes override these defaults by name
245
258
  # @yield [page] customizes each generated page element
246
259
  # @yieldparam page [Sevgi::Graphics::Element] generated page element
247
260
  # @yieldreturn [Object] ignored customization result
@@ -262,8 +275,8 @@ module Sevgi
262
275
  # @param width [Numeric] finite positive page width, normalized to an SVG number
263
276
  # @param height [Numeric] finite positive page height, normalized to an SVG number
264
277
  # @param gap [Numeric] finite non-negative gap, normalized to an SVG number
265
- # @param namedview [Hash] namedview attributes; String and Symbol names are normalized and must not collide
266
- # @param page [Hash] page defaults; normalized page attributes override these defaults by name
278
+ # @param namedview [Hash] namedview attributes. String and Symbol names are normalized and must not collide
279
+ # @param page [Hash] page defaults. Normalized page attributes override these defaults by name
267
280
  # @yield [page] customizes each generated page element
268
281
  # @yieldparam page [Sevgi::Graphics::Element] generated page element
269
282
  # @yieldreturn [Object] ignored customization result
@@ -114,7 +114,7 @@ module Sevgi
114
114
  def License_LAL(**kwargs, &block) = License(**kwargs, license: "https://artlibre.org/licence/lal/en/", &block)
115
115
 
116
116
  # Builds an RDF root element.
117
- # @param kwargs [Hash] options; RDF currently accepts none
117
+ # @param kwargs [Hash] options. RDF currently accepts none
118
118
  # @yield evaluates the RDF drawing DSL
119
119
  # @yieldreturn [Object] ignored block result
120
120
  # @return [Sevgi::Graphics::Element] RDF element
@@ -13,68 +13,6 @@ module Sevgi
13
13
  STYLES = %i[hybrid inline block].freeze
14
14
  SVG_NAMESPACE = "http://www.w3.org/2000/svg"
15
15
 
16
- # Attribute rendering strategies.
17
- # @api private
18
- module Attributes
19
- # Block-style attribute renderer.
20
- # @api private
21
- module Block
22
- # Renders attributes in block form.
23
- # @param element [Sevgi::Graphics::Element] rendered element
24
- # @param depth [Integer] element depth
25
- # @return [void]
26
- def attributes(element, depth)
27
- attributes_block(element, depth, element.attributes.send(:xml_lines))
28
- end
29
- end
30
-
31
- # Hybrid attribute renderer.
32
- # @api private
33
- module Hybrid
34
- # Renders attributes inline or in block form according to line length.
35
- # @param element [Sevgi::Graphics::Element] rendered element
36
- # @param depth [Integer] element depth
37
- # @return [void]
38
- def attributes(element, depth)
39
- if attributes_as_block?(lines = element.attributes.send(:xml_lines), depth)
40
- attributes_block(element, depth, lines)
41
- else
42
- attributes_inline(element, depth, lines)
43
- end
44
- end
45
-
46
- # Reports whether attributes should be rendered in block form.
47
- # @param lines [Array<String>] rendered attribute lines
48
- # @param depth [Integer] element depth
49
- # @return [Boolean]
50
- def attributes_as_block?(lines, depth)
51
- linelength(lines, depth) > options[:linelength]
52
- end
53
-
54
- # Returns the effective inline line length.
55
- # @param lines [Array<String>] rendered attribute lines
56
- # @param depth [Integer] element depth
57
- # @return [Integer]
58
- def linelength(lines, depth)
59
- indent(depth).length + lines.sum(&:length)
60
- end
61
- end
62
-
63
- # Inline attribute renderer.
64
- # @api private
65
- module Inline
66
- # Renders attributes inline.
67
- # @param element [Sevgi::Graphics::Element] rendered element
68
- # @param depth [Integer] element depth
69
- # @return [void]
70
- def attributes(element, depth)
71
- attributes_inline(element, depth, element.attributes.send(:xml_lines))
72
- end
73
- end
74
- end
75
-
76
- private_constant :Attributes
77
-
78
16
  ELEMENTS_WITH_INLINE_CONTENT = %i[title].freeze
79
17
  ELEMENTS_WITH_BLOCK_CONTENT = %i[style].freeze
80
18
  SEPARATOR = "\n"
@@ -102,17 +40,11 @@ module Sevgi
102
40
  # Inline mark with start, stop, and depth.
103
41
  Mark = Struct.new(:start, :stop, :depth)
104
42
 
105
- # Creates an inline splice tracker.
106
- # @return [void]
107
43
  def initialize
108
44
  @marks = []
109
45
  @stack = []
110
46
  end
111
47
 
112
- # Starts an inline splice range.
113
- # @param index [Integer] output index
114
- # @param depth [Integer] element depth
115
- # @return [Sevgi::Graphics::Mixtures::Render::Renderer::Inlines::Mark] opened mark
116
48
  def start(index, depth)
117
49
  Mark.new(start: index, depth:).tap do |mark|
118
50
  @marks << mark
@@ -120,10 +52,6 @@ module Sevgi
120
52
  end
121
53
  end
122
54
 
123
- # Ends the innermost inline splice range.
124
- # @param index [Integer] output index
125
- # @return [Integer]
126
- # @raise [Sevgi::PanicError] when no inline range is open
127
55
  def stop(index)
128
56
  mark = @stack.pop
129
57
  PanicError.("Inline content range was not opened") unless mark
@@ -131,12 +59,6 @@ module Sevgi
131
59
  mark.stop = index
132
60
  end
133
61
 
134
- # Joins marked output ranges into inline content.
135
- # @param output [Array<Array<String>, nil>] renderer output buffer
136
- # @param indent [String] indentation unit
137
- # @param separator [String] line separator
138
- # @return [Array<Array<String>, nil>, nil]
139
- # @raise [Sevgi::PanicError] when an inline range was not closed
140
62
  def join(output, indent:, separator:)
141
63
  return if @marks.empty?
142
64
 
@@ -175,8 +97,7 @@ module Sevgi
175
97
  @options = self.class.send(:validate, **)
176
98
  @output = []
177
99
  @inlines = Inlines.new
178
-
179
- build
100
+ unclosed
180
101
  end
181
102
 
182
103
  # @overload call(root, **options)
@@ -246,6 +167,14 @@ module Sevgi
246
167
 
247
168
  attr_reader :inlines
248
169
 
170
+ def attributes(element, depth)
171
+ lines = element.attributes.send(:xml_lines)
172
+ block = options[:style] == :block ||
173
+ (options[:style] == :hybrid && indent(depth).length + lines.sum(&:length) > options[:linelength])
174
+
175
+ block ? attributes_block(element, depth, lines) : attributes_inline(element, depth, lines)
176
+ end
177
+
249
178
  def attributes_block(element, depth, lines)
250
179
  return attributes_inline(element, depth, lines) if lines.empty?
251
180
 
@@ -265,19 +194,6 @@ module Sevgi
265
194
  end
266
195
  end
267
196
 
268
- def build
269
- case options[:style]
270
- when :hybrid
271
- extend(Attributes::Hybrid)
272
- when :inline
273
- extend(Attributes::Inline)
274
- when :block
275
- extend(Attributes::Block)
276
- end
277
-
278
- unclosed
279
- end
280
-
281
197
  def childless?(element)
282
198
  element.children.empty? && element.contents.empty?
283
199
  end
@@ -361,9 +277,9 @@ module Sevgi
361
277
 
362
278
  # @overload Render(**options)
363
279
  # Renders this element as SVG source.
364
- # Elements with inline text content may also contain inline children such as `tspan`; the renderer keeps those
280
+ # Elements with inline text content can also contain inline children such as `tspan`. The renderer keeps those
365
281
  # descendants in the same text line. Whitespace inside content objects is preserved as given, and encoded
366
- # content is XML-escaped unless a verbatim content object is used. SVG `style` elements use block content;
282
+ # content is XML-escaped unless a verbatim content object is used. SVG `style` elements use block content.
367
283
  # same-named elements under a foreign default namespace retain ordinary inline text formatting.
368
284
  # @example Keep every attribute on the element's opening line
369
285
  # SVG(:minimal) { rect id: "card", width: 80, height: 40 }.Render(style: :inline)
@@ -54,10 +54,10 @@ module Sevgi
54
54
 
55
55
  # Saves rendered SVG when its content differs from the destination.
56
56
  # Relative destinations are expanded before being returned. When a non-empty backup suffix is given, an
57
- # existing destination is copied immediately before replacement; unchanged saves leave both files untouched.
57
+ # existing destination is copied immediately before replacement. Unchanged saves leave both files untouched.
58
58
  # Missing parent directories are created. An existing directory target uses the default file name.
59
59
  # @example Save to a relative destination
60
- # path = Sevgi::Graphics.SVG(:minimal).Save("build/drawing.svg")
60
+ # path = Sevgi::Graphics.SVG.Save("build/drawing.svg")
61
61
  # path == File.expand_path("build/drawing.svg") # => true
62
62
  # @param path [String, #to_path, nil] output path or existing directory
63
63
  # @param default [String, #to_path, nil] default output path
@@ -65,7 +65,7 @@ module Sevgi
65
65
 
66
66
  private_constant :Expansion
67
67
 
68
- # Renders module callables as symbols under defs. Named modules default the defs id to their final constant name;
68
+ # Renders module callables as symbols under defs. Named modules default the defs id to their final constant name.
69
69
  # anonymous modules omit the id unless supplied.
70
70
  # @param mod [Module] module extended with {Sevgi::Graphics::Module}
71
71
  # @param args [Array<Object>] callable arguments
@@ -78,7 +78,7 @@ module Sevgi
78
78
  # def tick = path d: "M 0 2 L 2 4 L 6 0"
79
79
  # end
80
80
  # Sevgi::Graphics.SVG(:minimal) { Symbols icons }
81
- # @param attributes [Hash] defs attributes; String and Symbol names are normalized and must not collide
81
+ # @param attributes [Hash] defs attributes. String and Symbol names are normalized and must not collide
82
82
  # @param ids [#call, nil] optional callable mapping each method name to a symbol id
83
83
  # @param kwargs [Hash] callable keyword arguments
84
84
  # @yield forwarded to each callable
@@ -8,7 +8,7 @@ module Sevgi
8
8
  # Use {Sevgi::Sundries::Tile} instead when Ruby code needs inspectable repeated geometry or row/column bounds
9
9
  # rather than SVG references.
10
10
  # @see Sevgi::Sundries::Tile
11
- # @see https://sevgi.roktas.dev/sundries/#choose-a-layout-model Choosing a layout model
11
+ # @see https://sevgi.roktas.dev/layout/#choose-a-layout-model Choosing a layout model
12
12
  module Tile
13
13
  # Stable prefix used for generated tile CSS classes.
14
14
  PREFIX = "tile"
@@ -30,7 +30,7 @@ module Sevgi
30
30
  # @param dy [Numeric] finite vertical spacing, normalized before coordinates are rendered
31
31
  # @param oy [Numeric] finite vertical offset, normalized before coordinates are rendered
32
32
  # @param proc [Proc, nil] optional callback invoked for each use as `(element, x:, y:, nx:, ny:)`, with
33
- # zero-based coordinates and total counts; the callback may mutate the element and its return value is ignored
33
+ # zero-based coordinates and total counts. The callback can mutate the element. Its return value is ignored
34
34
  # @yield evaluates the template drawing DSL in a generated `defs` group named by id
35
35
  # @yieldreturn [Object] ignored block result
36
36
  # @return [Sevgi::Graphics::Element] self
@@ -83,7 +83,7 @@ module Sevgi
83
83
  # @param d [Numeric] finite horizontal spacing, normalized before coordinates are rendered
84
84
  # @param o [Numeric] finite horizontal offset, normalized before coordinates are rendered
85
85
  # @param proc [Proc, nil] optional callback invoked for each use as `(element, x:, n:)`, with a zero-based column
86
- # and total count; the callback may mutate the element and its return value is ignored
86
+ # and total count. The callback can mutate the element. Its return value is ignored
87
87
  # @yield evaluates the template drawing DSL in a generated `defs` group named by id
88
88
  # @yieldreturn [Object] ignored block result
89
89
  # @return [Sevgi::Graphics::Element] self
@@ -117,7 +117,7 @@ module Sevgi
117
117
  # @param d [Numeric] finite vertical spacing, normalized before coordinates are rendered
118
118
  # @param o [Numeric] finite vertical offset, normalized before coordinates are rendered
119
119
  # @param proc [Proc, nil] optional callback invoked for each use as `(element, y:, n:)`, with a zero-based row and
120
- # total count; the callback may mutate the element and its return value is ignored
120
+ # total count. The callback can mutate the element. Its return value is ignored
121
121
  # @yield evaluates the template drawing DSL in a generated `defs` group named by id
122
122
  # @yieldreturn [Object] ignored block result
123
123
  # @return [Sevgi::Graphics::Element] self
@@ -11,13 +11,18 @@ module Sevgi
11
11
  # rect(width: 8, height: 4).Translate(12, 6).Rotate(15, 4, 2)
12
12
  # end
13
13
  module Transform
14
+ # Keep box validation here: mixture helper methods would also enter the SVG DSL namespace.
15
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
16
+
14
17
  # Aligns an inner box inside an outer box.
15
18
  # @param position [Symbol, String, nil] alignment name
16
- # @param inner [#width, #height, nil] inner box
17
- # @param outer [#width, #height, nil] outer box
19
+ # @param inner [#width, #height, nil] inner box, with optional position exposing x and y
20
+ # @param outer [#width, #height, nil] outer box, with optional position exposing x and y
18
21
  # @return [Sevgi::Graphics::Element] self
19
22
  # @raise [Sevgi::ArgumentError] when alignment is unsupported
20
23
  # @raise [Sevgi::ArgumentError] when a box dimension is not a finite real number
24
+ # @raise [Sevgi::ArgumentError] when a supplied box position does not expose finite real x and y coordinates
25
+ # @note Size-only boxes have origin (0, 0). Positions describe known box geometry, not renderer-computed bounds.
21
26
  def Align(position, inner:, outer:)
22
27
  return self unless position && inner && outer
23
28
 
@@ -25,12 +30,26 @@ module Sevgi
25
30
  when :center
26
31
  dimensions = [inner.width, inner.height, outer.width, outer.height]
27
32
  iw, ih, ow, oh = dimensions.map { Scalar.number(it, context: "alignment", field: :dimension) }
28
- Translate((ow - iw) / 2.0, (oh - ih) / 2.0)
33
+ origins = [inner, outer].map do |box|
34
+ next [0, 0] unless box.respond_to?(:position)
35
+
36
+ origin = box.position
37
+ unless origin.respond_to?(:x) && origin.respond_to?(:y)
38
+ ArgumentError.("Alignment box position must expose x and y")
39
+ end
40
+
41
+ [origin.x, origin.y].map { Scalar.number(it, context: "alignment", field: :position) }
42
+ end
43
+
44
+ (ix, iy), (ox, oy) = origins
45
+ Translate(ox - ix + ((ow - iw) / 2.0), oy - iy + ((oh - ih) / 2.0))
29
46
  else
30
47
  ArgumentError.("Unsupported alignment: #{position}")
31
48
  end
32
49
  end
33
50
 
51
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
52
+
34
53
  # Appends a scale(-1, -1) transform.
35
54
  # @return [Sevgi::Graphics::Element] self
36
55
  def Flip
@@ -18,7 +18,7 @@ module Sevgi
18
18
  # Adds an XML comment.
19
19
  # @param comment [Object] comment text
20
20
  # @return [Sevgi::Graphics::Element] floating comment element
21
- # @raise [Sevgi::ArgumentError] when comment cannot be stringified as valid XML or would form malformed markup
21
+ # @raise [Sevgi::ArgumentError] when comment cannot be stringified as valid XML or forms malformed markup
22
22
  def Comment(comment)
23
23
  comment = XML.text(comment, context: "XML comment")
24
24
 
@@ -29,7 +29,7 @@ module Sevgi
29
29
  end
30
30
 
31
31
  # Merges `-context` metadata from the document root, ancestors, and this element.
32
- # Only the direct root-to-self ancestor chain participates; sibling subtrees are ignored. When the same key is
32
+ # Only the direct root-to-self ancestor chain participates. Sibling subtrees are ignored. When the same key is
33
33
  # present on multiple chain elements, the nearest element to the receiver wins. Context remains available in
34
34
  # memory but is omitted from rendered SVG. It is unrelated to the `_:` XML namespace syntax preserved by
35
35
  # Derender.
@@ -5,6 +5,55 @@ module Sevgi
5
5
  module Mixtures
6
6
  # DSL wrappers for common SVG shapes and content patterns.
7
7
  module Wrappers
8
+ # rubocop:disable Metrics/ParameterLists
9
+
10
+ # Builds an elliptical arc path ending at an absolute point.
11
+ # SVG resolves the ellipse from its endpoints, radii, and flags, including radius correction and zero radii.
12
+ # @example Draw an upper semicircle in screen coordinates
13
+ # Sevgi::Graphics.SVG { ArcTo x1: 0, y1: 10, x2: 20, y2: 10, rx: 10, ry: 10, sweep: true }
14
+ # @param x2 [Numeric] finite ending x coordinate
15
+ # @param y2 [Numeric] finite ending y coordinate
16
+ # @param rx [Numeric] finite local x radius
17
+ # @param ry [Numeric] finite local y radius
18
+ # @param x1 [Numeric] finite starting x coordinate
19
+ # @param y1 [Numeric] finite starting y coordinate
20
+ # @param rotation [Numeric] clockwise ellipse rotation in degrees
21
+ # @param large [Boolean] select the arc exceeding a half turn
22
+ # @param sweep [Boolean] select increasing parameter angles
23
+ # @return [Sevgi::Graphics::Element] path element
24
+ # @raise [Sevgi::ArgumentError] when an operand is not finite real or a flag is not Boolean
25
+ def ArcTo(x2:, y2:, rx:, ry:, x1: 0, y1: 0, rotation: 0, large: false, sweep: false, **)
26
+ x1, y1, x2, y2, rx, ry, rotation = Scalar.numbers([x1, y1, x2, y2, rx, ry, rotation], context: "absolute arc")
27
+ unless [large, sweep].all? { it.equal?(true) || it.equal?(false) }
28
+ ArgumentError.("Arc flags must be Boolean")
29
+ end
30
+
31
+ path(d: "M #{x1} #{y1} A #{rx} #{ry} #{rotation} #{large ? 1 : 0} #{sweep ? 1 : 0} #{x2} #{y2}", **)
32
+ end
33
+
34
+ # Builds an elliptical arc path ending at a relative offset.
35
+ # @param dx [Numeric] finite x displacement from the starting point
36
+ # @param dy [Numeric] finite y displacement from the starting point
37
+ # @param rx [Numeric] finite local x radius
38
+ # @param ry [Numeric] finite local y radius
39
+ # @param x [Numeric] finite starting x coordinate
40
+ # @param y [Numeric] finite starting y coordinate
41
+ # @param rotation [Numeric] clockwise ellipse rotation in degrees
42
+ # @param large [Boolean] select the arc exceeding a half turn
43
+ # @param sweep [Boolean] select increasing parameter angles
44
+ # @return [Sevgi::Graphics::Element] path element
45
+ # @raise [Sevgi::ArgumentError] when an operand is not finite real or a flag is not Boolean
46
+ # @see #ArcTo
47
+ def ArcBy(dx:, dy:, rx:, ry:, x: 0, y: 0, rotation: 0, large: false, sweep: false, **)
48
+ x, y, dx, dy, rx, ry, rotation = Scalar.numbers([x, y, dx, dy, rx, ry, rotation], context: "relative arc")
49
+ unless [large, sweep].all? { it.equal?(true) || it.equal?(false) }
50
+ ArgumentError.("Arc flags must be Boolean")
51
+ end
52
+
53
+ path(d: "M #{x} #{y} a #{rx} #{ry} #{rotation} #{large ? 1 : 0} #{sweep ? 1 : 0} #{dx} #{dy}", **)
54
+ end
55
+ # rubocop:enable Metrics/ParameterLists
56
+
8
57
  # Builds a line path ending at an absolute point.
9
58
  # @example Render a rational coordinate as an SVG number
10
59
  # Sevgi::Graphics.SVG { LineTo(x2: Rational(1, 2), y2: 1) }
@@ -3,6 +3,6 @@
3
3
  module Sevgi
4
4
  module Graphics
5
5
  # Current graphics component version.
6
- VERSION = "0.98.2"
6
+ VERSION = "1.0.0"
7
7
  end
8
8
  end
@@ -11,8 +11,9 @@ module Sevgi
11
11
  NCNAME_CHAR = "#{NCNAME_START}\\-.0-9\u{B7}\u{300}-\u{36F}\u{203F}-\u{2040}".freeze
12
12
  NCNAME = "[#{NCNAME_START}][#{NCNAME_CHAR}]*".freeze
13
13
  QNAME = /\A#{NCNAME}(?::#{NCNAME})?\z/u
14
+ ILLEGAL_CHARACTER = /[^\u0009\u000A\u000D\u0020-\uD7FF\uE000-\uFFFD\u{10000}-\u{10FFFF}]/u
14
15
 
15
- private_constant :NCNAME, :NCNAME_CHAR, :NCNAME_START, :QNAME
16
+ private_constant :NCNAME, :NCNAME_CHAR, :NCNAME_START, :QNAME, :ILLEGAL_CHARACTER
16
17
 
17
18
  class << self
18
19
  # Validates an XML 1.0 string representation.
@@ -104,8 +105,8 @@ module Sevgi
104
105
  ArgumentError.("#{context} must be valid UTF-8") unless value.valid_encoding?
105
106
 
106
107
  text = value.encode("UTF-8")
107
- if (codepoint = text.each_codepoint.find { !legal_codepoint?(it) })
108
- ArgumentError.("#{context} contains illegal character U+#{format("%04X", codepoint)}")
108
+ if (match = ILLEGAL_CHARACTER.match(text))
109
+ ArgumentError.("#{context} contains illegal character U+#{format("%04X", match[0].ord)}")
109
110
  end
110
111
 
111
112
  text
@@ -113,12 +114,6 @@ module Sevgi
113
114
  ArgumentError.("#{context} must be valid UTF-8: #{e.message}")
114
115
  end
115
116
 
116
- def legal_codepoint?(codepoint)
117
- [0x9, 0xA, 0xD].include?(codepoint) ||
118
- (0x20..0xD7FF).cover?(codepoint) ||
119
- (0xE000..0xFFFD).cover?(codepoint) ||
120
- (0x10000..0x10FFFF).cover?(codepoint)
121
- end
122
117
  end
123
118
  end
124
119