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
@@ -8,9 +8,8 @@ module Emfsvg
8
8
  # Translate <path d="..."> to EMF drawing records.
9
9
  #
10
10
  # v1 strategy: flatten to absolute, fully-resolved points. Bezier
11
- # control points are preserved as cubic Beziers; arcs/quads are
12
- # approximated (TODO). Emits a single PolyBezier record when
13
- # cubics are present, else Polyline / Polygon.
11
+ # control points and canonical circular arcs are preserved; other
12
+ # arcs and quads remain unsupported. Separate figures retain their closure and order.
14
13
  class PathHandler
15
14
  include SharedGdi
16
15
  include Emf::Emr::Binary::TypeCodes
@@ -20,28 +19,184 @@ module Emfsvg
20
19
  end
21
20
 
22
21
  def translate(element, context)
23
- return if element.commands.empty?
22
+ return if element.commands.empty? && element.path_record_kind != :stroke_path
24
23
 
25
24
  with_gdi_state(element, context) do
26
- flattened = PathFlattener.flatten(element.commands)
27
- emit_path(flattened, element, context)
25
+ context.with_local_origin(LocalOrigin.for_path(element, context.scaler)) do
26
+ translate_geometry(element, context)
27
+ end
28
28
  end
29
29
  end
30
30
 
31
31
  private
32
32
 
33
- def emit_path(flattened, _element, context)
34
- case flattened.shape_kind
35
- when :polygon then emit_polygon(flattened, context)
36
- when :polyline then emit_polyline(flattened, context)
37
- when :polybezier then emit_polybezier(flattened, context)
33
+ def translate_geometry(element, context)
34
+ if %i[arc chord pie].include?(element.path_record_kind)
35
+ arc = CircularArc.from_commands(element.commands, kind: element.path_record_kind)
36
+ if element.path_record_kind == :arc
37
+ arc ||= MappedArc.from_commands(element.commands, scaler: context.scaler)
38
+ end
39
+ if element.path_record_kind == :pie
40
+ arc ||= MappedPie.from_commands(element.commands,
41
+ scaler: context.scaler)
42
+ end
38
43
  end
