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.
- checksums.yaml +7 -0
- data/LICENSE.txt +24 -0
- data/README.adoc +104 -0
- data/exe/emfsvg +69 -0
- data/lib/emfsvg/bsd_rand.rb +29 -0
- data/lib/emfsvg/bsd_rand_sequence.txt +5000 -0
- data/lib/emfsvg/clip_region.rb +75 -0
- data/lib/emfsvg/compat.rb +134 -0
- data/lib/emfsvg/device_context.rb +240 -0
- data/lib/emfsvg/dib_decoder.rb +302 -0
- data/lib/emfsvg/error.rb +9 -0
- data/lib/emfsvg/format_helpers.rb +30 -0
- data/lib/emfsvg/glyph_index_mapper.rb +109 -0
- data/lib/emfsvg/object_table.rb +51 -0
- data/lib/emfsvg/options.rb +11 -0
- data/lib/emfsvg/png_encoder.rb +29 -0
- data/lib/emfsvg/renderer.rb +242 -0
- data/lib/emfsvg/svg_builder.rb +77 -0
- data/lib/emfsvg/transform_stack.rb +40 -0
- data/lib/emfsvg/version.rb +5 -0
- data/lib/emfsvg/visitors/emr_visitor.rb +2223 -0
- data/lib/emfsvg/visitors.rb +7 -0
- data/lib/emfsvg.rb +49 -0
- metadata +153 -0
|
@@ -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("&", "&")
|
|
68
|
+
.gsub("<", "<")
|
|
69
|
+
.gsub(">", ">")
|
|
70
|
+
.gsub('"', """)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def escape_text(value)
|
|
74
|
+
value.gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
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
|