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,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Emfsvg
6
+ module Svg
7
+ # Parses an SVG document string into a Svg::Document value object.
8
+ # Nokogiri is the only XML/HTML parser used in this gem.
9
+ #
10
+ # Width/height are coerced to floats (CSS units other than px are
11
+ # out of scope for v1; a numeric value is treated as user units).
12
+ # viewBox is parsed into a 4-tuple [min_x, min_y, width, height].
13
+ module Parser
14
+ module_function
15
+
16
+ def call(string)
17
+ doc = Nokogiri::XML(string, &:noblanks)
18
+ root = doc.root
19
+ raise ParseError, "not an SVG document: missing root element" unless root
20
+
21
+ Document.new(
22
+ width: parse_dimension(root["width"]),
23
+ height: parse_dimension(root["height"]),
24
+ view_box: parse_view_box(root["viewBox"]),
25
+ root_element: Element.from_node(root)
26
+ )
27
+ end
28
+
29
+ def parse_dimension(value)
30
+ return 0.0 if value.nil? || value.empty?
31
+
32
+ match = value.match(/\A(-?\d+(?:\.\d+)?)/)
33
+ match ? match[1].to_f : 0.0
34
+ end
35
+
36
+ def parse_view_box(value)
37
+ return nil if value.nil? || value.empty?
38
+
39
+ parts = value.split(/[\s,]+/).map(&:to_f)
40
+ parts.length == 4 ? parts : nil
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emfsvg
4
+ module Svg
5
+ module PathData
6
+ # One parsed SVG path command (e.g. "M 1 2" or "L 3 4").
7
+ # `letter` preserves case (absolute uppercase vs relative lowercase).
8
+ Command = Struct.new(:letter, :args, keyword_init: true) do
9
+ def absolute?
10
+ letter == letter.upcase
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emfsvg
4
+ module Svg
5
+ # Namespace for the SVG path-data parser. Pure parser, no EMF
6
+ # concerns. The translation layer (Phase 03 path handler) consumes
7
+ # the resulting Command list.
8
+ module PathData
9
+ autoload :Command, "emfsvg/svg/path_data/command"
10
+
11
+ # Arg count per command letter. M repeats as L (handled in parser).
12
+ ARG_COUNTS = {
13
+ "M" => 2, "L" => 2, "H" => 1, "V" => 1, "C" => 6, "S" => 4,
14
+ "Q" => 4, "T" => 2, "A" => 7, "Z" => 0
15
+ }.freeze
16
+
17
+ # Stateful parser walks the string once, emitting Commands as
18
+ # enough args accumulate for the current letter. Implicit repeats:
19
+ # subsequent arg groups after M/m are treated as L/l.
20
+ module Parser
21
+ module_function
22
+
23
+ def parse(string)
24
+ return [] if string.nil? || string.empty?
25
+
26
+ commands = []
27
+ scanner = Scanner.new(string)
28
+ current_letter = nil
29
+ args = []
30
+
31
+ while (token = scanner.next_token)
32
+ case token
33
+ when Symbol # command letter
34
+ flush_command(commands, current_letter, args) if current_letter && args.any?
35
+ current_letter = token.to_s
36
+ args = []
37
+ # Zero-arg commands (Z/z) emit immediately.
38
+ if ARG_COUNTS.fetch(current_letter.upcase, 1).zero?
39
+ emit_command(commands, current_letter, [])
40
+ current_letter = nil
41
+ end
42
+ when Numeric
43
+ raise FormatError, "path data begins with a number: #{string.inspect}" if current_letter.nil?
44
+
45
+ args << token
46
+ if args.size == ARG_COUNTS.fetch(current_letter.upcase)
47
+ emit_command(commands, current_letter, args)
48
+ args = []
49
+ current_letter = implicit_repeat_letter(current_letter)
50
+ end
51
+ end
52
+ end
53
+
54
+ flush_command(commands, current_letter, args) if current_letter && args.any?
55
+ commands
56
+ end
57
+
58
+ def emit_command(commands, letter, args)
59
+ raise FormatError, "unknown path command: #{letter}" unless ARG_COUNTS.key?(letter.upcase)
60
+
61
+ commands << Command.new(letter: letter, args: args.dup)
62
+ end
63
+
64
+ def flush_command(_commands, letter, args)
65
+ return unless letter
66
+ return if args.empty?
67
+
68
+ expected = ARG_COUNTS.fetch(letter.upcase, 0)
69
+ return if args.size == expected
70
+
71
+ raise FormatError, "path command #{letter} expected #{expected} args, got #{args.size}"
72
+ end
73
+
74
+ # M/m repeats as L/l per SVG spec; all other commands repeat as
75
+ # themselves.
76
+ def implicit_repeat_letter(letter)
77
+ case letter
78
+ when "M" then "L"
79
+ when "m" then "l"
80
+ else letter
81
+ end
82
+ end
83
+ end
84
+
85
+ # Character-stream tokenizer for path data. Emits command letters
86
+ # as Symbols (e.g. :M, :z) and numbers as Floats.
87
+ class Scanner
88
+ NUMBER_RE = /[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/
89
+
90
+ def initialize(string)
91
+ @string = string
92
+ @pos = 0
93
+ end
94
+
95
+ def next_token
96
+ skip_separators
97
+ return nil if @pos >= @string.length
98
+
99
+ char = @string[@pos]
100
+ if command_letter?(char)
101
+ @pos += 1
102
+ char.to_sym
103
+ else
104
+ read_number
105
+ end
106
+ end
107
+
108
+ private
109
+
110
+ def skip_separators
111
+ @pos += 1 while @pos < @string.length && separator?(@string[@pos])
112
+ end
113
+
114
+ def separator?(char)
115
+ case char
116
+ when " ", "\t", "\n", "\r", "," then true
117
+ else false
118
+ end
119
+ end
120
+
121
+ def command_letter?(char)
122
+ ("A".."Z").cover?(char) || ("a".."z").cover?(char)
123
+ end
124
+
125
+ def read_number
126
+ match = @string.match(NUMBER_RE, @pos)
127
+ raise FormatError, "expected number at position #{@pos} in path data" unless match
128
+
129
+ @pos = match.end(0)
130
+ match.to_s.to_f
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emfsvg
4
+ module Svg
5
+ # Parsed SVG stroke style: color, width, dash pattern, line cap,
6
+ # line join. Maps directly to the EMF LOGPEN / EXTLOGPEN32 fields.
7
+ #
8
+ # Pen style bits (per MS-WMF 2.1.1.8 / emr_visitor.rb mirror):
9
+ #
10
+ # PS_SOLID=0, PS_DASH=1, PS_DOT=2, PS_DASHDOT=3, PS_DASHDOTDOT=4,
11
+ # PS_NULL=5
12
+ #
13
+ # bits 8-11: PS_ENDCAP_ROUND=0x0000, _SQUARE=0x0100, _FLAT=0x0200
14
+ # bits 12-15: PS_JOIN_ROUND=0x0000, _BEVEL=0x1000, _MITER=0x2000
15
+ #
16
+ # emfsvg's EMF→SVG renderer emits `stroke-width="1px"` (with the px
17
+ # suffix) AND no linecap/linejoin attrs as the fallback for a NULL
18
+ # pen with a solid brush. When the SVG→EMF side sees that exact
19
+ # form, it round-trips back to a NULL pen to preserve the rendering.
20
+ class Stroke
21
+ END_CAP_BITS = { "butt" => 0x0200, "round" => 0x0000, "square" => 0x0100 }.freeze
22
+ LINE_JOIN_BITS = { "miter" => 0x2000, "round" => 0x0000, "bevel" => 0x1000 }.freeze
23
+ NULL_PEN_WIDTH_SENTINEL = "1px"
24
+
25
+ attr_reader :paint, :width, :dash_array, :line_cap, :line_join, :raw_width,
26
+ :line_cap_explicit, :line_join_explicit
27
+
28
+ def initialize(paint:, width: 1.0, dash_array: nil, line_cap: "round",
29
+ line_join: "round", raw_width: nil,
30
+ line_cap_explicit: false, line_join_explicit: false)
31
+ @paint = paint
32
+ @width = width.to_f
33
+ @dash_array = dash_array
34
+ @line_cap = line_cap
35
+ @line_join = line_join
36
+ @raw_width = raw_width
37
+ @line_cap_explicit = line_cap_explicit
38
+ @line_join_explicit = line_join_explicit
39
+ end
40
+
41
+ def null_pen_sentinel?
42
+ raw_width == NULL_PEN_WIDTH_SENTINEL &&
43
+ !line_cap_explicit &&
44
+ !line_join_explicit
45
+ end
46
+
47
+ PS_GEOMETRIC = 0x00010000
48
+
49
+ # Pen style bitmask (full 32 bits). Includes:
50
+ # * dash style (bits 0-7)
51
+ # * end-cap (bits 8-11)
52
+ # * line-join (bits 12-15)
53
+ # * pen type GEOMETRIC (bit 16) — set when width > 1, so
54
+ # emfsvg's EMF→SVG renderer uses the actual pen.width
55
+ # instead of hardcoding 1.
56
+ def pen_style
57
+ base = if paint.null? || null_pen_sentinel?
58
+ 5 # PS_NULL
59
+ else
60
+ dash_style
61
+ end
62
+ type_bit = width > 1.0 && !paint.null? ? PS_GEOMETRIC : 0
63
+ base | type_bit | END_CAP_BITS.fetch(line_cap, 0x0000) | LINE_JOIN_BITS.fetch(line_join, 0x0000)
64
+ end
65
+
66
+ def self.parse(stroke:, width:, dash_array:, line_cap:, line_join:)
67
+ new(
68
+ paint: Paint.from_stroke(stroke),
69
+ width: parse_width(width),
70
+ dash_array: parse_dash_array(dash_array),
71
+ line_cap: line_cap || "round",
72
+ line_join: line_join || "round",
73
+ raw_width: width,
74
+ line_cap_explicit: !line_cap.nil?,
75
+ line_join_explicit: !line_join.nil?
76
+ )
77
+ end
78
+
79
+ def self.parse_width(value)
80
+ return 1.0 if value.nil? || value.strip.empty?
81
+
82
+ match = value.match(/\A(-?\d+(?:\.\d+)?)/)
83
+ match ? match[1].to_f : 1.0
84
+ end
85
+
86
+ # SVG dasharray is a comma/space separated list of lengths; "none"
87
+ # means solid. We map the first length to a discrete dash style:
88
+ # a single length matching roughly the width → PS_DOT, longer →
89
+ # PS_DASH. Anything more complex is approximated as PS_DASH for v1.
90
+ def dash_style
91
+ return 0 if dash_array.nil? || dash_array.empty?
92
+
93
+ first = dash_array.first
94
+ return 0 if first.zero?
95
+
96
+ first < (width * 2) ? 2 : 1 # PS_DOT=2, PS_DASH=1
97
+ end
98
+
99
+ def self.parse_dash_array(value)
100
+ return nil if value.nil? || value.strip == "none" || value.strip.empty?
101
+
102
+ parts = value.split(/[\s,]+/).map(&:to_f)
103
+ parts.empty? ? nil : parts
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emfsvg
4
+ module Svg
5
+ # Parse SVG `transform="..."` attribute syntax into a single
6
+ # `Emf::Model::Geometry::Matrix`. Handles translate(), scale(),
7
+ # rotate(), matrix(), skewX(), skewY().
8
+ #
9
+ # Convention: the emf gem's Matrix uses EMF's row-vector layout
10
+ # (v_new = v * M) — same as emfsvg's existing emr_visitor multiply.
11
+ # SVG `transform="A B"` means "apply A first, then B to the result"
12
+ # in row-vector terms, which equals the matrix product A * B.
13
+ module TransformParser
14
+ module_function
15
+
16
+ TRANSFORM_FN_RE = /([a-zA-Z]+)\s*\(([^)]*)\)/
17
+
18
+ def parse(string)
19
+ return Emf::Model::Geometry::Matrix.identity if string.nil? || string.strip.empty?
20
+
21
+ # Row-vector convention: v_new = v * M. SVG `transform="A B"`
22
+ # applies B first then A to local coords, which in row-vector
23
+ # equals the product B * A. Scanning left-to-right we compose
24
+ # on the LEFT so each new function pre-multiplies the accumulator.
25
+ string.scan(TRANSFORM_FN_RE).inject(Emf::Model::Geometry::Matrix.identity) do |composed, (name, args_str)|
26
+ args = args_str.split(/[\s,]+/).map(&:to_f)
27
+ fn_matrix = build_function(name.downcase, args)
28
+ multiply(fn_matrix, composed)
29
+ end
30
+ end
31
+
32
+ def build_function(name, args)
33
+ case name
34
+ when "matrix" then from_matrix_args(args)
35
+ when "translate" then translate_matrix(*args)
36
+ when "scale" then scale_matrix(*args)
37
+ when "rotate" then rotate_matrix(*args)
38
+ when "skewx" then skewx_matrix(args.first)
39
+ when "skewy" then skewy_matrix(args.first)
40
+ else
41
+ raise FormatError, "unknown SVG transform function: #{name}"
42
+ end
43
+ end
44
+
45
+ def from_matrix_args(args)
46
+ raise FormatError, "matrix() requires 6 args" if args.size != 6
47
+
48
+ Emf::Model::Geometry::Matrix.new(
49
+ m11: args[0], m12: args[1], m21: args[2], m22: args[3],
50
+ dx: args[4], dy: args[5]
51
+ )
52
+ end
53
+
54
+ def translate_matrix(*args)
55
+ tx = args.fetch(0, 0.0)
56
+ ty = args.fetch(1, 0.0)
57
+ Emf::Model::Geometry::Matrix.new(
58
+ m11: 1.0, m12: 0.0, m21: 0.0, m22: 1.0,
59
+ dx: tx, dy: ty
60
+ )
61
+ end
62
+
63
+ def scale_matrix(*args)
64
+ sx = args.fetch(0, 1.0)
65
+ sy = args.fetch(1, sx)
66
+ Emf::Model::Geometry::Matrix.new(
67
+ m11: sx, m12: 0.0, m21: 0.0, m22: sy,
68
+ dx: 0.0, dy: 0.0
69
+ )
70
+ end
71
+
72
+ # SVG rotate(angle) is visually clockwise (Y grows downward).
73
+ # With the emf gem's row-vector matrix layout, the equivalent
74
+ # transform applied around (cx, cy) is: T(-cx,-cy) * R * T(cx,cy).
75
+ # (The SVG spec text writes it as T(cx,cy) * R * T(-cx,-cy), but
76
+ # that's column-vector notation — row-vector reverses the order.)
77
+ def rotate_matrix(*args)
78
+ deg = args.fetch(0, 0.0)
79
+ cx = args.fetch(1, 0.0)
80
+ cy = args.fetch(2, 0.0)
81
+ rad = deg * Math::PI / 180.0
82
+ cos = Math.cos(rad)
83
+ sin = Math.sin(rad)
84
+ rot = Emf::Model::Geometry::Matrix.new(
85
+ m11: cos, m12: sin, m21: -sin, m22: cos,
86
+ dx: 0.0, dy: 0.0
87
+ )
88
+ return rot if cx.zero? && cy.zero?
89
+
90
+ multiply(multiply(translate_matrix(-cx, -cy), rot), translate_matrix(cx, cy))
91
+ end
92
+
93
+ def skewx_matrix(deg)
94
+ rad = deg * Math::PI / 180.0
95
+ Emf::Model::Geometry::Matrix.new(
96
+ m11: 1.0, m12: 0.0, m21: Math.tan(rad), m22: 1.0,
97
+ dx: 0.0, dy: 0.0
98
+ )
99
+ end
100
+
101
+ def skewy_matrix(deg)
102
+ rad = deg * Math::PI / 180.0
103
+ Emf::Model::Geometry::Matrix.new(
104
+ m11: 1.0, m12: Math.tan(rad), m21: 0.0, m22: 1.0,
105
+ dx: 0.0, dy: 0.0
106
+ )
107
+ end
108
+
109
+ # Row-vector matrix multiply: v * (a * b) = (v * a) * b. Matches
110
+ # emfsvg's emr_visitor#multiply_matrix formula.
111
+ def multiply(a, b)
112
+ Emf::Model::Geometry::Matrix.new(
113
+ m11: (a.m11 * b.m11) + (a.m12 * b.m21),
114
+ m12: (a.m11 * b.m12) + (a.m12 * b.m22),
115
+ m21: (a.m21 * b.m11) + (a.m22 * b.m21),
116
+ m22: (a.m21 * b.m12) + (a.m22 * b.m22),
117
+ dx: (a.dx * b.m11) + (a.dy * b.m21) + b.dx,
118
+ dy: (a.dx * b.m12) + (a.dy * b.m22) + b.dy
119
+ )
120
+ end
121
+ end
122
+ end
123
+ end
data/lib/emfsvg/svg.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emfsvg
4
+ # Pure SVG domain model. No EMF knowledge lives here.
5
+ #
6
+ # Svg::Parser consumes a Nokogiri-parsed SVG document and produces
7
+ # Svg::Document / Svg::Element value objects. Translation layer
8
+ # (Emfsvg::Translation) consumes these.
9
+ module Svg
10
+ autoload :Document, "emfsvg/svg/document"
11
+ autoload :Element, "emfsvg/svg/element"
12
+ autoload :OpenElement, "emfsvg/svg/element"
13
+ autoload :Parser, "emfsvg/svg/parser"
14
+ autoload :AttributeParser, "emfsvg/svg/attribute_parser"
15
+ autoload :Color, "emfsvg/svg/color"
16
+ autoload :Paint, "emfsvg/svg/paint"
17
+ autoload :Stroke, "emfsvg/svg/stroke"
18
+ autoload :PathData, "emfsvg/svg/path_data"
19
+ autoload :TransformParser, "emfsvg/svg/transform_parser"
20
+ autoload :ClipPathRegistry, "emfsvg/svg/clip_path_registry"
21
+ autoload :Elements, "emfsvg/svg/elements"
22
+ end
23
+ end
24
+
25
+ require "emfsvg/svg/elements"
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "emf"
4
+
5
+ module Emfsvg
6
+ module Translation
7
+ # Mutable translation state carried through the element tree walk.
8
+ #
9
+ # Holds the GDI device context (pen/brush/font/transform), the
10
+ # 1-indexed object table, the SaveDC/RestoreDC stack, the
11
+ # coordinate scaler, and the output record list. All handlers
12
+ # receive a Context instance and operate on it functionally.
13
+ #
14
+ # Context also carries a reference to the HandlerRegistry so that
15
+ # GroupHandler (and other container handlers) can recurse into
16
+ # their children uniformly.
17
+ class Context
18
+ attr_reader :device_context, :object_table, :transform_stack, :emitter,
19
+ :handler_registry, :clip_path_registry, :scaler
20
+
21
+ def initialize(handler_registry: EmfRenderer::DEFAULT_REGISTRY,
22
+ clip_path_registry: nil,
23
+ scaler: Scaler::Identity.new)
24
+ @device_context = DeviceContext.new
25
+ @object_table = ObjectTable.new
26
+ @transform_stack = TransformStack.new
27
+ @emitter = RecordEmitter.new
28
+ @handler_registry = handler_registry
29
+ @clip_path_registry = clip_path_registry
30
+ @scaler = scaler
31
+ @next_handle = 1
32
+ end
33
+
34
+ # Allocate the next 1-indexed GDI object handle. Always increments
35
+ # — no reuse in v1 (see TODO.roadmap/09-coalescing-optimization.adoc).
36
+ def allocate_handle
37
+ handle = @next_handle
38
+ @next_handle += 1
39
+ handle
40
+ end
41
+
42
+ def emit(wire_class, **attrs)
43
+ @emitter.emit(wire_class, **attrs)
44
+ end
45
+
46
+ def records
47
+ @emitter.records
48
+ end
49
+
50
+ # Dispatch an element through the registry. Container handlers
51
+ # (e.g. GroupHandler) call this to recurse into their children.
52
+ def dispatch(element)
53
+ handler = @handler_registry.handler_for(element)
54
+ handler&.call(element, self)
55
+ end
56
+
57
+ # ---- Coordinate-scaled wire helpers (DRY: handlers route all
58
+ # coords through these so the scaler affects everything) ----
59
+
60
+ def point_l(x, y)
61
+ Emf::Binary::Types::PointL.new(x: scale_int(x), y: scale_int(y))
62
+ end
63
+
64
+ def rect_l(left, top, right, bottom)
65
+ Emf::Binary::Types::RectL.new(
66
+ left: scale_int(left), top: scale_int(top),
67
+ right: scale_int(right), bottom: scale_int(bottom)
68
+ )
69
+ end
70
+
71
+ def size_l(cx, cy)
72
+ Emf::Binary::Types::SizeL.new(cx: scale_int(cx), cy: scale_int(cy))
73
+ end
74
+
75
+ # Convert a Matrix to its wire form, scaling the translation
76
+ # (dx/dy) by the scaler's factor. The 2x2 rotation/scale
77
+ # coefficients are NOT scaled — they're dimensionless ratios
78
+ # and survive any isometric map-mode change intact.
79
+ def xform_wire_from(matrix)
80
+ factor = @scaler.factor
81
+ return matrix.to_wire if factor == 1
82
+
83
+ scaled = Emf::Model::Geometry::Matrix.new(
84
+ m11: matrix.m11, m12: matrix.m12,
85
+ m21: matrix.m21, m22: matrix.m22,
86
+ dx: matrix.dx * factor,
87
+ dy: matrix.dy * factor
88
+ )
89
+ scaled.to_wire
90
+ end
91
+
92
+ private
93
+
94
+ def scale_int(value)
95
+ @scaler.to_int(value)
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emfsvg
4
+ module Translation
5
+ # Walks an Svg::Document tree to decide whether any coordinate
6
+ # attribute has a non-integer value. If yes, the renderer installs
7
+ # `Scaler::Fixed` and emits MapMode scaling records so the
8
+ # decimals survive the int32 truncation in EMF.
9
+ #
10
+ # Also detects overflow: if any decimal coord is so large that
11
+ # scaling would overflow int32, the renderer falls back to
12
+ # Identity (accepting the truncation for that fixture).
13
+ module DecimalDetector
14
+ module_function
15
+
16
+ def needs_scaling?(document)
17
+ any_decimal?(document.root_element)
18
+ end
19
+
20
+ def safe_for_fixed_scale?(document, scaler)
21
+ !any_unsafe?(document.root_element, scaler)
22
+ end
23
+
24
+ # ---- private ----
25
+
26
+ def any_decimal?(element)
27
+ return false unless element
28
+
29
+ element_attrs = decimal_attrs_for(element)
30
+ return true if element_attrs.any? { |attr| has_decimal?(element.send(attr)) }
31
+
32
+ path = path_commands(element)
33
+ return true if path && path.any? { |cmd| cmd.args.any? { |a| a.is_a?(Float) && a != a.to_i } }
34
+
35
+ element.children.any? { |c| any_decimal?(c) }
36
+ end
37
+
38
+ def any_unsafe?(element, scaler)
39
+ return false unless element
40
+
41
+ element_attrs = decimal_attrs_for(element)
42
+ return true if element_attrs.any? { |attr| unsafe_value?(element.send(attr), scaler) }
43
+
44
+ path = path_commands(element)
45
+ return true if path && path.any? { |cmd| cmd.args.any? { |a| scaler.unsafe?(a) } }
46
+
47
+ element.children.any? { |c| any_unsafe?(c, scaler) }
48
+ end
49
+
50
+ # Coordinate-bearing attributes per element class. Returns [] for
51
+ # elements with no coords (Group, Defs, etc).
52
+ def decimal_attrs_for(element)
53
+ case element
54
+ when Svg::Elements::Rect then %i[x y width height rx ry]
55
+ when Svg::Elements::Ellipse then %i[cx cy rx ry]
56
+ when Svg::Elements::Circle then %i[cx cy r]
57
+ when Svg::Elements::Line then %i[x1 y1 x2 y2]
58
+ when Svg::Elements::Polyline, Svg::Elements::Polygon then [:points]
59
+ when Svg::Elements::Path then [:commands]
60
+ when Svg::Elements::Text then %i[x y font_size]
61
+ when Svg::Elements::Image then %i[x y width height]
62
+ else []
63
+ end
64
+ end
65
+
66
+ def path_commands(element)
67
+ return nil unless element.is_a?(Svg::Elements::Path)
68
+
69
+ element.commands
70
+ end
71
+
72
+ # `value` may be: Float, Integer, Array of [x,y] pairs, Array of
73
+ # PathData::Command, or nil.
74
+ def has_decimal?(value)
75
+ return false if value.nil?
76
+
77
+ case value
78
+ when Float then !value.finite? || (value != value.to_i)
79
+ when Integer then false
80
+ when Array
81
+ value.any? { |v| pair_or_command?(v) ? has_decimal?(v) : v.is_a?(Float) && v != v.to_i }
82
+ when String then value.match?(/\.\d/)
83
+ else false
84
+ end
85
+ end
86
+
87
+ def pair_or_command?(value)
88
+ value.is_a?(Array) || value.is_a?(Svg::PathData::Command)
89
+ end
90
+
91
+ def unsafe_value?(value, scaler)
92
+ return false if value.nil?
93
+
94
+ case value
95
+ when Float then scaler.unsafe?(value)
96
+ when Integer then false
97
+ when Array then value.any? { |v| unsafe_value?(v, scaler) }
98
+ when String then extract_floats(value).any? { |f| scaler.unsafe?(f) }
99
+ else false
100
+ end
101
+ end
102
+
103
+ def path_unsafe?(path_str, scaler)
104
+ extract_floats(path_str).any? { |f| scaler.unsafe?(f) }
105
+ end
106
+
107
+ def extract_floats(str)
108
+ str.to_s.scan(/-?\d+(?:\.\d+)?/).map(&:to_f)
109
+ end
110
+ end
111
+ end
112
+ end