44
+ if arc&.safe_for?(context)
45
+ arc.emit(context)
46
+ return
47
+ end
48
+
49
+ flattened = PathFlattener.flatten(element.commands,
50
+ preserve_initial_line: element.path_record_kind == :stroke_path)
51
+ if element.path_record_kind == :bitblt
52
+ emit_bitblt(flattened.first, context)
53
+ return
54
+ end
55
+ return emit_current_curve(flattened, context) if element.path_record_kind == :polybezier_to
56
+ return emit_current_polyline(flattened.first, context) if element.line_record_kind == :polyline_to
57
+
58
+ if %i[end_path fill_path stroke_path stroke_and_fill_path].include?(element.path_record_kind)
59
+ emit_figures(flattened, context, stroke_null: element.stroke.paint.null?, unfilled: element.fill.null?,
60
+ pending: element.path_record_kind == :end_path)
61
+ return
62
+ end
63
+ emit_subpaths(flattened, context,
64
+ unfilled: element.fill.null?, stroke_null: element.stroke.paint.null?,
65
+ line_record_kind: element.line_record_kind,
66
+ path_record_kind: element.path_record_kind)
67
+ end
68
+
69
+ def emit_bitblt(path, context)
70
+ left, top = path.points.first
71
+ right, bottom = path.points[2]
72
+ position = context.point_attrs(left, top)
73
+ size = context.size_attrs(right - left, bottom - top)
74
+ context.emit(Emf::Emr::Binary::Records::BitBlt,
75
+ i_type: BITBLT, rcl_bounds: context.rect_attrs(left, top, right, bottom),
76
+ x_dest: position[:x], y_dest: position[:y],
77
+ cx_dest: size[:cx], cy_dest: size[:cy],
78
+ dw_rop: 0x00F00021, cb_bits_src: 0)
79
+ end
80
+
81
+ def emit_subpaths(subpaths, context, unfilled:, stroke_null:, line_record_kind:, path_record_kind:)
82
+ kinds = subpaths.map(&:shape_kind).uniq
83
+ if unfilled && prefixed_polybezier?(subpaths)
84
+ first = subpaths.first.points.first
85
+ context.emit(Emf::Emr::Binary::Records::MoveToEx, i_type: MOVETOEX,
86
+ origin: context.point_attrs(*first))
87
+ emit_poly(Emf::Emr::Binary::Records::PolyBezier, POLYBEZIER, subpaths.last, context)
88
+ elsif subpaths.size > 1 && kinds.size == 1 && %i[polygon polyline].include?(kinds.first)
89
+ emit_multi(subpaths, kinds.first == :polygon, context)
90
+ elsif canonical_poly_polygon?(subpaths, path_record_kind, kinds)
91
+ emit_multi(subpaths, true, context)
92
+ elsif line_record_kind != :polyline && single_line_path?(subpaths)
93
+ subpaths.first.segments.each { |letter, points| emit_segment(letter, points, subpaths.first, context) }
94
+ elsif subpaths.size > 1 || kinds == [:path]
95
+ emit_figures(subpaths, context, stroke_null: stroke_null, unfilled: unfilled)
96
+ else
97
+ flattened = subpaths.first
98
+ case flattened.shape_kind
99
+ when :polygon then emit_polygon(flattened, context)
100
+ when :polyline then emit_polyline(flattened, context)
101
+ when :polybezier then emit_polybezier(flattened, context)
102
+ end
103
+ end
104
+ end
105
+
106
+ def prefixed_polybezier?(subpaths)
107
+ subpaths.size == 2 && subpaths.first.segments.map(&:first) == ["M"] &&
108
+ subpaths.last.shape_kind == :polybezier
109
+ end
110
+
111
+ def single_line_path?(subpaths)
112
+ return false unless subpaths.size == 1
113
+
114
+ segments = subpaths.first.segments
115
+ segments.size == 2 && segments.first.first == "M" &&
116
+ %w[L H V].include?(segments.last.first)
117
+ end
118
+
119
+ def canonical_poly_polygon?(subpaths, path_record_kind, kinds)
120
+ path_record_kind == :polypolygon && subpaths.size == 1 && kinds == [:polygon]
121
+ end
122
+
123
+ def emit_multi(subpaths, closed, context)
124
+ points = subpaths.flat_map(&:points).map { |x, y| context.point_attrs(x, y) }
125
+ context.emit(closed ? Emf::Emr::Binary::Records::PolyPolygon : Emf::Emr::Binary::Records::PolyPolyline,
126
+ i_type: closed ? POLYPOLYGON : POLYPOLYLINE,
127
+ rcl_bounds: combined_bounds(subpaths, context),
128
+ n_polys: subpaths.size, c_ptl_total: points.size,
129
+ a_poly_counts: subpaths.map { |path| path.points.size }, aptl: points)
130
+ end
131
+
132
+ def emit_current_curve(subpaths, context)
133
+ subpaths.each do |path|
134
+ context.emit(Emf::Emr::Binary::Records::MoveToEx, i_type: MOVETOEX,
135
+ origin: context.point_attrs(*path.points.first))
136
+ emit_polybezier_to(path, context)
137
+ end
138
+ end
139
+
140
+ def emit_polybezier_to(path, context)
141
+ curve = PathFlattener::Result.new(points: path.points.drop(1), bounds: path.bounds)
142
+ emit_poly(Emf::Emr::Binary::Records::PolyBezierTo, POLYBEZIERTO, curve, context)
143
+ end
144
+
145
+ def emit_figures(subpaths, context, stroke_null:, unfilled:, pending: false)
146
+ context.emit(Emf::Emr::Binary::Records::BeginPath, i_type: BEGINPATH)
147
+ if pending && subpaths.size > 1 && subpaths.all? { |path| path.shape_kind == :polygon }
148
+ emit_multi(subpaths, true, context)
149
+ else
150
+ subpaths.each do |path|
151
+ path.segments.each { |letter, points| emit_segment(letter, points, path, context) }
152
+ end
153
+ end
154
+ context.emit(Emf::Emr::Binary::Records::EndPath, i_type: ENDPATH)
155
+ return if pending
156
+
157
+ action, i_type = if stroke_null && !unfilled
158
+ [Emf::Emr::Binary::Records::FillPath, FILLPATH]
159
+ elsif unfilled && !stroke_null
160
+ [Emf::Emr::Binary::Records::StrokePath, STROKEPATH]
161
+ else
162
+ [Emf::Emr::Binary::Records::StrokeAndFillPath, STROKEANDFILLPATH]
163
+ end
164
+ context.emit(action, i_type: i_type, rcl_bounds: combined_bounds(subpaths, context))
165
+ end
166
+
167
+ def emit_segment(letter, points, path, context)
168
+ case letter
169
+ when "M", "L", "H", "V"
170
+ context.emit(letter == "M" ? Emf::Emr::Binary::Records::MoveToEx : Emf::Emr::Binary::Records::LineTo,
171
+ i_type: letter == "M" ? MOVETOEX : LINETO,
172
+ origin: context.point_attrs(*points.first))
173
+ when "C"
174
+ curve = PathFlattener::Result.new(points: points, bounds: path.bounds)
175
+ emit_poly(Emf::Emr::Binary::Records::PolyBezierTo, POLYBEZIERTO, curve, context)
176
+ when "Z"
177
+ context.emit(Emf::Emr::Binary::Records::CloseFigure, i_type: CLOSEFIGURE)
178
+ end
179
+ end
180
+
181
+ def combined_bounds(subpaths, context)
182
+ return context.rect_attrs(0, 0, 0, 0) if subpaths.empty?
183
+
184
+ bounds = subpaths.map(&:bounds)
185
+ context.rect_attrs(bounds.map(&:left).min, bounds.map(&:top).min,
186
+ bounds.map(&:right).max, bounds.map(&:bottom).max)
39
187
  end
