emfsvg 0.1.3 → 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 (58) 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 +3 -3
  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/paint.rb +8 -2
  24. data/lib/emfsvg/svg/parser.rb +8 -1
  25. data/lib/emfsvg/svg/path_data/command.rb +1 -1
  26. data/lib/emfsvg/svg/path_data.rb +47 -36
  27. data/lib/emfsvg/svg/stroke.rb +78 -12
  28. data/lib/emfsvg/svg_builder.rb +38 -9
  29. data/lib/emfsvg/translation/context.rb +262 -10
  30. data/lib/emfsvg/translation/decimal_detector.rb +26 -8
  31. data/lib/emfsvg/translation/emf_renderer.rb +16 -12
  32. data/lib/emfsvg/translation/handler_registry.rb +16 -6
  33. data/lib/emfsvg/translation/handlers/circular_arc.rb +103 -0
  34. data/lib/emfsvg/translation/handlers/defs_handler.rb +33 -4
  35. data/lib/emfsvg/translation/handlers/ellipse_handler.rb +2 -2
  36. data/lib/emfsvg/translation/handlers/elliptical_arc.rb +125 -0
  37. data/lib/emfsvg/translation/handlers/group_handler.rb +7 -0
  38. data/lib/emfsvg/translation/handlers/image_handler.rb +53 -8
  39. data/lib/emfsvg/translation/handlers/line_handler.rb +2 -2
  40. data/lib/emfsvg/translation/handlers/mapped_arc.rb +41 -0
  41. data/lib/emfsvg/translation/handlers/mapped_circular_arc.rb +55 -0
  42. data/lib/emfsvg/translation/handlers/mapped_pie.rb +30 -0
  43. data/lib/emfsvg/translation/handlers/path_flattener.rb +70 -49
  44. data/lib/emfsvg/translation/handlers/path_handler.rb +169 -14
  45. data/lib/emfsvg/translation/handlers/polyline_handler.rb +2 -2
  46. data/lib/emfsvg/translation/handlers/rect_handler.rb +21 -5
  47. data/lib/emfsvg/translation/handlers/shared_gdi.rb +75 -12
  48. data/lib/emfsvg/translation/handlers/text_handler.rb +92 -29
  49. data/lib/emfsvg/translation/handlers.rb +5 -0
  50. data/lib/emfsvg/translation/header_builder.rb +20 -0
  51. data/lib/emfsvg/translation/local_origin.rb +40 -0
  52. data/lib/emfsvg/translation/record_emitter.rb +8 -5
  53. data/lib/emfsvg/translation/scaler/fixed.rb +8 -2
  54. data/lib/emfsvg/translation.rb +1 -0
  55. data/lib/emfsvg/version.rb +1 -1
  56. data/lib/emfsvg/visitors/emr_visitor.rb +197 -129
  57. data/lib/emfsvg.rb +49 -2
  58. metadata +24 -10
@@ -17,6 +17,30 @@ module Emfsvg
17
17
  # 7. </svg>
18
18
  class Renderer
19
19
  MAX_OUTPUT_BYTES = 500 * 1024 * 1024
20
+ # Downstream collection policy, independent of parser input/array limits.
21
+ MAX_RECORDS = 1_000_000
22
+
23
+ # Records that set pathDrawn=true in libemf2svg's U_emf_onerec_analyse
24
+ # pre-pass. Used to distinguish wtBefore from wtAfter.
25
+ DRAWING_PATH_RECORDS = %w[
26
+ PolyBezier Polygon Polyline PolyBezierTo PolylineTo PolyPolyline PolyPolygon
27
+ SetWindowExtEx LineTo ArcTo PolyDraw
28
+ PolyBezier16 Polygon16 Polyline16 PolyBezierTo16 PolylineTo16
29
+ PolyPolyline16 PolyPolygon16 PolyDraw16
30
+ ].freeze
31
+
32
+ # Unqualified wire class name => the role a record plays in build_path_analysis.
33
+ PATH_RECORD_KINDS = {
34
+ "BeginPath" => :begin_path,
35
+ "EndPath" => :end_path,
36
+ "AbortPath" => :abort_path,
37
+ "FillPath" => :fill_path,
38
+ "StrokePath" => :stroke_path,
39
+ "StrokeAndFillPath" => :stroke_and_fill_path,
40
+ "SetWorldTransform" => :set_world_transform,
41
+ "ModifyWorldTransform" => :modify_world_transform,
42
+ **DRAWING_PATH_RECORDS.to_h { |name| [name, :drawing] }
43
+ }.freeze
20
44
 
