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
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emfsvg
4
+ module Translation
5
+ module Handlers
6
+ # Recover only circles whose integer radial points reproduce the source.
7
+ # Native Arc/Chord/Pie do not update the current point; SVG closure does.
8
+ class CircularArc
9
+ include Emf::Emr::Binary::TypeCodes
10
+
11
+ def self.from_commands(commands, kind: :arc)
12
+ return unless supported_commands?(commands, kind)
13
+
14
+ prefix, start, args = commands.first(3).map(&:args)
15
+ radius, ry, rotation, large, sweep, x, y = args
16
+ return unless radius.positive? && radius == ry && radius == radius.round
17
+ return unless rotation.zero? && large == sweep && [0, 1].include?(sweep)
18
+
19
+ finish = [x, y]
20
+ center = circle_center(start, finish, radius)
21
+ return unless valid_center?(center, commands, kind)
22
+
23
+ radials = [start, finish].map { |point| point.zip(center).map { |value, origin| (value - origin).round } }
24
+ return unless [start, finish].zip(radials).all? do |point, radial|
25
+ projects_to?(point, radial, center, radius)
26
+ end
27
+
28
+ new(prefix, kind == :arc ? finish : start, center, radius, radials, sweep, kind)
29
+ end
30
+
31
+ def self.valid_center?(center, commands, kind)
32
+ center && (kind != :pie || commands[3].args == center)
33
+ end
34
+ private_class_method :valid_center?
35
+
36
+ def self.supported_commands?(commands, kind)
37
+ letters = { arc: %w[M M A], chord: %w[M M A Z], pie: %w[M M A L Z] }
38
+ letters.key?(kind) && commands.map(&:letter) == letters[kind]
39
+ end
40
+ private_class_method :supported_commands?
41
+
42
+ def self.circle_center(start, finish, radius)
43
+ x, y = finish
44
+ dx, dy = finish.zip(start).map { |a, b| a - b }
45
+ distance = Math.hypot(dx, dy)
46
+ return unless distance.positive? && distance <= 2 * radius
47
+
48
+ height = Math.sqrt([(radius**2) - ((distance**2) / 4), 0].max)
49
+ center = [((start[0] + x) / 2) + ((dy * height) / distance),
50
+ ((start[1] + y) / 2) - ((dx * height) / distance)]
51
+ center.map(&:round) if center.all? { |value| (value - value.round).abs < 0.001 }
52
+ end
53
+ private_class_method :circle_center
54
+
55
+ def self.projects_to?(point, radial, center, radius)
56
+ length = Math.hypot(*radial)
57
+ length.positive? && point.zip(center, radial).all? do |value, origin, delta|
58
+ format("%.4f", value) == format("%.4f", origin + ((radius * delta) / length))
59
+ end
60
+ end
61
+ private_class_method :projects_to?
62
+
63
+ def initialize(prefix, finish, center, radius, radials, sweep, kind)
64
+ @prefix = prefix
65
+ @finish = finish
66
+ @center = center
67
+ @radius = radius
68
+ @radials = radials
69
+ @sweep = sweep
70
+ @kind = kind
71
+ end
72
+
73
+ def safe_for?(context)
74
+ x, y = @center
75
+ [x - @radius, y - @radius, x + @radius, y + @radius].all? do |value|
76
+ (-(2**31)..((2**31) - 1)).cover?((value * context.scaler.factor).round)
77
+ end
78
+ end
79
+
80
+ def emit(context)
81
+ records = Emf::Emr::Binary::Records
82
+ move(context, @prefix)
83
+ context.emit(records::SetArcDirection, i_type: SETARCDIRECTION, arc_direction: @sweep.zero? ? 1 : 2)
84
+ x, y = @center
85
+ points = @radials.map { |radial| radial.zip(@center).map { |delta, origin| delta + origin } }
86
+ record, type = { arc: [records::Arc, ARC], chord: [records::Chord, CHORD],
87
+ pie: [records::Pie, PIE] }.fetch(@kind)
88
+ context.emit(record, i_type: type,
89
+ rcl_box: context.rect_attrs(x - @radius, y - @radius, x + @radius, y + @radius),
90
+ ptl_start: context.point_attrs(*points[0]),
91
+ ptl_end: context.point_attrs(*points[1]))
92
+ move(context, @finish)
93
+ end
94
+
95
+ private
96
+
97
+ def move(context, point)
98
+ context.emit(Emf::Emr::Binary::Records::MoveToEx, i_type: MOVETOEX, origin: context.point_attrs(*point))
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -5,12 +5,41 @@ module Emfsvg
5
5
  module Handlers