40
188
 
41
189
  def emit_polygon(flattened, context)
42
190
  emit_poly(Emf::Emr::Binary::Records::Polygon, POLYGON, flattened, context)
43
191
  end
44
192
 
193
+ def emit_current_polyline(path, context)
194
+ context.emit(Emf::Emr::Binary::Records::MoveToEx, i_type: MOVETOEX,
195
+ origin: context.point_attrs(*path.points.first))
196
+ line = PathFlattener::Result.new(points: path.points.drop(1), bounds: path.bounds)
197
+ emit_poly(Emf::Emr::Binary::Records::PolylineTo, POLYLINETO, line, context)
198
+ end
199
+
45
200
  def emit_polyline(flattened, context)
46
201
  emit_poly(Emf::Emr::Binary::Records::Polyline, POLYLINE, flattened, context)
47
202
  end
@@ -55,14 +210,14 @@ module Emfsvg
55
210
  if first
56
211
  context.emit(Emf::Emr::Binary::Records::MoveToEx,
57
212
  i_type: MOVETOEX,
58
- origin: context.point_l(first[0], first[1]))
213
+ origin: context.point_attrs(first[0], first[1]))
59
214
  end
60
215
  emit_poly(Emf::Emr::Binary::Records::PolyBezier, POLYBEZIER, flattened, context)
61
216
  end
62
217
 
63
218
  def emit_poly(record_class, i_type, flattened, context)
