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
@@ -38,12 +38,12 @@ module Emfsvg
38
38
  @registry[element_name] = subclass
39
39
  end
40
40
 
41
- def from_node(node)
41
+ def from_node(node, &source_tag)
42
42
  subclass = @registry[node.name]
43
- return subclass.from_node(node) if subclass
43
+ return subclass.from_node(node, &source_tag) if subclass
44
44
 
45
45
  OpenElement.new(name: node.name, children: node.element_children.map do |c|
46
- from_node(c)
46
+ from_node(c, &source_tag)
47
47
  end)
48
48
  end
49
49
  end
@@ -9,29 +9,90 @@ module Emfsvg
9
9
  ELEMENT_NAME = "clipPath"
10
10
  Element.register("clipPath", self)
11
11
 
12
- attr_reader :id, :children
12
+ attr_reader :id, :children, :unused_rectangle_bounds, :selected_path_data
13
13
 
14
- def initialize(id:, children:)
14
+ def initialize(id:, children:, unused_rectangle_bounds: nil, selected_path_data: nil)
15
15
  @id = id
16
16
  @children = children
17
+ @unused_rectangle_bounds = unused_rectangle_bounds
18
+ @selected_path_data = selected_path_data
17
19
  end
18
20
 
19
- def self.from_node(node)
21
+ def self.from_node(node, &source_tag)
20
22
  new(
21
23
  id: node["id"],
22
- children: node.element_children.map { |c| Element.from_node(c) }
24
+ selected_path_data: canonical_selected_path(node, &source_tag),
25
+ unused_rectangle_bounds: canonical_unused_rectangle(node, &source_tag),
26
+ children: node.element_children.map { |c| Element.from_node(c, &source_tag) }
23
27
  )
24
28
  end
25
29
 