21
45
  def self.call(metafile, options)
22
46
  new(metafile, options).call
@@ -25,12 +49,12 @@ module Emfsvg
25
49
  def initialize(metafile, options)
26
50
  @metafile = metafile
27
51
  @options = options
52
+ compute_header_params
28
53
  @builder = SvgBuilder.new
29
54
  @dc = DeviceContext.new
30
55
  @object_table = ObjectTable.new
31
56
  @transform_stack = TransformStack.new
32
57
  BsdRand.reset
33
- compute_header_params
34
58
  end
35
59
 
36
60
  def call
@@ -40,10 +64,7 @@ module Emfsvg
40
64
  visit_records
41
65
  end
42
66
  end
43
- result = @builder.to_s
44
- raise RenderError, "SVG output exceeds #{MAX_OUTPUT_BYTES} bytes" if result.bytesize > MAX_OUTPUT_BYTES
45
-
46
- result
67
+ @builder.to_s
47
68
  end
48
69
 
49
70
  private
@@ -52,8 +73,34 @@ module Emfsvg
52
73
 
53
74
  def compute_header_params
54
75
  b = @metafile.header.bounds
55
- @fix_broken_y = (b.top * b.bottom).negative?
76
+ validate_header_dimensions!(b)
77
+ @fix_broken_y = (b.top.negative? && b.bottom.positive?) || (b.top.positive? && b.bottom.negative?)
78
+
79
+ compute_output_dimensions(b)
80
+
81
+ bounds_width = (b.right - b.left).abs
82
+ @scaling = bounds_width.zero? ? 1.0 : @img_width / bounds_width.to_f
83
+ @ref_x = b.left.to_f
84
+ @ref_y = b.top.to_f
85
+
86
+ dev = @metafile.header.device_pixels
87
+ mm = @metafile.header.device_mm
88
+ @px_per_mm = mm.cx.zero? ? 1.0 : dev.cx.to_f / mm.cx
89
+
90
+ @open_groups = 0
91
+ end
92
+
93
+ def validate_header_dimensions!(b)
94
+ [b.left, b.top, b.right, b.bottom].each do |coordinate|
95
+ raise RenderError, "Header coordinates must be finite" unless coordinate.to_f.finite?
96
+ end
97
+ validate_dimension!((b.right - b.left).abs.to_f, "Header width")
98
+ validate_dimension!((b.bottom - b.top).abs.to_f, "Header height")
99
+ validate_dimension!(@options.width.to_f, "Output width") if @options.width
100
+ validate_dimension!(@options.height.to_f, "Output height") if @options.height
101
+ end
56
102
 
103
+ def compute_output_dimensions(b)
57
104
  ratio_xy = (b.right - b.left).to_f / (b.bottom - b.top)
58
105
 
59
106
  if @options.width && @options.height
@@ -76,16 +123,14 @@ module Emfsvg
76
123
  @img_height = (b.bottom - b.top).abs.to_f
77
124
  end
78
125
 
79
- bounds_width = (b.right - b.left).abs
80
- @scaling = bounds_width.zero? ? 1.0 : @img_width / bounds_width.to_f
81
- @ref_x = b.left.to_f
82
- @ref_y = b.top.to_f
126
+ validate_dimension!(@img_width, "Output width")
127
+ validate_dimension!(@img_height, "Output height")
128
+ end
83
129
 
84
- dev = @metafile.header.device_pixels
85
- mm = @metafile.header.device_mm
86
- @px_per_mm = mm.cx.zero? ? 1.0 : dev.cx.to_f / mm.cx
130
+ def validate_dimension!(value, label)
131
+ return if value.finite? && value.positive?
87
132
 
88
- @open_groups = 0
133
+ raise RenderError, "#{label} must be finite and positive"
89
134
  end
90
135
 
91
136
  def emit_xml_declaration