64
- points = flattened.points.map { |x, y| context.point_l(x, y) }
65
- scaled_bounds = context.rect_l(
219
+ points = flattened.points.map { |x, y| context.point_attrs(x, y) }
220
+ scaled_bounds = context.rect_attrs(
66
221
  flattened.bounds.left, flattened.bounds.top,
67
222
  flattened.bounds.right, flattened.bounds.bottom
68
223
  )
@@ -26,7 +26,7 @@ module Emfsvg
26
26
 
27
27
  def emit_poly(points, element, context)
28
28
  bounds = bounding_box(points, context)
29
- wire_points = points.map { |x, y| context.point_l(x, y) }
29
+ wire_points = points.map { |x, y| context.point_attrs(x, y) }
30
30
  record_class = if element.is_a?(Svg::Elements::Polygon)
31
31
  Emf::Emr::Binary::Records::Polygon
32
32
  else
@@ -43,7 +43,7 @@ module Emfsvg
43
43
  def bounding_box(points, context)
44
44
  xs = points.map { |x, _| x }
45
45
  ys = points.map { |_, y| y }
46
- context.rect_l(xs.min, ys.min, xs.max, ys.max)
46
+ context.rect_attrs(xs.min, ys.min, xs.max, ys.max)
47
47
  end
48
48
  end
49
49
  end
@@ -24,12 +24,23 @@ module Emfsvg
24
24
  private
25
25
 
26
26
  def emit_rect(element, context)
27
- box = context.rect_l(element.x, element.y,
28
- element.x + element.width,
29
- element.y + element.height)
27
+ box = context.rect_attrs(element.x, element.y,
28
+ element.x + element.width,
29
+ element.y + element.height)
30
30
 
31
- if element.rounded?
32
- corner = context.size_l(element.rx * 2, element.ry * 2)
31
+ if raster_fill?(element)
32
+ # Rectangle's NULL-pen fallback adds a brush-colored outline.
33
+ # A source-free raster fill retains SVG's fill-only boundary.
34
+ position = context.point_attrs(element.x, element.y)
35
+ size = context.size_attrs(element.width, element.height)
36
+ context.emit(Emf::Emr::Binary::Records::BitBlt,
37
+ i_type: BITBLT, rcl_bounds: box,
38
+ x_dest: position[:x], y_dest: position[:y],
39
+ cx_dest: size[:cx], cy_dest: size[:cy],
40
+ dw_rop: 0x00F00021, cb_bits_src: 0)
41
+ elsif element.rounded?
42
+ # The pinned C renderer emits szlCorner directly as SVG rx/ry.
43
+ corner = context.size_attrs(element.rx, element.ry)
33
44
  context.emit(Emf::Emr::Binary::Records::RoundRect,
34
45
  i_type: ROUNDRECT, rcl_box: box, szl_corner: corner)
35
46
  else
@@ -37,6 +48,11 @@ module Emfsvg
37
48
  i_type: RECTANGLE, rcl_box: box)
38
49
  end
39
50
  end
51
+
52
+ def raster_fill?(element)
53
+ !element.rounded? && element.clip_path.nil? &&
54
+ element.stroke.paint.null? && element.fill.style == Svg::Paint::SOLID
55
+ end
40
56
  end
41
57
  end
42
58
  end
@@ -14,27 +14,72 @@ module Emfsvg
14
14
  # to EMF records and is what the round-trip test exercises.
15
15
  #
16
16
  # If the element has a rectangular clip-path, the entire draw is
17
- # wrapped in SaveDC + IntersectClipRect + RestoreDC so the clip
18
- # is scoped to this element only.
17
+ # wrapped in SaveDC + IntersectClipRect + RestoreDC. Adjacent built-in
18
+ # drawings that install all state they read can share that single scope.
19
19
  module SharedGdi
20
20
  include Emf::Emr::Binary::TypeCodes
21
21
 
22
- def with_gdi_state(element, context)
22
+ def with_gdi_state(element, context, &)
23
23
  clip_bounds = clip_bounds_for(element, context)
24
24
  wrap_with_clip = !clip_bounds.nil?
25
25
 
26
- emit_save_dc(context) if wrap_with_clip
26
+ signature = clip_scope_signature(element, context, clip_bounds)
27
+ reuse_clip = context.reuse_clip_scope?(signature)
28
+ emit_save_dc(context) if wrap_with_clip && !reuse_clip
29
+ if element.stroke && !element.stroke.paint.null?
30
+ context.emit(Emf::Emr::Binary::Records::SetMiterLimit,
31
+ i_type: SETMITERLIMIT,
32
+ miter_limit: serialized_miter_limit(element))
33
+ end
27
34
  pen_handle = emit_create_pen(context, element.stroke) if element.stroke
28
- brush_handle = emit_create_brush(context, element.fill) if element.fill
29
- if wrap_with_clip
35
+ brush = element.stroke&.fallback_brush || element.fill
36
+ brush_handle = emit_create_brush(context, brush) if brush
37
+ if wrap_with_clip && !reuse_clip
30
38
  context.emit(Emf::Emr::Binary::Records::IntersectClipRect,
31
39
  i_type: INTERSECTCLIPRECT,
32
- rcl_clip: context.rect_l(*clip_bounds))
40
+ rcl_clip: context.rect_attrs(*clip_bounds))
33
41
  end
34
- yield
42
+ context.with_drawing_hint(element, &)
35
43
  emit_delete(context, pen_handle) if pen_handle
36
44
  emit_delete(context, brush_handle) if brush_handle