6
6
  # <defs> contains reusable resources (clipPath, pattern, image)
7
7
  # that are referenced by id from elsewhere in the document. The
8
- # contents must NOT be emitted as drawing records — they are
9
- # rendered only when referenced.
8
+ # contents are never emitted as drawings. Unused canonical clipping
9
+ # records can be preserved without applying a clip to later drawings.
10
10
  class DefsHandler
11
- def self.call(_element, _context)
12
- # Intentional no-op: do not descend into <defs> children.
11
+ def self.call(element, context)
12
+ pattern = element.selected_pattern || element.unused_pattern
13
+ emit_pattern(pattern, context) if pattern
14
+ registry = context.clip_path_registry
15
+ return unless registry
16
+
17
+ element.children.each do |child|
18
+ next unless child.is_a?(Svg::Elements::ClipPath)
19
+ next if context.preserve_selected_clip(child)
20
+ next if context.preserve_unused_selected_clip(child)
21
+ next if registry.referenced?(child.id)
22
+ next unless child.unused_rectangle_bounds
23
+
24
+ context.emit_unused_clip_rectangle(child.unused_rectangle_bounds)
25
+ end
26
+ end
27
+
28
+ def self.emit_pattern(pattern, context)
29
+ image = pattern.image
30
+ decoded = PngDecoder.decode(image.decoded_bytes)
31
+ return unless decoded && decoded.width == image.width && decoded.height == image.height
32
+
33
+ dib = DibEncoder.encode(decoded.width, decoded.height, decoded.pixels)
34
+ handle = context.allocate_handle
35
+ context.emit(Emf::Emr::Binary::Records::CreateDibPatternBrushPt,
36
+ i_type: Emf::Emr::Binary::TypeCodes::CREATEDIBPATTERNBRUSHPT,
37
+ ih_brush: handle, i_usage: 0,
38
+ off_bmi: 32, cb_bmi: 40, off_bits: 72, cb_bits: dib.bytesize - 40,
39
+ body: dib)
40
+ context.pattern_handles[pattern.id] = handle
13
41
  end
42
+ private_class_method :emit_pattern
14
43
  end
15
44
  end
16
45
  end
@@ -16,8 +16,8 @@ module Emfsvg
16
16
 
17
17
  def translate(element, context)
18
18
  with_gdi_state(element, context) do
19
- box = context.rect_l(element.cx - element.rx, element.cy - element.ry,
20
- element.cx + element.rx, element.cy + element.ry)
19
+ box = context.rect_attrs(element.cx - element.rx, element.cy - element.ry,
20
+ element.cx + element.rx, element.cy + element.ry)
21
21
  context.emit(Emf::Emr::Binary::Records::Ellipse,
22
22
  i_type: ELLIPSE, rcl_box: box)
23
23
  end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emfsvg