@@ -131,13 +176,13 @@ module Emfsvg
131
176
  end
132
177
 
133
178
  def visit_records
134
- records = @metafile.to_a
179
+ records = bounded_records
135
180
  path_actions, path_transforms = build_path_analysis(records)
136
181
  visitor = Visitors::EmrVisitor.new(
137
182
  @builder, @dc, @object_table, @transform_stack,
138
183
  @metafile.header.bounds, @fix_broken_y, @options,
139
- scaling: @scaling, ref_x: @ref_x, ref_y: @ref_y,
140
- px_per_mm: @px_per_mm,
184
+ frame: Visitors::EmrVisitor::Frame.new(scaling: @scaling, ref_x: @ref_x, ref_y: @ref_y,
185
+ px_per_mm: @px_per_mm),
141
186
  path_actions: path_actions,
142
187
  path_transforms: path_transforms
143
188
  )
@@ -149,6 +194,17 @@ module Emfsvg
149
194
  @open_groups = visitor.open_group_count
150
195
  end
151
196
 
197
+ def bounded_records
198
+ records = []
199
+ @metafile.each do |record|
200
+ records << record
201
+ next unless records.length > MAX_RECORDS
202
+
203
+ raise ResourceLimitError.new(resource: :records, limit: MAX_RECORDS, actual: records.length)
204
+ end
205
+ records
206
+ end
207
+
152
208
  # Pre-scan records to build two maps keyed by BeginPath record index:
153
209
  # path_actions: { idx => :fill|:stroke|:stroke_fill|:end|:abort }
154
210
  # path_transforms: { idx => { wt_before: PathTransform?, wt_after: PathTransform? } }
@@ -162,7 +218,8 @@ module Emfsvg
162
218
  # SetWorldTransform (just assign) or 1/2/3 for ModifyWorldTransform
163
219
  # (MWT_IDENTITY/LEFTMULTIPLY/RIGHTMULTIPLY).
164
220
  def build_path_analysis(records)
165
- actions = {}
221
+ kinds = record_kinds(records)
222
+ actions = path_actions_for(kinds)
166
223
  transforms = {}
167
224
  in_path = false
168
225
  path_drawn = false
@@ -170,31 +227,30 @@ module Emfsvg
170
227
  wt_before = nil
171
228
  wt_after = nil
172
229
  records.each_with_index do |r, i|
173
- name = r.wire.class.name.split("::").last
174
- case name
175
- when "BeginPath"
176
- actions[i] = find_path_action(records, i)
230
+ case kinds[i]
231
+ when :begin_path
177
232
  cur_begin = i
178
233
  wt_before = nil
179
234
  wt_after = nil
180
235
  in_path = true
181
236
  path_drawn = false
182
- when "EndPath", "AbortPath"
237
+ when :end_path, :abort_path
183
238
  transforms[cur_begin] = { wt_before: wt_before, wt_after: wt_after } if cur_begin
184
239
  in_path = false
185
240
  path_drawn = false
186
241
  cur_begin = nil
187
- when "SetWorldTransform"
242
+ when :set_world_transform
188
243
  next unless in_path
189
244
 
190
- pt = path_transform_for(r.wire, 0)
245
+ pt = path_transform_for(path_fields(r), 0)
191
246
  path_drawn ? (wt_before = pt) : (wt_after = pt)
192
- when "ModifyWorldTransform"
247
+ when :modify_world_transform
193
248
  next unless in_path
194
249
 
195
- pt = path_transform_for(r.wire, r.wire.modify_mode)
250
+ fields = path_fields(r)
251
+ pt = path_transform_for(fields, fields.modify_mode)
196
252
  path_drawn ? (wt_before = pt) : (wt_after = pt)
197
- when *DRAWING_PATH_RECORDS
253
+ when :drawing
198
254
  path_drawn = true if in_path
199
255
  end
200
256
  end
@@ -203,40 +259,43 @@ module Emfsvg
203
259
 
204
260
  PathTransform = Struct.new(:matrix, :mode, keyword_init: true)
205
261
 
262
+ def path_fields(record)
263
+ RecordAccess.fields(record, RecordAccess.wire_class(record))
264
+ end
265
+
206
266
  def path_transform_for(wire, mode)