37
- emit_restore_dc(context) if wrap_with_clip
45
+ return unless wrap_with_clip
46
+
47
+ restore = emit_restore_dc(context)
48
+ context.remember_clip_scope(signature, restore)
49
+ end
50
+
51
+ def serialized_miter_limit(element)
52
+ limit = element.stroke.miter_limit
53
+ return limit unless element.is_a?(Svg::Elements::Path)
54
+
55
+ cubic_figure = element.path_record_kind == :stroke_path &&
56
+ element.commands.any? { |command| command.letter == "C" }
57
+ return limit unless element.line_record_kind == :polyline_to || cubic_figure
58
+ return limit unless limit.between?(1, 0x7FFFFF) && limit == limit.to_i
59
+
60
+ # Canonical PolylineTo and cubic StrokePath use libuemf's integer wire contract.
61
+ # The dependency exposes a float field; subnormal bits preserve the
62
+ # integer payload, which our reader also recognizes. Other limits
63
+ # retain floats rather than truncating or exceeding that reader range.
64
+ [limit.to_i].pack("V").unpack1("e")
65
+ end
66
+
67
+ def clip_scope_signature(element, context, clip_bounds)
68
+ return unless clip_bounds && complete_draw_state?(element)
69
+
70
+ [:complete_draw, context.rect_attrs(*clip_bounds), context.canonical_empty_clip_scope?(element)]
71
+ end
72
+
73
+ def complete_draw_state?(element)
74
+ # Exact built-ins opt in: custom handlers may depend on inherited
75
+ # state. Shapes install pen/brush/miter and absolute geometry;
76
+ # untransformed text installs font/color/alignment and its position.
77
+ # Images use explicit DIB data and destination geometry.
78
+ return !element.transformed? if instance_of?(TextHandler)
79
+ return true if instance_of?(ImageHandler)
80
+
81
+ [PathHandler, RectHandler, EllipseHandler, LineHandler,
82
+ PolylineHandler, PolygonHandler].include?(self.class) && element.stroke && element.fill
38
83
  end
39
84
 
40
85
  def clip_bounds_for(element, context)
@@ -45,6 +90,7 @@ module Emfsvg
45
90
 
46
91
  clip = context.clip_path_registry.lookup(attr)
47
92
  return nil unless clip
93
+ return nil if context.carried_clip?(clip) || context.selected_clip?(clip) || context.interval_clip?(clip)
48
94
 
49
95
  clip.clip_bounds
50
96
  end
@@ -60,12 +106,20 @@ module Emfsvg
60
106
 
61
107
  def emit_create_pen(context, stroke)
62
108
  handle = context.allocate_handle
109
+ width = if stroke.canonical_cosmetic_dash
110
+ # Cosmetic dash lengths use stored width without coordinate mapping.
111
+ { x: stroke.canonical_cosmetic_dash[0], y: 0 }
112
+ elsif stroke.cosmetic_pixel_width?
113
+ { x: 1, y: 0 }
114
+ else
115
+ context.point_attrs(stroke.width, 0)
116
+ end
63
117
  context.emit(Emf::Emr::Binary::Records::CreatePen,
64
118
  i_type: CREATEPEN,
65
119
  ih_pen: handle,
66
120
  pen_style: stroke.pen_style,
67
- width: context.point_l(stroke.width, 0),
68
- color: stroke.paint.color.to_emf_wire.to_wire)
121
+ width: width,
122
+ color: stroke.paint.color.wire_attrs)
69
123
  context.emit(Emf::Emr::Binary::Records::SelectObject,
70
124
  i_type: SELECTOBJECT,
71
125
  ih_object: handle)
@@ -73,12 +127,21 @@ module Emfsvg
73
127
  end
74
128
 
75
129
  def emit_create_brush(context, paint)
130
+ if paint.reference
131
+ handle = context.pattern_handles.fetch(paint.reference) do
132
+ raise FormatError, "undefined SVG pattern: #{paint.reference.inspect}"
133
+ end
134
+ context.emit(Emf::Emr::Binary::Records::SelectObject, i_type: SELECTOBJECT, ih_object: handle)
135
+ # Definition brushes remain available for subsequent draws.
136
+ return nil
137
+ end
138
+
76
139
  handle = context.allocate_handle