4
+ module Translation
5
+ module Handlers
6
+ # Converts SVG endpoint-form elliptical arcs into cubic Beziers.
7
+ class EllipticalArc
8
+ def self.to_cubics(start, args, absolute:)
9
+ finish = endpoint(start, args, absolute)
10
+ return [] if start == finish
11
+
12
+ rx, ry, rotation, large_arc, sweep = args
13
+ return [[:line, [finish]]] if rx.zero? || ry.zero?
14
+
15
+ geometry = ellipse_geometry(start, finish, rx.abs, ry.abs, rotation, large_arc, sweep)
16
+ cubic_segments(geometry, finish)
17
+ end
18
+
19
+ def self.endpoint(start, args, absolute)
20
+ x, y = args.last(2)
21
+ absolute ? [x, y] : [start[0] + x, start[1] + y]
22
+ end
23
+ private_class_method :endpoint
24
+
25
+ def self.ellipse_geometry(start, finish, rx, ry, rotation, large_arc, sweep)
26
+ phi = rotation * Math::PI / 180.0
27
+ cos_phi = Math.cos(phi)
28
+ sin_phi = Math.sin(phi)
29
+ dx = (start[0] - finish[0]) / 2.0
30
+ dy = (start[1] - finish[1]) / 2.0
31
+ x1 = (cos_phi * dx) + (sin_phi * dy)
32
+ y1 = (-sin_phi * dx) + (cos_phi * dy)
33
+ lambda = ((x1**2) / (rx**2)) + ((y1**2) / (ry**2))
34
+ if lambda > 1
35
+ factor = Math.sqrt(lambda)
36
+ rx *= factor
37
+ ry *= factor
38
+ end
39
+
40
+ cx1, cy1 = center_offset(rx, ry, x1, y1, large_arc, sweep)
41
+ center = center_point(start, finish, cx1, cy1, cos_phi, sin_phi)
42
+ theta, delta = arc_angles(rx, ry, x1, y1, cx1, cy1, large_arc, sweep)
43
+ { center: center, rx: rx, ry: ry, cos_phi: cos_phi, sin_phi: sin_phi, theta: theta, delta: delta }
44
+ end
45
+ private_class_method :ellipse_geometry
46
+
47
+ def self.center_offset(rx, ry, x1, y1, large_arc, sweep)
48
+ numerator = [((rx**2) * (ry**2)) - ((rx**2) * (y1**2)) - ((ry**2) * (x1**2)), 0].max
49
+ denominator = ((rx**2) * (y1**2)) + ((ry**2) * (x1**2))
50
+ factor = denominator.zero? ? 0.0 : Math.sqrt(numerator / denominator)
51
+ factor *= -1 if large_arc == sweep
52
+ [factor * rx * y1 / ry, -factor * ry * x1 / rx]
53
+ end
54
+ private_class_method :center_offset
55
+
56
+ def self.center_point(start, finish, cx1, cy1, cos_phi, sin_phi)
57
+ [
58
+ (cos_phi * cx1) - (sin_phi * cy1) + ((start[0] + finish[0]) / 2.0),
59
+ (sin_phi * cx1) + (cos_phi * cy1) + ((start[1] + finish[1]) / 2.0)
60
+ ]
61
+ end
62
+ private_class_method :center_point
63
+
64
+ def self.arc_angles(rx, ry, x1, y1, cx1, cy1, large_arc, sweep)
65
+ ux = (x1 - cx1) / rx
66
+ uy = (y1 - cy1) / ry
67
+ vx = (-x1 - cx1) / rx
68
+ vy = (-y1 - cy1) / ry
69
+ theta = Math.atan2(uy, ux)
70
+ delta = Math.atan2((ux * vy) - (uy * vx), (ux * vx) + (uy * vy))
71
+ delta -= 2 * Math::PI if sweep.zero? && delta.positive?
72
+ delta += 2 * Math::PI if sweep != 0 && delta.negative?
73
+ delta += (sweep.zero? ? -2 : 2) * Math::PI if large_arc != 0 && delta.abs < Math::PI
74
+ delta += (sweep.zero? ? 2 : -2) * Math::PI if large_arc.zero? && delta.abs > Math::PI
75
+ [theta, delta]
76
+ end
77
+ private_class_method :arc_angles
78
+
79
+ def self.cubic_segments(geometry, finish)
80
+ center = geometry.fetch(:center)
81
+ rx = geometry.fetch(:rx)
82
+ ry = geometry.fetch(:ry)
83
+ cos_phi = geometry.fetch(:cos_phi)
84
+ sin_phi = geometry.fetch(:sin_phi)
85
+ theta = geometry.fetch(:theta)
86
+ delta = geometry.fetch(:delta)
87
+ count = (delta.abs / (Math::PI / 2)).ceil
88
+ step = delta / count
89
+ Array.new(count) do |index|
90
+ a = theta + (index * step)
91
+ b = a + step
92
+ alpha = (4.0 / 3.0) * Math.tan((b - a) / 4.0)
93
+ point_a, derivative_a = point_and_derivative(center, rx, ry, cos_phi, sin_phi, a)
94
+ point_b, derivative_b = point_and_derivative(center, rx, ry, cos_phi, sin_phi, b)
95
+ point_b = finish if index == count - 1
96
+ [
97
+ :cubic,
98
+ [
99
+ [point_a[0] + (alpha * derivative_a[0]), point_a[1] + (alpha * derivative_a[1])],
100
+ [point_b[0] - (alpha * derivative_b[0]), point_b[1] - (alpha * derivative_b[1])],
101
+ point_b
102
+ ]
103
+ ]
104
+ end
105
+ end
106
+ private_class_method :cubic_segments
107
+
108
+ def self.point_and_derivative(center, rx, ry, cos_phi, sin_phi, angle)
109
+ cos_angle = Math.cos(angle)
110
+ sin_angle = Math.sin(angle)
111
+ point = [
112
+ center[0] + (rx * cos_phi * cos_angle) - (ry * sin_phi * sin_angle),
113
+ center[1] + (rx * sin_phi * cos_angle) + (ry * cos_phi * sin_angle)
114
+ ]
115
+ derivative = [
116
+ (-rx * cos_phi * sin_angle) - (ry * sin_phi * cos_angle),
117
+ (-rx * sin_phi * sin_angle) + (ry * cos_phi * cos_angle)
118
+ ]
119
+ [point, derivative]
120
+ end
121
+ private_class_method :point_and_derivative
122
+ end
123
+ end
124
+ end
125
+ end
@@ -22,9 +22,16 @@ module Emfsvg
22
22
  # record, regardless of whether the matrix is identity. Skipping