207
267
  matrix = Emf::Model::Geometry::Matrix.from_wire(wire.xform)
208
268
  PathTransform.new(matrix: matrix, mode: mode)
209
269
  end
210
270
 
211
- def find_path_action(records, start)
212
- action = :end
213
- ((start + 1)...records.size).each do |j|
214
- name = records[j].wire.class.name.split("::").last
215
- case name
216
- when "EndPath"
217
- next
218
- when "FillPath"
219
- return :fill
220
- when "StrokePath"
221
- return :stroke
222
- when "StrokeAndFillPath"
223
- return :stroke_fill
224
- when "AbortPath"
225
- return :abort
226
- when "BeginPath"
227
- return :end
228
- end
271
+ # Path-analysis kind per record (see PATH_RECORD_KINDS; nil when the
272
+ # record is irrelevant to the analysis), computed once per wire class.
273
+ def record_kinds(records)
274
+ cache = {}
275
+ records.map do |r|
276
+ klass = RecordAccess.wire_class(r)
277
+ cache.fetch(klass) { cache[klass] = PATH_RECORD_KINDS[klass.name.split("::").last] }
229
278
  end
230
- action
231
279
  end
232
280
 
233
- # Records that set pathDrawn=true in libemf2svg's U_emf_onerec_analyse
234
- # pre-pass. Used to distinguish wtBefore from wtAfter.
235
- DRAWING_PATH_RECORDS = %w[
236
- PolyBezier Polygon Polyline PolyBezierTo PolylineTo PolyPolyline PolyPolygon
237
- SetWindowExtEx LineTo ArcTo PolyDraw
238
- PolyBezier16 Polygon16 Polyline16 PolyBezierTo16 PolylineTo16
239
- PolyPolyline16 PolyPolygon16 PolyDraw16
240
- ].freeze
281
+ # Resolve each BeginPath's action in one backward pass. The action is set
282
+ # by the first Fill/Stroke/StrokeAndFill/AbortPath after the BeginPath;
283
+ # another BeginPath first, or none at all, leaves it :end.
284
+ def path_actions_for(kinds)
285
+ actions = {}
286
+ following = :end
287
+ (kinds.size - 1).downto(0) do |i|
288
+ case kinds[i]
289
+ when :begin_path
290
+ actions[i] = following
291
+ following = :end
292
+ when :fill_path then following = :fill
293
+ when :stroke_path then following = :stroke
294
+ when :stroke_and_fill_path then following = :stroke_fill
295
+ when :abort_path then following = :abort
296
+ end
297
+ end
298
+ actions
299
+ end
241
300
  end
242
301
  end
@@ -11,24 +11,48 @@ module Emfsvg
11
11
 
12
12
  def initialize
13
13
  @clip_paths = {}
14
+ @references = {}
15
+ @resolved = {}
16
+ @exports = {}
17
+ @imports = {}
18
+ @selected = {}
19
+ @unused_selected = {}
20
+ @interval_starts = {}
21
+ @interval_ends = {}
22
+ @interval_groups = {}
14
23
  end
15
24
 
16
25
  def register(clip_path)
26
+ @resolved.clear
17
27
  @clip_paths[clip_path.id] = clip_path
18
28
  end
19
29
 
30
+ def referenced?(id)
31
+ @references.key?(id)
32
+ end
33
+
34
+ def reference(clip_path_attr)
35
+ match = clip_path_attr&.match(URL_RE)
36
+ @references[match[1]] = @references.fetch(match[1], 0) + 1 if match
37
+ end
38
+
39
+ # Shapes reference the same few clips over and over, so each
40
+ # distinct attribute value is resolved once (until the next
41
+ # registration).
20
42
  def lookup(clip_path_attr)
21
43
  return nil if clip_path_attr.nil?
22
44
 
23
- match = clip_path_attr.match(URL_RE)
24
- return nil unless match
25
-
26
- @clip_paths[match[1]]
45
+ @resolved.fetch(clip_path_attr) { @resolved[clip_path_attr] = resolve(clip_path_attr) }
27
46
  end
28
47
 
29
48
  def self.from_document(document)
30
49
  registry = new
31
50
  collect_clip_paths(document.root_element, registry)
