emfsvg 0.1.0

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.
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emfsvg
4
+ autoload :Visitors, "emfsvg/visitors"
5
+ autoload :FormatHelpers, "emfsvg/format_helpers"
6
+
7
+ # Top-level renderer: walks an Emf::Model::Metafile and emits an SVG
8
+ # string via SvgBuilder.
9
+ #
10
+ # Matches the exact SVG structure of libemf2svg:
11
+ # 1. XML declaration
12
+ # 2. <svg version="1.1" ... width="W.0000" height="H.0000">
13
+ # 3. <g transform="translate(-RefX, -RefY)"> (header translate)
14
+ # 4. ... content with per-element point_cal coordinate transform ...
15
+ # 5. </g> (close world transform if open)
16
+ # 6. </g> (close header translate)
17
+ # 7. </svg>
18
+ class Renderer
19
+ MAX_OUTPUT_BYTES = 500 * 1024 * 1024
20
+
21
+ def self.call(metafile, options)
22
+ new(metafile, options).call
23
+ end
24
+
25
+ def initialize(metafile, options)
26
+ @metafile = metafile
27
+ @options = options
28
+ @builder = SvgBuilder.new
29
+ @dc = DeviceContext.new
30
+ @object_table = ObjectTable.new
31
+ @transform_stack = TransformStack.new
32
+ BsdRand.reset
33
+ compute_header_params
34
+ end
35
+
36
+ def call
37
+ emit_xml_declaration
38
+ emit_svg_open do
39
+ emit_header_translate_group do
40
+ visit_records
41
+ end
42
+ end
43
+ result = @builder.to_s
44
+ raise RenderError, "SVG output exceeds #{MAX_OUTPUT_BYTES} bytes" if result.bytesize > MAX_OUTPUT_BYTES
45
+
46
+ result
47
+ end
48
+
49
+ private
50
+
51
+ attr_reader :scaling, :ref_x, :ref_y, :fix_broken_y, :px_per_mm, :img_width, :img_height
52
+
53
+ def compute_header_params
54
+ b = @metafile.header.bounds
55
+ @fix_broken_y = (b.top * b.bottom).negative?
56
+
57
+ ratio_xy = (b.right - b.left).to_f / (b.bottom - b.top)
58
+
59
+ if @options.width && @options.height
60
+ @img_width = @options.width.to_f
61
+ @img_height = @options.height.to_f
62
+ tmp_w = @img_height * ratio_xy
63
+ if tmp_w > @img_width
64
+ @img_height = @img_width / ratio_xy
65
+ else
66
+ @img_width = tmp_w
67
+ end
68
+ elsif @options.height
69
+ @img_height = @options.height.to_f
70
+ @img_width = @img_height * ratio_xy
71
+ elsif @options.width
72
+ @img_width = @options.width.to_f
73
+ @img_height = @img_width / ratio_xy
74
+ else
75
+ @img_width = (b.right - b.left).abs.to_f
76
+ @img_height = (b.bottom - b.top).abs.to_f
77
+ end
78
+
79
+ bounds_width = (b.right - b.left).abs
80
+ @scaling = bounds_width.zero? ? 1.0 : @img_width / bounds_width.to_f
81
+ @ref_x = b.left.to_f
82
+ @ref_y = b.top.to_f
83
+
84
+ dev = @metafile.header.device_pixels
85
+ mm = @metafile.header.device_mm
86
+ @px_per_mm = mm.cx.zero? ? 1.0 : dev.cx.to_f / mm.cx
87
+
88
+ @open_groups = 0
89
+ end
90
+
91
+ def emit_xml_declaration
92
+ @builder.rawln('<?xml version="1.0" encoding="UTF-8" standalone="no"?>')
93
+ end
94
+
95
+ def emit_svg_open(&)
96
+ w = FormatHelpers.fmt(@img_width)
97
+ h = FormatHelpers.fmt(@img_height)
98
+ if @fix_broken_y
99
+ w = FormatHelpers.fmt(@img_width + 1)
100
+ h = FormatHelpers.fmt(@img_height + 1)
101
+ end
102
+ attrs = {
103
+ "version" => "1.1",
104
+ "xmlns" => "http://www.w3.org/2000/svg",
105
+ "xmlns:xlink" => "http://www.w3.org/1999/xlink",
106
+ "width" => w,
107
+ "height" => h
108
+ }
109
+ attrs["xmlns:svg"] = "http://www.w3.org/2000/svg" if @options.namespace == "svg"
110
+ @builder.raw("<")
111
+ @builder.raw("svg")
112
+ attrs.each do |k, v|
113
+ @builder.raw(" #{k}=\"#{v}\"")
114
+ end
115
+ @builder.rawln(">")
116
+ yield
117
+ @builder.rawln("</svg>")
118
+ end
119
+
120
+ def emit_header_translate_group
121
+ if @fix_broken_y
122
+ # Match libemf2svg's literal "0.00 00" (typo in the C source).
123
+ @builder.rawln('<g transform="translate(0.0000, 0.00 00)">')
124
+ else
125
+ tx = FormatHelpers.fmt(-1.0 * @ref_x * @scaling)
126
+ ty = FormatHelpers.fmt(-1.0 * @ref_y * @scaling)
127
+ @builder.rawln("<g transform=\"translate(#{tx}, #{ty})\">")
128
+ end
129
+ yield
130
+ @builder.rawln("</g>")
131
+ end
132
+
133
+ def visit_records
134
+ records = @metafile.to_a
135
+ path_actions, path_transforms = build_path_analysis(records)
136
+ visitor = Visitors::EmrVisitor.new(
137
+ @builder, @dc, @object_table, @transform_stack,
138
+ @metafile.header.bounds, @fix_broken_y, @options,
139
+ scaling: @scaling, ref_x: @ref_x, ref_y: @ref_y,
140
+ px_per_mm: @px_per_mm,
141
+ path_actions: path_actions,
142
+ path_transforms: path_transforms
143
+ )
144
+ records.each_with_index do |record, idx|
145
+ visitor.current_record_idx = idx
146
+ record.accept(visitor)
147
+ end
148
+ visitor.flush_open_groups
149
+ @open_groups = visitor.open_group_count
150
+ end
151
+
152
+ # Pre-scan records to build two maps keyed by BeginPath record index:
153
+ # path_actions: { idx => :fill|:stroke|:stroke_fill|:end|:abort }
154
+ # path_transforms: { idx => { wt_before: PathTransform?, wt_after: PathTransform? } }
155
+ #
156
+ # path_transforms captures SetWorldTransform/ModifyWorldTransform records
157
+ # that occur inside BeginPath..EndPath, split by whether drawing has
158
+ # occurred yet (pathDrawn). Mirrors libemf2svg's U_emf_onerec_analyse
159
+ # recording wtBeforeSet/wtAfterSet on pathStackLast.
160
+ #
161
+ # PathTransform = Struct.new(:matrix, :mode) where mode is 0 for
162
+ # SetWorldTransform (just assign) or 1/2/3 for ModifyWorldTransform
163
+ # (MWT_IDENTITY/LEFTMULTIPLY/RIGHTMULTIPLY).
164
+ def build_path_analysis(records)
165
+ actions = {}
166
+ transforms = {}
167
+ in_path = false
168
+ path_drawn = false
169
+ cur_begin = nil
170
+ wt_before = nil
171
+ wt_after = nil
172
+ records.each_with_index do |r, i|
173
+ name = r.wire.class.name.split("::").last
174
+ case name
175
+ when "BeginPath"
176
+ actions[i] = find_path_action(records, i)
177
+ cur_begin = i
178
+ wt_before = nil
179
+ wt_after = nil
180
+ in_path = true
181
+ path_drawn = false
182
+ when "EndPath", "AbortPath"
183
+ transforms[cur_begin] = { wt_before: wt_before, wt_after: wt_after } if cur_begin
184
+ in_path = false
185
+ path_drawn = false
186
+ cur_begin = nil
187
+ when "SetWorldTransform"
188
+ next unless in_path
189
+
190
+ pt = path_transform_for(r.wire, 0)
191
+ path_drawn ? (wt_before = pt) : (wt_after = pt)
192
+ when "ModifyWorldTransform"
193
+ next unless in_path
194
+
195
+ pt = path_transform_for(r.wire, r.wire.modify_mode)
196
+ path_drawn ? (wt_before = pt) : (wt_after = pt)
197
+ when *DRAWING_PATH_RECORDS
198
+ path_drawn = true if in_path
199
+ end
200
+ end
201
+ [actions, transforms]
202
+ end
203
+
204
+ PathTransform = Struct.new(:matrix, :mode, keyword_init: true)
205
+
206
+ def path_transform_for(wire, mode)
207
+ matrix = Emf::Model::Geometry::Matrix.from_wire(wire.xform)
208
+ PathTransform.new(matrix: matrix, mode: mode)
209
+ end
210
+
211
+ def find_path_action(records, start)
212
+ action = :end
213
+ ((start + 1)...records.size).each do |j|
214
+ name = records[j].wire.class.name.split("::").last
215
+ case name
216
+ when "EndPath"
217
+ next
218
+ when "FillPath"
219
+ return :fill
220
+ when "StrokePath"
221
+ return :stroke
222
+ when "StrokeAndFillPath"
223
+ return :stroke_fill
224
+ when "AbortPath"
225
+ return :abort
226
+ when "BeginPath"
227
+ return :end
228
+ end
229
+ end
230
+ action
231
+ end
232
+
233
+ # Records that set pathDrawn=true in libemf2svg's U_emf_onerec_analyse
234
+ # pre-pass. Used to distinguish wtBefore from wtAfter.
235
+ DRAWING_PATH_RECORDS = %w[
236
+ PolyBezier Polygon Polyline PolyBezierTo PolylineTo PolyPolyline PolyPolygon
237
+ SetWindowExtEx LineTo ArcTo PolyDraw
238
+ PolyBezier16 Polygon16 Polyline16 PolyBezierTo16 PolylineTo16
239
+ PolyPolyline16 PolyPolygon16 PolyDraw16
240
+ ].freeze
241
+ end
242
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emfsvg
4
+ # Lightweight XML builder matching libemf2svg's exact byte output:
5
+ # - no indentation
6
+ # - self-closing elements use `/>` (no leading space)
7
+ # - callers control newlines explicitly via `rawln` / `newline`
8
+ class SvgBuilder
9
+ def initialize
10
+ @out = (+"").force_encoding("ASCII-8BIT")
11
+ end
12
+
13
+ attr_reader :out
14
+
15
+ def tag(name, attrs = {}, single: false)
16
+ open_tag(name, attrs, single: single)
17
+ if block_given?
18
+ yield
19
+ close_tag(name)
20
+ end
21
+ self
22
+ end
23
+
24
+ def text(content)
25
+ @out << escape_text(content)
26
+ self
27
+ end
28
+
29
+ # Raw append, no newline.
30
+ def raw(string)
31
+ @out << string
32
+ self
33
+ end
34
+
35
+ # Raw append with trailing newline.
36
+ def rawln(string)
37
+ @out << string << "\n"
38
+ self
39
+ end
40
+
41
+ def newline
42
+ @out << "\n"
43
+ self
44
+ end
45
+
46
+ def to_s
47
+ @out
48
+ end
49
+
50
+ private
51
+
52
+ def open_tag(name, attrs, single:)
53
+ @out << "<" << name
54
+ attrs.each do |k, v|
55
+ next if v.nil?
56
+
57
+ @out << " " << k.to_s << '="' << escape_attr(v.to_s) << '"'
58
+ end
59
+ @out << (single ? "/>" : ">")
60
+ end
61
+
62
+ def close_tag(name)
63
+ @out << "</" << name << ">"
64
+ end
65
+
66
+ def escape_attr(value)
67
+ value.gsub("&", "&amp;")
68
+ .gsub("<", "&lt;")
69
+ .gsub(">", "&gt;")
70
+ .gsub('"', "&quot;")
71
+ end
72
+
73
+ def escape_text(value)
74
+ value.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emfsvg
4
+ # SaveDC/RestoreDC stack of DeviceContext snapshots.
5
+ #
6
+ # IMPORTANT: libemf2svg's restoreDeviceContext WALKS the stack to find the
7
+ # target level but does NOT remove entries — the same save can be restored
8
+ # multiple times. This mirrors Windows GDI behavior where RestoreDC's
9
+ # saved_dc argument identifies a level (not a one-shot pop). We replicate
10
+ # that exactly: restore() reads the target snapshot but leaves the stack
11
+ # intact.
12
+ class TransformStack
13
+ def initialize
14
+ @stack = []
15
+ end
16
+
17
+ def push(dc)
18
+ @stack.push(dc.dup)
19
+ end
20
+
21
+ # Returns the snapshot at the target level. The stack is NOT modified.
22
+ # saved_dc semantics match libemf2svg: negative N = "N levels back from
23
+ # the top" (so -1 is the top, -2 is one below, etc.); positive N is
24
+ # treated as the equivalent 1-based absolute level.
25
+ def restore(saved_dc)
26
+ target_index = if saved_dc.negative?
27
+ @stack.size + saved_dc
28
+ else
29
+ saved_dc - 1
30
+ end
31
+ return nil if target_index.negative? || target_index >= @stack.size
32
+
33
+ @stack[target_index]
34
+ end
35
+
36
+ def size
37
+ @stack.size
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emfsvg
4
+ VERSION = "0.1.0"
5
+ end