23
23
  # it would drop the group wrapper from the round-tripped SVG.
24
24
  emit_world_transform(element.transform_matrix, context)
25
+ context.begin_selected_clip(element)
25
26
  element.children.each { |child| context.dispatch(child) }
26
27
  context.emit(Emf::Emr::Binary::Records::RestoreDc,
27
28
  i_type: RESTOREDC, saved_dc: -1)
29
+ context.finish_selected_clip(element)
30
+ context.finish_carried_clip(element)
31
+ # RestoreDC changes native state; the next SetWorldTransform closes
32
+ # the SVG group. Emit here to retain the source definition's group
33
+ # while letting the following sibling inherit its native clip.
34
+ context.carry_clip_from(element)
28
35
  end
29
36
 
30
37
  private
@@ -7,10 +7,10 @@ module Emfsvg
7
7
  module Translation
8
8
  module Handlers
9
9
  # Translate <image href="data:image/png;base64,..."/> into
10
- # EMR_STRETCHDIBITS with an embedded BI_RGB DIB.
10
+ # EMR_STRETCHDIBITS with an embedded DIB.
11
11
  #
12
12
  # v1 limitations:
13
- # * PNG data URIs only. JPEG and other formats fall through silently.
13
+ # * PNG and JPEG data URIs only. Other formats fall through silently.
14
14
  # * No aspect-ratio handling (uses width/height verbatim).
15
15
  class ImageHandler
16
16
  include SharedGdi
@@ -20,6 +20,8 @@ module Emfsvg
20
20
  # 6*4 (int32 src/dest dims) + 6*4 (off/cb/i_usage/dw_rop/cx/cy)
21
21
  # = 80 bytes.
22
22
  STRETCHDIBITS_FIXED_SIZE = 80
23
+ JPEG_TERMINAL_MARKERS = [0xDA, 0xD9].freeze
24
+ JPEG_NON_FRAME_MARKERS = [0xC4, 0xC8, 0xCC].freeze
23
25
 
24
26
  def self.call(element, context)
25
27
  new.translate(element, context)
@@ -27,24 +29,67 @@ module Emfsvg
27
29
 
28
30
  def translate(element, context)
29
31
  return unless element.data_uri?
30
- return unless element.mime_type == "image/png"
32
+ return unless %w[image/png image/jpeg image/jpg].include?(element.mime_type)
31
33
 
32
34
  dib = build_dib(element)
33
35
  return unless dib
34
36
 
35
- emit_stretch_dibits(element, dib, context)
37
+ with_gdi_state(element, context) do
38
+ emit_stretch_dibits(element, dib, context)
39
+ end
36
40
  end
37
41
 
38
42
  private
39
43
 
40
44
  def build_dib(element)
45
+ return build_jpeg_dib(element.decoded_bytes) if %w[image/jpeg image/jpg].include?(element.mime_type)
46
+
41
47
  png = element.decoded_bytes
42
48
  return nil unless png
43
49
 
44
50
  decoded = PngDecoder.decode(png)
45
51
  return nil unless decoded
46
52
 