77
140
  context.emit(Emf::Emr::Binary::Records::CreateBrushIndirect,
78
141
  i_type: CREATEBRUSHINDIRECT,
79
142
  ih_brush: handle,
80
143
  brush_style: paint.style,
81
- color: paint.color.to_emf_wire.to_wire,
144
+ color: paint.color.wire_attrs,
82
145
  brush_hatch: 0)
83
146
  context.emit(Emf::Emr::Binary::Records::SelectObject,
84
147
  i_type: SELECTOBJECT,
@@ -25,32 +25,71 @@ module Emfsvg
25
25
  end
26
26
 
27
27
  def translate(element, context)
28
- return if element.content.nil? || element.content.empty?
28
+ return if element.content.nil?
29
+ return if element.content.empty? && !element.empty_cdata_content && !element.font_size.zero?
29
30
 
30
31
  clip_bounds = clip_bounds_for(element, context)
31
32
  wrap_with_clip = !clip_bounds.nil?
32
33
  transformed = element.transformed?
33
34
 
34
- emit_save_dc(context) if wrap_with_clip || transformed
35
+ signature = clip_scope_signature(element, context, clip_bounds)
36
+ reuse_clip = context.reuse_clip_scope?(signature)
37
+ emit_save_dc(context) if (wrap_with_clip || transformed) && !reuse_clip
35
38
  if transformed
36
39
  context.emit(Emf::Emr::Binary::Records::SetWorldTransform,
37
40
  i_type: SETWORLDTRANSFORM,
38
41
  xform: context.xform_wire_from(element.transform_matrix))
39
42
  end
40
- if wrap_with_clip
43
+ if wrap_with_clip && !reuse_clip
41
44
  context.emit(Emf::Emr::Binary::Records::IntersectClipRect,
42
45
  i_type: INTERSECTCLIPRECT,
43
- rcl_clip: context.rect_l(*clip_bounds))
46
+ rcl_clip: context.rect_attrs(*clip_bounds))
44
47
  end
45
- font_handle = emit_create_font(element, context)
46
- emit_set_text_color(element, context)
47
- emit_ext_text_out_w(element, context)
48
- emit_delete(context, font_handle)
49
- emit_restore_dc(context) if wrap_with_clip || transformed
48
+ emit_mapped_text(element, context, reuse: !wrap_with_clip && !transformed)
49
+ return unless wrap_with_clip || transformed
50
+
51
+ restore = emit_restore_dc(context)
52
+ context.remember_clip_scope(signature, restore)
50
53
  end
51
54
 
52
55
  private
53
56
 
57
+ def emit_mapped_text(element, context, reuse:)
58
+ rotation = element.canonical_text_rotation
59
+ @text_scaler = context.scaler
60
+ if rotation&.native_height && context.scaler.scaled?
61
+ finer = Scaler::Fixed.new(1_000_000)
62
+ values = [element.x, rotation.native_y, rotation.native_height,
63
+ element.x + (element.content.length * element.font_size * 0.6), element.y - element.font_size]
64
+ @text_scaler = finer unless values.any? { |value| finer.unsafe?(value) }
65
+ end
66
+ return emit_text(element, context, reuse: reuse) if @text_scaler.equal?(context.scaler)
67
+
68
+ emit_save_dc(context)
69
+ context.emit(Emf::Emr::Binary::Records::SetWindowExtEx,
70
+ i_type: SETWINDOWEXTEX, extent: { cx: @text_scaler.factor, cy: @text_scaler.factor })
71
+ emit_text(element, context, reuse: false)
72
+ # Both renderers keep mapping extents outside the saved drawing state.
73
+ context.emit(Emf::Emr::Binary::Records::SetWindowExtEx,
74
+ i_type: SETWINDOWEXTEX, extent: { cx: context.scaler.factor, cy: context.scaler.factor })
75
+ emit_restore_dc(context)
76
+ end
77
+
78
+ def emit_text(element, context, reuse:)
79
+ # Packed LOGFONT includes rotation, weight, size, family and italic;
80
+ # positions and content may vary without changing selected GDI state.
81
+ signature = [build_logfont(element, context), element.fill.color.wire_attrs, text_align(element)] if reuse
82
+ font_handle = context.reuse_text_state(signature)
83
+ unless font_handle
84
+ font_handle = emit_create_font(element, context)
85
+ emit_set_text_color(element, context)
86
+ emit_set_text_align(element, context)
87
+ end
88
+ emit_ext_text_out_w(element, context)
89
+ deletion = emit_delete(context, font_handle)
90
+ context.remember_text_state(signature, font_handle, deletion)
91
+ end
92
+
54
93
  def emit_create_font(element, context)