51
+ registry.plan_sibling_clips(document.root_element)
52
+ registry.plan_selected_clips(document.root_element)
53
+ registry.plan_clip_intervals(document.header_translate_group)
54
+ registry.plan_text_clip_intervals(document.root_element)
55
+ registry.plan_pending_clips(document.root_element, document.header_translate_group)
32
56
  registry
33
57
  end
34
58
 
@@ -36,8 +60,267 @@ module Emfsvg
36
60
  return unless element
37
61
 
38
62
  registry.register(element) if element.is_a?(Elements::ClipPath)
63
+ registry.reference(element.clip_path) if element.respond_to?(:clip_path)
39
64
  element.children.each { |c| collect_clip_paths(c, registry) }
40
65
  end
66
+
67
+ def exported_clip(group)
68
+ @exports[group]
69
+ end
70
+
71
+ def imported_clip(group)
72
+ @imports[group]
73
+ end
74
+
75
+ def selected_clip(group)
76
+ @selected[group]
77
+ end
78
+
79
+ # A pending native path and its definition must agree exactly, and every
80
+ # remaining child must reference it. RestoreDC then bounds its lifetime.
81
+ def plan_selected_clips(group)
82
+ return unless group
83
+
84
+ group.children.each { |child| plan_selected_clips(child) }
85
+ clip = selected_group_clip(group)
86
+ @selected[group] = clip if clip
87
+ end
88
+
89
+ # A canonical identity group containing only a definition can carry its
90
+ # clip into the immediately following canonical drawing group. The sole
91
+ # reference bounds the lifetime; Context also requires a known-empty clip.
92
+ def plan_sibling_clips(parent)
93
+ return unless parent
94
+
95
+ parent.children.each { |child| plan_sibling_clips(child) }
96
+ return unless parent.is_a?(Elements::Group) && parent.transform_matrix.identity?
97
+
98
+ parent.children.each_cons(2) do |source, target|
99
+ clip = sibling_clip(source, target)
100
+ next unless clip
101
+
102
+ @exports[source] = clip
103
+ @imports[target] = clip
104
+ end
105
+ end
106
+
107
+ def interval_group?(element)
108
+ @interval_groups.key?(element)
109
+ end
110
+
111
+ def interval_start(element)
112
+ @interval_starts[element]
113
+ end
114
+
115
+ def interval_end(element)
116
+ @interval_ends[element]
117
+ end
118
+
119
+ def unused_selected_clip?(clip)
120
+ @unused_selected.key?(clip)
121
+ end
122
+
123
+ # A definition immediately following its exact pending native path can
124
+ # select that path. Exclusive adjacent image or path uses bound its lifetime;
125
+ # an unused selection is reset before any later drawing.
126
+ def plan_pending_clips(parent, header)
127
+ return unless parent
128
+
129
+ parent.children.each { |child| plan_pending_clips(child, header) }
130
+ return unless parent.equal?(header) ||
131
+ (parent.is_a?(Elements::Group) && parent.canonical_matrix? && parent.transform_matrix.identity?)
132
+
133
+ parent.children.each_cons(2).with_index do |(path, defs), index|
134
+ next unless defs.is_a?(Elements::Defs) && defs.children.size == 1
135
+
136
+ clip = defs.children.first
137
+ next unless clip.is_a?(Elements::ClipPath) && matching_pending_clip?(path, clip)
138
+
139
+ if referenced?(clip.id)
140
+ plan_pending_draw_interval(parent.children.drop(index + 2), defs, clip)
141
+ else
142
+ @unused_selected[clip] = true
143
+ end
144
+ end
145
+ end
146
+
147
+ # A canonical rectangle may surround nested clips and then be reused.
148
+ # Only flat, fully explicit built-in drawings participate. Unused defs
149
+ # and text transformed under a different clip remain boundaries.
150
+ def plan_clip_intervals(parent)
151
+ return unless parent
152
+
153
+ parent.children.grep(Elements::Group).each do |group|
154
+ plan_clip_intervals(group) if group.canonical_matrix?
155
+ end
156
+
157
+ children = parent.children
158
+ positions = children.each_index.group_by { |index| lookup(children[index].clip_path) }
159
+ occupied = []
160
+ candidates = positions.reject { |clip, indices| !clip || indices.size < 2 }
161
+ candidates.sort_by { |_, indices| -(indices.last - indices.first) }.each do |clip, indices|
162
+ range = indices.first..indices.last
163
+ next unless compatible_interval?(range, occupied, children, clip)
164
+ next unless clip.unused_rectangle_bounds
165
+ next unless children[range].all? { |child| interval_child?(child, clip) }
166
+
167
+ @interval_starts[children[range.begin]] = clip
168
+ @interval_ends[children[range.end]] = clip
169
+ occupied << range
170
+ @interval_groups[parent] = true
171
+ end
172
+ end
173
+
174
+ # Canonical text draws can share a clip across a painted native path,
175
+ # whose renderer spelling omits the clipping attribute. The exact three
176
+ # drawing sequence and exclusive references bound this scope.
177
+ def plan_text_clip_intervals(parent)
178
+ return unless parent
179
+
180
+ parent.children.each { |child| plan_text_clip_intervals(child) }
181
+ return unless parent.is_a?(Elements::Group) && parent.canonical_matrix?
182
+
183
+ parent.children.each_cons(5) do |previous, defs, first, path, last|
184
+ clip = text_interval_clip(previous, defs, first, path, last)
185
+ next unless clip
186
+
187
+ # The general rectangle planner may already start this same interval
188
+ # at its first text. Move that start to the definition instead of
189
+ # opening two native scopes with only one shared end.
190
+ @interval_starts.delete(first) if @interval_starts[first].equal?(clip)
191
+ @interval_starts[defs] = clip
192
+ @interval_ends[last] = clip
193
+ end
194
+ end
195
+
196
+ private
197
+
198
+ def plan_pending_draw_interval(children, defs, clip)
199
+ draws = children.take_while do |draw|
200
+ supported = draw.instance_of?(Elements::Image) ||
201
+ (draw.instance_of?(Elements::Path) && draw.stroke && draw.fill &&
202
+ draw.path_record_kind != :end_path)
203
+ supported && lookup(draw.clip_path).equal?(clip)
204
+ end
205
+ return if draws.empty? || @references[clip.id] != draws.size
206
+
207
+ @interval_starts[defs] = clip
208
+ @interval_ends[draws.last] = clip
209
+ end
210
+
211
+ # Crossing ranges cannot form native scopes. A nested range contains
212
+ # only its own clip, so its final RestoreDC returns to the outer scope
213
+ # even for renderers which retain restored snapshots.
214
+ def compatible_interval?(range, occupied, children, clip)
215
+ return false if occupied.any? do |prior|
216
+ prior.cover?(range.begin) != prior.cover?(range.end)
217
+ end
218
+
219
+ parents = occupied.count { |prior| prior.cover?(range.begin) && prior.cover?(range.end) }
220
+ uniform = children[range].all? { |child| child.is_a?(Elements::Defs) || lookup(child.clip_path).equal?(clip) }
221
+ return !uniform if parents.zero?
222
+
223
+ parents == 1 && uniform
224
+ end
225
+
226
+ def interval_child?(child, outer)
227
+ return interval_definitions?(child) if child.is_a?(Elements::Defs)
228
+
229
+ classes = [Elements::Path, Elements::Rect, Elements::Ellipse, Elements::Line,
230
+ Elements::Polyline, Elements::Polygon, Elements::Text, Elements::Image]
231
+ return false unless classes.include?(child.class)
232
+ return false if child.is_a?(Elements::Text) && child.transformed? && !lookup(child.clip_path).equal?(outer)
233
+ return true if child.is_a?(Elements::Path) && !child.clip_path && child.stroke&.null_pen_fallback
234
+
235
+ bounds = lookup(child.clip_path)&.unused_rectangle_bounds
236
+ return false unless bounds
237
+
238
+ left, top, right, bottom = outer.unused_rectangle_bounds
239
+ bounds[0] >= left && bounds[1] >= top && bounds[2] <= right && bounds[3] <= bottom
240
+ end
241
+
242
+ def interval_definitions?(defs)
243
+ # Creating an unselected canonical brush does not change the active clip.
244
+ return true if defs.unused_pattern
245
+
246
+ defs.children.all? do |clip|
247
+ clip.is_a?(Elements::ClipPath) && clip.unused_rectangle_bounds && referenced?(clip.id)
248
+ end
249
+ end
250
+
251
+ def text_interval_clip(previous, defs, first, path, last)
252
+ return unless defs.is_a?(Elements::Defs) && defs.children.size == 1
253
+
254
+ clip = defs.children.first
255
+ return unless clip.is_a?(Elements::ClipPath) && @references[clip.id] == 2
256
+ return unless [first, last].all? do |text|
257
+ text.instance_of?(Elements::Text) && !text.transformed? && lookup(text.clip_path).equal?(clip)
258
+ end
259
+ return unless path.instance_of?(Elements::Path) && !path.clip_path
260
+ return unless path.path_record_kind == :stroke_and_fill_path
261
+ return clip if clip.unused_rectangle_bounds
262
+
263
+ clip if matching_pending_clip?(previous, clip)
264
+ end
265
+
266
+ def matching_pending_clip?(path, clip)
267
+ clip.selected_path_data && path.instance_of?(Elements::Path) &&
268
+ path.path_record_kind == :end_path && !path.clip_path && matching_clip_path?(path, clip)
269
+ end
270
+
271
+ def selected_group_clip(group)
272
+ return unless group.is_a?(Elements::Group) && group.canonical_matrix? && group.transform_matrix.identity?
273
+
274
+ unused, path, defs, *draws = group.children
275
+ return unless unused_clip_definitions?(unused)
276
+ return unless path.is_a?(Elements::Path) && path.path_record_kind == :end_path && !path.clip_path
277
+ return unless defs.is_a?(Elements::Defs) && defs.children.size == 1 && !draws.empty?
278
+
279
+ clip = defs.children.first
280
+ return unless clip.is_a?(Elements::ClipPath) && clip.selected_path_data
281
+ return unless matching_clip_path?(path, clip)
282
+ return unless exclusive_clip_draws?(draws, clip)
283
+
284
+ clip
285
+ end
286
+
287
+ def exclusive_clip_draws?(draws, clip)
288
+ @references[clip.id] == draws.size && draws.all? do |draw|
289
+ draw.is_a?(Elements::Path) && lookup(draw.clip_path).equal?(clip)
290
+ end
291
+ end
292
+
293
+ def unused_clip_definitions?(defs)
294
+ defs.is_a?(Elements::Defs) && !defs.children.empty? && defs.children.all? do |child|
295
+ child.is_a?(Elements::ClipPath) && child.unused_rectangle_bounds && !referenced?(child.id)
296
+ end
297
+ end
298
+
299
+ def matching_clip_path?(path, clip)
300
+ commands = Svg::PathData::Parser.parse(clip.selected_path_data)
301
+ path.commands.map { |cmd| [cmd.letter, cmd.args] } == commands.map { |cmd| [cmd.letter, cmd.args] }
302
+ end
303
+
304
+ def sibling_clip(source, target)
305
+ return unless source.is_a?(Elements::Group) && source.canonical_matrix? && source.transform_matrix.identity?
306
+ return unless target.is_a?(Elements::Group) && target.canonical_matrix?
307
+ return unless source.children.size == 1 && target.children.size == 1
308
+
309
+ defs = source.children.first
310
+ path = target.children.first
311
+ return unless defs.is_a?(Elements::Defs) && defs.children.size == 1 && path.is_a?(Elements::Path)
312
+
313
+ clip = defs.children.first
314
+ return unless clip.is_a?(Elements::ClipPath) && clip.unused_rectangle_bounds
315
+ return unless @references[clip.id] == 1 && lookup(path.clip_path).equal?(clip)
316
+
317
+ clip
318
+ end
319
+
320
+ def resolve(clip_path_attr)
321
+ match = clip_path_attr.match(URL_RE)
322
+ match && @clip_paths[match[1]]
323
+ end
41
324
  end