47
- DibEncoder.encode(decoded.width, decoded.height, decoded.pixels)
53
+ # BI_PNG keeps the original compression and color format. Expanding
54
+ # to BGRA and re-encoding would change otherwise untouched payloads.
55
+ [40, decoded.width, decoded.height, 1, 0, 5, png.bytesize, 0, 0, 0, 0].pack("Vl<l<vvVVVVVV") + png
56
+ end
57
+
58
+ # JPEG frame dimensions describe the embedded bitmap, independently of
59
+ # the SVG destination size. Preserve its compressed bytes with BI_JPEG.
60
+ def build_jpeg_dib(bytes)
61
+ dimensions = jpeg_dimensions(bytes)
62
+ return unless dimensions
63
+
64
+ width, height = dimensions
65
+ [40, width, height, 1, 0, 4, bytes.bytesize, 0, 0, 0, 0].pack("Vl<l<vvVVVVVV") + bytes
66
+ end
67
+
68
+ def jpeg_dimensions(bytes)
69
+ return unless bytes&.start_with?("\xFF\xD8".b)
70
+
71
+ offset = 2
72
+ while offset + 4 <= bytes.bytesize
73
+ return unless bytes.getbyte(offset) == 0xFF
74
+
75
+ offset += 1 while bytes.getbyte(offset) == 0xFF
76
+ marker = bytes.getbyte(offset)
77
+ return if JPEG_TERMINAL_MARKERS.include?(marker)
78
+
79
+ length = bytes.byteslice(offset + 1, 2)&.unpack1("n")
80
+ return unless length && length >= 2 && offset + 1 + length <= bytes.bytesize
81
+
82
+ if (0xC0..0xCF).cover?(marker) && !JPEG_NON_FRAME_MARKERS.include?(marker)
83
+ return unless length >= 8
84
+
85
+ height, width = bytes.byteslice(offset + 4, 4).unpack("nn")
86
+ return [width, height] if width.positive? && height.positive?
87
+
88
+ return
89
+ end
90
+ offset += 1 + length
91
+ end
92
+ nil
48
93
  end
49
94
 
50
95
  def emit_stretch_dibits(element, dib, context)
@@ -53,9 +98,9 @@ module Emfsvg
53
98
  off_bmi = STRETCHDIBITS_FIXED_SIZE
54
99
  off_bits = off_bmi + bmi_size
55
100
 
56
- bounds = context.rect_l(element.x, element.y,
57
- element.x + element.width,
58
- element.y + element.height)
101
+ bounds = context.rect_attrs(element.x, element.y,
102
+ element.x + element.width,
103
+ element.y + element.height)
59
104
  x_dest = context.scaler.to_int(element.x)
60
105
  y_dest = context.scaler.to_int(element.y)
61
106
  cx_dest = context.scaler.to_int(element.width)
@@ -18,10 +18,10 @@ module Emfsvg
18
18
  with_gdi_state(element, context) do
19
19
  context.emit(Emf::Emr::Binary::Records::MoveToEx,
20
20
  i_type: MOVETOEX,
21
- origin: context.point_l(element.x1, element.y1))
21
+ origin: context.point_attrs(element.x1, element.y1))
22
22
  context.emit(Emf::Emr::Binary::Records::LineTo,
23
23
  i_type: LINETO,
24
- origin: context.point_l(element.x2, element.y2))
24
+ origin: context.point_attrs(element.x2, element.y2))
25
25
  end
26
26
  end
27
27
  end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emfsvg
