Class: Postsvg::SvgBuilder

Inherits:
Object
  • Object
show all
Defined in:
lib/postsvg/svg_builder.rb

Overview

Append-only SVG emitter. The Renderer / Visitors call methods on a builder instance; #to_s finalizes and returns the SVG string.

Determinism invariants:

  • Two runs of the same input produce byte-equal output.
  • IDs (clip1, grad1, pattern1) start at 1 on every SvgBuilder.new.
  • Floats formatted through FormatNumber.
  • block orders clipPaths, gradients, then patterns by registration order so output is reproducible.

Constant Summary collapse

CLIP_PREFIX =
"clip".freeze
GRADIENT_PREFIX =
"grad".freeze
PATTERN_PREFIX =
"pattern".freeze

Instance Method Summary collapse

Constructor Details

#initializeSvgBuilder

Returns a new instance of SvgBuilder.



18
19
20
21
22
23
24
25
26
27
28
29
30
31
# File 'lib/postsvg/svg_builder.rb', line 18

def initialize
  @buffer = String.new(capacity: 4096)
  @defs_buffer = String.new(capacity: 1024)
  @body_buffer = String.new(capacity: 4096)
  @clip_paths = {}     # path_d -> id
  @gradients = {}      # signature -> id
  @patterns = {}       # signature -> id
  @next_clip_id = 1
  @next_gradient_id = 1
  @next_pattern_id = 1
  @svg_open = false
  @group_depth = 0
  @header_height = nil
end

Instance Method Details

#buffer_sizeObject

Expose internal buffers for testing only.



205
206
207
# File 'lib/postsvg/svg_builder.rb', line 205

def buffer_size
  @buffer.bytesize + @defs_buffer.bytesize + @body_buffer.bytesize
end

#close_groupObject

Raises:



58
59
60
61
62
63
64
# File 'lib/postsvg/svg_builder.rb', line 58

def close_group
  raise RenderError, "no open group" if @group_depth.zero?

  @body_buffer << %(</g>\n)
  @group_depth -= 1
  self
end

#close_svgObject

Raises:



185
186
187
188
189
190
191
192
193
194
195
196
# File 'lib/postsvg/svg_builder.rb', line 185

def close_svg
  raise RenderError, "svg not open" unless @svg_open
  raise RenderError, "unclosed groups: #{@group_depth}" if @group_depth.positive?

  @buffer << "<defs>\n" unless @clip_paths.empty? && @gradients.empty? && @patterns.empty?
  @buffer << @defs_buffer
  @buffer << "</defs>\n" unless @clip_paths.empty? && @gradients.empty? && @patterns.empty?
  @buffer << @body_buffer
  @buffer << "</svg>\n"
  @svg_open = false
  self
end

#comment(text) ⇒ Object



180
181
182
183
# File 'lib/postsvg/svg_builder.rb', line 180