42
325
  end
43
326
  end
@@ -29,6 +29,19 @@ module Emfsvg
29
29
  Emf::Model::Geometry::Color.new(red: red, green: green, blue: blue)
30
30
  end
31
31
 
32
+ # The ColorRef reserved byte emfsvg has always written for a colour.
33
+ WIRE_RESERVED = 255
34
+
35
+ # Attribute-hash form of #to_emf_wire(...).to_wire: a record field
36
+ # accepts it and builds the ColorRef in place, without first
37
+ # constructing a model colour and a wire object that the record
38
+ # would only copy.
39
+ #
40
+ # A Color never changes, so the one frozen Hash is returned each time.
41
+ def wire_attrs
42
+ @wire_attrs ||= { red: red, green: green, blue: blue, reserved: WIRE_RESERVED }.freeze
43
+ end
44
+
32
45
  def ==(other)
33
46
  other.is_a?(self.class) && red == other.red && green == other.green && blue == other.blue
34
47
  end
@@ -38,12 +51,16 @@ module Emfsvg
38
51
  [self.class, red, green, blue].hash
39
52
  end
40
53
 
54
+ NULL_WIRE_ATTRS = { red: 0, green: 0, blue: 0, reserved: WIRE_RESERVED }.freeze
55
+ private_constant :NULL_WIRE_ATTRS
56
+
41
57
  NULL = NullColor = Class.new do
