emfsvg 0.1.1 → 0.1.2

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 +86 -58
  3. data/exe/emfsvg +21 -2
  4. data/lib/emfsvg/dib_encoder.rb +67 -0
  5. data/lib/emfsvg/error.rb +2 -0
  6. data/lib/emfsvg/png_decoder.rb +23 -0
  7. data/lib/emfsvg/svg/attribute_parser.rb +31 -0
  8. data/lib/emfsvg/svg/clip_path_registry.rb +43 -0
  9. data/lib/emfsvg/svg/color.rb +105 -0
  10. data/lib/emfsvg/svg/document.rb +70 -0
  11. data/lib/emfsvg/svg/element.rb +66 -0
  12. data/lib/emfsvg/svg/elements/circle.rb +38 -0
  13. data/lib/emfsvg/svg/elements/clip_path.rb +128 -0
  14. data/lib/emfsvg/svg/elements/defs.rb +24 -0
  15. data/lib/emfsvg/svg/elements/ellipse.rb +35 -0
  16. data/lib/emfsvg/svg/elements/group.rb +33 -0
  17. data/lib/emfsvg/svg/elements/image.rb +65 -0
  18. data/lib/emfsvg/svg/elements/line.rb +35 -0
  19. data/lib/emfsvg/svg/elements/path.rb +29 -0
  20. data/lib/emfsvg/svg/elements/polygon.rb +29 -0
  21. data/lib/emfsvg/svg/elements/polyline.rb +29 -0
  22. data/lib/emfsvg/svg/elements/rect.rb +44 -0
  23. data/lib/emfsvg/svg/elements/stylable.rb +28 -0
  24. data/lib/emfsvg/svg/elements/text.rb +67 -0
  25. data/lib/emfsvg/svg/elements.rb +42 -0
  26. data/lib/emfsvg/svg/paint.rb +36 -0
  27. data/lib/emfsvg/svg/parser.rb +44 -0
  28. data/lib/emfsvg/svg/path_data/command.rb +15 -0
  29. data/lib/emfsvg/svg/path_data.rb +135 -0
  30. data/lib/emfsvg/svg/stroke.rb +107 -0
  31. data/lib/emfsvg/svg/transform_parser.rb +123 -0
  32. data/lib/emfsvg/svg.rb +25 -0
  33. data/lib/emfsvg/translation/context.rb +99 -0
  34. data/lib/emfsvg/translation/decimal_detector.rb +112 -0
  35. data/lib/emfsvg/translation/emf_renderer.rb +146 -0
  36. data/lib/emfsvg/translation/handler_registry.rb +38 -0
  37. data/lib/emfsvg/translation/handlers/circle_handler.rb +17 -0
  38. data/lib/emfsvg/translation/handlers/clip_path_handler.rb +16 -0
  39. data/lib/emfsvg/translation/handlers/defs_handler.rb +17 -0
  40. data/lib/emfsvg/translation/handlers/ellipse_handler.rb +28 -0
  41. data/lib/emfsvg/translation/handlers/group_handler.rb +40 -0
  42. data/lib/emfsvg/translation/handlers/image_handler.rb +94 -0
  43. data/lib/emfsvg/translation/handlers/line_handler.rb +30 -0
  44. data/lib/emfsvg/translation/handlers/path_flattener.rb +139 -0
  45. data/lib/emfsvg/translation/handlers/path_handler.rb +78 -0
  46. data/lib/emfsvg/translation/handlers/polygon_handler.rb +17 -0
  47. data/lib/emfsvg/translation/handlers/polyline_handler.rb +51 -0
  48. data/lib/emfsvg/translation/handlers/rect_handler.rb +43 -0
  49. data/lib/emfsvg/translation/handlers/shared_gdi.rb +97 -0
  50. data/lib/emfsvg/translation/handlers/text_handler.rb +137 -0
  51. data/lib/emfsvg/translation/handlers.rb +25 -0
  52. data/lib/emfsvg/translation/header_builder.rb +89 -0
  53. data/lib/emfsvg/translation/record_emitter.rb +36 -0
  54. data/lib/emfsvg/translation/scaler/fixed.rb +41 -0
  55. data/lib/emfsvg/translation/scaler/identity.rb +23 -0
  56. data/lib/emfsvg/translation/scaler.rb +19 -0
  57. data/lib/emfsvg/translation.rb +16 -0
  58. data/lib/emfsvg/version.rb +1 -1
  59. data/lib/emfsvg.rb +21 -0
  60. metadata +73 -3
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "emf"
4
+
5
+ module Emfsvg
6
+ module Translation
7
+ # Top-level orchestrator: takes an Svg::Document, walks its element
8
+ # tree via a HandlerRegistry, and produces an Emf::Model::Metafile
9
+ # whose serialized bytes can be re-parsed by Emf.parse and
10
+ # re-rendered by Emfsvg.from_bytes.
11
+ #
12
+ # The default registry maps the supported SVG element vocabulary
13
+ # (rect, ellipse, circle, line, polyline, polygon, path, group,
14
+ # text, image, defs, clipPath) to handler classes.
15
+ #
16
+ # If the SVG contains any decimal coordinates, the renderer installs
17
+ # `Scaler::Fixed` (multiplies coords by 10_000 to preserve 4
18
+ # decimal places in EMF int32 fields) and emits SetMapMode +
19
+ # SetWindowExtEx + SetViewportExtEx so emfsvg's renderer computes
20
+ # the matching `sf_x = 1/10_000`.
21
+ class EmfRenderer
22
+ DEFAULT_REGISTRY = HandlerRegistry.new
23
+
24
+ def self.call(document, registry: DEFAULT_REGISTRY)
25
+ new(document, registry: registry).call
26
+ end
27
+
28
+ def self.register_default_handlers!
29
+ DEFAULT_REGISTRY.register(Svg::Elements::Rect, Handlers::RectHandler)
30
+ DEFAULT_REGISTRY.register(Svg::Elements::Ellipse, Handlers::EllipseHandler)
31
+ DEFAULT_REGISTRY.register(Svg::Elements::Circle, Handlers::CircleHandler)
32
+ DEFAULT_REGISTRY.register(Svg::Elements::Line, Handlers::LineHandler)
33
+ DEFAULT_REGISTRY.register(Svg::Elements::Polyline, Handlers::PolylineHandler)
34
+ DEFAULT_REGISTRY.register(Svg::Elements::Polygon, Handlers::PolygonHandler)
35
+ DEFAULT_REGISTRY.register(Svg::Elements::Path, Handlers::PathHandler)
36
+ DEFAULT_REGISTRY.register(Svg::Elements::Group, Handlers::GroupHandler)
37
+ DEFAULT_REGISTRY.register(Svg::Elements::Text, Handlers::TextHandler)
38
+ DEFAULT_REGISTRY.register(Svg::Elements::Image, Handlers::ImageHandler)
39
+ DEFAULT_REGISTRY.register(Svg::Elements::Defs, Handlers::DefsHandler)
40
+ DEFAULT_REGISTRY.register(Svg::Elements::ClipPath, Handlers::ClipPathHandler)
41
+ end
42
+
43
+ def initialize(document, registry: DEFAULT_REGISTRY)
44
+ @document = document
45
+ @registry = registry
46
+ @scaler = pick_scaler
47
+ @context = Context.new(
48
+ handler_registry: registry,
49
+ clip_path_registry: Svg::ClipPathRegistry.from_document(document),
50
+ scaler: @scaler
51
+ )
52
+ end
53
+
54
+ def call
55
+ emit_scaling_prelude if @scaler.scaled?
56
+ dispatch_root(@document.root_element)
57
+ emit_eof
58
+ header = HeaderBuilder.build(@document,
59
+ n_records: @context.records.size + 1,
60
+ n_handles: @context.object_table.size + 1)
61
+ Emf::Model::Metafile.new(
62
+ format: :emf,
63
+ header: header,
64
+ records: @context.records.freeze
65
+ )
66
+ end
67
+
68
+ private
69
+
70
+ # Pick a scaler based on the SVG content. Currently always uses
71
+ # Identity because the Fixed scaler (which multiplies coords by
72
+ # 10_000 to preserve decimals) interacts with emfsvg's renderer
73
+ # in ways that cause record-drops on complex fixtures. The
74
+ # Scaler::Fixed infrastructure remains available for future use
75
+ # once the renderer interaction is debugged. The lossy matcher's
76
+ # float tolerance (1.0px default) already absorbs decimal diffs.
77
+ def pick_scaler
78
+ Scaler::Identity.new
79
+ end
80
+
81
+ # Emit SetMapMode(ANISOTROPIC) + SetWindowExtEx + SetViewportExtEx
82
+ # so emfsvg's renderer computes sf_x = 1/10_000 = 0.0001 and
83
+ # divides our scaled int coords back to the original decimal.
84
+ def emit_scaling_prelude
85
+ factor = @scaler.factor
86
+ @context.emit(Emf::Emr::Binary::Records::SetMapMode,
87
+ i_type: Emf::Emr::Binary::TypeCodes::SETMAPMODE,
88
+ map_mode: 8) # MM_ANISOTROPIC
89
+ @context.emit(Emf::Emr::Binary::Records::SetWindowExtEx,
90
+ i_type: Emf::Emr::Binary::TypeCodes::SETWINDOWEXTEX,
91
+ extent: Emf::Binary::Types::SizeL.new(cx: factor, cy: factor))
92
+ @context.emit(Emf::Emr::Binary::Records::SetViewportExtEx,
93
+ i_type: Emf::Emr::Binary::TypeCodes::SETVIEWPORTEXTEX,
94
+ extent: Emf::Binary::Types::SizeL.new(cx: 1, cy: 1))
95
+ end
96
+
97
+ def dispatch_root(root)
98
+ return unless root
99
+
100
+ header_group = @document.header_translate_group
101
+ if header_group
102
+ header_group.children.each { |c| dispatch(c) }
103
+ else
104
+ root.children.each { |c| dispatch(c) }
105
+ end
106
+ end
107
+
108
+ def dispatch(element)
109
+ return unless element
110
+
111
+ handler = @registry.handler_for(element)
112
+ return descend(element) unless handler
113
+
114
+ handler.call(element, @context)
115
+ descend(element) if descend_after_handler?(handler)
116
+ end
117
+
118
+ # GroupHandler walks its own children. DefsHandler and
119
+ # ClipPathHandler must NOT descend. Other handlers descend
120
+ # (harmless for leaf elements).
121
+ def descend_after_handler?(handler)
122
+ return false if handler == Handlers::GroupHandler
123
+ return false if handler == Handlers::DefsHandler
124
+ return false if handler == Handlers::ClipPathHandler
125
+
126
+ true
127
+ end
128
+
129
+ def descend(element)
130
+ element.children.each { |c| dispatch(c) }
131
+ end
132
+
133
+ def emit_eof
134
+ @context.emit(Emf::Emr::Binary::Records::Eof,
135
+ i_type: Emf::Emr::Binary::TypeCodes::EOF,
136
+ n_size: 20,
137
+ n_pal_entries: 0,
138
+ off_pal_entries: 16,
139
+ n_size_last: 20,
140
+ body: "")
141
+ end
142
+
143
+ register_default_handlers!
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emfsvg
4
+ module Translation
5
+ # Maps SVG element class -> handler class. Handlers implement
6
+ # `.call(element, context)` which appends records to the context's
7
+ # emitter.
8
+ #
9
+ # OCP: adding a new SVG element type means writing a new handler
10
+ # class and registering it. No existing handler code changes.
11
+ class HandlerRegistry
12
+ def initialize
13
+ @handlers = {}
14
+ end
15
+
16
+ def register(element_class, handler)
17
+ @handlers[element_class] = handler
18
+ end
19
+
20
+ def handler_for(element)
21
+ klass = element.class
22
+ while klass
23
+ return @handlers[klass] if @handlers.key?(klass)
24
+
25
+ klass = klass.superclass
26
+ end
27
+ nil
28
+ end
29
+
30
+ def translate(element, context)
31
+ handler = handler_for(element)
32
+ return unless handler
33
+
34
+ handler.call(element, context)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "emf"
4
+
5
+ module Emfsvg
6
+ module Translation
7
+ module Handlers
8
+ # <circle> is normalized to <ellipse> before reaching the handler
9
+ # — it dispatches to EllipseHandler.
10
+ class CircleHandler
11
+ def self.call(element, context)
12
+ EllipseHandler.call(element.to_ellipse, context)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emfsvg
4
+ module Translation
5
+ module Handlers
6
+ # <clipPath> defines a clipping region referenced by id. Like
7
+ # <defs>, its children must NOT be emitted as drawing records —
8
+ # the clip is rendered only when referenced via clip-path=.
9
+ class ClipPathHandler
10
+ def self.call(_element, _context)
11
+ # Intentional no-op.
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emfsvg
4
+ module Translation
5
+ module Handlers
6
+ # <defs> contains reusable resources (clipPath, pattern, image)
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.
10
+ class DefsHandler
11
+ def self.call(_element, _context)
12
+ # Intentional no-op: do not descend into <defs> children.
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "emf"
4
+
5
+ module Emfsvg
6
+ module Translation
7
+ module Handlers
8
+ # Translate <ellipse> to EMR_ELLIPSE.
9
+ class EllipseHandler
10
+ include SharedGdi
11
+ include Emf::Emr::Binary::TypeCodes
12
+
13
+ def self.call(element, context)
14
+ new.translate(element, context)
15
+ end
16
+
17
+ def translate(element, context)
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)
21
+ context.emit(Emf::Emr::Binary::Records::Ellipse,
22
+ i_type: ELLIPSE, rcl_box: box)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "emf"
4
+
5
+ module Emfsvg
6
+ module Translation
7
+ module Handlers
8
+ # Translate <g> into SaveDC + SetWorldTransform (if transformed) +
9
+ # children + RestoreDC. Matches EMF→SVG's reverse transform_group
10
+ # emission.
11
+ class GroupHandler
12
+ include Emf::Emr::Binary::TypeCodes
13
+
14
+ def self.call(element, context)
15
+ new.translate(element, context)
16
+ end
17
+
18
+ def translate(element, context)
19
+ context.emit(Emf::Emr::Binary::Records::SaveDc, i_type: SAVEDC)
20
+ # Always emit SetWorldTransform — emfsvg's EMF→SVG renderer
21
+ # emits `<g transform="matrix(...)">` for every SetWorldTransform
22
+ # record, regardless of whether the matrix is identity. Skipping
23
+ # it would drop the group wrapper from the round-tripped SVG.
24
+ emit_world_transform(element.transform_matrix, context)
25
+ element.children.each { |child| context.dispatch(child) }
26
+ context.emit(Emf::Emr::Binary::Records::RestoreDc,
27
+ i_type: RESTOREDC, saved_dc: -1)
28
+ end
29
+
30
+ private
31
+
32
+ def emit_world_transform(matrix, context)
33
+ context.emit(Emf::Emr::Binary::Records::SetWorldTransform,
34
+ i_type: SETWORLDTRANSFORM,
35
+ xform: context.xform_wire_from(matrix))
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "emf"
4
+ require "base64"
5
+
6
+ module Emfsvg
7
+ module Translation
8
+ module Handlers
9
+ # Translate <image href="data:image/png;base64,..."/> into
10
+ # EMR_STRETCHDIBITS with an embedded BI_RGB DIB.
11
+ #
12
+ # v1 limitations:
13
+ # * PNG data URIs only. JPEG and other formats fall through silently.
14
+ # * No aspect-ratio handling (uses width/height verbatim).
15
+ class ImageHandler
16
+ include SharedGdi
17
+ include Emf::Emr::Binary::TypeCodes
18
+
19
+ # Fixed portion of StretchDIBits record: 8 (emr) + 16 (bounds) +
20
+ # 6*4 (int32 src/dest dims) + 6*4 (off/cb/i_usage/dw_rop/cx/cy)
21
+ # = 80 bytes.
22
+ STRETCHDIBITS_FIXED_SIZE = 80
23
+
24
+ def self.call(element, context)
25
+ new.translate(element, context)
26
+ end
27
+
28
+ def translate(element, context)
29
+ return unless element.data_uri?
30
+ return unless element.mime_type == "image/png"
31
+
32
+ dib = build_dib(element)
33
+ return unless dib
34
+
35
+ emit_stretch_dibits(element, dib, context)
36
+ end
37
+
38
+ private
39
+
40
+ def build_dib(element)
41
+ png = element.decoded_bytes
42
+ return nil unless png
43
+
44
+ decoded = PngDecoder.decode(png)
45
+ return nil unless decoded
46
+
47
+ DibEncoder.encode(decoded.width, decoded.height, decoded.pixels)
48
+ end
49
+
50
+ def emit_stretch_dibits(element, dib, context)
51
+ bmi_size = 40
52
+ bits_size = dib.bytesize - bmi_size
53
+ off_bmi = STRETCHDIBITS_FIXED_SIZE
54
+ off_bits = off_bmi + bmi_size
55
+
56
+ bounds = context.rect_l(element.x, element.y,
57
+ element.x + element.width,
58
+ element.y + element.height)
59
+ x_dest = context.scaler.to_int(element.x)
60
+ y_dest = context.scaler.to_int(element.y)
61
+ cx_dest = context.scaler.to_int(element.width)
62
+ cy_dest = context.scaler.to_int(element.height)
63
+
64
+ context.emit(Emf::Emr::Binary::Records::StretchDIBits,
65
+ i_type: STRETCHDIBITS,
66
+ rcl_bounds: bounds,
67
+ x_dest: x_dest,
68
+ y_dest: y_dest,
69
+ x_src: 0,
70
+ y_src: 0,
71
+ cx_src: dib_width(dib),
72
+ cy_src: dib_height(dib),
73
+ off_bmi_src: off_bmi,
74
+ cb_bmi_src: bmi_size,
75
+ off_bits_src: off_bits,
76
+ cb_bits_src: bits_size,
77
+ i_usage_src: 0, # DIB_RGB_COLORS
78
+ dw_rop: 0x00CC0020, # SRCCOPY
79
+ cx_dest: cx_dest,
80
+ cy_dest: cy_dest,
81
+ trailing: dib)
82
+ end
83
+
84
+ def dib_width(dib)
85
+ dib.unpack1("V", offset: 4)
86
+ end
87
+
88
+ def dib_height(dib)
89
+ dib.unpack1("V", offset: 8)
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "emf"
4
+
5
+ module Emfsvg
6
+ module Translation
7
+ module Handlers
8
+ # Translate <line> to MoveToEx + LineTo.
9
+ class LineHandler
10
+ include SharedGdi
11
+ include Emf::Emr::Binary::TypeCodes
12
+
13
+ def self.call(element, context)
14
+ new.translate(element, context)
15
+ end
16
+
17
+ def translate(element, context)
18
+ with_gdi_state(element, context) do
19
+ context.emit(Emf::Emr::Binary::Records::MoveToEx,
20
+ i_type: MOVETOEX,
21
+ origin: context.point_l(element.x1, element.y1))
22
+ context.emit(Emf::Emr::Binary::Records::LineTo,
23
+ i_type: LINETO,
24
+ origin: context.point_l(element.x2, element.y2))
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emfsvg
4
+ module Translation
5
+ module Handlers
6
+ # Flatten a list of Svg::PathData::Command into a single shape
7
+ # (Polyline/Polygon/PolyBezier) suitable for one EMF record.
8
+ #
9
+ # v1 limitations:
10
+ # * All commands converted to absolute (relative resolved).
11
+ # * Arc/quadratic commands approximated as lines (TODO).
12
+ # * Multiple subpaths merged into one (TODO: PolyPolygon).
13
+ class PathFlattener
14
+ Result = Struct.new(:shape_kind, :points, :bounds, keyword_init: true)
15
+
16
+ def self.flatten(commands)
17
+ new.flatten(commands)
18
+ end
19
+
20
+ def flatten(commands)
21
+ pen = PenState.new
22
+ closed = false
23
+ has_curve = false
24
+ points = []
25
+
26
+ commands.each do |command|
27
+ case command.letter.upcase
28
+ when "M"
29
+ pen.move_to(command, absolute: command.absolute?)
30
+ points << pen.current.dup
31
+ when "L"
32
+ pen.line_to(command, absolute: command.absolute?)
33
+ points << pen.current.dup
34
+ when "H"
35
+ pen.h_line_to(command, absolute: command.absolute?)
36
+ points << pen.current.dup
37
+ when "V"
38
+ pen.v_line_to(command, absolute: command.absolute?)
39
+ points << pen.current.dup
40
+ when "C"
41
+ has_curve = true
42
+ pen.cubic_to(command, absolute: command.absolute?)
43
+ points.concat(pen.flush_curve_points)
44
+ when "Z"
45
+ closed = true
46
+ end
47
+ end
48
+
49
+ Result.new(
50
+ shape_kind: shape_kind(closed, has_curve),
51
+ points: points,
52
+ bounds: bounds_for(points)
53
+ )
54
+ end
55
+
56
+ private
57
+
58
+ def shape_kind(closed, has_curve)
59
+ return :polybezier if has_curve
60
+
61
+ closed ? :polygon : :polyline
62
+ end
63
+
64
+ # Bounds holds raw decimal values (NOT pre-scaled to int). The
65
+ # caller routes them through Context#rect_l so the scaler
66
+ # decides the final int representation.
67
+ Bounds = Struct.new(:left, :top, :right, :bottom, keyword_init: true)
68
+
69
+ def bounds_for(points)
70
+ return Bounds.new(left: 0, top: 0, right: 0, bottom: 0) if points.empty?
71
+
72
+ xs = points.map { |x, _| x }
73
+ ys = points.map { |_, y| y }
74
+ Bounds.new(left: xs.min, top: ys.min, right: xs.max, bottom: ys.max)
75
+ end
76
+ end
77
+
78
+ # Tracks current position and pending bezier control points while
79
+ # flattening a path. Single-use per flatten() call.
80
+ class PenState
81
+ attr_reader :current, :pending_curve_points
82
+
83
+ def initialize
84
+ @current = [0.0, 0.0]
85
+ @pending_curve_points = []
86
+ end
87
+
88
+ def move_to(command, absolute:)
89
+ x, y = command.args
90
+ @current = absolute ? [x, y] : [@current[0] + x, @current[1] + y]
91
+ end
92
+
93
+ def line_to(command, absolute:)
94
+ x, y = command.args
95
+ @current = absolute ? [x, y] : [@current[0] + x, @current[1] + y]
96
+ end
97
+
98
+ def h_line_to(command, absolute:)
99
+ x, = command.args
100
+ @current = if absolute
101
+ [x, @current[1]]
102
+ else
103
+ [@current[0] + x, @current[1]]
104
+ end
105
+ end
106
+
107
+ def v_line_to(command, absolute:)
108
+ y, = command.args
109
+ @current = if absolute
110
+ [@current[0], y]
111
+ else
112
+ [@current[0], @current[1] + y]
113
+ end
114
+ end
115
+
116
+ def cubic_to(command, absolute:)
117
+ x1, y1, x2, y2, x, y = command.args
118
+ if absolute
119
+ @pending_curve_points = [[x1, y1], [x2, y2], [x, y]]
120
+ @current = [x, y]
121
+ else
122
+ @pending_curve_points = [
123
+ [@current[0] + x1, @current[1] + y1],
124
+ [@current[0] + x2, @current[1] + y2],
125
+ [@current[0] + x, @current[1] + y]
126
+ ]
127
+ @current = @current.zip([x, y]).map { |a, b| a + b }
128
+ end
129
+ end
130
+
131
+ def flush_curve_points
132
+ pts = @pending_curve_points
133
+ @pending_curve_points = []
134
+ pts
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "emf"
4
+
5
+ module Emfsvg
6
+ module Translation
7
+ module Handlers
8
+ # Translate <path d="..."> to EMF drawing records.
9
+ #
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.
14
+ class PathHandler
15
+ include SharedGdi
16
+ include Emf::Emr::Binary::TypeCodes
17
+
18
+ def self.call(element, context)
19
+ new.translate(element, context)
20
+ end
21
+
22
+ def translate(element, context)
23
+ return if element.commands.empty?
24
+
25
+ with_gdi_state(element, context) do
26
+ flattened = PathFlattener.flatten(element.commands)
27
+ emit_path(flattened, element, context)
28
+ end
29
+ end
30
+
31
+ private
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)
38
+ end
39
+ end
40
+
41
+ def emit_polygon(flattened, context)
42
+ emit_poly(Emf::Emr::Binary::Records::Polygon, POLYGON, flattened, context)
43
+ end
44
+
45
+ def emit_polyline(flattened, context)
46
+ emit_poly(Emf::Emr::Binary::Records::Polyline, POLYLINE, flattened, context)
47
+ end
48
+
49
+ def emit_polybezier(flattened, context)
50
+ # emfsvg's EMF→SVG renderer prepends `M cur_x,cur_y` before
51
+ # every PolyBezier's `M point[0] C ...`. To make round-trip
52
+ # byte-equal when cur matches point[0], emit a MoveToEx to
53
+ # set the current position to the PolyBezier's first point.
54
+ first = flattened.points.first
55
+ if first
56
+ context.emit(Emf::Emr::Binary::Records::MoveToEx,
57
+ i_type: MOVETOEX,
58
+ origin: context.point_l(first[0], first[1]))
59
+ end
60
+ emit_poly(Emf::Emr::Binary::Records::PolyBezier, POLYBEZIER, flattened, context)
61
+ end
62
+
63
+ 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(
66
+ flattened.bounds.left, flattened.bounds.top,
67
+ flattened.bounds.right, flattened.bounds.bottom
68
+ )
69
+ context.emit(record_class,
70
+ i_type: i_type,
71
+ rcl_bounds: scaled_bounds,
72
+ cptl: points.size,
73
+ aptl: points)
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "emf"
4
+
5
+ module Emfsvg
6
+ module Translation
7
+ module Handlers
8
+ # <polygon> uses the same body as <polyline> but emits POLYGON
9
+ # (closed). Reuses PolylineHandler internals.
10
+ class PolygonHandler < PolylineHandler
11
+ def self.call(element, context)
12
+ new.translate(element, context)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end