55
94
  handle = context.allocate_handle
56
95
  logfont_bytes = build_logfont(element, context)
@@ -65,52 +104,78 @@ module Emfsvg
65
104
  end
66
105
 
67
106
  def emit_set_text_color(element, context)
68
- color = element.fill.color.to_emf_wire.to_wire
107
+ color = element.fill.color.wire_attrs
69
108
  context.emit(Emf::Emr::Binary::Records::SetTextColor,
70
109
  i_type: SETTEXTCOLOR,
71
110
  color: color)
72
111
  end
73
112
 
113
+ def text_align(element)
114
+ vertical = element.canonical_text_rotation&.vertical_alignment == :top ? 0 : 0x18
115
+ horizontal = case element.text_anchor
116
+ when "middle" then 0x06
117
+ when "end" then 0x02
118
+ else 0
119
+ end
120
+ vertical | horizontal
121
+ end
122
+
123
+ def emit_set_text_align(element, context)
124
+ context.emit(Emf::Emr::Binary::Records::SetTextAlign,
125
+ i_type: SETTEXTALIGN,
126
+ text_align_mode: text_align(element))
127
+ end
128
+
74
129
  def emit_ext_text_out_w(element, context)
75
130
  text_utf16 = element.content.encode("UTF-16LE").force_encoding("ASCII-8BIT")
76
131
  n_chars = element.content.length
77
132
  off_string = EXTTEXTOUTW_FIXED_SIZE
78
- off_dx = off_string + (n_chars * 2)
133
+ # The Dx array starts on a DWORD boundary, including after odd-length text.
134
+ off_dx = (off_string + text_utf16.bytesize + 3) & ~3
79
135
  dx_bytes = ("\x00" * 4) * n_chars # zero Dx — natural advance
80
136
 
81
137
  bounds = text_bounds(element, context)
82
- # emfsvg's EMF→SVG renderer adds (lfHeight * 0.9) to the
83
- # stored y when text-align is TA_TOP (default). Reverse
84
- # that adjustment using the SAME scaled lfHeight that
85
- # emfsvg will read back so the round-trip y matches.
86
- stored_font_height = context.scaler.to_int(element.font_size)
87
- adjusted_y = element.y - (stored_font_height * 0.9 / context.scaler.factor)
88
138
  context.emit(Emf::Emr::Binary::Records::ExtTextOutW,
89
139
  i_type: EXTTEXTOUTW,
90
140
  rcl_bounds: bounds,
91
141
  i_graphics_mode: 2, # GM_COMPATIBLE
92
142
  ex_scale: 1.0,
93
143
  ey_scale: 1.0,
94
- ptl_reference: context.point_l(element.x, adjusted_y),
144
+ ptl_reference: {
145
+ x: @text_scaler.to_int(element.x), y: @text_scaler.to_int(reference_y(element))
146
+ },
95
147
  n_chars: n_chars,
96
148
  off_string: off_string,
97
149
  f_options: 0,
98
- rcl: context.rect_l(0, 0, 0, 0),
150
+ rcl: context.rect_attrs(0, 0, 0, 0),
99
151
  off_dx: off_dx,
100
- trailing: text_utf16 + dx_bytes)
152
+ trailing: text_utf16.ljust(off_dx - off_string, "\x00") + dx_bytes)
153
+ end
154
+
155
+ def reference_y(element)
156
+ rotation = element.canonical_text_rotation
157
+ return rotation.native_y if rotation&.native_y && @text_scaler.scaled? && @text_scaler.factor == 1_000_000
158
+ return element.y unless rotation&.vertical_alignment == :top
159
+
160
+ element.y - (element.font_size * 0.9)
101
161
  end
102
162
 
103
163
  # Pack a LOGFONT struct (92 bytes) per MS-WMF 2.2.13.
104
- def build_logfont(element, context)
164
+ def build_logfont(element, _context)
105
165
  face_utf16 = (element.font_family || "").encode("UTF-16LE")