42
58
  def null? = true
43
59
  def red = 0
44
60
  def green = 0
45
61
  def blue = 0
46
62
  def to_emf_wire = Emf::Model::Geometry::Color.new(red: 0, green: 0, blue: 0)
63
+ def wire_attrs = NULL_WIRE_ATTRS
47
64
  def ==(other) = other.is_a?(self.class)
48
65
  alias_method :eql?, :==
49
66
  def hash = NullColor.hash
@@ -71,19 +88,25 @@ module Emfsvg
71
88
  "cyan" => [0x00, 0xFF, 0xFF]
72
89
  }.freeze
73
90
 
91
+ HEX3_RE = /\A#\h{3}\z/
92
+ HEX6_RE = /\A#\h{6}\z/
93
+ RGB_RE = /\Argb\(\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*\)\z/i
94
+
74
95
  def self.parse(value)
75
- return NULL if value.nil? || value.strip == "none" || value.strip.empty?
96
+ return NULL if value.nil?
76
97
 
77
98
  v = value.strip
78
- if (m = v.match(/\A#([0-9a-fA-F]{3})\z/))
79
- expand_short_hex(m[1])
80
- elsif (m = v.match(/\A#([0-9a-fA-F]{6})\z/))
81
- new(red: m[1][0, 2].to_i(16), green: m[1][2, 2].to_i(16), blue: m[1][4, 2].to_i(16))
82
- elsif (m = v.match(/\Argb\(\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*\)\z/i))
99
+ return NULL if v.empty? || v == "none"
100
+
101
+ if v.match?(HEX3_RE)
102
+ expand_short_hex(v[1, 3])
103
+ elsif v.match?(HEX6_RE)
104
+ rgb = v[1, 6].to_i(16)
105
+ new(red: rgb >> 16, green: (rgb >> 8) & 0xFF, blue: rgb & 0xFF)
106
+ elsif (m = RGB_RE.match(v))
83
107
  new(red: m[1].to_i, green: m[2].to_i, blue: m[3].to_i)
84
- elsif NAMED_COLORS.key?(v.downcase)
85
- r, g, b = NAMED_COLORS[v.downcase]
86
- new(red: r, green: g, blue: b)
108
+ elsif (rgb = NAMED_COLORS[v.downcase])
109
+ new(red: rgb[0], green: rgb[1], blue: rgb[2])
87
110
  else
88
111
  raise FormatError, "unsupported SVG color: #{value.inspect}"
89
112
  end
@@ -36,6 +36,13 @@ module Emfsvg
36
36
  candidate
37
37
  end
38
38
 
39
+ # True when the header translate group carries the legacy broken-Y
40
+ # marker (see Elements::Group::LEGACY_BROKEN_Y_TRANSFORM).
41
+ def legacy_broken_y_header?
42
+ group = header_translate_group
43
+ !group.nil? && group.legacy_broken_y?
44
+ end
45
+
39
46
  # A matrix equivalent to a pure translate: identity 2×2 portion
40
47
  # (modulo floating-point rounding).
41
48
  IDENTITY_TOLERANCE = 1e-9