def comment(text)
  @body_buffer << %(<!-- #{escape(text)} -->\n)
  self
end

#image(href:, x:, y:, width:, height:, transform: nil) ⇒ Object



170
171
172
173
174
175
176
177
178
# File 'lib/postsvg/svg_builder.rb', line 170

def image(href:, x:, y:, width:, height:, transform: nil)
  attrs = +""
  attrs << %( transform="#{transform}") if transform
  attrs << %( x="#{num(x)}" y="#{num(y)}")
  attrs << %( width="#{num(width)}" height="#{num(height)}")
  attrs << %( href="#{escape(href)}")
  @body_buffer << %(<image#{attrs} />\n)
  self
end

#open_group(transform: nil, clip_path_id: nil) ⇒ Object



49
50
51
52
53
54
55
56
# File 'lib/postsvg/svg_builder.rb', line 49

def open_group(transform: nil, clip_path_id: nil)
  attrs = +""
  attrs << %( transform="#{transform}") if transform
  attrs << %( clip-path="url(##{clip_path_id})") if clip_path_id
  @body_buffer << %(<g#{attrs}>\n)
  @group_depth += 1
  self
end

#open_svg(viewbox:, width:, height:) ⇒ Object

Raises:



33
34
35
36
37
38
39
40
41
42
43
# File 'lib/postsvg/svg_builder.rb', line 33

def open_svg(viewbox:, width:, height:)
  raise RenderError, "svg already open" if @svg_open

  @svg_open = true
  @header_height = height
  @buffer << %(<?xml version="1.0" encoding="UTF-8"?>\n)
  @buffer << %(<svg xmlns="http://www.w3.org/2000/svg" )
  @buffer << %(viewBox="#{viewbox}" )
  @buffer << %(width="#{num(width)}" height="#{num(height)}">\n)
  self
end

#open_y_flip_groupObject



45
46
47
# File 'lib/postsvg/svg_builder.rb', line 45

def open_y_flip_group
  open_group(transform: "translate(0 #{num(@header_height)}) scale(1 -1)")
end

#path(d:, mode:, color: nil, stroke_color: nil, stroke_width: nil, line_cap: nil, line_join: nil, dash: nil, clip_path_id: nil, fill_id: nil) ⇒ Object

Emit a . mode is one of :fill, :stroke, :fill_and_stroke.



132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
# File 'lib/postsvg/svg_builder.rb', line 132

def path(d:, mode:, color: nil, stroke_color: nil,
         stroke_width: nil, line_cap: nil, line_join: nil,
         dash: nil, clip_path_id: nil, fill_id: nil)
  attrs = +"d=\"#{d}\""

  attrs << case mode
           when :fill
             fill_attr(color, fill_id)
           when :stroke
             %( fill="none" stroke="#{color_attr(stroke_color || color)}")
           when :fill_and_stroke
             "#{fill_attr(color, fill_id)} stroke=\"#{color_attr(stroke_color || color)}\""
           else
             %( fill="none" stroke="none")
           end

  attrs << %( stroke-width="#{num(stroke_width)}") if stroke_width && stroke_width != 1.0
  attrs << %( stroke-linecap="#{line_cap}") if line_cap && line_cap != :butt
  attrs << %( stroke-linejoin="#{line_join}") if line_join && line_join != :miter
  attrs << %( stroke-dasharray="#{dash}") if dash
  attrs << %( clip-path="url(##{clip_path_id})") if clip_path_id

  @body_buffer << %(<path #{attrs} />\n)
  self
end

#register_clip_path(path_d) ⇒ Object

Register a clip path. Returns the id used. If path_d was previously registered, returns the existing id (dedup).



68
69
70
71
72
73
74
75
76
# File 'lib/postsvg/svg_builder.rb', line 68

def register_clip_path(path_d)
  return @clip_paths[path_d] if @clip_paths.key?(path_d)

  id = "#{CLIP_PREFIX}#{@next_clip_id}"
  @next_clip_id += 1
  @clip_paths[path_d] = id
  @defs_buffer << %(<clipPath id="#{id}"><path d="#{path_d}" /></clipPath>\n)
  id
end

#register_linear_gradient(stops:, x1:, y1:, x2:, y2:) ⇒ Object



78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'lib/postsvg/svg_builder.rb', line 78

def register_linear_gradient(stops:, x1:, y1:, x2:, y2:)
  sig = gradient_signature("linear", stops, [x1, y1, x2, y2])
  return @gradients[sig] if @gradients.key?(sig)

  id = "#{GRADIENT_PREFIX}#{@next_gradient_id}"
  @next_gradient_id += 1
  @gradients[sig] = id
  @defs_buffer << %(<linearGradient id="#{id}" )
  @defs_buffer << %(x1="#{num(x1)}" y1="#{num(y1)}" )
  @defs_buffer << %(x2="#{num(x2)}" y2="#{num(y2)}">\n)
  stops.each do |stop|
    @defs_buffer << %(<stop offset="#{num(stop[:offset])}" )
    @defs_buffer << %(stop-color="#{stop[:color].to_svg}" />\n)
  end
  @defs_buffer << %(</linearGradient>\n)
  id
end

#register_pattern(width:, height:, body_blocks:) ⇒ Object



116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/postsvg/svg_builder.rb', line 116

def register_pattern(width:, height:, body_blocks:)
  sig = "pattern:#{width}:#{height}:#{body_blocks.join('|')}"
  return @patterns[sig] if @patterns.key?(sig)

  id = "#{PATTERN_PREFIX}#{@next_pattern_id}"
  @next_pattern_id += 1
  @patterns[sig] = id
  @defs_buffer << %(<pattern id="#{id}" )
  @defs_buffer << %(width="#{num(width)}" height="#{num(height)}" )
  @defs_buffer << %(patternUnits="userSpaceOnUse">\n)
  body_blocks.each { |b| @defs_buffer << b }
  @defs_buffer << %(</pattern>\n)
  id
end

#register_radial_gradient(stops:, cx:, cy:, r:, fx: nil, fy: nil) ⇒ Object



96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
# File 'lib/postsvg/svg_builder.rb', line 96

def register_radial_gradient(stops:, cx:, cy:, r:, fx: nil, fy: nil)
  fx ||= cx
  fy ||= cy
  sig = gradient_signature("radial", stops, [cx, cy, r, fx, fy])
  return @gradients[sig] if @gradients.key?(sig)

  id = "#{GRADIENT_PREFIX}#{@next_gradient_id}"
  @next_gradient_id += 1
  @gradients[sig] = id
  @defs_buffer << %(<radialGradient id="#{id}" )
  @defs_buffer << %(cx="#{num(cx)}" cy="#{num(cy)}" r="#{num(r)}" )
  @defs_buffer << %(fx="#{num(fx)}" fy="#{num(fy)}">\n)
  stops.each do |stop|
    @defs_buffer << %(<stop offset="#{num(stop[:offset])}" )
    @defs_buffer << %(stop-color="#{stop[:color].to_svg}" />\n)
  end
  @defs_buffer << %(</radialGradient>\n)
  id
end

#registered_clip_countObject



209
210
211
# File 'lib/postsvg/svg_builder.rb', line 209

def registered_clip_count
  @clip_paths.size
end

#registered_gradient_countObject



213
214
215
# File 'lib/postsvg/svg_builder.rb', line 213

def registered_gradient_count
  @gradients.size
end

#text(content:, x:, y:, font_family:, font_size:, color:, transform: nil) ⇒ Object



158
159
160
161
162
163
164
165
166
167
168
# File 'lib/postsvg/svg_builder.rb', line 158

def text(content:, x:, y:, font_family:, font_size:, color:,
         transform: nil)
  attrs = +""
  attrs << %( transform="#{transform}") if transform
  attrs << %( x="#{num(x)}" y="#{num(y)}")
  attrs << %( font-family="#{escape(font_family)}")
  attrs << %( font-size="#{num(font_size)}")
  attrs << %( fill="#{color_attr(color)}")
  @body_buffer << %(<text#{attrs}>#{escape(content)}</text>\n)
  self
end

#to_sObject

Raises:



198
199
200
201
202
# File 'lib/postsvg/svg_builder.rb', line 198

def to_s
  raise RenderError, "svg not closed" if @svg_open

  @buffer.dup
end