4
+ module Translation
5
+ module Handlers
6
+ # Recover mapped near-semicircles only when integer centers reproduce both endpoints.
7
+ class MappedArc < MappedCircularArc
8
+ def self.from_commands(commands, scaler:)
9
+ return unless scaler.scaled? && commands.map(&:letter) == %w[M M A]
10
+
11
+ prefix, start, args = commands.map(&:args)
12
+ rx, ry, rotation, large, sweep, x, y = args
13
+ return unless rotation.zero? && large == sweep && [0, 1].include?(sweep)
14
+
15
+ finish = [x, y]
16
+ midpoint = start.zip(finish).map { |a, b| ((a + b) / 2).round }
17
+ [-1, 0, 1].repeated_permutation(2) do |offsets|
18
+ center = midpoint.zip(offsets).map { |value, offset| value + offset }
19
+ radius = Math.hypot(start[0] - center[0], start[1] - center[1]).round
20
+ next unless radius.positive?
21
+
22
+ origin = mapping_origin(rx, ry, radius)
23
+ next unless origin
24
+
25
+ radials = [start, finish].map { |point| recover_radial(point, center, radius, origin, scaler.factor) }
26
+ next if radials.any?(&:nil?)
27
+
28
+ return new(prefix, finish, center, radius, radials, sweep, :arc, origin: origin)
29
+ end
30
+ nil
31
+ end
32
+
33
+ def self.mapping_origin(rx, ry, radius)
34
+ origin = [rx - radius, ry - radius]
35
+ origin if origin.all? { |value| value == value.round && Scaler::Fixed::INT32_RANGE.cover?(value) }
36
+ end
37
+ private_class_method :mapping_origin
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emfsvg
4
+ module Translation
5
+ module Handlers
6
+ # Invert C circle mapping while preserving endpoint rounding and local state.
7
+ class MappedCircularArc < CircularArc
8
+ def self.recover_radial(point, center, radius, origin, factor)
9
+ logical_center = center.zip(origin).map { |value, offset| ((value - offset) * factor).round }
10
+ logical_radius = (radius * factor).round
11
+ # Four-decimal endpoints describe rounding intervals, not exact radial directions.
12
+ # Try bounded directions inside those intervals while retaining int32 safety.
13
+ [1, 10, 100].product([-0.000049, 0, 0.000049].repeated_permutation(2).to_a) do |multiple, offsets|
14
+ radial = point.zip(center, offsets).map do |value, base, offset|
15
+ ((value - base + offset) * factor * multiple).round
16
+ end
17
+ unless radial.zip(logical_center).all? { |delta, base| Scaler::Fixed::INT32_RANGE.cover?(delta + base) }
18
+ next
19
+ end
20
+
21
+ length = Math.hypot(*radial)
22
+ next if length.zero?
23
+
24
+ projected = logical_center.zip(radial, origin).map do |base, delta, offset|
25
+ ((base + ((logical_radius * delta) / length)) / factor) + offset
26
+ end
27
+ return radial.map { |value| value.to_f / factor } if point.zip(projected).all? do |expected, actual|
28
+ format("%.4f", expected) == format("%.4f", actual)
29
+ end
30
+ end
31
+ nil
32
+ end
33
+ private_class_method :recover_radial
34
+
35
+ def initialize(*args, origin:)
36
+ super(*args)
37
+ @origin = origin
38
+ end
39
+
40
+ def safe_for?(context)
41
+ points = [@prefix, @finish, *@radials.map { |radial| radial.zip(@center).map { |delta, base| delta + base } },
42
+ [@center[0] - @radius, @center[1] - @radius],
43
+ [@center[0] + @radius, @center[1] + @radius]]
44
+ points.all? do |point|
45
+ point.zip(@origin).all? { |value, offset| !context.scaler.unsafe?(value - offset) }
46
+ end
47
+ end
48
+
49
+ def emit(context)
50
+ context.with_local_origin(@origin) { super }
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emfsvg
4
+ module Translation
5
+ module Handlers
6
+ # C's Pie formatter maps radii as points, adding the viewport origin.
7
+ # Recover that origin only when an integer circle reproduces every byte.
8
+ class MappedPie < MappedCircularArc
9
+ def self.from_commands(commands, scaler:)
10
+ return unless scaler.scaled? && commands.map(&:letter) == %w[M M A L Z]
11
+
12
+ prefix, start, args, center = commands.first(4).map(&:args)
13
+ rx, ry, rotation, large, sweep, x, y = args
14
+ return unless rotation.zero? && large == sweep && [0, 1].include?(sweep)
15
+
16
+ distance = Math.hypot(start[0] - center[0], start[1] - center[1])
17
+ origin = [rx - distance, ry - distance].map(&:round)
18
+ radius = rx - origin[0]
19
+ return unless radius.positive? && (radius - (ry - origin[1])).abs < 0.00001
20
+ return unless origin.all? { |value| Scaler::Fixed::INT32_RANGE.cover?(value) }
21
+
22
+ radials = [start, [x, y]].map { |point| recover_radial(point, center, radius, origin, scaler.factor) }
23
+ return if radials.any?(&:nil?)
24
+
25
+ new(prefix, start, center, radius, radials, sweep, :pie, origin: origin)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end