30
+ def self.canonical_selected_path(node)
31
+ return unless node["id"]&.match?(/\Aclip-\d+\z/) && node.attribute_nodes.size == 1
32
+ return unless node.element_children.size == 1 && block_given?
33
+
34
+ path = node.element_children.first
35
+ return unless path.name == "path" && path.attribute_nodes.map(&:name) == ["d"]
36
+
37
+ number = "-?\\d+\\.\\d{4}"
38
+ point = "#{number},#{number}"
39
+ segment = "(?: L #{point}| C #{point} #{point} #{point})"
40
+ contour = "M #{point}(?: L #{point})+ Z "
41
+ return unless path["d"]&.match?(/\A(?:M #{point}(?:#{segment})+ |(?:#{contour}){2,})Z\z/)
42
+ return unless yield(path) == %(<path d="#{path['d']}" />)
43
+
44
+ path["d"].delete_suffix("Z")
45
+ end
46
+ private_class_method :canonical_selected_path
47
+
48
+ # Only the renderer's exact rectangle spelling supports preservation of
49
+ # an otherwise inert resource via IntersectClipRect followed by reset.
50
+ def self.canonical_unused_rectangle(node)
51
+ return unless node["id"]&.match?(/\Aclip-\d+\z/) && node.attribute_nodes.size == 1
52
+ return unless node.element_children.size == 1 && block_given?
53
+
54
+ path = node.element_children.first
55
+ return unless path.name == "path" && path.attribute_nodes.map(&:name) == ["d"]
56
+
57
+ number = "(-?\\d+\\.\\d{4})"
58
+ lines = " L #{number},#{number}" * 4
59
+ pattern = "\\AM #{number},#{number}#{lines} Z Z\\z"
60
+ match = path["d"]&.match(Regexp.new(pattern))
61
+ return unless match && yield(path) == %(<path d="#{path['d']}" />)
62
+
63
+ left, top, right, top2, right2, bottom, left2, bottom2, left3, top3 = match.captures.map(&:to_f)
64
+ return unless [left, top, right, bottom, left, top] == [left2, top2, right2, bottom2, left3, top3]
65
+ return unless left <= right && top <= bottom
66
+
67
+ [left, top, right, bottom]
68
+ end
69
+ private_class_method :canonical_unused_rectangle
70
+
26
71
  # Bounds [left, top, right, bottom] of the clip region for
27
72
  # IntersectClipRect emission. Handles three forms:
28
73
  #
29
74
  # 1. Single <rect> child — use rect bounds directly.
30
75
  # 2. Single <path> child whose d-string traces a rectangle
31
76
  # (4 unique corners + close) — extract corner bounds.
32
- # 3. Anything else — use the union bounding box of all child
33
- # geometry (lossy: curves flattened to bounds).
77
+ # Compound paths cannot use IntersectClipRect. Other single contours
78
+ # and multiple children retain the existing bounds fallback.
79
+ #
80
+ # Every shape that references the clip asks for these bounds, so
81
+ # they are computed once and the (frozen) result is reused.
34
82
  def clip_bounds
83
+ return @clip_bounds if defined?(@clip_bounds)
84
+
85
+ @clip_bounds = compute_clip_bounds&.freeze
86
+ end
87
+
88
+ # Alias retained for backward compatibility with shared_gdi.rb.
89
+ def rectangular_bounds
90
+ clip_bounds
91
+ end
92
+
93
+ private
94
+
95
+ def compute_clip_bounds
35
96
  return nil if children.empty?
36
97
 
37
98
  if children.size == 1
@@ -43,13 +104,6 @@ module Emfsvg
43
104
  bounding_box_of(children)
44
105
  end
45
106
 
46
- # Alias retained for backward compatibility with shared_gdi.rb.
47
- def rectangular_bounds
48
- clip_bounds
49
- end
50
-
51
- private
52
-
53
107
  def rect_bounds(rect)
54
108
  [rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]
55
109
  end
@@ -58,24 +112,38 @@ module Emfsvg
58
112
  # path: M X1,Y1 L X2,Y1 L X2,Y2 L X1,Y2 L X1,Y1 Z Z. Detect
59
113
  # that shape and recover (X1, Y1, X2, Y2).
60
114
  def path_rect_bounds(path)
115
+ return unless path.commands.one? { |command| command.letter == "M" }
116
+
61
117
  points = PathFlattenerForClip.new.flatten(path.commands)
62
- return nil unless points && points.size >= 4
118
+ return unless points && points.size >= 4
63
119
 
64
120
  xs = points.map(&:first)
65
121
  ys = points.map(&:last)
66
- [xs.min, ys.min, xs.max, ys.max]
122
+ bounds = [xs.min, ys.min, xs.max, ys.max]
123
+ # Preserve the established single-contour fallback outside the
124
+ # four-corner case. Multiple contours must never collapse to bounds.
125
+ return bounds unless points.uniq.size == 4
126
+ return unless path.commands.map(&:letter).join.match?(/\AM[LHV]{3,4}Z+\z/)
127
+
128
+ points.pop if points.last == points.first
129
+ return unless points.size == 4
130
+ return unless (points + [points.first]).each_cons(2).all? { |a, b| (a.first == b.first) ^ (a.last == b.last) }
131
+ return unless xs.uniq.size == 2 && ys.uniq.size == 2
132
+
133
+ bounds
67
134
  end
68
135
 
69
136
  def bounding_box_of(elements)
70
- xs, ys = [], []
137
+ xs = []
138
+ ys = []
71
139
  elements.each do |e|
72
140
  case e
73
141
  when Elements::Rect
74
- xs << e.x << e.x + e.width
75
- ys << e.y << e.y + e.height
142
+ xs << e.x << (e.x + e.width)
143
+ ys << e.y << (e.y + e.height)
76
144
  when Elements::Ellipse
77
- xs << e.cx - e.rx << e.cx + e.rx
78
- ys << e.cy - e.ry << e.cy + e.ry
145
+ xs << (e.cx - e.rx) << (e.cx + e.rx)
146
+ ys << (e.cy - e.ry) << (e.cy + e.ry)
79
147
  when Elements::Path
80
148
  points = PathFlattenerForClip.new.flatten(e.commands)
81
149
  next unless points
@@ -101,10 +169,7 @@ module Emfsvg
101
169
  current = [0.0, 0.0]
102
170
  commands.each do |cmd|
103
171
  case cmd.letter
104
- when "M"
105
- current = cmd.args
106
- points << current.dup
107
- when "L"
172
+ when "M", "L"
108
173
  current = cmd.args
109
174
  points << current.dup
110
175
  when "H"
@@ -9,15 +9,96 @@ module Emfsvg
9
9
  ELEMENT_NAME = "defs"
10
10
  Element.register("defs", self)
11
11
 
12
- attr_reader :children
12
+ HREF_ATTRIBUTES = %w[href xlink:href].freeze
13
+ CanonicalPattern = Struct.new(:image, :id, keyword_init: true)
13
14
 
14
- def initialize(children:)
15
+ attr_reader :children, :unused_pattern, :selected_pattern
16
+
17
+ def initialize(children:, unused_pattern: nil, selected_pattern: nil)
15
18
  @children = children
19
+ @unused_pattern = unused_pattern
20
+ @selected_pattern = selected_pattern
21
+ end
22
+
23
+ def self.from_node(node, &source_tag)
24
+ new(children: node.element_children.map { |c| Element.from_node(c, &source_tag) },
25
+ unused_pattern: canonical_unused_pattern(node),
26
+ selected_pattern: canonical_selected_pattern(node))
27
+ end
28
+
29
+ # The renderer emits this pair when a DIB brush is created, even if
30
+ # never selected. Keep that provenance separate from ordinary resources.
31
+ def self.canonical_unused_pattern(node, selected: false)
32
+ image, pattern = node.element_children
33
+ return unless node.element_children.size == 2 && node.attribute_nodes.empty?
34
+ return unless image.name == "image" && pattern.name == "pattern"
35
+
36
+ id = image["id"]
37
+ width = image["width"]
38
+ height = image["height"]
39
+ return unless id&.match?(/\Aimg-[1-9]\d*\z/)
40
+ return unless [width, height].all? { |v| v&.match?(/\A[1-9]\d*\z/) }
41
+ return unless canonical_image_attributes?(image, id, width, height)
42
+ return unless pattern.attribute_pairs == [["id", "#{id}-ref"], %w[x 0], %w[y 0],
43
+ ["width", width], ["height", height],
44
+ %w[patternUnits userSpaceOnUse]]
45
+
46
+ use = pattern.element_children.first
47
+ return unless pattern.element_children.size == 1 && use.name == "use"
48
+ return unless canonical_use?(use, id)
49
+
50
+ return unless canonical_pattern_reference?(node.document.root, id, use, selected)
51
+
52
+ CanonicalPattern.new(image: Image.from_node(image), id: "#{id}-ref").freeze
53
+ end
54
+ private_class_method :canonical_unused_pattern
55
+
56
+ def self.canonical_pattern_reference?(root, id, use, selected)
57
+ return selected_pattern_reference?(root, "#{id}-ref") if selected
58
+
59
+ !referenced_pattern?(root, [id, "#{id}-ref"], use)
60
+ end
61
+ private_class_method :canonical_pattern_reference?
62
+
63
+ def self.canonical_selected_pattern(node)
64
+ canonical_unused_pattern(node, selected: true)
65
+ end
66
+ private_class_method :canonical_selected_pattern
67
+
68
+ def self.selected_pattern_reference?(node, id)
69
+ supported = %w[rect ellipse circle polygon polyline path]
70
+ return true if supported.include?(node.name) && ["##{id}", "url(##{id})"].include?(node["fill"])
71
+
72
+ node.element_children.any? { |child| selected_pattern_reference?(child, id) }
16
73
  end
74
+ private_class_method :selected_pattern_reference?
75
+
76
+ def self.canonical_use?(use, id)
77
+ use.attribute_pairs == [["id", "#{id}-ign"], ["xlink:href", "##{id}"]] && use.element_children.empty?
78
+ end
79
+ private_class_method :canonical_use?
80
+
81
+ def self.canonical_image_attributes?(image, id, width, height)
82
+ image.element_children.empty? && image["xlink:href"]&.start_with?("data:image/png;base64,") &&
83
+ image.attribute_pairs == [["id", id], ["x", "0"], ["y", "0"], ["width", width], ["height", height],
84
+ ["xlink:href", image["xlink:href"]], ["preserveAspectRatio", "none"]]
85
+ end
86
+ private_class_method :canonical_image_attributes?
87
+
88
+ def self.referenced_pattern?(node, ids, internal_use)
89
+ return false if node == internal_use
90
+ # Stylesheets can select resources without a local presentation attribute.
91
+ return true if node.name == "style"
17
92
 
18
- def self.from_node(node)
19
- new(children: node.element_children.map { |c| Element.from_node(c) })
93
+ referenced = node.attribute_pairs.any? do |name, value|
94
+ ids.any? do |id|
95
+ ((HREF_ATTRIBUTES.include?(name) || name == "fill") && value == "##{id}") ||
96
+ value.match?(/url\(\s*["']?\##{Regexp.escape(id)}["']?\s*\)/)
97
+ end
98
+ end
99
+ referenced || node.element_children.any? { |child| referenced_pattern?(child, ids, internal_use) }
20
100
  end
101
+ private_class_method :referenced_pattern?
21
102
  end
22
103
  end
23
104
  end
@@ -11,22 +11,48 @@ module Emfsvg
11
11
  ELEMENT_NAME = "g"
12
12
  Element.register("g", self)
13
13
 
14
+ # Exact literal libemf2svg emits as the header group of a
15
+ # broken-Y (Sparx/Wine) metafile. Not valid SVG translate syntax.
16
+ LEGACY_BROKEN_Y_TRANSFORM = "translate(0.0000, 0.00 00)"
17
+
14
18
  attr_reader :children, :transform_matrix
15
19
 
16
- def initialize(children:, transform_matrix: Emf::Model::Geometry::Matrix.identity)
20
+ def initialize(children:, transform_matrix: Emf::Model::Geometry::Matrix.identity,
21
+ legacy_broken_y: false, canonical_matrix: false)
17
22
  @children = children
18
23
  @transform_matrix = transform_matrix
24
+ @legacy_broken_y = legacy_broken_y
25
+ @canonical_matrix = canonical_matrix
26
+ end
27
+
28
+ # True when the source transform was exactly the legacy literal.
29
+ def legacy_broken_y?
30
+ @legacy_broken_y
31
+ end
32
+
33
+ def canonical_matrix?
34
+ @canonical_matrix
19
35
  end
20
36
 
21
37
  def transformed?
22
38
  !transform_matrix.identity?
23
39
  end
24
40
 
25
- def self.from_node(node)
26
- children = node.element_children.map { |c| Element.from_node(c) }
41
+ def self.from_node(node, &source_tag)
42
+ children = node.element_children.map { |c| Element.from_node(c, &source_tag) }
27
43
  new(children: children,
28
- transform_matrix: Svg::TransformParser.parse(node["transform"]))
44
+ transform_matrix: Svg::TransformParser.parse(node["transform"]),
45
+ legacy_broken_y: node["transform"] == LEGACY_BROKEN_Y_TRANSFORM,
46
+ canonical_matrix: canonical_matrix_node?(node))
47
+ end
48
+
49
+ def self.canonical_matrix_node?(node)
50
+ return false unless node.attribute_nodes.map(&:name) == ["transform"]
51
+
52
+ number = "-?\\d+\\.\\d{4}"
53
+ node["transform"]&.match?(Regexp.new("\\Amatrix\\(#{number}(?: #{number}){5}\\)\\z"))
29
54
  end
55
+ private_class_method :canonical_matrix_node?
30
56
  end
31
57
  end
32
58
  end
@@ -8,21 +8,257 @@ module Emfsvg
8
8
  ELEMENT_NAME = "path"
9
9
  Element.register("path", self)
10
10
 
11
- attr_reader :commands, :fill, :stroke, :clip_path
11
+ attr_reader :commands, :fill, :stroke, :clip_path, :line_record_kind, :path_record_kind
12
12
 
13
- def initialize(commands:, fill:, stroke:, clip_path: nil)
13
+ def initialize(commands:, fill:, stroke:, clip_path: nil, line_record_kind: nil, path_record_kind: nil)
14
14
  @commands = commands
15
15
  @fill = fill
16
16
  @stroke = stroke
17
17
  @clip_path = clip_path
18
+ @line_record_kind = line_record_kind
19
+ @path_record_kind = path_record_kind
18
20
  end
19
21
 
20
22
  def self.from_node(node)
23
+ source_tag = yield(node) if block_given?
24
+ style = Stylable.parse_style(node)
25
+ style[:stroke] = style[:stroke].with_canonical_cosmetic_dash(source_tag) if source_tag
26
+ path_record_kind = canonical_path_record_kind(node, source_tag) if source_tag
27
+ brush_fallback = source_tag && canonical_brush_fallback?(node, source_tag)
28
+ if brush_fallback
29
+ paint = style[:stroke].paint
30
+ style[:stroke] = Svg::Stroke.new(paint: paint, raw_width: "1px", fallback_brush: paint)
31
+ end
32
+ style[:fill] = Svg::Paint.from_fill(node["style"].delete_prefix("fill:")) if path_record_kind == :bitblt
21
33
  new(
22
34
  commands: Svg::PathData::Parser.parse(node["d"]),
23
- **Stylable.parse_style(node)
35
+ line_record_kind: brush_fallback ? :polyline : canonical_line_record_kind(node, source_tag),
36
+ path_record_kind: path_record_kind,
37
+ **style
24
38
  )
25
39
  end
40
+
41
+ # A NULL pen uses its solid brush to draw open Polyline strokes. The
42
+ # renderer writes fill=none even though that independent brush is set.
43
+ def self.canonical_brush_fallback?(node, source_tag)
44
+ number = "-?\\d+\\.\\d{4}"
45
+ return false unless node["d"]&.match?(/\AM #{number},#{number}(?: L #{number},#{number})+ \z/)
46
+ return false unless node["stroke"]&.match?(/\A#[0-9A-F]{6}\z/)
47
+
48
+ source_tag == %(<path d="#{node['d']}" stroke-width="1px" stroke="#{node['stroke']}" fill="none" />)
49
+ end
50
+ private_class_method :canonical_brush_fallback?
51
+
52
+ def self.canonical_path_record_kind(node, source_tag)
53
+ arc_kind = canonical_arc_kind(node, source_tag)
54
+ return arc_kind if arc_kind
55
+ return :end_path if canonical_pending_path?(node, source_tag)
56
+ return :fill_path if canonical_filled_curve?(node, source_tag) || canonical_filled_contours?(node, source_tag)
57
+ return :bitblt if canonical_bitblt?(node, source_tag)
58
+ return :polypolygon if canonical_poly_polygon?(node, source_tag)
59
+ return :stroke_and_fill_path if canonical_open_painted_path?(node, source_tag)
60
+ return :polybezier_to if canonical_stroked_curve?(node, source_tag)
61
+ return :stroke_path if canonical_cubic_figure?(node, source_tag)
62
+ return :stroke_path if canonical_orphan_or_empty_path?(node, source_tag)
63
+
64
+ nil
65
+ end
66
+ private_class_method :canonical_path_record_kind
67
+
68
+ # EndPath without a painting record has this exact invisible spelling.
69
+ # Its geometry remains available to a following SelectClipPath.
70
+ def self.canonical_pending_path?(node, source_tag)
71
+ number = "-?\\d+\\.\\d{4}"
72
+ point = "#{number},#{number}"
73
+ segment = "(?: L #{point}| C #{point} #{point} #{point})"
74
+ contour = "M #{point}(?: L #{point})+ Z "
75
+ node["d"]&.match?(/\A(?:M #{point}(?:#{segment})+ |(?:#{contour}){2,})\z/) &&
76
+ source_tag == %(<path d="#{node['d']}" fill="none" stroke="none" />)
77
+ end
78
+ private_class_method :canonical_pending_path?
79
+
80
+ # A pure cubic FillPath omits PolyBezier's extra current-position move.
81
+ def self.canonical_filled_curve?(node, source_tag)
82
+ number = "-?\\d+\\.\\d{4}"
83
+ curve = " C #{number},#{number} #{number},#{number} #{number},#{number}"
84
+ node["d"]&.match?(/\AM #{number},#{number}(?:#{curve})+ \z/) &&
85
+ node["fill"]&.match?(/\A#[0-9A-F]{6}\z/) &&
86
+ source_tag == %(<path d="#{node['d']}" fill="#{node['fill']}" stroke="none" />)
87
+ end
88
+ private_class_method :canonical_filled_curve?
89
+
90
+ # FillPath retains every closed contour and has a fill-first tag with
91
+ # no pen attributes. Polygon's NULL-pen brush fallback draws an outline.
92
+ def self.canonical_filled_contours?(node, source_tag)
93
+ number = "-?\\d+\\.\\d{4}"
94
+ point = "#{number},#{number}"
95
+ segment = "(?: L #{point}| C #{point} #{point} #{point})"
96
+ contour = "M #{point}(?:#{segment})+ Z "
97
+ node["d"]&.match?(/\A(?:#{contour})+\z/) &&
98
+ node["fill"]&.match?(/\A#[0-9A-F]{6}\z/) &&
99
+ source_tag == %(<path d="#{node['d']}" fill="#{node['fill']}" stroke="none" />)
100
+ end
101
+ private_class_method :canonical_filled_contours?
102
+
103
+ # Path painting implicitly closes only the fill, retaining open strokes.
104
+ # Fill-first output distinguishes it from unfilled Polyline/PolyBezier.
105
+ def self.canonical_open_painted_path?(node, source_tag)
106
+ number = "-?\\d+\\.\\d{4}"
107
+ point = "#{number},#{number}"
108
+ segment = "(?: L #{point}| C #{point} #{point} #{point})"
109
+ node["d"]&.match?(/\AM #{point}(?:#{segment})+ \z/) && canonical_fill_first_style?(node, source_tag)
110
+ end
111
+ private_class_method :canonical_open_painted_path?
112
+
113
+ # PolyBezier includes a separate current-position move in its output;
114
+ # PolyBezierTo emits only its existing current position.
115
+ def self.canonical_stroked_curve?(node, source_tag)
116
+ number = "-?\\d+\\.\\d{4}"
117
+ point = "#{number},#{number}"
118
+ node["d"]&.match?(/\AM #{point}(?: C #{point} #{point} #{point})+ \z/) &&
119
+ canonical_stroked_path?(node, source_tag, fill_gap: true)
120
+ end
121
+ private_class_method :canonical_stroked_curve?
122
+
123
+ # StrokePath figures have one initial move and the ordinary fill gap.
124
+ # Cubics distinguish these from Polyline; PolyBezier has an extra move.
125
+ def self.canonical_cubic_figure?(node, source_tag)
126
+ number = "-?\\d+\\.\\d{4}"
127
+ point = "#{number},#{number}"
128
+ segment = "(?: L #{point}| C #{point} #{point} #{point})"
129
+ node["d"]&.include?(" C ") &&
130
+ node["d"].match?(/\AM #{point}(?:#{segment})+(?: Z)? \z/) &&
131
+ canonical_stroked_path?(node, source_tag)
132
+ end
133
+ private_class_method :canonical_cubic_figure?
134
+
135
+ # A stroked EMF path can start with LineTo or contain no figures at
136
+ # all. Only the renderer's exact spelling carries that provenance.
137
+ def self.canonical_orphan_or_empty_path?(node, source_tag)
138
+ number = "-?\\d+\\.\\d{4}"
139
+ data = node["d"]
140
+ return false unless data == "" || data&.match?(/\AL #{number},#{number}(?: [ML] #{number},#{number})* \z/)
141
+
142
+ canonical_stroked_path?(node, source_tag)
143
+ end
144
+ private_class_method :canonical_orphan_or_empty_path?
145
+
146
+ # Zero-source BitBlt emits a style-first rectangle without a trailing
147
+ # space after Z. Only that exact spelling carries record provenance.
148
+ def self.canonical_bitblt?(node, source_tag)
149
+ number = "-?\\d+\\.\\d{4}"
150
+ rectangle = /
151
+ \AM[ ](?<x>#{number}),(?<y>#{number})
152
+ [ ]L[ ](?<right>#{number}),\k<y>
153
+ [ ]L[ ]\k<right>,(?<bottom>#{number})
154
+ [ ]L[ ]\k<x>,\k<bottom>[ ]Z\z
155
+ /x
156
+ node["style"]&.match?(/\Afill:#[0-9a-f]{6}\z/) &&
157
+ node["d"]&.match?(rectangle) &&
158
+ source_tag == %(<path style="#{node['style']}" d="#{node['d']}" />)
159
+ end
160
+ private_class_method :canonical_bitblt?
161
+
162
+ # PolylineTo starts at the current position and adds an extra space
163
+ # before fill. A single line shares that spelling with LineTo.
164
+ def self.canonical_line_record_kind(node, source_tag)
165
+ return unless source_tag
166
+ return :polyline if canonical_polyline?(node, source_tag)
167
+
168
+ number = "-?\\d+\\.\\d{4}"
169
+ return unless node["d"]&.match?(/\AM #{number},#{number}(?: L #{number},#{number}){2,} \z/)
170
+
171
+ :polyline_to if canonical_stroked_path?(node, source_tag, fill_gap: true)
172
+ end
173
+ private_class_method :canonical_line_record_kind
174
+
175
+ # Only the exact legacy renderer spelling provides record provenance.
176
+ # Ordinary SVG paths keep the existing translation strategy.
177
+ def self.canonical_polyline?(node, source_tag)
178
+ number = "-?\\d+\\.\\d{4}"
179
+ return false unless node["d"]&.match?(/\AM #{number},#{number} L #{number},#{number} \z/)
180
+
181
+ canonical_stroked_path?(node, source_tag)
182
+ end
183
+ private_class_method :canonical_polyline?
184
+
185
+ def self.canonical_arc_kind(node, source_tag)
186
+ number = "-?\\d+\\.\\d{4}"
187
+ arc = /
188
+ \AM[ ]#{number},#{number}[ ]M[ ]#{number},#{number}
189
+ [ ]A[ ]#{number},#{number}[ ]0[ ](?<sweep>[01])[ ]\k<sweep>[ ]#{number},#{number}
190
+ [ ](?<closure>(?:L[ ]#{number},#{number}[ ])?Z[ ])?\z
191
+ /x
192
+ match = arc.match(node["d"].to_s)
193
+ return unless match
194
+
195
+ kind = if match[:closure]
196
+ match[:closure].start_with?("L") ? :pie : :chord
197
+ else
198
+ :arc
199
+ end
200
+ kind if canonical_stroked_path?(node, source_tag, fill_gap: true, closed: kind != :arc)
201
+ end
202
+ private_class_method :canonical_arc_kind
203
+
204
+ def self.canonical_stroked_path?(node, source_tag, fill_gap: false, closed: false)
205
+ number = "-?\\d+\\.\\d{4}"
206
+ return false unless node["stroke"]&.match?(/\A#[0-9A-Fa-f]{6}\z/)
207
+ return false unless node["stroke-width"]&.match?(/\A(?:#{number}|1px)\z/)
208
+ return false unless %w[round square butt].include?(node["stroke-linecap"])
209
+ return false unless %w[round bevel miter].include?(node["stroke-linejoin"])
210
+ return false unless closed ? node["fill"]&.match?(/\A#[0-9A-Fa-f]{6}\z/) : node["fill"] == "none"
211
+
212
+ expected = +"<path "
213
+ if node["clip-path"]
214
+ return false unless node["clip-path"].match?(/\Aurl\(#clip-\d+\)\z/)
215
+
216
+ expected << %( clip-path="#{node['clip-path']}" )
217
+ end
218
+ expected << %(d="#{node['d']}" stroke="#{node['stroke']}" stroke-width="#{node['stroke-width']}" )
219
+ expected << %(stroke-dasharray="#{node['stroke-dasharray']}" ) if node["stroke-dasharray"]
220
+ expected << %( stroke-linecap="#{node['stroke-linecap']}" stroke-linejoin="#{node['stroke-linejoin']}" )
221
+ expected << %( stroke-miterlimit="#{node['stroke-miterlimit']}" ) if node["stroke-miterlimit"]
222
+ expected << " " if fill_gap
223
+ expected << %(fill="#{node['fill']}")
224
+ expected << " " if closed
225
+ expected << " />"
226
+ source_tag == expected
227
+ end
228
+ private_class_method :canonical_stroked_path?
229
+
230
+ # PolyPolygon's C renderer has a distinctive fill-first start tag and
231
+ # retains a trailing space after Z. Preserve that record only for the
232
+ # exact spelling emitted by the renderer; ordinary closed paths use
233
+ # Polygon as before.
234
+ def self.canonical_poly_polygon?(node, source_tag)
235
+ number = "-?\\d+\\.\\d{4}"
236
+ node["d"]&.match?(/\AM #{number},#{number}(?: L #{number},#{number})+ Z \z/) &&
237
+ canonical_fill_first_style?(node, source_tag)
238
+ end
239
+ private_class_method :canonical_poly_polygon?
240
+
241
+ def self.canonical_fill_first_style?(node, source_tag)
242
+ number = "-?\\d+\\.\\d{4}"
243
+ return false unless node["fill"]&.match?(/\A#[0-9A-F]{6}\z/)
244
+ return false unless node["stroke"]&.match?(/\A#[0-9A-F]{6}\z/)
245
+ return false unless node["stroke-width"]&.match?(/\A(?:#{number}|1px)\z/)
246
+ return false unless %w[round square butt].include?(node["stroke-linecap"])
247
+ return false unless %w[round bevel miter].include?(node["stroke-linejoin"])
248
+ return false if node["stroke-dasharray"]
249
+
250
+ expected = %(<path d="#{node['d']}" fill="#{node['fill']}" stroke="#{node['stroke']}" )
251
+ expected << %(stroke-width="#{node['stroke-width']}" stroke-linecap="#{node['stroke-linecap']}" )
252
+ expected << %( stroke-linejoin="#{node['stroke-linejoin']}" )
253
+ if node["stroke-miterlimit"]
254
+ return false unless node["stroke-linejoin"] == "miter"
255
+
256
+ expected << %( stroke-miterlimit="#{node['stroke-miterlimit']}" )
257
+ end
258
+ expected << "/>"
259
+ source_tag == expected
260
+ end
261
+ private_class_method :canonical_fill_first_style?
26
262
  end
27
263
  end
28
264
  end
@@ -9,9 +9,10 @@ module Emfsvg
9
9
  ELEMENT_NAME = "rect"
10
10
  Element.register("rect", self)
11
11
 
12
- attr_reader :x, :y, :width, :height, :rx, :ry, :fill, :stroke, :clip_path
12
+ attr_reader :x, :y, :width, :height, :rx, :ry, :fill, :stroke, :clip_path, :serialization_hint
13
13
 
14
- def initialize(x:, y:, width:, height:, rx:, ry:, fill:, stroke:, clip_path: nil)
14
+ def initialize(x:, y:, width:, height:, rx:, ry:, fill:, stroke:, clip_path: nil, serialization_hint: nil)
15
+ @serialization_hint = serialization_hint
15
16
  @x = x
16
17
  @y = y
17
18
  @width = width
@@ -24,7 +25,9 @@ module Emfsvg
24
25
  end
25
26
 
26
27
  def self.from_node(node)
28
+ source = yield(node) if block_given?
27
29
  new(
30
+ serialization_hint: DrawingHint.rectangle_values(source),
28
31
  x: AttributeParser.float(node["x"]),
29
32
  y: AttributeParser.float(node["y"]),
30
33
  width: AttributeParser.float(node["width"]),