emfsvg 0.1.2 → 0.1.4

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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +98 -14
  3. data/lib/emfsvg/device_context.rb +9 -6
  4. data/lib/emfsvg/dib_decoder.rb +12 -8
  5. data/lib/emfsvg/drawing_hint.rb +88 -0
  6. data/lib/emfsvg/error.rb +11 -0
  7. data/lib/emfsvg/format_helpers.rb +46 -2
  8. data/lib/emfsvg/glyph_index_mapper.rb +5 -6
  9. data/lib/emfsvg/object_table.rb +7 -15
  10. data/lib/emfsvg/record_access.rb +38 -0
  11. data/lib/emfsvg/renderer.rb +114 -55
  12. data/lib/emfsvg/svg/clip_path_registry.rb +287 -4
  13. data/lib/emfsvg/svg/color.rb +32 -9
  14. data/lib/emfsvg/svg/document.rb +7 -0
  15. data/lib/emfsvg/svg/element.rb +4 -4
  16. data/lib/emfsvg/svg/elements/clip_path.rb +89 -24
  17. data/lib/emfsvg/svg/elements/defs.rb +85 -4
  18. data/lib/emfsvg/svg/elements/group.rb +30 -4
  19. data/lib/emfsvg/svg/elements/path.rb +239 -3
  20. data/lib/emfsvg/svg/elements/rect.rb +5 -2
  21. data/lib/emfsvg/svg/elements/stylable.rb +41 -11
  22. data/lib/emfsvg/svg/elements/text.rb +101 -16
  23. data/lib/emfsvg/svg/elements.rb +1 -1
  24. data/lib/emfsvg/svg/paint.rb +8 -2
  25. data/lib/emfsvg/svg/parser.rb +15 -4
  26. data/lib/emfsvg/svg/path_data/command.rb +1 -1
  27. data/lib/emfsvg/svg/path_data.rb +47 -36
  28. data/lib/emfsvg/svg/stroke.rb +78 -12
  29. data/lib/emfsvg/svg.rb +1 -1
  30. data/lib/emfsvg/svg_builder.rb +38 -9
  31. data/lib/emfsvg/translation/context.rb +262 -10
  32. data/lib/emfsvg/translation/decimal_detector.rb +26 -8
  33. data/lib/emfsvg/translation/emf_renderer.rb +16 -12
  34. data/lib/emfsvg/translation/handler_registry.rb +16 -6
  35. data/lib/emfsvg/translation/handlers/circular_arc.rb +103 -0
  36. data/lib/emfsvg/translation/handlers/defs_handler.rb +33 -4
  37. data/lib/emfsvg/translation/handlers/ellipse_handler.rb +2 -2
  38. data/lib/emfsvg/translation/handlers/elliptical_arc.rb +125 -0
  39. data/lib/emfsvg/translation/handlers/group_handler.rb +7 -0
  40. data/lib/emfsvg/translation/handlers/image_handler.rb +53 -8
  41. data/lib/emfsvg/translation/handlers/line_handler.rb +2 -2
  42. data/lib/emfsvg/translation/handlers/mapped_arc.rb +41 -0
  43. data/lib/emfsvg/translation/handlers/mapped_circular_arc.rb +55 -0
  44. data/lib/emfsvg/translation/handlers/mapped_pie.rb +30 -0
  45. data/lib/emfsvg/translation/handlers/path_flattener.rb +70 -49
  46. data/lib/emfsvg/translation/handlers/path_handler.rb +169 -14
  47. data/lib/emfsvg/translation/handlers/polyline_handler.rb +2 -2
  48. data/lib/emfsvg/translation/handlers/rect_handler.rb +21 -5
  49. data/lib/emfsvg/translation/handlers/shared_gdi.rb +75 -12
  50. data/lib/emfsvg/translation/handlers/text_handler.rb +92 -29
  51. data/lib/emfsvg/translation/handlers.rb +5 -0
  52. data/lib/emfsvg/translation/header_builder.rb +20 -0
  53. data/lib/emfsvg/translation/local_origin.rb +40 -0
  54. data/lib/emfsvg/translation/record_emitter.rb +8 -5
  55. data/lib/emfsvg/translation/scaler/fixed.rb +8 -2
  56. data/lib/emfsvg/translation.rb +1 -0
  57. data/lib/emfsvg/version.rb +1 -1
  58. data/lib/emfsvg/visitors/emr_visitor.rb +197 -129
  59. data/lib/emfsvg.rb +49 -2
  60. metadata +24 -10
