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,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emfsvg
4
+ # Immutable clip region value object. Holds either a rectangular
5
+ # bounds (most common case) or a complex path (for SelectClipPath
6
+ # with arbitrary paths).
7
+ #
8
+ # Combine modes per MS-WMF 2.1.5:
9
+ # RGN_AND=1, RGN_OR=2, RGN_XOR=3, RGN_DIFF=4, RGN_COPY=5
10
+ class ClipRegion
11
+ RGN_AND = 1
12
+ RGN_OR = 2
13
+ RGN_XOR = 3
14
+ RGN_DIFF = 4
15
+ RGN_COPY = 5
16
+
17
+ def initialize(bounds: nil, path_d: nil)
18
+ @bounds = bounds
19
+ @path_d = path_d
20
+ end
21
+
22
+ attr_reader :bounds, :path_d
23
+
24
+ def empty?
25
+ bounds.nil? && path_d.nil?
26
+ end
27
+
28
+ # SVG element body for the clip shape: `<rect .../>` or `<path .../>`.
29
+ def to_svg_element(builder)
30
+ if path_d
31
+ builder.tag("path", { "d" => path_d }, single: true)
32
+ elsif bounds
33
+ builder.tag("rect", rect_attrs(bounds), single: true)
34
+ end
35
+ end
36
+
37
+ # Combine with another region per the given mode.
38
+ # For MVP we approximate: only AND and COPY are handled precisely;
39
+ # OR/XOR/DIFF fall back to the new region's bounds.
40
+ def combine(other, mode)
41
+ case mode
42
+ when RGN_AND
43
+ return other if empty?
44
+ return self if other.empty?
45
+
46
+ ClipRegion.new(bounds: intersect_rects(bounds, other.bounds))
47
+ else # RGN_COPY, RGN_OR, RGN_XOR, RGN_DIFF — MVP: use new region
48
+ other
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def rect_attrs(rect)
55
+ {
56
+ "x" => rect.left,
57
+ "y" => rect.top,
58
+ "width" => rect.right - rect.left,
59
+ "height" => rect.bottom - rect.top
60
+ }
61
+ end
62
+
63
+ def intersect_rects(a, b)
64
+ return b unless a
65
+ return a unless b
66
+
67
+ Emf::Model::Geometry::Rect.new(
68
+ left: [a.left, b.left].max,
69
+ top: [a.top, b.top].max,
70
+ right: [a.right, b.right].min,
71
+ bottom: [a.bottom, b.bottom].min
72
+ )
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Compares emfsvg's output against libemf2svg's reference output for the
4
+ # same EMF input. Used by spec/compat_with_libemf2svg_spec.rb.
5
+ #
6
+ # This is NOT a "must produce identical SVG" test — emfsvg is a clean-room
7
+ # rewrite that handles a subset of EMF records. It IS:
8
+ # - a structural-equivalence check for the records emfsvg handles
9
+ # - a quantification of the gap (compatibility ratio per fixture)
10
+ # - a regression guard so the ratio doesn't shrink over time
11
+ #
12
+ # Usage:
13
+ # Emfsvg::Compat::Comparator.call(fixtures_glob, ref_dir, out_dir)
14
+ #
15
+ # Requires the libemf2svg-conv binary (or our emf2svg_ref wrapper) at
16
+ # Emfsvg::Compat::LIBEMF2SVG_REF_PATH. The spec auto-skips if missing.
17
+
18
+ require "open3"
19
+ require "fileutils"
20
+ require "tmpdir"
21
+ require "rexml/document"
22
+
23
+ module Emfsvg
24
+ module Compat
25
+ REF_BINARY_DEFAULT = "/Users/mulgogi/src/claricle/libemf2svg/emf2svg_ref"
26
+ REF_LIB_DIR_DEFAULT = "/Users/mulgogi/src/claricle/libemf2svg/build"
27
+
28
+ module_function
29
+
30
+ def ref_binary_available?
31
+ path = ENV.fetch("EMF2SVG_REF_PATH", REF_BINARY_DEFAULT)
32
+ File.executable?(path)
33
+ end
34
+
35
+ # Generate a reference SVG using libemf2svg. Returns the SVG bytes,
36
+ # or nil if generation failed.
37
+ def generate_reference(emf_path, output_path = nil)
38
+ binary = ENV.fetch("EMF2SVG_REF_PATH", REF_BINARY_DEFAULT)
39
+ lib_dir = ENV.fetch("EMF2SVG_REF_LIB_DIR", REF_LIB_DIR_DEFAULT)
40
+ env = { "DYLD_LIBRARY_PATH" => lib_dir }
41
+ output_path ||= "#{Dir.mktmpdir}/#{File.basename(emf_path, '.emf')}.svg"
42
+ _, _, status = Open3.capture3(env, binary, emf_path, output_path)
43
+ return nil unless status.success?
44
+
45
+ File.binread(output_path)
46
+ ensure
47
+ File.delete(output_path) if output_path && !block_given?
48
+ end
49
+
50
+ # Generate emfsvg output. Requires the emfsvg gem to be loaded.
51
+ def generate_emfsvg(emf_path, options = {})
52
+ Emfsvg.from_file(emf_path, **options)
53
+ end
54
+
55
+ # Normalise an SVG document for structural comparison:
56
+ # - parse to REXML
57
+ # - return the root element
58
+ def parse_svg(svg_string)
59
+ REXML::Document.new(svg_string).root
60
+ rescue REXML::ParseException
61
+ nil
62
+ end
63
+
64
+ # Count drawing elements in an SVG. Returns a hash like
65
+ # { "rect" => 3, "path" => 17, "ellipse" => 2 }.
66
+ def count_elements(svg_root)
67
+ counts = Hash.new(0)
68
+ return counts unless svg_root
69
+
70
+ REXML::XPath.each(svg_root, "descendant-or-self::*") do |el|
71
+ counts[el.name] += 1
72
+ end
73
+ counts
74
+ end
75
+
76
+ # Per-fixture comparison result.
77
+ Result = Struct.new(:fixture, :ref_bytes, :emfsvg_bytes, :ref_elements,
78
+ :emfsvg_elements, keyword_init: true) do
79
+ def byte_ratio
80
+ ref_bytes.zero? ? 0.0 : (emfsvg_bytes.to_f / ref_bytes * 100).round(1)
81
+ end
82
+
83
+ def element_overlap
84
+ return 0.0 if emfsvg_elements.empty?
85
+
86
+ shared = emfsvg_elements.keys & ref_elements.keys
87
+ shared.sum { |k| [emfsvg_elements[k], ref_elements[k]].min }.to_f /
88
+ emfsvg_elements.values.sum
89
+ end
90
+
91
+ def to_s
92
+ format("%-40s ref=%8d out=%7d (%5.1f%%) elements_overlap=%.1f",
93
+ fixture, ref_bytes, emfsvg_bytes, byte_ratio, element_overlap * 100)
94
+ end
95
+ end
96
+
97
+ # Walk a fixtures glob, produce both outputs, return Result array.
98
+ def compare_glob(fixtures_glob)
99
+ results = []
100
+ Dir.glob(fixtures_glob).each do |emf|
101
+ ref_svg = generate_reference(emf)
102
+ next unless ref_svg
103
+
104
+ emfsvg_svg = begin
105
+ generate_emfsvg(emf)
106
+ rescue StandardError
107
+ nil
108
+ end
109
+ next unless emfsvg_svg
110
+
111
+ results << Result.new(
112
+ fixture: File.basename(emf),
113
+ ref_bytes: ref_svg.bytesize,
114
+ emfsvg_bytes: emfsvg_svg.bytesize,
115
+ ref_elements: count_elements(parse_svg(ref_svg)),
116
+ emfsvg_elements: count_elements(parse_svg(emfsvg_svg))
117
+ )
118
+ end
119
+ results
120
+ end
121
+
122
+ # Summary across many results.
123
+ def summarize(results)
124
+ return "no results" if results.empty?
125
+
126
+ total_ref = results.sum(&:ref_bytes)
127
+ total_out = results.sum(&:emfsvg_bytes)
128
+ ratio = total_ref.zero? ? 0 : (total_out.to_f / total_ref * 100).round(1)
129
+ avg_overlap = results.sum(&:element_overlap) / results.size
130
+ format("fixtures=%d total_ref=%d total_out=%d byte_ratio=%.1f%% avg_element_overlap=%.1f%%",
131
+ results.size, total_ref, total_out, ratio, avg_overlap * 100)
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emfsvg
4
+ # Coordinate state that lives OUTSIDE the device-context stack in
5
+ # libemf2svg (MapMode, window/viewport extents and origins). The C
6
+ # code keeps these on drawingStates directly, NOT on
7
+ # EMF_DEVICE_CONTEXT, so SaveDC/RestoreDC never saves/restores them.
8
+ # They persist as global state for the duration of the render.
9
+ #
10
+ # This is a mutable struct shared by reference across every dup'd
11
+ # DeviceContext — when visit_set_map_mode changes the map mode, every
12
+ # DC observes the new value, even ones that were saved earlier.
13
+ class CoordinateState
14
+ attr_accessor :map_mode, :window_ext, :viewport_ext,
15
+ :window_org, :viewport_org,
16
+ :window_ext_set, :viewport_ext_set
17
+
18
+ def initialize
19
+ @map_mode = 1 # MM_TEXT
20
+ @window_ext = [0, 0]
21
+ @viewport_ext = [0, 0]
22
+ @window_org = [0, 0]
23
+ @viewport_org = [0, 0]
24
+ @window_ext_set = false
25
+ @viewport_ext_set = false
26
+ end
27
+ end
28
+
29
+ # Snapshot of GDI drawing state. Pushed onto TransformStack on SaveDC,
30
+ # popped on RestoreDC.
31
+ class DeviceContext
32
+ def initialize
33
+ @pen = Pen.default
34
+ @brush = Brush.default
35
+ @font = Font.default
36
+ @text_color = Emf::Model::Geometry::Color.black
37
+ @bk_color = Emf::Model::Geometry::Color.white
38
+ @bk_mode = 1 # TRANSPARENT
39
+ @poly_fill_mode = 1 # ALTERNATE (evenodd)
40
+ @text_align = 0
41
+ @stretch_mode = 1
42
+ @miter_limit = 10.0
43
+ @world_transform = Emf::Model::Geometry::Matrix.identity
44
+ @brush_origin = [0, 0]
45
+ @icm_mode = 0
46
+ @layout = 0
47
+ @mapper_flags = 0
48
+ @arc_direction = 0 # default; set by SetArcDirection (1=CW, -1=CCW in C)
49
+ @clip_region = nil
50
+ @clip_id = nil
51
+ # Coordinate state shared across all dup'd DCs (see CoordinateState).
52
+ @coordinate_state = CoordinateState.new
53
+ end
54
+
55
+ attr_accessor :pen, :brush, :font, :text_color, :bk_color, :bk_mode, :poly_fill_mode, :text_align, :stretch_mode, :miter_limit,
56
+ :world_transform, :brush_origin, :icm_mode, :layout, :mapper_flags, :arc_direction, :clip_region, :clip_id, :coordinate_state
57
+
58
+ def map_mode
59
+ coordinate_state.map_mode
60
+ end
61
+
62
+ def map_mode=(value)
63
+ coordinate_state.map_mode = value
64
+ end
65
+
66
+ def window_ext
67
+ coordinate_state.window_ext
68
+ end
69
+
70
+ def window_ext=(value)
71
+ coordinate_state.window_ext = value
72
+ end
73
+
74
+ def viewport_ext
75
+ coordinate_state.viewport_ext
76
+ end
77
+
78
+ def viewport_ext=(value)
79
+ coordinate_state.viewport_ext = value
80
+ end
81
+
82
+ def window_org
83
+ coordinate_state.window_org
84
+ end
85
+
86
+ def window_org=(value)
87
+ coordinate_state.window_org = value
88
+ end
89
+
90
+ def viewport_org
91
+ coordinate_state.viewport_org
92
+ end
93
+
94
+ def viewport_org=(value)
95
+ coordinate_state.viewport_org = value
96
+ end
97
+
98
+ def window_ext_set?
99
+ coordinate_state.window_ext_set
100
+ end
101
+
102
+ def viewport_ext_set?
103
+ coordinate_state.viewport_ext_set
104
+ end
105
+
106
+ def window_ext_set=(value)
107
+ coordinate_state.window_ext_set = value
108
+ end
109
+
110
+ def viewport_ext_set=(value)
111
+ coordinate_state.viewport_ext_set = value
112
+ end
113
+
114
+ def dup
115
+ other = DeviceContext.allocate
116
+ other.pen = pen.dup
117
+ other.brush = brush.dup
118
+ other.font = font.dup
119
+ other.text_color = text_color
120
+ other.bk_color = bk_color
121
+ other.bk_mode = bk_mode
122
+ other.poly_fill_mode = poly_fill_mode
123
+ other.text_align = text_align
124
+ other.stretch_mode = stretch_mode
125
+ other.miter_limit = miter_limit
126
+ other.world_transform = world_transform
127
+ other.brush_origin = brush_origin.dup
128
+ other.icm_mode = icm_mode
129
+ other.layout = layout
130
+ other.mapper_flags = mapper_flags
131
+ other.arc_direction = arc_direction
132
+ other.clip_region = clip_region
133
+ other.clip_id = clip_id
134
+ # CoordinateState is shared by reference — SaveDC/RestoreDC must
135
+ # not snapshot/restore the coordinate state per libemf2svg.
136
+ other.coordinate_state = coordinate_state
137
+ other
138
+ end
139
+
140
+ protected :coordinate_state=
141
+ end
142
+
143
+ # Pen value object: style, width, color.
144
+ class Pen
145
+ SOLID = 0
146
+ DASH = 1
147
+ DOT = 2
148
+ DASHDOT = 3
149
+ DASHDOTDOT = 4
150
+ NULL = 5
151
+ INSIDEFRAME = 6
152
+
153
+ def initialize(style: SOLID, width: 1, color: Emf::Model::Geometry::Color.black)
154
+ @style = style
155
+ @width = width
156
+ @color = color
157
+ end
158
+
159
+ attr_reader :style, :width, :color
160
+
161
+ def self.default
162
+ new
163
+ end
164
+
165
+ def null?
166
+ style == NULL
167
+ end
168
+
169
+ def dup
170
+ Pen.new(style: style, width: width, color: color)
171
+ end
172
+ end
173
+
174
+ # Brush value object: style, color, hatch.
175
+ class Brush
176
+ SOLID = 0
177
+ NULL = 1
178
+ HATCHED = 2
179
+ PATTERN = 3
180
+ # MONOPATTERN: 8 (BS_MONOPATTERN / U_BS_MONOPATTERN in MS-WMF). Brush
181
+ # references a DIB image stored in the SVG <defs>; fill becomes
182
+ # `url(#img-N-ref)`. brush.image_id is the N.
183
+ MONOPATTERN = 8
184
+
185
+ def initialize(style: SOLID, color: Emf::Model::Geometry::Color.black, hatch: 0, image_id: nil)
186
+ @style = style
187
+ @color = color
188
+ @hatch = hatch
189
+ @image_id = image_id
190
+ end
191
+
192
+ attr_reader :style, :color, :hatch, :image_id
193
+
194
+ def self.default
195
+ new
196
+ end
197
+
198
+ def null?
199
+ style == NULL
200
+ end
201
+
202
+ def dup
203
+ Brush.new(style: style, color: color, hatch: hatch, image_id: image_id)
204
+ end
205
+ end
206
+
207
+ # Font value object: minimal subset of LOGFONT for text rendering.
208
+ class Font
209
+ DEFAULT_HEIGHT = 0
210
+
211
+ def initialize(face_name: nil, height: DEFAULT_HEIGHT, weight: 0,
212
+ italic: false, underline: false, strikeout: false,
213
+ escapement: 0, charset: 0)
214
+ @face_name = face_name
215
+ @height = height
216
+ @weight = weight
217
+ @italic = italic
218
+ @underline = underline
219
+ @strikeout = strikeout
220
+ @escapement = escapement
221
+ @charset = charset
222
+ end
223
+
224
+ attr_reader :face_name, :height, :weight, :italic, :underline, :strikeout, :escapement, :charset
225
+
226
+ def self.default
227
+ new
228
+ end
229
+
230
+ def bold?
231
+ weight >= 700
232
+ end
233
+
234
+ def dup
235
+ Font.new(face_name: face_name, height: height, weight: weight,
236
+ italic: italic, underline: underline, strikeout: strikeout,
237
+ escapement: escapement, charset: charset)
238
+ end
239
+ end
240
+ end