sevgi-graphics 0.95.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +221 -2
- data/README.md +12 -9
- data/lib/sevgi/graphics/attribute.rb +166 -45
- data/lib/sevgi/graphics/auxiliary/canvas.rb +100 -43
- data/lib/sevgi/graphics/auxiliary/content.rb +56 -47
- data/lib/sevgi/graphics/auxiliary/margin.rb +19 -12
- data/lib/sevgi/graphics/auxiliary/paper.rb +74 -49
- data/lib/sevgi/graphics/auxiliary/path.rb +44 -0
- data/lib/sevgi/graphics/auxiliary/scalar.rb +36 -7
- data/lib/sevgi/graphics/auxiliary.rb +1 -0
- data/lib/sevgi/graphics/document/base.rb +6 -2
- data/lib/sevgi/graphics/document/default.rb +1 -1
- data/lib/sevgi/graphics/document.rb +239 -117
- data/lib/sevgi/graphics/element.rb +132 -34
- data/lib/sevgi/graphics/mixtures/call.rb +234 -88
- data/lib/sevgi/graphics/mixtures/core.rb +67 -27
- data/lib/sevgi/graphics/mixtures/duplicate.rb +47 -25
- data/lib/sevgi/graphics/mixtures/export.rb +54 -12
- data/lib/sevgi/graphics/mixtures/hatch.rb +49 -7
- data/lib/sevgi/graphics/mixtures/identify.rb +30 -17
- data/lib/sevgi/graphics/mixtures/include.rb +26 -8
- data/lib/sevgi/graphics/mixtures/inkscape.rb +214 -47
- data/lib/sevgi/graphics/mixtures/rdf.rb +59 -7
- data/lib/sevgi/graphics/mixtures/render.rb +60 -120
- data/lib/sevgi/graphics/mixtures/save.rb +79 -35
- data/lib/sevgi/graphics/mixtures/symbols.rb +81 -12
- data/lib/sevgi/graphics/mixtures/tile.rb +85 -67
- data/lib/sevgi/graphics/mixtures/transform.rb +89 -30
- data/lib/sevgi/graphics/mixtures/underscore.rb +16 -7
- data/lib/sevgi/graphics/mixtures/validate.rb +2 -2
- data/lib/sevgi/graphics/mixtures/wrappers.rb +111 -23
- data/lib/sevgi/graphics/mixtures.rb +15 -13
- data/lib/sevgi/graphics/version.rb +1 -1
- data/lib/sevgi/graphics/xml.rb +4 -9
- data/lib/sevgi/graphics.rb +69 -18
- metadata +7 -6
|
@@ -4,48 +4,72 @@ require "forwardable"
|
|
|
4
4
|
|
|
5
5
|
module Sevgi
|
|
6
6
|
module Graphics
|
|
7
|
-
# SVG canvas size, margins, viewport, and viewBox.
|
|
7
|
+
# Immutable SVG canvas size, margins, viewport, and viewBox.
|
|
8
|
+
#
|
|
9
|
+
# `size` is the outer paper. `inner` subtracts the margins but remains a
|
|
10
|
+
# size value, not a positioned rectangle. By default the viewBox origin is
|
|
11
|
+
# the negative left/top margin, so drawing coordinates `(0, 0)` begin at the
|
|
12
|
+
# inner area's top-left while the SVG viewport still includes the margins.
|
|
13
|
+
# Pass nil to {#viewbox} or {#attributes} for a literal zero origin.
|
|
14
|
+
# @example Build from a paper profile or an explicit size
|
|
15
|
+
# page = Sevgi::Graphics::Canvas.from_paper(:a4, margins: [10])
|
|
16
|
+
# icon = Sevgi::Graphics::Canvas.call(width: 24, height: 24, unit: :px)
|
|
17
|
+
# page.viewbox # uses the negative left and top margins as its origin
|
|
18
|
+
# icon.viewbox(nil) # uses `0 0` as its origin
|
|
19
|
+
# @see Sevgi::Graphics::Document
|
|
20
|
+
# @see Sevgi::Sundries::Grid
|
|
8
21
|
class Canvas
|
|
22
|
+
DEFAULTS = {unit: "mm", name: :custom, margins: []}.freeze
|
|
23
|
+
FIELDS = %i[width height unit name margins].freeze
|
|
9
24
|
ORIGIN_FIELDS = %i[x y].freeze
|
|
10
|
-
|
|
11
|
-
private_constant :ORIGIN_FIELDS, :
|
|
25
|
+
REQUIRED = %i[width height].freeze
|
|
26
|
+
private_constant :DEFAULTS, :FIELDS, :ORIGIN_FIELDS, :REQUIRED
|
|
12
27
|
|
|
13
|
-
# @overload call(
|
|
14
|
-
# Builds a canvas from a paper profile
|
|
15
|
-
# @param
|
|
16
|
-
# @param
|
|
28
|
+
# @overload call(paper, **overrides)
|
|
29
|
+
# Builds a canvas from a paper profile with optional field overrides.
|
|
30
|
+
# @param paper [Sevgi::Graphics::Paper, Symbol, String] paper object or registered profile
|
|
31
|
+
# @param overrides [Hash] canvas field overrides
|
|
17
32
|
# @return [Sevgi::Graphics::Canvas]
|
|
18
|
-
# @raise [Sevgi::ArgumentError] when the paper
|
|
19
|
-
|
|
33
|
+
# @raise [Sevgi::ArgumentError] when the paper or an override is invalid
|
|
34
|
+
# @overload call(width:, height:, unit: "mm", name: :custom, margins: [])
|
|
35
|
+
# Builds a canvas from an explicit size.
|
|
36
|
+
# @param width [Numeric] canvas width
|
|
37
|
+
# @param height [Numeric] canvas height
|
|
38
|
+
# @param unit [Symbol, String] SVG unit
|
|
39
|
+
# @param name [Symbol, String] paper name
|
|
40
|
+
# @param margins [Array<Numeric>] margin shorthand values
|
|
41
|
+
# @return [Sevgi::Graphics::Canvas]
|
|
42
|
+
# @raise [Sevgi::ArgumentError] when a required field is omitted or a value is invalid
|
|
43
|
+
def self.call(paper = Undefined, **fields)
|
|
44
|
+
paper.equal?(Undefined) ? new(**fields) : from_paper(paper, **fields)
|
|
45
|
+
end
|
|
20
46
|
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
47
|
+
# @overload from_paper(paper, **overrides)
|
|
48
|
+
# Builds a canvas from a paper profile with optional field overrides.
|
|
49
|
+
# @param paper [Sevgi::Graphics::Paper, Symbol, String] paper object or registered profile
|
|
50
|
+
# @param overrides [Hash] canvas field overrides
|
|
51
|
+
# @return [Sevgi::Graphics::Canvas]
|
|
52
|
+
# @raise [Sevgi::ArgumentError] when the paper is omitted or invalid, or an override is invalid
|
|
53
|
+
# @example Override a registered paper profile
|
|
54
|
+
# Sevgi::Graphics::Canvas.from_paper(:a4, margins: [10])
|
|
55
|
+
def self.from_paper(paper = Undefined, **overrides)
|
|
56
|
+
ArgumentError.("Paper is required") if paper.equal?(Undefined)
|
|
57
|
+
|
|
58
|
+
new(**profile(paper).to_h, **overrides)
|
|
33
59
|
end
|
|
34
60
|
|
|
35
|
-
def self.paper
|
|
36
|
-
case
|
|
61
|
+
def self.profile(paper)
|
|
62
|
+
case paper
|
|
37
63
|
when Paper
|
|
38
|
-
|
|
64
|
+
paper
|
|
39
65
|
when ::Symbol, ::String
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
Paper.public_send(arg)
|
|
66
|
+
Paper.fetch(paper)
|
|
43
67
|
else
|
|
44
|
-
ArgumentError.("
|
|
68
|
+
ArgumentError.("Paper must be a Paper, Symbol, or String: #{paper}")
|
|
45
69
|
end
|
|
46
70
|
end
|
|
47
71
|
|
|
48
|
-
private_class_method :
|
|
72
|
+
private_class_method :profile
|
|
49
73
|
|
|
50
74
|
extend Forwardable
|
|
51
75
|
|
|
@@ -64,17 +88,24 @@ module Sevgi
|
|
|
64
88
|
# @return [Sevgi::Graphics::Paper]
|
|
65
89
|
attr_reader :inner
|
|
66
90
|
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
91
|
+
# @overload initialize(width:, height:, unit: "mm", name: :custom, margins: [])
|
|
92
|
+
# Creates a canvas with finite real dimensions greater than zero and margins that leave a positive inner area.
|
|
93
|
+
# @param width [Numeric] canvas width
|
|
94
|
+
# @param height [Numeric] canvas height
|
|
95
|
+
# @param unit [Symbol, String] SVG unit
|
|
96
|
+
# @param name [Symbol, String] paper name
|
|
97
|
+
# @param margins [Array<Numeric>] margin shorthand values
|
|
98
|
+
# @return [void]
|
|
99
|
+
# @raise [Sevgi::ArgumentError] when a required field is omitted, an option is unknown, or a value is invalid
|
|
100
|
+
def initialize(**fields)
|
|
101
|
+
unknown = fields.keys - FIELDS
|
|
102
|
+
ArgumentError.("Unknown canvas option: #{unknown.first}") unless unknown.empty?
|
|
103
|
+
ArgumentError.("Canvas width and height are required") unless REQUIRED.all? { fields.key?(it) }
|
|
104
|
+
|
|
105
|
+
fields = DEFAULTS.merge(fields)
|
|
106
|
+
|
|
107
|
+
@size = Paper[*fields.values_at(:width, :height, :unit, :name)]
|
|
108
|
+
@margin = Margin[*fields[:margins]]
|
|
78
109
|
|
|
79
110
|
compute
|
|
80
111
|
freeze
|
|
@@ -82,22 +113,48 @@ module Sevgi
|
|
|
82
113
|
|
|
83
114
|
# @overload attributes(origin = Undefined)
|
|
84
115
|
# Returns SVG root viewport attributes.
|
|
85
|
-
#
|
|
116
|
+
# Omission uses the negative left and top margins. Nil uses zero for both coordinates.
|
|
117
|
+
# @example Compare margin-aware and zero-origin viewBoxes
|
|
118
|
+
# canvas = Sevgi::Graphics::Canvas.call(width: 80, height: 50, margins: [5, 10])
|
|
119
|
+
# canvas.attributes[:viewBox] # => "-10 -5 80 50"
|
|
120
|
+
# canvas.attributes(nil)[:viewBox] # => "0 0 80 50"
|
|
121
|
+
# @param origin [Numeric, Array<Numeric>, nil, Sevgi::Undefined] viewBox origin. A scalar sets both coordinates
|
|
86
122
|
# @return [Hash{Symbol => String}] SVG viewport and viewBox attributes
|
|
87
123
|
# @raise [Sevgi::ArgumentError] when origin is invalid
|
|
88
124
|
def attributes(...) = {**viewport, viewBox: viewbox(...)}
|
|
89
125
|
|
|
126
|
+
# Reports structural canvas equality by size and margins.
|
|
127
|
+
# @example Compare equivalent canvas values
|
|
128
|
+
# Sevgi::Graphics::Canvas.from_paper(:a4, margins: [10]) ==
|
|
129
|
+
# Sevgi::Graphics::Canvas.from_paper("a4", margins: [10]) # => true
|
|
130
|
+
# @param other [Object] object to compare
|
|
131
|
+
# @return [Boolean]
|
|
132
|
+
def eql?(other) = self.class == other.class && size.eql?(other.size) && margin.eql?(other.margin)
|
|
133
|
+
|
|
134
|
+
# Returns a hash compatible with structural equality.
|
|
135
|
+
# @return [Integer]
|
|
136
|
+
def hash = [self.class, size, margin].hash
|
|
137
|
+
|
|
138
|
+
alias == eql?
|
|
139
|
+
|
|
90
140
|
# Returns SVG width and height attributes.
|
|
91
141
|
# @return [Hash{Symbol => String}] SVG width and height attributes
|
|
92
142
|
def viewport = {width: "#{width}#{unit}", height: "#{height}#{unit}"}
|
|
93
143
|
|
|
94
144
|
# Returns the SVG viewBox string.
|
|
95
|
-
#
|
|
145
|
+
# Omission uses the negative left and top margins. Nil uses zero for both coordinates.
|
|
146
|
+
# @param origin [Numeric, Array<Numeric>, nil, Sevgi::Undefined] viewBox origin. A scalar sets both coordinates
|
|
96
147
|
# @return [String]
|
|
97
148
|
# @raise [Sevgi::ArgumentError] when origin is invalid
|
|
98
149
|
def viewbox(origin = Undefined) = prettify(*originate(origin), width, height).join(" ")
|
|
99
150
|
|
|
100
151
|
# Returns a canvas with selected fields replaced.
|
|
152
|
+
# Unspecified fields, including margins, retain their current values.
|
|
153
|
+
# @example Derive a canvas while preserving the source
|
|
154
|
+
# source = Sevgi::Graphics::Canvas.call(width: 80, height: 50, margins: [5])
|
|
155
|
+
# derived = source.with(width: 100, margins: [10, 5])
|
|
156
|
+
# source.width # => 80.0
|
|
157
|
+
# derived.inner.deconstruct # => [90.0, 30.0, :mm, :custom]
|
|
101
158
|
# @param kwargs [Hash] replacement options
|
|
102
159
|
# @option kwargs [Numeric] :width replacement canvas width
|
|
103
160
|
# @option kwargs [Numeric] :height replacement canvas height
|
|
@@ -108,7 +165,7 @@ module Sevgi
|
|
|
108
165
|
# @raise [Sevgi::ArgumentError] when an unknown option is supplied
|
|
109
166
|
# @raise [Sevgi::ArgumentError] when a replacement value is invalid
|
|
110
167
|
def with(**kwargs)
|
|
111
|
-
unknown = kwargs.keys -
|
|
168
|
+
unknown = kwargs.keys - FIELDS
|
|
112
169
|
|
|
113
170
|
ArgumentError.("Unknown canvas option: #{unknown.first}") unless unknown.empty?
|
|
114
171
|
|
|
@@ -2,10 +2,38 @@
|
|
|
2
2
|
|
|
3
3
|
module Sevgi
|
|
4
4
|
module Graphics
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
5
|
+
# Extensible protocol for renderable text-like content inside an SVG element. Use {.cdata}, {.css}, {.encoded}, or
|
|
6
|
+
# {.verbatim} for built-in content, or subclass Content and implement {#render} for a custom serialization. Content
|
|
7
|
+
# owns an immutable deep snapshot: strings and containers are copied, mutable leaf objects are stringified once
|
|
8
|
+
# during construction, and {#content} returns caller-owned copies. Later mutations cannot change rendering or
|
|
9
|
+
# invalidate the construction-time XML checks.
|
|
10
|
+
#
|
|
11
|
+
# @abstract Subclasses implement {#render} and expose their own construction API.
|
|
12
|
+
# @example Choose a built-in content channel
|
|
13
|
+
# Sevgi::Graphics.SVG(:minimal) do
|
|
14
|
+
# text "A & B" # ordinary String arguments are encoded automatically
|
|
15
|
+
# text Sevgi::Graphics::Content.encoded("A & B")
|
|
16
|
+
# style Sevgi::Graphics::Content.cdata(".note { fill: red; }")
|
|
17
|
+
# style Sevgi::Graphics::Content.css(".note" => {fill: "red"})
|
|
18
|
+
# g Sevgi::Graphics::Content.verbatim("<title>trusted markup</title>")
|
|
19
|
+
# end
|
|
20
|
+
# @example Define custom content that emits an SVG tspan
|
|
21
|
+
# class Emphasis < Sevgi::Graphics::Content
|
|
22
|
+
# def self.[](content) = send(:new, content)
|
|
23
|
+
#
|
|
24
|
+
# def render(output, depth)
|
|
25
|
+
# text = Sevgi::Graphics::Content.encoded(to_s).to_s
|
|
26
|
+
# output.append(depth + 1, %(<tspan font-style="italic">#{text}</tspan>))
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# Sevgi::Graphics.SVG(:minimal) { text Emphasis["important & safe"] }.Render
|
|
31
|
+
#
|
|
32
|
+
# # The renderer ignores Emphasis#render's return value. Custom content must escape inserted data itself.
|
|
33
|
+
# @see https://sevgi.roktas.dev/documents/#content-safety Content safety guide
|
|
8
34
|
class Content
|
|
35
|
+
private_class_method :new
|
|
36
|
+
|
|
9
37
|
# Immutable payload capture and caller-owned copy helpers.
|
|
10
38
|
# @api private
|
|
11
39
|
module Snapshot
|
|
@@ -78,33 +106,28 @@ module Sevgi
|
|
|
78
106
|
# @return [Object] wrapped content snapshot
|
|
79
107
|
def content = Snapshot.copy(@content)
|
|
80
108
|
|
|
81
|
-
# Creates immutable content from a deep payload snapshot. Strings and containers are copied recursively
|
|
109
|
+
# Creates immutable content from a deep payload snapshot. Strings and containers are copied recursively. Mutable
|
|
82
110
|
# non-container objects are stringified once during construction. The caller's objects are never retained.
|
|
83
111
|
# @param content [Object] wrapped content
|
|
84
112
|
# @return [void]
|
|
85
113
|
# @raise [Sevgi::ArgumentError] when content cannot be stringified, contains invalid encoding or illegal XML
|
|
86
114
|
# characters, contains cycles, or has keys that collide after stringification
|
|
115
|
+
# @api private
|
|
87
116
|
def initialize(content)
|
|
88
117
|
@content = Snapshot.capture(content)
|
|
89
118
|
XML.validate(@content)
|
|
90
119
|
end
|
|
91
120
|
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
def initialize_copy(original)
|
|
97
|
-
@content = Snapshot.capture(original.content)
|
|
98
|
-
super
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
# Renders content with a renderer.
|
|
121
|
+
# Appends this content's serialized XML lines to rendering output.
|
|
122
|
+
# The output collaborator responds to `append(depth, *lines)`, where `depth` is an Integer or nil and every line
|
|
123
|
+
# is a String containing valid serialized XML text. The rendering engine ignores both `append` and `render` return
|
|
124
|
+
# values. Custom content is responsible for escaping data that it inserts into markup.
|
|
102
125
|
# @abstract Subclasses implement content rendering.
|
|
103
|
-
# @param
|
|
126
|
+
# @param _output [#append] rendering output collaborator
|
|
104
127
|
# @param _depth [Integer] current render depth
|
|
105
|
-
# @return [
|
|
128
|
+
# @return [Object] ignored by the rendering engine
|
|
106
129
|
# @raise [Sevgi::PanicError] when a subclass does not implement render
|
|
107
|
-
def render(
|
|
130
|
+
def render(_output, _depth) = PanicError.("#{self.class}#render must be implemented")
|
|
108
131
|
|
|
109
132
|
# Returns content as a string.
|
|
110
133
|
# @return [String]
|
|
@@ -121,15 +144,7 @@ module Sevgi
|
|
|
121
144
|
# @return [Sevgi::Graphics::Content::CData]
|
|
122
145
|
# @raise [Sevgi::ArgumentError] when content cannot be stringified, contains invalid encoding or illegal XML 1.0
|
|
123
146
|
# characters, contains cycles, or has keys that collide after stringification
|
|
124
|
-
def self.cdata(...) = CData.new
|
|
125
|
-
|
|
126
|
-
# Wraps content arguments, encoding non-content values.
|
|
127
|
-
# Mutable non-content values are stringified by encoded content during construction, before XML text escaping.
|
|
128
|
-
# @param args [Array<Object>] content arguments
|
|
129
|
-
# @return [Array<Sevgi::Graphics::Content>]
|
|
130
|
-
# @raise [Sevgi::ArgumentError] when an argument cannot be stringified, contains invalid encoding or illegal XML
|
|
131
|
-
# 1.0 characters, contains cycles, or has keys that collide after stringification
|
|
132
|
-
def self.contents(*args) = args.map { it.is_a?(Content) ? it : encoded(it) }
|
|
147
|
+
def self.cdata(...) = CData.send(:new, ...)
|
|
133
148
|
|
|
134
149
|
# @overload css(content)
|
|
135
150
|
# Builds CSS content.
|
|
@@ -138,7 +153,7 @@ module Sevgi
|
|
|
138
153
|
# @raise [Sevgi::ArgumentError] when content is not a hash, contains a malformed style, cannot be stringified,
|
|
139
154
|
# contains invalid encoding or illegal XML 1.0 characters, contains cycles, or has keys that collide after
|
|
140
155
|
# stringification
|
|
141
|
-
def self.css(...) = CSS.new
|
|
156
|
+
def self.css(...) = CSS.send(:new, ...)
|
|
142
157
|
|
|
143
158
|
# @overload encoded(content)
|
|
144
159
|
# Builds XML text-encoded content.
|
|
@@ -147,12 +162,7 @@ module Sevgi
|
|
|
147
162
|
# @return [Sevgi::Graphics::Content::Encoded]
|
|
148
163
|
# @raise [Sevgi::ArgumentError] when content cannot be stringified, contains invalid encoding or illegal XML 1.0
|
|
149
164
|
# characters, contains cycles, or has keys that collide after stringification
|
|
150
|
-
def self.encoded(...) = Encoded.new
|
|
151
|
-
|
|
152
|
-
# Joins content lines with newlines.
|
|
153
|
-
# @param contents [Object, Array<Object>] content lines
|
|
154
|
-
# @return [String]
|
|
155
|
-
def self.text(contents) = Array(contents).join("\n")
|
|
165
|
+
def self.encoded(...) = Encoded.send(:new, ...)
|
|
156
166
|
|
|
157
167
|
# @overload verbatim(content)
|
|
158
168
|
# Builds verbatim content.
|
|
@@ -161,17 +171,17 @@ module Sevgi
|
|
|
161
171
|
# @return [Sevgi::Graphics::Content::Verbatim]
|
|
162
172
|
# @raise [Sevgi::ArgumentError] when content cannot be stringified, contains invalid encoding or illegal XML 1.0
|
|
163
173
|
# characters, contains cycles, or has keys that collide after stringification
|
|
164
|
-
def self.verbatim(...) = Verbatim.new
|
|
174
|
+
def self.verbatim(...) = Verbatim.send(:new, ...)
|
|
165
175
|
|
|
166
176
|
# CDATA section content backed by an immutable payload snapshot. Mutable leaf objects are stringified during
|
|
167
|
-
# construction
|
|
177
|
+
# construction. Embedded terminators are split during rendering.
|
|
168
178
|
# @see Content.cdata
|
|
169
179
|
class CData < Content
|
|
170
180
|
# Renders CDATA content.
|
|
171
181
|
# Embedded `]]>` terminators are split across adjacent CDATA sections so the output remains valid XML.
|
|
172
|
-
# @param renderer [
|
|
182
|
+
# @param renderer [#append] rendering output collaborator
|
|
173
183
|
# @param depth [Integer] current render depth
|
|
174
|
-
# @return [
|
|
184
|
+
# @return [Object] ignored by the rendering engine
|
|
175
185
|
def render(renderer, depth)
|
|
176
186
|
depth += 1
|
|
177
187
|
|
|
@@ -185,7 +195,7 @@ module Sevgi
|
|
|
185
195
|
def safe(value) = XML.cdata(value)
|
|
186
196
|
end
|
|
187
197
|
|
|
188
|
-
# CSS content rendered inside a CDATA section. Rules are captured recursively during construction
|
|
198
|
+
# CSS content rendered inside a CDATA section. Rules are captured recursively during construction. Mutable
|
|
189
199
|
# selectors, property names, and values are stringified once, and embedded CDATA terminators are split safely.
|
|
190
200
|
# @see Content.css
|
|
191
201
|
class CSS < Content
|
|
@@ -195,6 +205,7 @@ module Sevgi
|
|
|
195
205
|
# @raise [Sevgi::ArgumentError] when content is not a hash, contains a malformed style, cannot be stringified,
|
|
196
206
|
# contains invalid encoding or illegal XML 1.0 characters, contains cycles, or has keys that collide after
|
|
197
207
|
# stringification
|
|
208
|
+
# @api private
|
|
198
209
|
def initialize(content)
|
|
199
210
|
ArgumentError.("CSS content must be a hash") unless content.is_a?(::Hash)
|
|
200
211
|
validate_styles(content)
|
|
@@ -202,11 +213,10 @@ module Sevgi
|
|
|
202
213
|
super
|
|
203
214
|
end
|
|
204
215
|
|
|
205
|
-
# rubocop:disable Metrics/MethodLength
|
|
206
216
|
# Renders CSS content.
|
|
207
|
-
# @param renderer [
|
|
217
|
+
# @param renderer [#append] rendering output collaborator
|
|
208
218
|
# @param depth [Integer] current render depth
|
|
209
|
-
# @return [
|
|
219
|
+
# @return [Object] ignored by the rendering engine
|
|
210
220
|
def render(renderer, depth)
|
|
211
221
|
depth += 1
|
|
212
222
|
|
|
@@ -230,7 +240,6 @@ module Sevgi
|
|
|
230
240
|
|
|
231
241
|
renderer.append(depth, "]]>")
|
|
232
242
|
end
|
|
233
|
-
# rubocop:enable Metrics/MethodLength
|
|
234
243
|
|
|
235
244
|
private
|
|
236
245
|
|
|
@@ -254,14 +263,14 @@ module Sevgi
|
|
|
254
263
|
def to_s = XML.text(payload).encode(xml: :text)
|
|
255
264
|
|
|
256
265
|
# Renders encoded text content.
|
|
257
|
-
# @param renderer [
|
|
266
|
+
# @param renderer [#append] rendering output collaborator
|
|
258
267
|
# @param depth [Integer] current render depth
|
|
259
|
-
# @return [
|
|
268
|
+
# @return [Object] ignored by the rendering engine
|
|
260
269
|
def render(renderer, depth) = renderer.append(depth + 1, to_s)
|
|
261
270
|
end
|
|
262
271
|
|
|
263
272
|
# Verbatim content backed by an immutable payload snapshot. Mutable leaf objects are stringified during
|
|
264
|
-
# construction. Verbatim content bypasses XML escaping
|
|
273
|
+
# construction. Verbatim content bypasses XML escaping. Validation guarantees encoding and legal XML 1.0 code
|
|
265
274
|
# points, not well-formed markup supplied by the caller.
|
|
266
275
|
# @see Content.verbatim
|
|
267
276
|
class Verbatim < Content
|
|
@@ -270,9 +279,9 @@ module Sevgi
|
|
|
270
279
|
def to_s = XML.text(payload)
|
|
271
280
|
|
|
272
281
|
# Renders verbatim content.
|
|
273
|
-
# @param renderer [
|
|
282
|
+
# @param renderer [#append] rendering output collaborator
|
|
274
283
|
# @param depth [Integer] current render depth
|
|
275
|
-
# @return [
|
|
284
|
+
# @return [Object] ignored by the rendering engine
|
|
276
285
|
def render(renderer, depth) = renderer.append(depth + 1, to_s)
|
|
277
286
|
end
|
|
278
287
|
end
|
|
@@ -3,6 +3,19 @@
|
|
|
3
3
|
module Sevgi
|
|
4
4
|
module Graphics
|
|
5
5
|
# Four-sided margin with CSS-like shorthand normalization.
|
|
6
|
+
# @!parse
|
|
7
|
+
# class Margin
|
|
8
|
+
# # Creates a margin from one to four CSS-like shorthand values.
|
|
9
|
+
# # @param top [Numeric, nil] top value or all-sides shorthand
|
|
10
|
+
# # @param right [Numeric, nil] right value or horizontal shorthand
|
|
11
|
+
# # @param bottom [Numeric, nil] bottom value
|
|
12
|
+
# # @param left [Numeric, nil] left value
|
|
13
|
+
# # @return [Sevgi::Graphics::Margin]
|
|
14
|
+
# # @raise [Sevgi::ArgumentError] when a supplied value is not a finite, non-negative real number
|
|
15
|
+
# # @example Create a vertical and horizontal margin
|
|
16
|
+
# # Sevgi::Graphics::Margin[5, 10]
|
|
17
|
+
# def self.[](top = nil, right = nil, bottom = nil, left = nil); end
|
|
18
|
+
# end
|
|
6
19
|
Margin = Data.define(:top, :right, :bottom, :left) do
|
|
7
20
|
include Comparable
|
|
8
21
|
|
|
@@ -28,14 +41,17 @@ module Sevgi
|
|
|
28
41
|
end
|
|
29
42
|
|
|
30
43
|
# Compares margins by top, right, bottom, then left.
|
|
31
|
-
# @param other [
|
|
32
|
-
# @return [Integer, nil]
|
|
33
|
-
def <=>(other)
|
|
44
|
+
# @param other [Object] margin to compare
|
|
45
|
+
# @return [Integer, nil] comparison result, or nil for a non-Margin operand
|
|
46
|
+
def <=>(other)
|
|
47
|
+
deconstruct <=> other.deconstruct if other.is_a?(self.class)
|
|
48
|
+
end
|
|
34
49
|
|
|
35
50
|
# Returns a margin inflated horizontally and vertically.
|
|
36
51
|
# @param h [Numeric] horizontal addition
|
|
37
52
|
# @param v [Numeric] vertical addition
|
|
38
53
|
# @return [Sevgi::Graphics::Margin]
|
|
54
|
+
# @raise [Sevgi::ArgumentError] when the adjusted margin is negative or not finite
|
|
39
55
|
def adjust(h, v) = self.class[top + v, right + h, bottom + v, left + h]
|
|
40
56
|
|
|
41
57
|
# Reports strict margin equality.
|
|
@@ -61,15 +77,6 @@ module Sevgi
|
|
|
61
77
|
# @return [Array<Float>]
|
|
62
78
|
alias_method :to_a, :deconstruct
|
|
63
79
|
|
|
64
|
-
# Builds a margin from an array-like shorthand.
|
|
65
|
-
# @param array [Object] value converted with Array()
|
|
66
|
-
# @return [Sevgi::Graphics::Margin]
|
|
67
|
-
def self.margin(array)
|
|
68
|
-
self[
|
|
69
|
-
*(array = Array(array)[0...(size = Margin.members.size)]).fill(nil, array.size, size - array.size)
|
|
70
|
-
]
|
|
71
|
-
end
|
|
72
|
-
|
|
73
80
|
# Returns a zero margin.
|
|
74
81
|
# @return [Sevgi::Graphics::Margin]
|
|
75
82
|
def self.zero = (@zero ||= self[0.0, 0.0, 0.0, 0.0])
|
|
@@ -3,6 +3,19 @@
|
|
|
3
3
|
module Sevgi
|
|
4
4
|
module Graphics
|
|
5
5
|
# Paper size and unit profile.
|
|
6
|
+
# @!parse
|
|
7
|
+
# class Paper
|
|
8
|
+
# # Creates a paper profile from dimensions and optional metadata.
|
|
9
|
+
# # @param width [Numeric] paper width
|
|
10
|
+
# # @param height [Numeric] paper height
|
|
11
|
+
# # @param unit [Symbol, String] SVG unit
|
|
12
|
+
# # @param name [Symbol, String] profile name
|
|
13
|
+
# # @return [Sevgi::Graphics::Paper]
|
|
14
|
+
# # @raise [Sevgi::ArgumentError] when dimensions, unit, or name are invalid
|
|
15
|
+
# # @example Create a custom paper profile with value notation
|
|
16
|
+
# # Sevgi::Graphics::Paper[90, 50, :mm, :card]
|
|
17
|
+
# def self.[](width, height, unit = "mm", name = :custom); end
|
|
18
|
+
# end
|
|
6
19
|
Paper = Data.define(:width, :height, :unit, :name) do
|
|
7
20
|
include Comparable
|
|
8
21
|
|
|
@@ -71,10 +84,25 @@ module Sevgi
|
|
|
71
84
|
name ? @mutex.synchronize { @profiles.key?(name) } : false
|
|
72
85
|
end
|
|
73
86
|
|
|
87
|
+
# Returns a registered paper profile by name.
|
|
88
|
+
# @param name [Symbol, String] profile name, including names that are not Ruby identifiers
|
|
89
|
+
# @return [Sevgi::Graphics::Paper] registered profile
|
|
90
|
+
# @raise [Sevgi::ArgumentError] when name is invalid or no profile is registered
|
|
91
|
+
# @example Look up a non-identifier profile name
|
|
92
|
+
# Sevgi::Graphics::Paper.define("business-card", width: 90, height: 50)
|
|
93
|
+
# Sevgi::Graphics::Paper.fetch("business-card")
|
|
94
|
+
def self.fetch(name)
|
|
95
|
+
name = normalize!(:name, name)
|
|
96
|
+
@mutex.synchronize { @profiles.fetch(name) { ArgumentError.("Unknown paper profile: #{name}") } }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Returns registered profile names.
|
|
100
|
+
# @return [Array<Symbol>] frozen name snapshot
|
|
101
|
+
def self.keys = @mutex.synchronize { @profiles.keys.freeze }
|
|
102
|
+
|
|
74
103
|
# Defines a named paper profile after complete validation. Registration is process-global and thread-atomic.
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
# `public_send`.
|
|
104
|
+
# Identical definitions return the canonical profile and conflicting definitions raise unless replacement is
|
|
105
|
+
# explicitly requested. Names that are not Ruby call syntax remain accessible through {.fetch}.
|
|
78
106
|
# @param name [Symbol, String] profile name
|
|
79
107
|
# @param overwrite [Boolean] true to replace an existing profile
|
|
80
108
|
# @param spec [Hash] paper dimensions and unit
|
|
@@ -84,7 +112,11 @@ module Sevgi
|
|
|
84
112
|
# @return [Sevgi::Graphics::Paper]
|
|
85
113
|
# @raise [Sevgi::ArgumentError] when the name, dimensions, unit, overwrite flag, or options are reserved or invalid,
|
|
86
114
|
# or a non-bang definition conflicts with the registered profile
|
|
87
|
-
|
|
115
|
+
# @example Define or reuse a matching profile
|
|
116
|
+
# Sevgi::Graphics::Paper.define(:card, width: 90, height: 50)
|
|
117
|
+
# @example Replace a profile explicitly
|
|
118
|
+
# Sevgi::Graphics::Paper.define(:card, width: 100, height: 60, overwrite: true)
|
|
119
|
+
def self.define(name, overwrite: false, **spec)
|
|
88
120
|
name = normalize!(:name, name)
|
|
89
121
|
ArgumentError.("Paper name is reserved: #{name}") if reserved?(name)
|
|
90
122
|
overwrite = overwrite!(overwrite)
|
|
@@ -93,68 +125,61 @@ module Sevgi
|
|
|
93
125
|
register(name, profile, overwrite:)
|
|
94
126
|
end
|
|
95
127
|
|
|
96
|
-
|
|
97
|
-
|
|
128
|
+
class << self
|
|
129
|
+
private
|
|
98
130
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
end
|
|
131
|
+
def install(name)
|
|
132
|
+
return if @accessors.key?(name)
|
|
102
133
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
ArgumentError.("Paper already defined differently: #{name}") unless current == profile
|
|
134
|
+
define_singleton_method(name) { @mutex.synchronize { @profiles.fetch(name) } }
|
|
135
|
+
@accessors[name] = true
|
|
136
|
+
end
|
|
107
137
|
|
|
108
|
-
|
|
109
|
-
|
|
138
|
+
def register(name, profile, overwrite:)
|
|
139
|
+
@mutex.synchronize do
|
|
140
|
+
if !overwrite && (current = @profiles[name])
|
|
141
|
+
ArgumentError.("Paper already defined differently: #{name}") unless current == profile
|
|
142
|
+
|
|
143
|
+
next current
|
|
144
|
+
end
|
|
110
145
|
|
|
111
|
-
|
|
112
|
-
|
|
146
|
+
install(name)
|
|
147
|
+
@profiles[name] = profile
|
|
148
|
+
end
|
|
113
149
|
end
|
|
114
|
-
end
|
|
115
150
|
|
|
116
|
-
|
|
151
|
+
def reserved?(name) = @reserved.include?(name)
|
|
117
152
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
153
|
+
def dimension!(field, value)
|
|
154
|
+
Scalar.finite(value, context: "paper", field:, positive: true)
|
|
155
|
+
end
|
|
121
156
|
|
|
122
|
-
|
|
123
|
-
|
|
157
|
+
def options!(options)
|
|
158
|
+
return if options.empty?
|
|
124
159
|
|
|
125
|
-
|
|
126
|
-
|
|
160
|
+
ArgumentError.("Unknown paper options: #{options.keys.join(", ")}")
|
|
161
|
+
end
|
|
127
162
|
|
|
128
|
-
|
|
129
|
-
|
|
163
|
+
def overwrite!(value)
|
|
164
|
+
return value if [true, false].include?(value)
|
|
130
165
|
|
|
131
|
-
|
|
132
|
-
|
|
166
|
+
ArgumentError.("Paper overwrite must be true or false")
|
|
167
|
+
end
|
|
133
168
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
169
|
+
def normalize(value)
|
|
170
|
+
normalized = value.to_sym if value.respond_to?(:to_sym)
|
|
171
|
+
normalized if normalized.is_a?(::Symbol)
|
|
172
|
+
rescue ::StandardError
|
|
173
|
+
nil
|
|
174
|
+
end
|
|
140
175
|
|
|
141
|
-
|
|
142
|
-
|
|
176
|
+
def normalize!(field, value)
|
|
177
|
+
normalize(value) || ArgumentError.("Invalid paper #{field}")
|
|
178
|
+
end
|
|
143
179
|
end
|
|
144
180
|
|
|
145
181
|
@reserved = methods.map(&:to_sym).freeze
|
|
146
182
|
|
|
147
|
-
private_class_method(
|
|
148
|
-
:dimension!,
|
|
149
|
-
:install,
|
|
150
|
-
:normalize,
|
|
151
|
-
:normalize!,
|
|
152
|
-
:options!,
|
|
153
|
-
:overwrite!,
|
|
154
|
-
:register,
|
|
155
|
-
:reserved?
|
|
156
|
-
)
|
|
157
|
-
|
|
158
183
|
PAPER_SIZES.each { |name, (width, height, unit)| define(name, width:, height:, unit:) }
|
|
159
184
|
|
|
160
185
|
# Returns the default paper profile.
|