106
166
  face_padded = face_utf16.ljust(64, "\x00".encode("UTF-16LE"))
107
167
  face_bytes = face_padded.bytes.pack("C*")[0, 64]
108
168
 
109
- scaled_height = context.scaler.to_int(element.font_size)
169
+ height = if @text_scaler.scaled? && @text_scaler.factor == 1_000_000
170
+ element.canonical_text_rotation&.native_height || element.font_size
171
+ else
172
+ element.font_size
173
+ end
174
+ scaled_height = @text_scaler.to_int(height)
110
175
  [
111
176
  scaled_height, # lfHeight (scaled)
112
- 0, # lfWidth
113
- 0, # lfEscapement
177
+ 0, # lfWidth
178
+ element.canonical_text_rotation&.escapement || (-element.font.rotation.to_i * 10), # tenths of a degree
114
179
  0, # lfOrientation
115
180
  element.font_weight.to_i, # lfWeight
116
181
  element.italic? ? 1 : 0, # lfItalic
@@ -124,12 +189,10 @@ module Emfsvg
124
189
  ].pack("l<l<l<l<l<CCCCCCCC") + face_bytes
125
190
  end
126
191
 
127
- def text_bounds(element, context)
192
+ def text_bounds(element, _context)
128
193
  width = element.content.length * element.font_size * 0.6
129
- context.rect_l(
130
- element.x, element.y - element.font_size,
131
- element.x + width, element.y
132
- )
194
+ { left: @text_scaler.to_int(element.x), top: @text_scaler.to_int(element.y - element.font_size),
195
+ right: @text_scaler.to_int(element.x + width), bottom: @text_scaler.to_int(element.y) }
133
196
  end
134
197
  end
135
198
  end
@@ -14,6 +14,11 @@ module Emfsvg
14
14
  autoload :PolylineHandler, "emfsvg/translation/handlers/polyline_handler"
15
15
  autoload :PolygonHandler, "emfsvg/translation/handlers/polygon_handler"
16
16
  autoload :PathHandler, "emfsvg/translation/handlers/path_handler"
17
+ autoload :MappedCircularArc, "emfsvg/translation/handlers/mapped_circular_arc"
18
+ autoload :MappedArc, "emfsvg/translation/handlers/mapped_arc"
19
+ autoload :MappedPie, "emfsvg/translation/handlers/mapped_pie"
20
+ autoload :CircularArc, "emfsvg/translation/handlers/circular_arc"
21
+ autoload :EllipticalArc, "emfsvg/translation/handlers/elliptical_arc"
17
22
  autoload :PathFlattener, "emfsvg/translation/handlers/path_flattener"
18
23
  autoload :GroupHandler, "emfsvg/translation/handlers/group_handler"
19
24
  autoload :TextHandler, "emfsvg/translation/handlers/text_handler"
@@ -42,6 +42,8 @@ module Emfsvg
42
42
  end
43
43
 
44
44
  def bounds_for(document)
45
+ return legacy_bounds_for(document) if legacy_header?(document)
46
+
45
47
  min_x, min_y, w, h = document.bounds
46
48
  Emf::Model::Geometry::Rect.new(
47
49
  left: min_x.to_i,
@@ -51,6 +53,24 @@ module Emfsvg
51
53
  )
52
54
  end
53
55
 
56
+ # Legacy broken-Y header: the original signed bounds are not
57
+ # recoverable, so emit the canonical equivalent [-1, -1, w-2, h-2],
58
+ # which renders back to the same header and viewport. Only for
59
+ # integral dimensions greater than 2; anything else keeps the
60
+ # ordinary path.
61
+ def legacy_header?(document)
62
+ document.view_box.nil? &&
63
+ document.legacy_broken_y_header? &&
64
+ [document.width, document.height].all? { |d| (d % 1).zero? && d > 2 }
65
+ end
66
+
67
+ def legacy_bounds_for(document)
68
+ Emf::Model::Geometry::Rect.new(
69
+ left: -1, top: -1,
70
+ right: document.width.to_i - 2, bottom: document.height.to_i - 2
71
+ )
72
+ end
73
+
54
74
  # Frame is the bounding rectangle in 0.01mm units. Computed from
55
75
  # bounds (pixels) using the document's effective DPI.
56
76
  def frame_for(bounds)