@@ -6,7 +6,8 @@ module Emfsvg
6
6
  # - self-closing elements use `/>` (no leading space)
7
7
  # - callers control newlines explicitly via `rawln` / `newline`
8
8
  class SvgBuilder
9
- def initialize
9
+ def initialize(max_output_bytes: Renderer::MAX_OUTPUT_BYTES)
10
+ @max_output_bytes = max_output_bytes
10
11
  @out = (+"").force_encoding("ASCII-8BIT")
11
12
  end
12
13
 
@@ -22,24 +23,25 @@ module Emfsvg
22
23
  end
23
24
 
24
25
  def text(content)
25
- @out << escape_text(content)
26
+ append(escape_text(content))
26
27
  self
27
28
  end
28
29
 
29
30
  # Raw append, no newline.
30
31
  def raw(string)
31
- @out << string
32
+ append(string)
32
33
  self
33
34
  end
34
35
 
35
36
  # Raw append with trailing newline.
36
37
  def rawln(string)
37
- @out << string << "\n"
38
+ append(string)
39
+ append("\n")
38
40
  self
39
41
  end
40
42
 
41
43
  def newline
42
- @out << "\n"
44
+ append("\n")
43
45
  self
44
46
  end
45
47
 
@@ -50,20 +52,46 @@ module Emfsvg
50
52
  private
51
53
 
52
54
  def open_tag(name, attrs, single:)
53
- @out << "<" << name
55
+ append("<")
56
+ append(name)
54
57
  attrs.each do |k, v|
55
58
  next if v.nil?
56
59
 
57
- @out << " " << k.to_s << '="' << escape_attr(v.to_s) << '"'
60
+ append(" ")
61
+ append(k.to_s)
62
+ append('="')
63
+ append(escape_attr(v.to_s))
64
+ append('"')
58
65
  end
59
- @out << (single ? "/>" : ">")
66
+ append(single ? "/>" : ">")
60
67
  end
61
68
 
62
69
  def close_tag(name)
63
- @out << "</" << name << ">"
70
+ append("</")
71
+ append(name)
72
+ append(">")
73
+ end
74
+
75
+ def append(string)
76
+ check_budget(string.bytesize)
77
+ @out << string
78
+ end
79
+
80
+ def check_budget(additional_bytes)
81
+ actual = @out.bytesize + additional_bytes
82
+ return if actual <= @max_output_bytes
83
+
84
+ raise ResourceLimitError.new(resource: :output_bytes, limit: @max_output_bytes, actual: actual)
85
+ end
86
+
87
+ def check_escaped_budget(value, attribute: false)
88
+ additional_bytes = value.bytesize + (value.count("&") * 4) + (value.count("<>") * 3)
89
+ additional_bytes += value.count('"') * 5 if attribute
90
+ check_budget(additional_bytes)
64
91
  end
65
92
 
66
93
  def escape_attr(value)
94
+ check_escaped_budget(value, attribute: true)
67
95
  value.gsub("&", "&amp;")
68
96
  .gsub("<", "&lt;")
69
97
  .gsub(">", "&gt;")
@@ -71,6 +99,7 @@ module Emfsvg
71
99
  end
72
100
 
73
101
  def escape_text(value)
102
+ check_escaped_budget(value)
74
103
  value.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
75
104
  end
76
105
  end
@@ -16,7 +16,7 @@ module Emfsvg
16
16
  # their children uniformly.
17
17
  class Context
18
18
  attr_reader :device_context, :object_table, :transform_stack, :emitter,
19
- :handler_registry, :clip_path_registry, :scaler
19
+ :handler_registry, :clip_path_registry, :scaler, :pattern_handles
20
20
 
21
21
  def initialize(handler_registry: EmfRenderer::DEFAULT_REGISTRY,
22
22
  clip_path_registry: nil,
@@ -29,6 +29,9 @@ module Emfsvg
29
29
  @clip_path_registry = clip_path_registry
30
30
  @scaler = scaler
31
31
  @next_handle = 1
32
+ @pattern_handles = {}
33
+ @unused_clip_safe = true
34
+ @local_origin = [0, 0]
32
35
  end
33
36
 
34
37
  # Allocate the next 1-indexed GDI object handle. Always increments
@@ -39,37 +42,286 @@ module Emfsvg
39
42
  handle
40
43
  end
41
44
 
42
- def emit(wire_class, **attrs)
43
- @emitter.emit(wire_class, **attrs)
45
+ # Includes the reserved zero slot required by the EMF header.
46
+ def handle_table_size
47
+ @next_handle
48
+ end
49
+
50
+ CLIP_MUTATIONS = %i[INTERSECTCLIPRECT EXCLUDECLIPRECT EXTSELECTCLIPRGN
51
+ OFFSETCLIPRGN SELECTCLIPPATH SETMETARGN].map do |name|
52
+ Emf::Emr::Binary::TypeCodes.const_get(name)
53
+ end.freeze
54
+
55
+ def emit(wire_class, attrs)
56
+ # Once an ordinary clip operation occurs, conservatively stop emitting
57
+ # unused resources. RestoreDC need not be modeled to prove a reset safe.
58
+ @unused_clip_safe = false if CLIP_MUTATIONS.include?(attrs[:i_type])
59
+ if @drawing_hint && DrawingHint::DRAW_TYPES.include?(attrs[:i_type])
60
+ kind, values = @drawing_hint
61
+ @drawing_hint = nil
62
+ record = @emitter.build(wire_class, attrs)
63
+ data = DrawingHint.encode(record, kind, values)
64
+ @emitter.emit(Emf::Emr::Binary::Records::Comment,
65
+ i_type: Emf::Emr::Binary::TypeCodes::COMMENT,
66
+ cb_data: data.bytesize, data: data.ljust((data.bytesize + 3) & ~3, "\x00"))
67
+ end
68
+ @emitter.emit(wire_class, attrs)
69
+ end
70
+
71
+ def with_drawing_hint(element)
72
+ @drawing_hint = DrawingHint.for_element(element)
73
+ yield
74
+ ensure
75
+ @drawing_hint = nil
76
+ end
77
+
78
+ def emit_unused_clip_rectangle(bounds)
79
+ return unless @unused_clip_safe
80
+
81
+ # These paired operations leave the known-empty clip unchanged and
82
+ # introduce no SaveDC snapshot into either native or reference stacks.
83
+ @emitter.emit(Emf::Emr::Binary::Records::IntersectClipRect,
84
+ i_type: Emf::Emr::Binary::TypeCodes::INTERSECTCLIPRECT,
85
+ rcl_clip: rect_attrs(*bounds))
86
+ @emitter.emit(Emf::Emr::Binary::Records::ExtSelectClipRgn,
87
+ i_type: Emf::Emr::Binary::TypeCodes::EXTSELECTCLIPRGN,
88
+ cb_rgn_data: 0, dw_mode: 5)
89
+ end
90
+
91
+ def preserve_unused_selected_clip(clip)
92
+ return unless @unused_clip_safe && @handler_registry.equal?(EmfRenderer::DEFAULT_REGISTRY)
93
+ return unless @clip_path_registry&.unused_selected_clip?(clip)
94
+
95
+ @emitter.emit(Emf::Emr::Binary::Records::SelectClipPath,
96
+ i_type: Emf::Emr::Binary::TypeCodes::SELECTCLIPPATH, region_mode: 5)
97
+ @emitter.emit(Emf::Emr::Binary::Records::ExtSelectClipRgn,
98
+ i_type: Emf::Emr::Binary::TypeCodes::EXTSELECTCLIPRGN,
99
+ cb_rgn_data: 0, dw_mode: 5)
100
+ true
101
+ end
102
+
103
+ # Registry proves an exclusive drawing tail in a saved identity group.
104
+ # Its RestoreDC returns to the known-empty clip, so these selections do
105
+ # not disable preservation of later unused definitions.
106
+ def begin_selected_clip(group)
107
+ clip = @clip_path_registry&.selected_clip(group)
108
+ @selected_clip = clip if clip && @unused_clip_safe
109
+ end
110
+
111
+ def preserve_selected_clip(clip)
112
+ return unless clip && (@selected_clip.equal?(clip) || (@interval_clip.equal?(clip) && clip.selected_path_data))
113
+
114
+ @emitter.emit(Emf::Emr::Binary::Records::SelectClipPath,
115
+ i_type: Emf::Emr::Binary::TypeCodes::SELECTCLIPPATH, region_mode: 5)
116
+ @active_selected_clip = clip
117
+ true
118
+ end
119
+
120
+ def selected_clip?(clip)
121
+ clip && @active_selected_clip.equal?(clip)
122
+ end
123
+
124
+ def finish_selected_clip(group)
125
+ clip = @clip_path_registry&.selected_clip(group)
126
+ return unless clip
127
+ return unless clip.equal?(@selected_clip)
128
+
129
+ @selected_clip = @active_selected_clip = nil
130
+ end
131
+
132
+ def carried_clip?(clip)
133
+ clip && @carried_clip.equal?(clip)
134
+ end
135
+
136
+ def carry_clip_from(group)
137
+ clip = @clip_path_registry&.exported_clip(group)
138
+ return unless clip && @unused_clip_safe
139
+
140
+ @carried_clip = clip
141
+ @emitter.emit(Emf::Emr::Binary::Records::IntersectClipRect,
142
+ i_type: Emf::Emr::Binary::TypeCodes::INTERSECTCLIPRECT,
143
+ rcl_clip: rect_attrs(*clip.unused_rectangle_bounds))
144
+ end
145
+
146
+ def finish_carried_clip(group)
147
+ clip = @clip_path_registry&.imported_clip(group)
148
+ return unless carried_clip?(clip)
149
+
150
+ @emitter.emit(Emf::Emr::Binary::Records::ExtSelectClipRgn,
151
+ i_type: Emf::Emr::Binary::TypeCodes::EXTSELECTCLIPRGN,
152
+ cb_rgn_data: 0, dw_mode: 5)
153
+ @carried_clip = nil
44
154
  end
45
155
 
46
156
  def records
47
157
  @emitter.records
48
158
  end
49
159
 
160
+ # Reuse only the exact tail restore from a compatible complete clipped draw.
161
+ # Keeping one outer SaveDC/RestoreDC pair also preserves native EMF
162
+ # popping semantics; nested saves cannot be coalesced this way.
163
+ def reuse_clip_scope?(signature)
164
+ previous = @clip_scope_tail
165
+ @clip_scope_tail = nil
166
+ return false unless signature && previous
167
+ return false unless previous[0] == signature && previous[1] == records.size - 1
168
+ return false unless records.last.equal?(previous[2])
169
+
170
+ records.pop
171
+ true
172
+ end
173
+
174
+ # Complete draws save the previously empty clip and restore it. Only the
175
+ # renderer's canonical rectangle provenance permits preserving subsequent
176
+ # unused definitions; ordinary clip mutations remain conservative.
177
+ def canonical_empty_clip_scope?(element)
178
+ return false unless @unused_clip_safe && !@carried_clip && !@active_selected_clip
179
+
180
+ clip = @clip_path_registry&.lookup(element.clip_path)
181
+ !!clip&.unused_rectangle_bounds
182
+ end
183
+
184
+ def remember_clip_scope(signature, restore)
185
+ @unused_clip_safe = true if signature && signature[2]
186
+ @clip_scope_tail = [signature, records.size - 1, restore] if signature
187
+ end
188
+
189
+ # Move only an adjacent identical text run's terminal deletion. Any
190
+ # intervening record, including a group boundary, ends the font lifetime.
191
+ def reuse_text_state(signature)
192
+ previous = @text_state_tail
193
+ @text_state_tail = nil
194
+ return unless signature && previous
195
+ return unless previous[0] == signature && previous[1] == records.size - 1
196
+ return unless records.last.equal?(previous[2])
197
+
198
+ records.pop
199
+ previous[3]
200
+ end
201
+
202
+ def remember_text_state(signature, handle, deletion)
203
+ @text_state_tail = [signature, records.size - 1, deletion, handle] if signature
204
+ end
205
+
206
+ def interval_clip?(clip)
207
+ clip && @interval_clip.equal?(clip)
208
+ end
209
+
210
+ def with_clip_interval(element)
211
+ empty_group = empty_interval_group?(element)
212
+ clip = @clip_path_registry&.interval_start(element) if @handler_registry.equal?(EmfRenderer::DEFAULT_REGISTRY)
213
+ if clip && (@unused_clip_safe || @interval_clip) && !@carried_clip && !@active_selected_clip
214
+ @clip_scope_tail = nil
215
+ (@interval_stack ||= []) << [@interval_clip, @unused_clip_safe]
216
+ @interval_clip = clip
217
+ emit(Emf::Emr::Binary::Records::SaveDc, i_type: Emf::Emr::Binary::TypeCodes::SAVEDC)
218
+ if clip.unused_rectangle_bounds
219
+ emit(Emf::Emr::Binary::Records::IntersectClipRect,
220
+ i_type: Emf::Emr::Binary::TypeCodes::INTERSECTCLIPRECT,
221
+ rcl_clip: rect_attrs(*clip.unused_rectangle_bounds))
222
+ end
223
+ end
224
+ yield
225
+ finish_clip_interval if @interval_clip && @clip_path_registry.interval_end(element).equal?(@interval_clip)
226
+ # A nested interval leaves snapshots in reference readers. The group's
227
+ # native RestoreDC returns to its proven empty entry clip.
228
+ reset_empty_clip if empty_group && empty_clip?
229
+ end
230
+
231
+ def finish_clip_interval
232
+ emit(Emf::Emr::Binary::Records::RestoreDc,
233
+ i_type: Emf::Emr::Binary::TypeCodes::RESTOREDC, saved_dc: -1)
234
+ # Native RestoreDC pops the interval; the reference renderer retains
235
+ # snapshots. Explicitly resetting its clip agrees with native empty state.
236
+ @active_selected_clip = nil if @active_selected_clip.equal?(@interval_clip)
237
+ @interval_clip, @unused_clip_safe = @interval_stack.pop
238
+ unless @interval_clip
239
+ reset_empty_clip
240
+ @unused_clip_safe = true
241
+ end
242
+ @clip_scope_tail = nil
243
+ end
244
+
245
+ def empty_interval_group?(element)
246
+ @handler_registry.equal?(EmfRenderer::DEFAULT_REGISTRY) &&
247
+ @clip_path_registry&.interval_group?(element) && empty_clip?
248
+ end
249
+
250
+ def empty_clip?
251
+ @unused_clip_safe && !@interval_clip && !@carried_clip && !@active_selected_clip
252
+ end
253
+
254
+ def reset_empty_clip
255
+ emit(Emf::Emr::Binary::Records::ExtSelectClipRgn,
256
+ i_type: Emf::Emr::Binary::TypeCodes::EXTSELECTCLIPRGN, cb_rgn_data: 0, dw_mode: 5)
257
+ end
258
+
50
259
  # Dispatch an element through the registry. Container handlers
51
260
  # (e.g. GroupHandler) call this to recurse into their children.
52
261
  def dispatch(element)
53
262
  handler = @handler_registry.handler_for(element)
54
- handler&.call(element, self)
263
+ with_clip_interval(element) { handler&.call(element, self) }
55
264
  end
56
265
 
57
266
  # ---- Coordinate-scaled wire helpers (DRY: handlers route all
58
267
  # coords through these so the scaler affects everything) ----
59
268
 
60
269
  def point_l(x, y)
61
- Emf::Binary::Types::PointL.new(x: scale_int(x), y: scale_int(y))
270
+ Emf::Binary::Types::PointL.new(**point_attrs(x, y))
62
271
  end
63
272
 
64
273
  def rect_l(left, top, right, bottom)
65
- Emf::Binary::Types::RectL.new(
66
- left: scale_int(left), top: scale_int(top),
67
- right: scale_int(right), bottom: scale_int(bottom)
68
- )
274
+ Emf::Binary::Types::RectL.new(**rect_attrs(left, top, right, bottom))
69
275
  end
70
276
 
71
277
  def size_l(cx, cy)
72
- Emf::Binary::Types::SizeL.new(cx: scale_int(cx), cy: scale_int(cy))
278
+ Emf::Binary::Types::SizeL.new(**size_attrs(cx, cy))
279
+ end
280
+
281
+ # Viewport origins use device units, while the line's coordinates retain
282
+ # the fixed logical scale. Explicit reset also supports readers whose
283
+ # mapping origins are not part of their saved device context.
284
+ def with_local_origin(origin)
285
+ return yield unless origin
286
+
287
+ previous = @local_origin
288
+ emit(Emf::Emr::Binary::Records::SaveDc, i_type: Emf::Emr::Binary::TypeCodes::SAVEDC)
289
+ emit_viewport_origin(origin)
290
+ @local_origin = origin
291
+ begin
292
+ yield
293
+ ensure
294
+ @local_origin = previous
295
+ emit_viewport_origin(previous)
296
+ emit(Emf::Emr::Binary::Records::RestoreDc,
297
+ i_type: Emf::Emr::Binary::TypeCodes::RESTOREDC, saved_dc: -1)
298
+ end
299
+ end
300
+
301
+ def emit_viewport_origin(origin)
302
+ emit(Emf::Emr::Binary::Records::SetViewportOrgEx,
303
+ i_type: Emf::Emr::Binary::TypeCodes::SETVIEWPORTORGEX,
304
+ origin: { x: origin[0], y: origin[1] })
305
+ end
306
+ private :emit_viewport_origin
307
+
308
+ # Attribute-hash forms of the helpers above. A record field accepts
309
+ # a Hash and builds its nested value in place, which avoids
310
+ # constructing a throwaway BinData object per coordinate that the
311
+ # record would only copy. Handlers use these when emitting.
312
+ def point_attrs(x, y)
313
+ { x: scale_int(x - @local_origin[0]), y: scale_int(y - @local_origin[1]) }
314
+ end
315
+
316
+ def rect_attrs(left, top, right, bottom)
317
+ {
318
+ left: scale_int(left - @local_origin[0]), top: scale_int(top - @local_origin[1]),
319
+ right: scale_int(right - @local_origin[0]), bottom: scale_int(bottom - @local_origin[1])
320
+ }
321
+ end
322
+
323
+ def size_attrs(cx, cy)
324
+ { cx: scale_int(cx), cy: scale_int(cy) }
73
325
  end
74
326
 
75
327
  # Convert a Matrix to its wire form, scaling the translation
@@ -26,11 +26,13 @@ module Emfsvg
26
26
  def any_decimal?(element)
27
27
  return false unless element
28
28
 
29
+ return true if explicit_cosmetic_pixel_stroke?(element)
30
+
29
31
  element_attrs = decimal_attrs_for(element)
30
- return true if element_attrs.any? { |attr| has_decimal?(element.send(attr)) }
32
+ return true if element_attrs.any? { |attr| decimal?(element.send(attr)) }
31
33
 
32
34
  path = path_commands(element)
33
- return true if path && path.any? { |cmd| cmd.args.any? { |a| a.is_a?(Float) && a != a.to_i } }
35
+ return true if path&.any? { |cmd| decimal?(cmd.args) }
34
36
 
35
37
  element.children.any? { |c| any_decimal?(c) }
36
38
  end
@@ -38,15 +40,27 @@ module Emfsvg
38
40
  def any_unsafe?(element, scaler)
39
41
  return false unless element
40
42
 
43
+ return false if LocalOrigin.for_path(element, scaler)
44
+
41
45
  element_attrs = decimal_attrs_for(element)
42
46
  return true if element_attrs.any? { |attr| unsafe_value?(element.send(attr), scaler) }
43
47
 
44
48
  path = path_commands(element)
45
- return true if path && path.any? { |cmd| cmd.args.any? { |a| scaler.unsafe?(a) } }
49
+ return true if path&.any? { |cmd| cmd.args.any? { |a| scaler.unsafe?(a) } }
46
50
 
47
51
  element.children.any? { |c| any_unsafe?(c, scaler) }
48
52
  end
49
53
 
54
+ # The EMF renderer preserves a cosmetic 1px width only when the
55
+ # translator uses a map scale. Explicit cap/join attributes
56
+ # distinguish real pens from the renderer's NULL-pen fallback.
57
+ def explicit_cosmetic_pixel_stroke?(element)
58
+ stroke = element.stroke if element.respond_to?(:stroke)
59
+ stroke && stroke.raw_width == "1px" &&
60
+ (stroke.line_cap_explicit || stroke.line_join_explicit) &&
61
+ !stroke.paint.null?
62
+ end
63
+
50
64
  # Coordinate-bearing attributes per element class. Returns [] for
51
65
  # elements with no coords (Group, Defs, etc).
52
66
  def decimal_attrs_for(element)
@@ -71,19 +85,23 @@ module Emfsvg
71
85
 
72
86
  # `value` may be: Float, Integer, Array of [x,y] pairs, Array of
73
87
  # PathData::Command, or nil.
74
- def has_decimal?(value)
88
+ def decimal?(value)
75
89
  return false if value.nil?
76
90
 
77
91
  case value
78
92
  when Float then !value.finite? || (value != value.to_i)
79
- when Integer then false
80
93
  when Array
81
- value.any? { |v| pair_or_command?(v) ? has_decimal?(v) : v.is_a?(Float) && v != v.to_i }
94
+ value.any? { |v| decimal?(v) }
95
+ when Svg::PathData::Command then decimal?(value.args)
82
96
  when String then value.match?(/\.\d/)
83
97
  else false
84
98
  end
85
99
  end
86
100
 
101
+ # Former name, kept so existing callers keep working.
102
+ alias has_decimal? decimal?
103
+ module_function :has_decimal?
104
+
87
105
  def pair_or_command?(value)
88
106
  value.is_a?(Array) || value.is_a?(Svg::PathData::Command)
89
107
  end
@@ -92,9 +110,9 @@ module Emfsvg
92
110
  return false if value.nil?
93
111
 
94
112
  case value
95
- when Float then scaler.unsafe?(value)
96
- when Integer then false
113
+ when Numeric then scaler.unsafe?(value)
97
114
  when Array then value.any? { |v| unsafe_value?(v, scaler) }
115
+ when Svg::PathData::Command then unsafe_value?(value.args, scaler)
98
116
  when String then extract_floats(value).any? { |f| scaler.unsafe?(f) }
99
117
  else false
100
118
  end
@@ -23,6 +23,10 @@ module Emfsvg
23
23
 
24
24
  def self.call(document, registry: DEFAULT_REGISTRY)
25
25
  new(document, registry: registry).call
26
+ rescue Scaler::Fixed::Overflow
27
+ # Derived bounds and relative path points may exceed the source values.
28
+ # Rebuild the entire record stream in the original unscaled coordinates.
29
+ new(document, registry: registry, scaler: Scaler::Identity.new).call
26
30
  end
27
31
 
28
32
  def self.register_default_handlers!
@@ -40,10 +44,10 @@ module Emfsvg
40
44
  DEFAULT_REGISTRY.register(Svg::Elements::ClipPath, Handlers::ClipPathHandler)
41
45
  end
42
46
 
43
- def initialize(document, registry: DEFAULT_REGISTRY)
47
+ def initialize(document, registry: DEFAULT_REGISTRY, scaler: nil)
44
48
  @document = document
45
49
  @registry = registry
46
- @scaler = pick_scaler
50
+ @scaler = scaler || pick_scaler
47
51
  @context = Context.new(
48
52
  handler_registry: registry,
49
53
  clip_path_registry: Svg::ClipPathRegistry.from_document(document),
@@ -57,7 +61,7 @@ module Emfsvg
57
61
  emit_eof
58
62
  header = HeaderBuilder.build(@document,
59
63
  n_records: @context.records.size + 1,
60
- n_handles: @context.object_table.size + 1)
64
+ n_handles: @context.handle_table_size)
61
65
  Emf::Model::Metafile.new(
62
66
  format: :emf,
63
67
  header: header,
@@ -67,15 +71,15 @@ module Emfsvg
67
71
 
68
72
  private
69
73
 
70
- # Pick a scaler based on the SVG content. Currently always uses
71
- # Identity because the Fixed scaler (which multiplies coords by
72
- # 10_000 to preserve decimals) interacts with emfsvg's renderer
73
- # in ways that cause record-drops on complex fixtures. The
74
- # Scaler::Fixed infrastructure remains available for future use
75
- # once the renderer interaction is debugged. The lossy matcher's
76
- # float tolerance (1.0px default) already absorbs decimal diffs.
74
+ # Preserve decimal coordinates and explicit cosmetic 1px pens when
75
+ # every scaled coordinate fits an int32.
77
76
  def pick_scaler
78
- Scaler::Identity.new
77
+ fixed = Scaler::Fixed.new
78
+ if DecimalDetector.needs_scaling?(@document) && DecimalDetector.safe_for_fixed_scale?(@document, fixed)
79
+ fixed
80
+ else
81
+ Scaler::Identity.new
82
+ end
79
83
  end
80
84
 
81
85
  # Emit SetMapMode(ANISOTROPIC) + SetWindowExtEx + SetViewportExtEx
@@ -111,7 +115,7 @@ module Emfsvg
111
115
  handler = @registry.handler_for(element)
112
116
  return descend(element) unless handler
113
117
 
114
- handler.call(element, @context)
118
+ @context.with_clip_interval(element) { handler.call(element, @context) }
115
119
  descend(element) if descend_after_handler?(handler)
116
120
  end
117
121
 
@@ -11,20 +11,19 @@ module Emfsvg
11
11
  class HandlerRegistry
12
12
  def initialize
13
13
  @handlers = {}
14
+ @resolved = {}
14
15
  end
15
16
 
16
17
  def register(element_class, handler)
18
+ @resolved.clear
17
19
  @handlers[element_class] = handler
18
20
  end
19
21
 
22
+ # Elements of one class share a handler, so each class is resolved
23
+ # (walking its ancestry) once, until the next registration.
20
24
  def handler_for(element)
21
25
  klass = element.class
22
- while klass
23
- return @handlers[klass] if @handlers.key?(klass)
24
-
25
- klass = klass.superclass
26
- end
27
- nil
26
+ @resolved.fetch(klass) { @resolved[klass] = resolve(klass) }
28
27
  end
29
28
 
30
29
  def translate(element, context)
@@ -33,6 +32,17 @@ module Emfsvg
33
32
 
34
33
  handler.call(element, context)
35
34
  end
35
+
36
+ private
37
+
38
+ def resolve(klass)
39
+ while klass
40
+ return @handlers[klass] if @handlers.key?(klass)
41
+
42
+ klass = klass.superclass
43
+ end
44
+ nil
45
+ end
36
46
  end
37
47
  end
38
48
  end