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,2223 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "emf"
5
+
6
+ module Emfsvg
7
+ module Visitors
8
+ # Walks Emf::Model::Emr::Records::WireAdapter records and dispatches
9
+ # by underlying wire class to handler methods.
10
+ #
11
+ # WireAdapter is emf's catch-all. Each wire class corresponds to a
12
+ # visit_<class_name_in_snake_case> method here. Adding a handler:
13
+ # 1. Write the method (e.g. `visit_polygon`).
14
+ # 2. Register it in HANDLERS map keyed by the wire class.
15
+ class EmrVisitor < Emf::Model::Visitor
16
+ # Maps wire class -> handler method name (as symbol).
17
+ HANDLERS = {
18
+ Emf::Emr::Binary::Records::Eof => :visit_eof,
19
+ Emf::Emr::Binary::Records::SetTextColor => :visit_set_text_color,
20
+ Emf::Emr::Binary::Records::SetBkColor => :visit_set_bk_color,
21
+ Emf::Emr::Binary::Records::SetBkMode => :visit_set_bk_mode,
22
+ Emf::Emr::Binary::Records::SetMapMode => :visit_set_map_mode,
23
+ Emf::Emr::Binary::Records::SetRop2 => :visit_set_rop2,
24
+ Emf::Emr::Binary::Records::SetPolyFillMode => :visit_set_poly_fill_mode,
25
+ Emf::Emr::Binary::Records::SetTextAlign => :visit_set_text_align,
26
+ Emf::Emr::Binary::Records::SetStretchBltMode => :visit_set_stretch_blt_mode,
27
+ Emf::Emr::Binary::Records::SaveDc => :visit_save_dc,
28
+ Emf::Emr::Binary::Records::RestoreDc => :visit_restore_dc,
29
+ Emf::Emr::Binary::Records::SelectObject => :visit_select_object,
30
+ Emf::Emr::Binary::Records::DeleteObject => :visit_delete_object,
31
+ Emf::Emr::Binary::Records::CreatePen => :visit_create_pen,
32
+ Emf::Emr::Binary::Records::CreateBrushIndirect => :visit_create_brush_indirect,
33
+ Emf::Emr::Binary::Records::SetWindowOrgEx => :visit_set_window_org_ex,
34
+ Emf::Emr::Binary::Records::SetWindowExtEx => :visit_set_window_ext_ex,
35
+ Emf::Emr::Binary::Records::SetViewportOrgEx => :visit_set_viewport_org_ex,
36
+ Emf::Emr::Binary::Records::SetViewportExtEx => :visit_set_viewport_ext_ex,
37
+ Emf::Emr::Binary::Records::MoveToEx => :visit_move_to_ex,
38
+ Emf::Emr::Binary::Records::LineTo => :visit_line_to,
39
+ Emf::Emr::Binary::Records::Rectangle => :visit_rectangle,
40
+ Emf::Emr::Binary::Records::Ellipse => :visit_ellipse,
41
+ Emf::Emr::Binary::Records::RoundRect => :visit_round_rect,
42
+ Emf::Emr::Binary::Records::Arc => :visit_arc,
43
+ Emf::Emr::Binary::Records::ArcTo => :visit_arc_to,
44
+ Emf::Emr::Binary::Records::Chord => :visit_chord,
45
+ Emf::Emr::Binary::Records::Pie => :visit_pie,
46
+ Emf::Emr::Binary::Records::Polygon => :visit_polygon,
47
+ Emf::Emr::Binary::Records::Polyline => :visit_polyline,
48
+ Emf::Emr::Binary::Records::Polygon16 => :visit_polygon16,
49
+ Emf::Emr::Binary::Records::Polyline16 => :visit_polyline16,
50
+ Emf::Emr::Binary::Records::PolyBezier => :visit_poly_bezier,
51
+ Emf::Emr::Binary::Records::PolyBezier16 => :visit_poly_bezier16,
52
+ Emf::Emr::Binary::Records::PolyBezierTo => :visit_poly_bezier_to,
53
+ Emf::Emr::Binary::Records::PolyBezierTo16 => :visit_poly_bezier_to16,
54
+ Emf::Emr::Binary::Records::PolylineTo => :visit_polyline_to,
55
+ Emf::Emr::Binary::Records::PolylineTo16 => :visit_polyline_to16,
56
+ Emf::Emr::Binary::Records::BeginPath => :visit_begin_path,
57
+ Emf::Emr::Binary::Records::EndPath => :visit_end_path,
58
+ Emf::Emr::Binary::Records::CloseFigure => :visit_close_figure,
59
+ Emf::Emr::Binary::Records::FillPath => :visit_fill_path,
60
+ Emf::Emr::Binary::Records::StrokePath => :visit_stroke_path,
61
+ Emf::Emr::Binary::Records::StrokeAndFillPath => :visit_stroke_and_fill_path,
62
+ Emf::Emr::Binary::Records::AbortPath => :visit_abort_path,
63
+ Emf::Emr::Binary::Records::SetWorldTransform => :visit_set_world_transform,
64
+ Emf::Emr::Binary::Records::ModifyWorldTransform => :visit_modify_world_transform,
65
+ Emf::Emr::Binary::Records::SetMiterLimit => :visit_set_miter_limit,
66
+ Emf::Emr::Binary::Records::Comment => :visit_comment,
67
+ Emf::Emr::Binary::Records::SetPixelV => :visit_set_pixel_v,
68
+ Emf::Emr::Binary::Records::CreateFontIndirectW => :visit_create_font_indirect_w,
69
+ Emf::Emr::Binary::Records::ExtTextOutW => :visit_ext_text_out_w,
70
+ Emf::Emr::Binary::Records::ExtTextOutA => :visit_ext_text_out_a,
71
+ Emf::Emr::Binary::Records::SmallTextOut => :visit_small_text_out,
72
+ Emf::Emr::Binary::Records::IntersectClipRect => :visit_intersect_clip_rect,
73
+ Emf::Emr::Binary::Records::ExcludeClipRect => :visit_exclude_clip_rect,
74
+ Emf::Emr::Binary::Records::SetBrushOrgEx => :visit_set_brush_org_ex,
75
+ Emf::Emr::Binary::Records::SetMetArgn => :visit_set_metargn,
76
+ Emf::Emr::Binary::Records::OffsetClipRgn => :visit_offset_clip_rgn,
77
+ Emf::Emr::Binary::Records::SetIcmMode => :visit_set_icm_mode,
78
+ Emf::Emr::Binary::Records::SetLayout => :visit_set_layout,
79
+ Emf::Emr::Binary::Records::ScaleViewportExtEx => :visit_scale_viewport_ext_ex,
80
+ Emf::Emr::Binary::Records::ScaleWindowExtEx => :visit_scale_window_ext_ex,
81
+ Emf::Emr::Binary::Records::SetMapperFlags => :visit_set_mapper_flags,
82
+ Emf::Emr::Binary::Records::SetArcDirection => :visit_set_arc_direction,
83
+ Emf::Emr::Binary::Records::RealizePalette => :visit_realize_palette,
84
+ Emf::Emr::Binary::Records::SelectPalette => :visit_select_palette,
85
+ Emf::Emr::Binary::Records::CreatePalette => :visit_create_palette,
86
+ Emf::Emr::Binary::Records::ResizePalette => :visit_resize_palette,
87
+ Emf::Emr::Binary::Records::SetPaletteEntries => :visit_set_palette_entries,
88
+ Emf::Emr::Binary::Records::PolyPolygon => :visit_poly_polygon,
89
+ Emf::Emr::Binary::Records::PolyPolygon16 => :visit_poly_polygon16,
90
+ Emf::Emr::Binary::Records::PolyPolyline => :visit_poly_polyline,
91
+ Emf::Emr::Binary::Records::PolyPolyline16 => :visit_poly_polyline16,
92
+ Emf::Emr::Binary::Records::PolyDraw => :visit_poly_draw,
93
+ Emf::Emr::Binary::Records::PolyDraw16 => :visit_poly_draw16,
94
+ Emf::Emr::Binary::Records::AngleArc => :visit_angle_arc,
95
+ Emf::Emr::Binary::Records::DrawEscape => :visit_draw_escape,
96
+ Emf::Emr::Binary::Records::ExtEscape => :visit_ext_escape,
97
+ Emf::Emr::Binary::Records::NamedEscape => :visit_named_escape,
98
+ Emf::Emr::Binary::Records::SmallTextOut => :visit_small_text_out,
99
+ Emf::Emr::Binary::Records::SetLinkedUfis => :visit_set_linked_ufis,
100
+ Emf::Emr::Binary::Records::ForceUfiMapping => :visit_force_ufi_mapping,
101
+ Emf::Emr::Binary::Records::ColorCorrectPalette => :visit_color_correct_palette,
102
+ Emf::Emr::Binary::Records::SetIcmProfileA => :visit_set_icm_profile_a,
103
+ Emf::Emr::Binary::Records::SetIcmProfileW => :visit_set_icm_profile_w,
104
+ Emf::Emr::Binary::Records::ColorMatchToTargetW => :visit_color_match_to_target_w,
105
+ Emf::Emr::Binary::Records::CreateColorspace => :visit_create_colorspace,
106
+ Emf::Emr::Binary::Records::CreateColorspaceW => :visit_create_colorspace_w,
107
+ Emf::Emr::Binary::Records::SetColorspace => :visit_set_colorspace,
108
+ Emf::Emr::Binary::Records::DeleteColorspace => :visit_delete_colorspace,
109
+ Emf::Emr::Binary::Records::GlsRecord => :visit_gls_record,
110
+ Emf::Emr::Binary::Records::GlsBoundedRecord => :visit_gls_bounded_record,
111
+ Emf::Emr::Binary::Records::PixelFormat => :visit_pixel_format,
112
+ Emf::Emr::Binary::Records::PolyTextOutA => :visit_poly_text_out_a,
113
+ Emf::Emr::Binary::Records::PolyTextOutW => :visit_poly_text_out_w,
114
+ Emf::Emr::Binary::Records::SetTextJustification => :visit_set_text_justification,
115
+ Emf::Emr::Binary::Records::CreateMonoBrush => :visit_create_mono_brush,
116
+ Emf::Emr::Binary::Records::CreateDibPatternBrushPt => :visit_create_dib_pattern_brush_pt,
117
+ Emf::Emr::Binary::Records::ExtCreatePen => :visit_ext_create_pen,
118
+ Emf::Emr::Binary::Records::StretchDIBits => :visit_stretch_dibits,
119
+ Emf::Emr::Binary::Records::AlphaBlend => :visit_alpha_blend,
120
+ Emf::Emr::Binary::Records::TransparentBlt => :visit_transparent_blt,
121
+ Emf::Emr::Binary::Records::BitBlt => :visit_bit_blt,
122
+ Emf::Emr::Binary::Records::StretchBlt => :visit_stretch_blt,
123
+ Emf::Emr::Binary::Records::ExtSelectClipRgn => :visit_ext_select_clip_rgn,
124
+ Emf::Emr::Binary::Records::SelectClipPath => :visit_select_clip_path,
125
+ Emf::Emr::Binary::Records::GradientFill => :visit_gradient_fill,
126
+ Emf::Emr::Binary::Records::FillRgn => :visit_fill_rgn,
127
+ Emf::Emr::Binary::Records::FrameRgn => :visit_frame_rgn,
128
+ Emf::Emr::Binary::Records::InvertRgn => :visit_invert_rgn,
129
+ Emf::Emr::Binary::Records::PaintRgn => :visit_paint_rgn
130
+ }.freeze
131
+
132
+ def initialize(builder, dc, object_table, transform_stack, bounds, fix_y, options,
133
+ scaling: 1.0, ref_x: 0.0, ref_y: 0.0, px_per_mm: 1.0,
134
+ path_actions: {}, path_transforms: {})
135
+ @builder = builder
136
+ @dc = dc
137
+ @object_table = object_table
138
+ @transform_stack = transform_stack
139
+ @bounds = bounds
140
+ @fix_y = fix_y
141
+ @options = options
142
+ @scaling = scaling
143
+ @ref_x = ref_x
144
+ @ref_y = ref_y
145
+ @px_per_mm = px_per_mm
146
+ @path_actions = path_actions
147
+ @path_transforms = path_transforms
148
+ @current_x = 0
149
+ @current_y = 0
150
+ @path_d = nil
151
+ @last_path_d = nil
152
+ @open_groups = 0
153
+ @in_path = false
154
+ @current_record_idx = nil
155
+ @current_path_begin_idx = nil
156
+ # Pattern-brush image library. Keyed by the raw DIB bytes (BmpSrc +
157
+ # cbBits) so identical DIBs share the same image_id, mirroring
158
+ # libemf2svg's image_library_find/image_library_add.
159
+ @image_library = {}
160
+ @next_image_id = 1
161
+ @glyph_mapper = GlyphIndexMapper.new
162
+ end
163
+
164
+ attr_reader :current_x, :current_y, :open_groups
165
+
166
+ attr_accessor :current_record_idx
167
+
168
+ def open_group_count
169
+ @open_groups
170
+ end
171
+
172
+ # The Emf wire-record-adapter calls this. Dispatch by wire class.
173
+ def visit_emr_wire_record(adapter)
174
+ wire = adapter.wire
175
+ method_name = HANDLERS[wire.class]
176
+ if method_name
177
+ send(method_name, wire)
178
+ elsif @options.verbose
179
+ warn "emfsvg: no handler for #{wire.class.name&.split('::')&.last}"
180
+ end
181
+ rescue StandardError => e
182
+ raise unless @options.verbose
183
+
184
+ warn "emfsvg: handler #{method_name} raised #{e.class}: #{e.message}"
185
+ end
186
+
187
+ def flush_open_groups
188
+ @open_groups.times { @builder.rawln("</g>") }
189
+ @open_groups = 0
190
+ end
191
+
192
+ # Per-element coordinate transformation matching libemf2svg's point_cal().
193
+ #
194
+ # CRITICAL: window/viewport origins are ONLY applied for ISOTROPIC and
195
+ # ANISOTROPIC modes. For MM_TEXT and metric modes, the C code leaves
196
+ # windowOrgX/Y and viewPortOrgX/Y at 0.0 (initialized but never set
197
+ # inside the switch). This is NOT a bug in the C code — it's the
198
+ # intentional GDI behavior for those map modes.
199
+
200
+ def cal_x(x)
201
+ if use_orgs?
202
+ (((x - @dc.window_org[0]) * sf_x) + @dc.viewport_org[0]) * @scaling
203
+ else
204
+ x * sf_x * @scaling
205
+ end
206
+ end
207
+
208
+ def cal_y(y)
209
+ if use_orgs?
210
+ (((y - @dc.window_org[1]) * sf_y) + @dc.viewport_org[1]) * @scaling
211
+ else
212
+ y * sf_y * @scaling
213
+ end
214
+ end
215
+
216
+ def use_orgs?
217
+ [7, 8].include?(@dc.map_mode) # ISOTROPIC or ANISOTROPIC
218
+ end
219
+
220
+ def scale_dim_x(w)
221
+ w * sf_x * @scaling
222
+ end
223
+
224
+ def scale_dim_y(h)
225
+ # libemf2svg's scaleY preserves sign (no .abs). Negative raw heights
226
+ # with negative sf_y produce positive output, matching C.
227
+ h * sf_y * @scaling
228
+ end
229
+
230
+ # FormatHelpers provides fmt and fmt_xy as module functions.
231
+ # The visitor delegates to them so handler code reads cleanly.
232
+ def fmt(n)
233
+ FormatHelpers.fmt(n)
234
+ end
235
+
236
+ def fmt_xy(x, y)
237
+ FormatHelpers.fmt_xy(x, y)
238
+ end
239
+
240
+ # C integer division truncates toward zero; Ruby's Integer#/ floors.
241
+ # For negative operands the results differ (e.g. -333/2: C=-166,
242
+ # Ruby=-167). This helper replicates C's truncation semantics.
243
+ def c_int_div(a, b)
244
+ (a.to_f / b).truncate
245
+ end
246
+
247
+ # --- Handlers ---
248
+
249
+ def visit_eof(_wire); end
250
+
251
+ def visit_set_text_color(wire)
252
+ @dc.text_color = Emf::Model::Geometry::Color.from_wire(wire.color)
253
+ end
254
+
255
+ def visit_set_bk_color(wire)
256
+ @dc.bk_color = Emf::Model::Geometry::Color.from_wire(wire.color)
257
+ end
258
+
259
+ def visit_set_bk_mode(wire)
260
+ @dc.bk_mode = wire.bk_mode
261
+ end
262
+
263
+ def visit_set_map_mode(wire)
264
+ @dc.map_mode = wire.map_mode
265
+ end
266
+
267
+ def visit_set_rop2(wire)
268
+ # ROP2 mostly ignored by SVG (we are not a raster engine).
269
+ end
270
+
271
+ def visit_set_poly_fill_mode(wire)
272
+ @dc.poly_fill_mode = wire.poly_fill_mode
273
+ end
274
+
275
+ def visit_set_text_align(wire)
276
+ @dc.text_align = wire.text_align_mode
277
+ end
278
+
279
+ def visit_set_stretch_blt_mode(wire)
280
+ @dc.stretch_mode = wire.stretch_mode
281
+ end
282
+
283
+ def visit_save_dc(_wire)
284
+ @transform_stack.push(@dc)
285
+ end
286
+
287
+ def visit_restore_dc(wire)
288
+ snapshot = @transform_stack.restore(wire.saved_dc)
289
+ @dc = snapshot ? snapshot.dup : @dc
290
+ end
291
+
292
+ ColorRGB = Struct.new(:r, :g, :b) do
293
+ def to_color
294
+ Emf::Model::Geometry::Color.new(red: r, green: g, blue: b)
295
+ end
296
+ end
297
+
298
+ STOCK_OBJECT_FLAG = 0x80000000
299
+ # Stock object indices (low 31 bits when STOCK_OBJECT_FLAG is set),
300
+ # matching MS-EMF 2.1.7. Used by visit_select_object.
301
+ STOCK = {
302
+ 0 => %i[brush white], # WHITE_BRUSH
303
+ 1 => %i[brush ltgray], # LTGRAY_BRUSH
304
+ 2 => %i[brush gray], # GRAY_BRUSH
305
+ 3 => %i[brush dkgray], # DKGRAY_BRUSH
306
+ 4 => %i[brush black], # BLACK_BRUSH
307
+ 5 => %i[brush null], # NULL_BRUSH
308
+ 6 => %i[pen white], # WHITE_PEN
309
+ 7 => %i[pen black], # BLACK_PEN
310
+ 8 => %i[pen null] # NULL_PEN
311
+ }.freeze
312
+
313
+ STOCK_BRUSH_COLORS = {
314
+ white: ColorRGB.new(0xFF, 0xFF, 0xFF),
315
+ ltgray: ColorRGB.new(0xC0, 0xC0, 0xC0),
316
+ gray: ColorRGB.new(0x80, 0x80, 0x80),
317
+ dkgray: ColorRGB.new(0x40, 0x40, 0x40),
318
+ black: ColorRGB.new(0x00, 0x00, 0x00)
319
+ }.freeze
320
+
321
+ def visit_select_object(wire)
322
+ ih = wire.ih_object
323
+
324
+ # Stock object?
325
+ if (ih & STOCK_OBJECT_FLAG).positive?
326
+ index = ih & 0x7FFFFFFF
327
+ apply_stock_object(index)
328
+ return
329
+ end
330
+
331
+ entry = @object_table.lookup(ih)
332
+ return unless entry
333
+
334
+ case entry.type
335
+ when :pen then @dc.pen = entry.object
336
+ when :brush then @dc.brush = entry.object
337
+ when :font then @dc.font = entry.object
338
+ end
339
+ end
340
+
341
+ def apply_stock_object(index)
342
+ kind, variant = STOCK[index]
343
+ return unless kind
344
+
345
+ case kind
346
+ when :brush
347
+ @dc.brush = if variant == :null
348
+ Brush.new(style: Brush::NULL,
349
+ color: Emf::Model::Geometry::Color.white)
350
+ else
351
+ Brush.new(style: Brush::SOLID,
352
+ color: STOCK_BRUSH_COLORS[variant].to_color)
353
+ end
354
+ when :pen
355
+ @dc.pen = if variant == :null
356
+ Pen.new(style: Pen::NULL, width: 1,
357
+ color: Emf::Model::Geometry::Color.black)
358
+ else
359
+ Pen.new(style: Pen::SOLID, width: 1,
360
+ color: STOCK_BRUSH_COLORS[variant].to_color)
361
+ end
362
+ end
363
+ end
364
+
365
+ def visit_delete_object(wire)
366
+ @object_table.delete(wire.ih_object)
367
+ end
368
+
369
+ def visit_create_pen(wire)
370
+ pen = Pen.new(
371
+ style: wire.pen_style,
372
+ width: wire.width.x,
373
+ color: Emf::Model::Geometry::Color.from_wire(wire.color)
374
+ )
375
+ @object_table.store(wire.ih_pen, :pen, pen)
376
+ end
377
+
378
+ def visit_create_brush_indirect(wire)
379
+ brush = Brush.new(
380
+ style: wire.brush_style,
381
+ color: Emf::Model::Geometry::Color.from_wire(wire.color),
382
+ hatch: wire.brush_hatch
383
+ )
384
+ @object_table.store(wire.ih_brush, :brush, brush)
385
+ end
386
+
387
+ def visit_set_window_org_ex(wire)
388
+ @dc.window_org = [wire.origin.x, wire.origin.y]
389
+ end
390
+
391
+ def visit_set_window_ext_ex(wire)
392
+ @dc.window_ext = [wire.extent.cx, wire.extent.cy]
393
+ @dc.window_ext_set = true
394
+ end
395
+
396
+ def visit_set_viewport_org_ex(wire)
397
+ @dc.viewport_org = [wire.origin.x, wire.origin.y]
398
+ end
399
+
400
+ def visit_set_viewport_ext_ex(wire)
401
+ @dc.viewport_ext = [wire.extent.cx, wire.extent.cy]
402
+ @dc.viewport_ext_set = true
403
+ end
404
+
405
+ def visit_move_to_ex(wire)
406
+ @current_x = wire.origin.x
407
+ @current_y = wire.origin.y
408
+ return unless @in_path
409
+
410
+ # In path: emit `M X,Y ` directly to the open d-string.
411
+ @builder.raw("M ")
412
+ @builder.raw(point_str(wire.origin))
413
+ end
414
+
415
+ def visit_line_to(wire)
416
+ if @in_path
417
+ # In path: emit `L X,Y ` to the open d-string, no wrapping.
418
+ @builder.raw("L ")
419
+ @builder.raw(fmt_xy(cal_x(wire.origin.x), cal_y(wire.origin.y)))
420
+ @current_x = wire.origin.x
421
+ @current_y = wire.origin.y
422
+ return
423
+ end
424
+
425
+ # Standalone LineTo (not in path): full <path> element via endPathDraw.
426
+ # C's startPathDraw creates U_POINT (int32) from cur_x/cur_y,
427
+ # truncating any fractional part before point_cal. We must match.
428
+ d = "M #{fmt(cal_x(@current_x.to_i))},#{fmt(cal_y(@current_y.to_i))} "
429
+ d << "L #{fmt(cal_x(wire.origin.x))},#{fmt(cal_y(wire.origin.y))} "
430
+ emit_path_element(d, closed: false, leading_fill_space: true)
431
+ @current_x = wire.origin.x
432
+ @current_y = wire.origin.y
433
+ end
434
+
435
+ def visit_rectangle(wire)
436
+ lt_x = cal_x(wire.rcl_box.left)
437
+ lt_y = cal_y(wire.rcl_box.top)
438
+ rb_x = cal_x(wire.rcl_box.right)
439
+ rb_y = cal_y(wire.rcl_box.bottom)
440
+ dim_x = rb_x - lt_x
441
+ dim_y = rb_y - lt_y
442
+ if @fix_y && dim_y.negative?
443
+ dim_y = -dim_y
444
+ lt_y -= dim_y
445
+ end
446
+ geom = "x=\"#{fmt(lt_x)}\" y=\"#{fmt(lt_y)}\" width=\"#{fmt(dim_x)}\" height=\"#{fmt(dim_y)}\""
447
+ emit_shape_element("rect", geom)
448
+ end
449
+
450
+ def visit_ellipse(wire)
451
+ lt_x = cal_x(wire.rcl_box.left)
452
+ lt_y = cal_y(wire.rcl_box.top)
453
+ rb_x = cal_x(wire.rcl_box.right)
454
+ rb_y = cal_y(wire.rcl_box.bottom)
455
+ cx = (lt_x + rb_x) / 2.0
456
+ cy = (lt_y + rb_y) / 2.0
457
+ rx = (rb_x - lt_x).abs / 2.0
458
+ ry = (rb_y - lt_y).abs / 2.0
459
+ geom = "cx=\"#{fmt(cx)}\" cy=\"#{fmt(cy)}\" rx=\"#{fmt(rx)}\" ry=\"#{fmt(ry)}\""
460
+ emit_shape_element("ellipse", geom)
461
+ end
462
+
463
+ def visit_round_rect(wire)
464
+ lt_x = cal_x(wire.rcl_box.left)
465
+ lt_y = cal_y(wire.rcl_box.top)
466
+ rb_x = cal_x(wire.rcl_box.right)
467
+ rb_y = cal_y(wire.rcl_box.bottom)
468
+ dim_x = rb_x - lt_x
469
+ dim_y = rb_y - lt_y
470
+ if @fix_y && dim_y.negative?
471
+ dim_y = -dim_y
472
+ lt_y -= dim_y
473
+ end
474
+ rx = scale_dim_x(wire.szl_corner.cx.to_i)
475
+ ry = scale_dim_x(wire.szl_corner.cy.to_i)
476
+ geom = "x=\"#{fmt(lt_x)}\" y=\"#{fmt(lt_y)}\" width=\"#{fmt(dim_x)}\" height=\"#{fmt(dim_y)}\" rx=\"#{fmt(rx)}\" ry=\"#{fmt(ry)}\""
477
+ emit_shape_element("rect", geom)
478
+ end
479
+
480
+ # TODO: arc/chord/pie arc reconstruction
481
+ def visit_arc(wire)
482
+ emit_arc(wire, mode: :arc)
483
+ end
484
+
485
+ def visit_arc_to(wire)
486
+ emit_arc(wire, mode: :arc)
487
+ end
488
+
489
+ def visit_chord(wire)
490
+ emit_arc(wire, mode: :chord)
491
+ end
492
+
493
+ def visit_pie(wire)
494
+ emit_arc(wire, mode: :pie)
495
+ end
496
+
497
+ def visit_polygon(wire)
498
+ emit_path_polygon(wire.aptl.to_a, closed: true)
499
+ end
500
+
501
+ def visit_polyline(wire)
502
+ emit_path_polygon(wire.aptl.to_a, closed: false)
503
+ end
504
+
505
+ def visit_polygon16(wire)
506
+ emit_path_polygon(wire.apts.to_a, closed: true)
507
+ end
508
+
509
+ def visit_polyline16(wire)
510
+ emit_path_polygon(wire.apts.to_a, closed: false)
511
+ end
512
+
513
+ # PolylineTo continues from current position — no M for first point,
514
+ # all L. Matches C's polyline_draw with ispolygon=false.
515
+ def visit_polyline_to(wire)
516
+ emit_polyline_to_common(wire.aptl.to_a)
517
+ end
518
+
519
+ def visit_polyline_to16(wire)
520
+ emit_polyline_to_common(wire.apts.to_a)
521
+ end
522
+
523
+ def emit_polyline_to_common(points)
524
+ return if points.empty?
525
+
526
+ if @in_path
527
+ points.each do |p|
528
+ @builder.raw("L ")
529
+ @builder.raw(point_str(p))
530
+ end
531
+ @current_x = points.last.x
532
+ @current_y = points.last.y
533
+ return
534
+ end
535
+
536
+ # Standalone PolylineTo (rare): emit a path starting at cur.
537
+ d = "M #{fmt(cal_x(@current_x.to_i))},#{fmt(cal_y(@current_y.to_i))} "
538
+ points.each do |p|
539
+ d << "L "
540
+ d << point_str(p)
541
+ end
542
+ emit_path_element(d, closed: false, leading_fill_space: true)
543
+ @current_x = points.last.x
544
+ @current_y = points.last.y
545
+ end
546
+
547
+ def visit_poly_bezier(wire)
548
+ emit_poly_bezier_common(wire.aptl.to_a, starting_point: true)
549
+ end
550
+
551
+ def visit_poly_bezier16(wire)
552
+ emit_poly_bezier_common(wire.apts.to_a, starting_point: true)
553
+ end
554
+
555
+ # PolyBezierTo continues from current position — no starting M.
556
+ def visit_poly_bezier_to(wire)
557
+ emit_poly_bezier_common(wire.aptl.to_a, starting_point: false)
558
+ end
559
+
560
+ def visit_poly_bezier_to16(wire)
561
+ emit_poly_bezier_common(wire.apts.to_a, starting_point: false)
562
+ end
563
+
564
+ # Matches cubic_bezier_draw / cubic_bezier16_draw.
565
+ # startPathDraw emits `M cur_x,cur_y ` (from the device context's
566
+ # current position). If starting_point is true (PolyBezier, not To),
567
+ # an additional `M point[0]` is emitted before the C commands.
568
+ #
569
+ # libemf2svg iterates over every point starting at `startingPoint`
570
+ # and emits a "C " prefix each time (i mod 3) == ctrl1; the next two
571
+ # points are appended without re-emitting "C ". This works even with
572
+ # fewer than 4 points (a single bezier curve) — there's no minimum.
573
+ def emit_poly_bezier_common(points, starting_point:)
574
+ return if points.empty?
575
+
576
+ if @in_path
577
+ if starting_point && points.first
578
+ @builder.raw("M ")
579
+ @builder.raw(point_str(points.first))
580
+ end
581
+ emit_bezier_points(points, starting_point: starting_point)
582
+ if points.last
583
+ @current_x = points.last.x
584
+ @current_y = points.last.y
585
+ end
586
+ return
587
+ end
588
+
589
+ d = "M #{fmt(cal_x(@current_x.to_i))},#{fmt(cal_y(@current_y.to_i))} "
590
+ if starting_point && points.first
591
+ d << "M "
592
+ d << point_str(points.first)
593
+ end
594
+ d << bezier_points_d(points, starting_point: starting_point)
595
+ emit_path_element(d, closed: false, leading_fill_space: true)
596
+ return unless points.last
597
+
598
+ @current_x = points.last.x
599
+ @current_y = points.last.y
600
+ end
601
+
602
+ # Emit "C p1 p2 p3 C p4 p5 p6 ..." matching cubic_bezier*_draw's
603
+ # loop. Each iteration emits "C " then the next 3 points; trailing
604
+ # incomplete triples are silently dropped (matches C, which only
605
+ # emits "C " on the ctrl1 index and ignores dangling points).
606
+ def emit_bezier_points(points, starting_point:)
607
+ ctrls = starting_point ? points[1..] : points
608
+ ctrls.each_slice(3) do |c1, c2, endpt|
609
+ next if [c1, c2, endpt].any?(&:nil?)
610
+
611
+ @builder.raw("C ")
612
+ @builder.raw(point_str(c1))
613
+ @builder.raw(point_str(c2))
614
+ @builder.raw(point_str(endpt))
615
+ end
616
+ end
617
+
618
+ def bezier_points_d(points, starting_point:)
619
+ ctrls = starting_point ? points[1..] : points
620
+ d = +""
621
+ ctrls.each_slice(3) do |c1, c2, endpt|
622
+ next if [c1, c2, endpt].any?(&:nil?)
623
+
624
+ d << "C "
625
+ d << point_str(c1)
626
+ d << point_str(c2)
627
+ d << point_str(endpt)
628
+ end
629
+ d
630
+ end
631
+
632
+ def visit_begin_path(_wire)
633
+ if @in_path
634
+ @pending_path_action = @path_actions[@current_record_idx] || :end
635
+ return
636
+ end
637
+
638
+ @current_path_begin_idx = @current_record_idx
639
+ # Apply deferred wtBefore transform (from Modify/SetWorldTransform
640
+ # records that occurred after drawing inside this path). Mirrors
641
+ # libemf2svg's U_EMRBEGINPATH_draw which calls transform_set +
642
+ # transform_draw if pathStack.wtBeforeSet is true.
643
+ apply_path_transform(:wt_before)
644
+ @path_start_pos = @builder.to_s.bytesize
645
+ @builder.raw("<path d=\"")
646
+ @last_path_d = +""
647
+ @in_path = true
648
+ @pending_path_action = @path_actions[@current_record_idx] || :end
649
+ end
650
+
651
+ def visit_end_path(_wire)
652
+ @in_path = false
653
+ finish_pending_path(@pending_path_action)
654
+ # Apply deferred wtAfter transform (from transforms that occurred
655
+ # before drawing inside this path). Mirrors libemf2svg's
656
+ # U_EMRENDPATH_draw which calls transform_set + transform_draw
657
+ # after emitting the path element if pathStack.wtAfterSet is true.
658
+ apply_path_transform(:wt_after)
659
+ @current_path_begin_idx = nil
660
+ end
661
+
662
+ def visit_fill_path(_wire)
663
+ # FillPath after EndPath: in C this is a no-op (work done at EndPath).
664
+ # If we never saw EndPath (FillPath directly closes path), close now.
665
+ return unless @in_path
666
+
667
+ @in_path = false
668
+ finish_pending_path(:fill)
669
+ end
670
+
671
+ def visit_stroke_path(_wire)
672
+ return unless @in_path
673
+
674
+ @in_path = false
675
+ finish_pending_path(:stroke)
676
+ end
677
+
678
+ def visit_stroke_and_fill_path(_wire)
679
+ return unless @in_path
680
+
681
+ @in_path = false
682
+ finish_pending_path(:stroke_fill)
683
+ end
684
+
685
+ def visit_abort_path(_wire)
686
+ @in_path = false
687
+ @pending_path_action = :abort
688
+ end
689
+
690
+ # Emit the closing quote + stroke/fill attrs for a path opened by
691
+ # visit_begin_path. Mode is one of: :end, :fill, :stroke, :stroke_fill.
692
+ def finish_pending_path(mode)
693
+ @last_fill_set = false
694
+ @last_stroke_set = false
695
+ # Capture the d-string content for SelectClipPath.
696
+ # Everything between `<path d="` (at @path_start_pos + 9) and the
697
+ # closing `"` that finish_pending_path is about to emit.
698
+ if @path_start_pos
699
+ d_start = @path_start_pos + 9 # length of `<path d="`
700
+ @last_path_d = @builder.to_s[d_start...].force_encoding("ASCII-8BIT")
701
+ end
702
+ @builder.raw("\" ")
703
+ case mode
704
+ when :fill
705
+ emit_fill_draw
706
+ when :stroke
707
+ emit_stroke_draw
708
+ when :stroke_fill
709
+ emit_fill_draw
710
+ emit_stroke_draw
711
+ end
712
+ @builder.raw("fill=\"none\" ") unless @last_fill_set
713
+ @builder.raw("stroke=\"none\" ") unless @last_stroke_set
714
+ @builder.rawln("/>")
715
+ end
716
+
717
+ def visit_close_figure(_wire)
718
+ @builder.raw("Z ") if @in_path
719
+ end
720
+
721
+ def visit_set_world_transform(wire)
722
+ @dc.world_transform = Emf::Model::Geometry::Matrix.from_wire(wire.xform)
723
+ emit_transform_group(@dc.world_transform)
724
+ end
725
+
726
+ def visit_modify_world_transform(wire)
727
+ xform = Emf::Model::Geometry::Matrix.from_wire(wire.xform)
728
+ @dc.world_transform = case wire.modify_mode
729
+ when 1 # MWT_IDENTITY
730
+ Emf::Model::Geometry::Matrix.identity
731
+ when 2 # MWT_LEFT_MULTIPLY
732
+ multiply_matrix(xform, @dc.world_transform)
733
+ when 3 # MWT_RIGHT_MULTIPLY
734
+ multiply_matrix(@dc.world_transform, xform)
735
+ else
736
+ xform
737
+ end
738
+ emit_transform_group(@dc.world_transform)
739
+ end
740
+
741
+ # Apply a deferred path transform (wtBefore at BeginPath, wtAfter at
742
+ # EndPath). Mirrors libemf2svg's pathStack.wtBefore/wtAfter handling
743
+ # which calls transform_set followed by transform_draw.
744
+ def apply_path_transform(key)
745
+ entry = @path_transforms[@current_path_begin_idx]
746
+ return unless entry
747
+
748
+ pt = entry[key]
749
+ return unless pt
750
+
751
+ if pt.mode.nil? || pt.mode.zero?
752
+ @dc.world_transform = pt.matrix
753
+ else
754
+ @dc.world_transform = case pt.mode
755
+ when 1 then Emf::Model::Geometry::Matrix.identity
756
+ when 2 then multiply_matrix(pt.matrix, @dc.world_transform)
757
+ when 3 then multiply_matrix(@dc.world_transform, pt.matrix)
758
+ else pt.matrix
759
+ end
760
+ end
761
+ emit_transform_group(@dc.world_transform)
762
+ end
763
+
764
+ def visit_set_miter_limit(wire)
765
+ # MS-EMF spec: iMiterLimit is a uint32 (miter length ratio). The emf
766
+ # wire parses it as IEEE float, so convert bits back to uint32.
767
+ @dc.miter_limit = [wire.miter_limit.to_f].pack("f").unpack1("V")
768
+ end
769
+
770
+ def visit_comment(_wire); end
771
+
772
+ def visit_set_pixel_v(_wire)
773
+ # FLAG_IGNORED in libemf2svg — emits nothing.
774
+ end
775
+
776
+ def visit_create_font_indirect_w(wire)
777
+ body = wire.body.to_s
778
+ font = parse_logfont(body, utf16: true)
779
+ @object_table.store(wire.ih_object, :font, font)
780
+ end
781
+
782
+ # Parse a LOGFONT struct from body bytes (MS-WMF 2.2.13).
783
+ # Layout:
784
+ # 0 int32 lfHeight
785
+ # 4 int32 lfWidth
786
+ # 8 int32 lfEscapement (tenths of a degree)
787
+ # 12 int32 lfOrientation
788
+ # 16 int32 lfWeight
789
+ # 20 byte lfItalic
790
+ # 21 byte lfUnderline
791
+ # 22 byte lfStrikeOut
792
+ # ... (charset etc ignored)
793
+ # 28 32 UTF-16 chars lfFaceName (64 bytes)
794
+ def parse_logfont(body, utf16:)
795
+ return Font.default if body.nil? || body.bytesize < 28
796
+
797
+ height = body.unpack1("l<")
798
+ _width = body.unpack("l<", offset: 4)[0]
799
+ # libemf2svg reduces lfEscapement modulo 3600 (so 3600 → 0, no rotation).
800
+ escapement = body.unpack("l<", offset: 8)[0] % 3600
801
+ weight = body.unpack("l<", offset: 16)[0]
802
+ italic = body.getbyte(20) != 0
803
+ underline = body.getbyte(21) != 0
804
+ strikeout = body.getbyte(22) != 0
805
+ charset = body.getbyte(23)
806
+ face_name = parse_logfont_face_name(body, utf16: utf16)
807
+ Font.new(face_name: face_name, height: height, weight: weight,
808
+ italic: italic, underline: underline, strikeout: strikeout,
809
+ escapement: escapement, charset: charset)
810
+ rescue StandardError
811
+ Font.default
812
+ end
813
+
814
+ def visit_ext_text_out_w(wire)
815
+ emit_ext_text(wire, utf16: true)
816
+ end
817
+
818
+ def visit_ext_text_out_a(wire)
819
+ emit_ext_text(wire, utf16: false)
820
+ end
821
+
822
+ # EMRSMALLTEXTOUT: simpler than EXTTEXTOUT — fixed-position Dest,
823
+ # no DX array, text follows optional RECTL. Mirrors C's
824
+ # U_EMRSMALLTEXTOUT_draw.
825
+ def visit_small_text_out(wire)
826
+ body = wire.body.to_s
827
+ fu = wire.fu_options.to_i
828
+ # Per MS-EMF 2.3.5.37: ETO_NO_RECT (0x100) UNSET means a U_RECTL
829
+ # (16 bytes) precedes the text. ETO_SMALL_CHARS (0x200) selects
830
+ # 8-bit per char instead of 16-bit.
831
+ text_offset = (fu & 0x100).zero? ? 16 : 0
832
+ small_chars = (fu & 0x200).positive? # ETO_SMALL_CHARS = 0x200
833
+ c_chars = wire.c_chars.to_i
834
+ bytes_per_char = small_chars ? 1 : 2
835
+ text_bytes = body[text_offset, c_chars * bytes_per_char]
836
+ text = decode_text_chars(text_bytes, small_chars: small_chars)
837
+ x = cal_x(wire.x_dest)
838
+ y = cal_y(wire.y_dest)
839
+ font = @dc.font
840
+ font_height = scale_dim_x(font.height.to_i).abs
841
+ @builder.raw("<text ")
842
+ @builder.raw(clip_path_attr_string)
843
+ emit_text_style(font, font_height, x, y)
844
+ @builder.raw(">")
845
+ @builder.raw("<![CDATA[#{text}]]>")
846
+ @builder.rawln("</text>")
847
+ end
848
+
849
+ def decode_text_chars(bytes, small_chars:)
850
+ return "" if bytes.nil? || bytes.empty?
851
+
852
+ if small_chars
853
+ bytes.force_encoding("CP1252").encode("UTF-8", undef: :replace, replace: "?")
854
+ else
855
+ bytes.force_encoding("UTF-16LE").encode("UTF-8", undef: :replace, replace: "?")
856
+ end
857
+ end
858
+
859
+ # Decode glyph-index text via fontconfig + fontisan reverse cmap.
860
+ # Mirrors libemf2svg's fontindex_to_utf8: find font file, build
861
+ # reverse cmap, map each glyph ID to Unicode, reverse for RTL.
862
+ def decode_glyph_indices(string_bytes, n_chars)
863
+ glyph_ids = string_bytes.unpack("v*").first(n_chars)
864
+ font = @dc.font
865
+ result = @glyph_mapper.map(
866
+ glyph_ids,
867
+ font_family: font.face_name,
868
+ weight: font.weight,
869
+ italic: font.italic,
870
+ charset: font.charset
871
+ )
872
+ # libemf2svg's fontindex_to_utf8 returns NULL when the font
873
+ # can't be found, producing empty CDATA. We must NOT fall back
874
+ # to UTF-16 decoding — that would produce wrong characters.
875
+ result || ""
876
+ end
877
+
878
+ def visit_intersect_clip_rect(wire)
879
+ # libemf2svg's clip_rgn_mix ignores the mode and REPLACES the clip
880
+ # region with the new rect. We replicate that.
881
+ @dc.clip_region = ClipRegion.new(bounds: Emf::Model::Geometry::Rect.from_wire(wire.rcl_clip))
882
+ emit_clip_def(@dc.clip_region)
883
+ end
884
+
885
+ def visit_exclude_clip_rect(wire)
886
+ @dc.clip_region = ClipRegion.new(bounds: Emf::Model::Geometry::Rect.from_wire(wire.rcl_clip))
887
+ emit_clip_def(@dc.clip_region)
888
+ end
889
+
890
+ # --- State-only handlers (no SVG output) ---
891
+
892
+ def visit_set_brush_org_ex(wire)
893
+ @dc.brush_origin = [wire.ptl_origin.x, wire.ptl_origin.y]
894
+ end
895
+
896
+ def visit_set_metargn(_wire); end
897
+
898
+ # libemf2svg's U_EMROFFSETCLIPRGN_draw: offsets the current clipRGN
899
+ # by ptlOffset, then calls clip_rgn_draw which generates a NEW
900
+ # clip ID and emits a NEW clipPath def. Without this, the BsdRand
901
+ # sequence diverges from C, causing all subsequent clip IDs to be
902
+ # wrong.
903
+ def visit_offset_clip_rgn(wire)
904
+ return unless @dc.clip_region
905
+
906
+ dx = wire.ptl_offset.x
907
+ dy = wire.ptl_offset.y
908
+ region = @dc.clip_region
909
+ if region.bounds
910
+ b = region.bounds
911
+ @dc.clip_region = ClipRegion.new(
912
+ bounds: Emf::Model::Geometry::Rect.new(
913
+ left: b.left + dx, top: b.top + dy,
914
+ right: b.right + dx, bottom: b.bottom + dy
915
+ )
916
+ )
917
+ elsif region.path_d
918
+ @dc.clip_region = ClipRegion.new(path_d: region.path_d)
919
+ end
920
+ emit_clip_def(@dc.clip_region)
921
+ end
922
+
923
+ def visit_set_icm_mode(wire)
924
+ @dc.icm_mode = wire.mode
925
+ end
926
+
927
+ def visit_set_layout(wire)
928
+ @dc.layout = wire.mode
929
+ end
930
+
931
+ def visit_scale_viewport_ext_ex(_wire); end
932
+
933
+ def visit_scale_window_ext_ex(_wire); end
934
+
935
+ def visit_set_mapper_flags(wire)
936
+ @dc.mapper_flags = wire.flags
937
+ end
938
+
939
+ def visit_set_arc_direction(wire)
940
+ # C maps: U_AD_CLOCKWISE (2) → arcdir=1, U_AD_COUNTERCLOCKWISE (1) → arcdir=-1
941
+ case wire.arc_direction
942
+ when 2 then @dc.arc_direction = 1 # AD_CLOCKWISE
943
+ when 1 then @dc.arc_direction = -1 # AD_COUNTERCLOCKWISE
944
+ end
945
+ end
946
+
947
+ def visit_realize_palette(_wire); end
948
+
949
+ def visit_select_palette(_wire); end
950
+
951
+ def visit_create_palette(_wire); end
952
+
953
+ def visit_resize_palette(_wire); end
954
+
955
+ def visit_set_palette_entries(_wire); end
956
+
957
+ # --- Drawing handlers ---
958
+
959
+ def visit_poly_polygon(wire)
960
+ emit_poly_multi(wire.a_poly_counts.to_a, wire.aptl.to_a, closed: true, _point_size: 8)
961
+ end
962
+
963
+ def visit_poly_polygon16(wire)
964
+ emit_poly_multi(wire.a_poly_counts.to_a, wire.apts.to_a, closed: true, _point_size: 4)
965
+ end
966
+
967
+ def visit_poly_polyline(wire)
968
+ emit_poly_multi(wire.a_poly_counts.to_a, wire.aptl.to_a, closed: false, _point_size: 8)
969
+ end
970
+
971
+ def visit_poly_polyline16(wire)
972
+ emit_poly_multi(wire.a_poly_counts.to_a, wire.apts.to_a, closed: false, _point_size: 4)
973
+ end
974
+
975
+ def visit_poly_draw(wire)
976
+ emit_poly_draw(wire.aptl.to_a, wire.ab_types.to_a, _point_size: 8)
977
+ end
978
+
979
+ def visit_poly_draw16(wire)
980
+ emit_poly_draw(wire.apts.to_a, wire.ab_types.to_a, _point_size: 4)
981
+ end
982
+
983
+ def visit_angle_arc(wire)
984
+ # Mirror libemf2svg's arc_circle_draw:
985
+ # startPathDraw emits `<path d="M cur_x,cur_y `
986
+ # then `M start_x,start_y A radii 0 large_arc sweep end_x,end_y `
987
+ # then endPathDraw emits closing quote + stroke + ` fill="none" />`
988
+ cx_raw = wire.ptl_center.x
989
+ cy_raw = wire.ptl_center.y
990
+ r_raw = wire.n_radius
991
+ start_angle = wire.start_angle
992
+ sweep_angle = wire.sweep_angle
993
+
994
+ # arcdir: 1 = CLOCKWISE (sweep=large=1), -1 = CCW (sweep=large=0).
995
+ # Default (0) is treated as not-positive, so sweep=large=0.
996
+ arcdir = @dc.arc_direction || 0
997
+ sweep_flag = arcdir.positive? ? 1 : 0
998
+ large_arc_flag = arcdir.positive? ? 1 : 0
999
+
1000
+ start_rad = start_angle * Math::PI / 180.0
1001
+ end_rad = (start_angle + sweep_angle) * Math::PI / 180.0
1002
+ sx_raw = (r_raw * Math.cos(start_rad)) + cx_raw
1003
+ sy_raw = (r_raw * Math.sin(start_rad)) + cy_raw
1004
+ ex_raw = (r_raw * Math.cos(end_rad)) + cx_raw
1005
+ ey_raw = (r_raw * Math.sin(end_rad)) + cy_raw
1006
+
1007
+ d = +"M "
1008
+ d << fmt_xy(cal_x(@current_x.to_i), cal_y(@current_y.to_i))
1009
+ d << "M "
1010
+ d << fmt_xy(cal_x(sx_raw), cal_y(sy_raw))
1011
+ d << "A "
1012
+ d << fmt_xy(cal_x(r_raw), cal_y(r_raw))
1013
+ d << "0 "
1014
+ d << "#{large_arc_flag} #{sweep_flag} "
1015
+ d << fmt_xy(cal_x(ex_raw), cal_y(ey_raw))
1016
+ emit_path_element(d, closed: false, leading_fill_space: true)
1017
+ end
1018
+
1019
+ # --- Escape and color-management no-ops ---
1020
+
1021
+ def visit_noop(_wire); end
1022
+ def visit_draw_escape(_wire); end
1023
+ def visit_ext_escape(_wire); end
1024
+ def visit_named_escape(_wire); end
1025
+ def visit_set_linked_ufis(_wire); end
1026
+ def visit_force_ufi_mapping(_wire); end
1027
+ def visit_color_correct_palette(_wire); end
1028
+ def visit_set_icm_profile_a(_wire); end
1029
+ def visit_set_icm_profile_w(_wire); end
1030
+ def visit_color_match_to_target_w(_wire); end
1031
+ def visit_create_colorspace(_wire); end
1032
+ def visit_create_colorspace_w(_wire); end
1033
+ def visit_set_colorspace(_wire); end
1034
+ def visit_delete_colorspace(_wire); end
1035
+ def visit_gls_record(_wire); end
1036
+ def visit_gls_bounded_record(_wire); end
1037
+ def visit_pixel_format(_wire); end
1038
+ def visit_poly_text_out_a(_wire); end
1039
+ def visit_poly_text_out_w(_wire); end
1040
+ def visit_set_text_justification(_wire); end
1041
+
1042
+ def visit_create_mono_brush(wire)
1043
+ register_pattern_brush(wire)
1044
+ end
1045
+
1046
+ def visit_create_dib_pattern_brush_pt(wire)
1047
+ register_pattern_brush(wire)
1048
+ end
1049
+
1050
+ # CreateMonoBrush / CreateDibPatternBrushPt: extract the embedded DIB,
1051
+ # emit a <defs><image id="img-N">…<pattern id="img-N-ref">…</defs>
1052
+ # block (matching U_EMRCREATEMONOBRUSH_draw + image_library_writer),
1053
+ # then store a MONOPATTERN brush in the object table so later fills
1054
+ # reference url(#img-N-ref).
1055
+ def register_pattern_brush(wire)
1056
+ # Wire types calling this (CreateMonoBrush, CreateDibPatternBrushPt)
1057
+ # always have off_bmi and off_bits — declared in their bindata
1058
+ # layouts. No need for respond_to? duck-typing.
1059
+
1060
+ # The BMI and bitmap data are located at absolute offsets within
1061
+ # the EMR record. The wire's `body` field starts after the emr
1062
+ # header (8 bytes) + 6 uint32 fixed fields (ih_brush, i_usage,
1063
+ # off_bmi, cb_bmi, off_bits, cb_bits = 24 bytes) = offset 32.
1064
+ # Convert record-relative offsets to body-relative indices.
1065
+ body = wire.body.to_s
1066
+ body_bytes = body.bytes
1067
+
1068
+ bmi_start = wire.off_bmi.to_i - 32
1069
+ cb_bmi = wire.cb_bmi.to_i
1070
+ bits_start = wire.off_bits.to_i - 32
1071
+ cb_bits = wire.cb_bits.to_i
1072
+
1073
+ return if bmi_start.negative? || bits_start.negative?
1074
+ return if bmi_start + cb_bmi > body_bytes.size
1075
+ return if bits_start + cb_bits > body_bytes.size
1076
+
1077
+ bmi_bytes = body_bytes[bmi_start, cb_bmi].pack("C*")
1078
+ bits_bytes = body_bytes[bits_start, cb_bits].pack("C*")
1079
+
1080
+ # libemf2svg's image_library_find uses memcmp(BmiSrc, lib->content,
1081
+ # cbBits). Since BmiSrc points at the BMI header (cbBmi bytes) and
1082
+ # cbBits is the bitmap data size, the comparison actually reads
1083
+ # cbBits bytes starting at the BMI — i.e. it spans BMI + the first
1084
+ # (cbBits - cbBmi) bytes of bitmap data (or all of bitmap if
1085
+ # cbBits < cbBmi, which doesn't happen in practice). Replicate that
1086
+ # exact key so identical DIBs dedupe to the same image_id.
1087
+ dedup_key = body_bytes[bmi_start, cb_bits].pack("C*")
1088
+
1089
+ # Deduplicate by raw bitmap bytes (image_library_find key).
1090
+ image_id = @image_library[dedup_key]
1091
+ if image_id.nil?
1092
+ image_id = @next_image_id
1093
+ @next_image_id += 1
1094
+ @image_library[dedup_key] = image_id
1095
+ emit_pattern_defs(image_id, bmi_bytes, bits_bytes)
1096
+ end
1097
+
1098
+ brush = Brush.new(style: Brush::MONOPATTERN,
1099
+ color: @dc.brush.color,
1100
+ image_id: image_id)
1101
+ @object_table.store(wire.ih_brush, :brush, brush)
1102
+ end
1103
+
1104
+ # Emit <defs><image id="img-N" .../><pattern id="img-N-ref"
1105
+ # ...>...</pattern></defs> exactly as libemf2svg's
1106
+ # image_library_writer does.
1107
+ def emit_pattern_defs(image_id, bmi_bytes, bits_bytes)
1108
+ # For 1-bpp MONOCHROME brushes libemf2svg's dib_img_writer is called
1109
+ # with assign_mono_colors_from_dc=true, which overrides palette[0]
1110
+ # with text_color and palette[1] with bk_color (alpha forced 0xff).
1111
+ # Decode with that override so PNG bytes match.
1112
+ dib = if bmi_bytes.getbyte(14) == 1 # bit_count == 1
1113
+ text = @dc.text_color
1114
+ bk = @dc.bk_color
1115
+ DibDecoder.decode(bmi_bytes + bits_bytes,
1116
+ mono_palette: [[text.red, text.green, text.blue, 255],
1117
+ [bk.red, bk.green, bk.blue, 255]])
1118
+ else
1119
+ DibDecoder.decode(bmi_bytes + bits_bytes)
1120
+ end
1121
+ return unless dib
1122
+
1123
+ png_bytes = PngEncoder.encode(dib.width, dib.height, dib.pixels)
1124
+ b64 = [png_bytes].pack("m0")
1125
+ ns = ""
1126
+ @builder.raw("<#{ns}defs><#{ns}image id=\"img-#{image_id}\" x=\"0\" y=\"0\" ")
1127
+ @builder.raw("width=\"#{dib.width}\" height=\"#{dib.height}\" ")
1128
+ @builder.raw("xlink:href=\"data:image/png;base64,#{b64}\" ")
1129
+ @builder.raw(" preserveAspectRatio=\"none\" />")
1130
+ @builder.raw("<#{ns}pattern id=\"img-#{image_id}-ref\" x=\"0\" y=\"0\" ")
1131
+ @builder.raw("width=\"#{dib.width}\" height=\"#{dib.height}\" ")
1132
+ @builder.raw("patternUnits=\"userSpaceOnUse\" >\n")
1133
+ @builder.raw("<#{ns}use id=\"img-#{image_id}-ign\" xlink:href=\"#img-#{image_id}\" />")
1134
+ @builder.raw("</#{ns}pattern></#{ns}defs>\n")
1135
+ end
1136
+
1137
+ # --- Region operations (TODO 08) ---
1138
+
1139
+ # libemf2svg marks FillRgn/FrameRgn/InvertRgn/PaintRgn as FLAG_IGNORED —
1140
+ # they emit nothing. Match that exactly so byte output lines up.
1141
+ def visit_fill_rgn(_wire); end
1142
+
1143
+ def visit_frame_rgn(_wire); end
1144
+
1145
+ def visit_invert_rgn(_wire); end
1146
+
1147
+ def visit_paint_rgn(_wire); end
1148
+
1149
+ def visit_ext_create_pen(wire)
1150
+ # EMREXTCREATEPEN body layout (after EMR + ihPen) per MS-EMF 2.3.7.9:
1151
+ # offBmi (4), cbBmi (4), offBits (4), cbBits (4),
1152
+ # then EXTLOGPEN32:
1153
+ # elpPenStyle (4), elpWidth (4), elpBrushStyle (4),
1154
+ # elpColor (COLORREF: R,G,B,reserved — 4 bytes),
1155
+ # elpHatch (4), elpNumEntries (4), elpStyleEntry[]
1156
+ body = wire.body.to_s
1157
+ if body.bytesize >= 32
1158
+ pen_style = body.unpack1("V", offset: 16)
1159
+ width = body.unpack1("V", offset: 20)
1160
+ # COLORREF at body offset 28: low byte is R, then G, then B
1161
+ r = body.getbyte(28)
1162
+ g = body.getbyte(29)
1163
+ b = body.getbyte(30)
1164
+ pen = Pen.new(
1165
+ style: pen_style,
1166
+ width: width,
1167
+ color: Emf::Model::Geometry::Color.new(red: r, green: g, blue: b)
1168
+ )
1169
+ @object_table.store(wire.ih_pen, :pen, pen)
1170
+ else
1171
+ @object_table.store(wire.ih_pen, :pen, Pen.new)
1172
+ end
1173
+ end
1174
+
1175
+ # --- Bitmap handlers ---
1176
+
1177
+ def visit_stretch_dibits(wire)
1178
+ record_bytes = wire.to_binary_s
1179
+ emit_image_from_dib(
1180
+ record_bytes, wire.off_bmi_src, wire.cb_bmi_src,
1181
+ wire.off_bits_src, wire.cb_bits_src,
1182
+ x: wire.x_dest, y: wire.y_dest,
1183
+ width: wire.cx_dest, height: wire.cy_dest
1184
+ )
1185
+ end
1186
+
1187
+ def visit_alpha_blend(wire)
1188
+ # AlphaBlend has a similar layout to StretchDIBits but with
1189
+ # a fixed alpha (bfBlendOp) and explicit source/dest dimensions.
1190
+ # The trailing body contains the BITMAPINFO + pixels.
1191
+ # For MVP: emit an image element using bounds-derived position.
1192
+ emit_image_from_body(
1193
+ wire, wire.rcl_bounds.left, wire.rcl_bounds.top,
1194
+ wire.rcl_bounds.right - wire.rcl_bounds.left,
1195
+ wire.rcl_bounds.bottom - wire.rcl_bounds.top
1196
+ )
1197
+ end
1198
+
1199
+ def visit_transparent_blt(wire)
1200
+ emit_image_from_body(
1201
+ wire, wire.rcl_bounds.left, wire.rcl_bounds.top,
1202
+ wire.rcl_bounds.right - wire.rcl_bounds.left,
1203
+ wire.rcl_bounds.bottom - wire.rcl_bounds.top
1204
+ )
1205
+ end
1206
+
1207
+ def visit_bit_blt(wire)
1208
+ # BitBlt with no source bitmap (cb_bits_src == 0) is used as a
1209
+ # "fill rectangle with current brush" operation. libemf2svg emits
1210
+ # this as <path style="fill:..." d="M x,y L x+w,y L x+w,y+h L x,y+h Z" />
1211
+ # when fill_mode is BS_SOLID or BS_MONOPATTERN. For other fill modes
1212
+ # (or unsupported raster ops), it emits nothing.
1213
+ if wire.cb_bits_src.to_i.zero?
1214
+ emit_bitblt_solid_fill(wire)
1215
+ return
1216
+ end
1217
+
1218
+ # Bitmap present: emit <image> using Dest/cDest (NOT rclBounds) per
1219
+ # libemf2svg's U_EMRBITBLT_draw which calls point_cal on Dest and
1220
+ # cDest, then reads BMI/bits via offBmiSrc/offBitsSrc.
1221
+ emit_image_from_offsets(wire, position_via_point_cal: true)
1222
+ end
1223
+
1224
+ def visit_stretch_blt(wire)
1225
+ if wire.cb_bits_src.to_i.positive?
1226
+ emit_image_from_offsets(wire, position_via_point_cal: true)
1227
+ return
1228
+ end
1229
+
1230
+ emit_image_from_body(
1231
+ wire, wire.rcl_bounds.left, wire.rcl_bounds.top,
1232
+ wire.rcl_bounds.right - wire.rcl_bounds.left,
1233
+ wire.rcl_bounds.bottom - wire.rcl_bounds.top
1234
+ )
1235
+ end
1236
+
1237
+ # Emit <image> for BitBlt/StretchBlt records that have explicit
1238
+ # offBmiSrc/cbBmiSrc/offBitsSrc/cbBitsSrc fields. Mirrors
1239
+ # libemf2svg's U_EMRSTRETCHBLT_draw: locate BMI/bitmap via the
1240
+ # offsets, decode, and emit as PNG data URI.
1241
+ #
1242
+ # libemf2svg uses point_cal for both position AND size (not just
1243
+ # position). For StretchBlt this matters because the size in
1244
+ # logical units gets transformed by the world matrix; for BitBlt
1245
+ # the matrix is usually identity so it doesn't matter.
1246
+ def emit_image_from_offsets(wire, position_via_point_cal: false)
1247
+ record_bytes = wire.to_binary_s
1248
+ rec_size = record_bytes.bytesize
1249
+ off_bmi = wire.off_bmi_src.to_i
1250
+ cb_bmi = wire.cb_bmi_src.to_i
1251
+ off_bits = wire.off_bits_src.to_i
1252
+ cb_bits = wire.cb_bits_src.to_i
1253
+ return if off_bmi.zero? || off_bits.zero?
1254
+ return if off_bmi + cb_bmi > rec_size
1255
+ return if off_bits + cb_bits > rec_size
1256
+
1257
+ bmi = record_bytes[off_bmi, cb_bmi]
1258
+ bits = record_bytes[off_bits, cb_bits]
1259
+ result = DibDecoder.decode(bmi + bits, bits_offset: cb_bmi)
1260
+ return unless result
1261
+
1262
+ if position_via_point_cal
1263
+ # StretchBlt path: emit width/height/x/y in C's exact order
1264
+ # using point_cal for both size and position.
1265
+ size_x = cal_x(wire.cx_dest.to_i)
1266
+ size_y = cal_y(wire.cy_dest.to_i)
1267
+ pos_x = cal_x(wire.x_dest.to_i)
1268
+ pos_y = cal_y(wire.y_dest.to_i)
1269
+ @builder.raw("<image width=\"#{fmt(size_x)}\" height=\"#{fmt(size_y)}\" ")
1270
+ @builder.raw("x=\"#{fmt(pos_x)}\" y=\"#{fmt(pos_y)}\" ")
1271
+ @builder.raw(clip_path_attr_string)
1272
+ @builder.raw("xlink:href=\"")
1273
+ @builder.raw(build_image_href(result))
1274
+ @builder.raw("\" />\n")
1275
+ else
1276
+ x = cal_x(wire.x_dest.to_i)
1277
+ y = maybe_flip_y(cal_y(wire.y_dest.to_i))
1278
+ w = scale_dim_x(wire.cx_dest.to_i)
1279
+ h = scale_dim_y(wire.cy_dest.to_i)
1280
+ attrs = {
1281
+ "x" => x, "y" => y, "width" => w, "height" => h,
1282
+ "xlink:href" => build_image_href(result), "preserveAspectRatio" => "none"
1283
+ }
1284
+ @builder.tag("image", attrs, single: true)
1285
+ end
1286
+ end
1287
+
1288
+ # --- Clip region handlers (TODO 06) ---
1289
+
1290
+ def visit_ext_select_clip_rgn(wire)
1291
+ # RGN_COPY (5) with no region data resets the clip in libemf2svg.
1292
+ if wire.dw_mode == 5 || wire.dw_mode.zero?
1293
+ @dc.clip_region = nil
1294
+ @dc.clip_id = nil
1295
+ return
1296
+ end
1297
+
1298
+ bounds = parse_rgn_data_bounds(wire.rgn_data.to_s)
1299
+ new_region = bounds ? ClipRegion.new(bounds: bounds) : ClipRegion.new
1300
+ @dc.clip_region = new_region
1301
+ emit_clip_def(@dc.clip_region)
1302
+ end
1303
+
1304
+ def visit_select_clip_path(_wire)
1305
+ return unless @last_path_d && !@last_path_d.empty?
1306
+
1307
+ @dc.clip_region = ClipRegion.new(path_d: @last_path_d.dup)
1308
+ emit_clip_def(@dc.clip_region)
1309
+ end
1310
+
1311
+ # libemf2svg marks U_EMRGRADIENTFILL_draw as FLAG_IGNORED — emits nothing.
1312
+ def visit_gradient_fill(_wire); end
1313
+
1314
+ private
1315
+
1316
+ def parse_rgn_data_bounds(rgn_data)
1317
+ return nil if rgn_data.nil? || rgn_data.bytesize < 32
1318
+
1319
+ # RGNDATAHEADER.rclBounds at offset 16
1320
+ left = rgn_data.getbyte(16) | (rgn_data.getbyte(17) << 8) |
1321
+ (rgn_data.getbyte(18) << 16) | (rgn_data.getbyte(19) << 24)
1322
+ top = rgn_data.getbyte(20) | (rgn_data.getbyte(21) << 8) |
1323
+ (rgn_data.getbyte(22) << 16) | (rgn_data.getbyte(23) << 24)
1324
+ right = rgn_data.getbyte(24) | (rgn_data.getbyte(25) << 8) |
1325
+ (rgn_data.getbyte(26) << 16) | (rgn_data.getbyte(27) << 24)
1326
+ bottom = rgn_data.getbyte(28) | (rgn_data.getbyte(29) << 8) |
1327
+ (rgn_data.getbyte(30) << 16) | (rgn_data.getbyte(31) << 24)
1328
+ Emf::Model::Geometry::Rect.new(left: left, top: top, right: right, bottom: bottom)
1329
+ end
1330
+
1331
+ # Region ops (FillRgn etc.) carry ih_brush then cbRgnData then
1332
+ # RGNDATA. Extract bounds from the body.
1333
+ def parse_rgn_body_bounds(body)
1334
+ return nil if body.nil? || body.bytesize < 36
1335
+
1336
+ # body layout: cbRgnData (4) + RGNDATAHEADER (32 min)
1337
+ # rclBounds is at RGNDATAHEADER offset 16 = body offset 4+16 = 20
1338
+ left = body.getbyte(20) | (body.getbyte(21) << 8) |
1339
+ (body.getbyte(22) << 16) | (body.getbyte(23) << 24)
1340
+ top = body.getbyte(24) | (body.getbyte(25) << 8) |
1341
+ (body.getbyte(26) << 16) | (body.getbyte(27) << 24)
1342
+ right = body.getbyte(28) | (body.getbyte(29) << 8) |
1343
+ (body.getbyte(30) << 16) | (body.getbyte(31) << 24)
1344
+ bottom = body.getbyte(32) | (body.getbyte(33) << 8) |
1345
+ (body.getbyte(34) << 16) | (body.getbyte(35) << 24)
1346
+ Emf::Model::Geometry::Rect.new(left: left, top: top, right: right, bottom: bottom)
1347
+ end
1348
+
1349
+ def emit_clip_def(region)
1350
+ return if region.nil? || region.empty?
1351
+
1352
+ clip_id = BsdRand.next
1353
+ @builder.raw("<defs><clipPath id=\"clip-#{clip_id}\">")
1354
+ @builder.raw("<path d=\"")
1355
+ @builder.raw(clip_region_path_d(region))
1356
+ @builder.raw("Z\" />")
1357
+ @builder.rawln("</clipPath></defs>")
1358
+ @dc.clip_id = clip_id
1359
+ end
1360
+
1361
+ # Build the path-d string for a clip region matching libemf2svg's
1362
+ # draw_path output (segments: M, L, ..., then implicit Z appended by
1363
+ # caller).
1364
+ def clip_region_path_d(region)
1365
+ if region.path_d
1366
+ region.path_d
1367
+ elsif region.bounds
1368
+ b = region.bounds
1369
+ pts = [[b.left, b.top], [b.right, b.top],
1370
+ [b.right, b.bottom], [b.left, b.bottom], [b.left, b.top]]
1371
+ d = +"M "
1372
+ d << fmt_xy(cal_x(pts.first[0]), cal_y(pts.first[1]))
1373
+ pts[1..].each do |x, y|
1374
+ d << "L "
1375
+ d << fmt_xy(cal_x(x), cal_y(y))
1376
+ end
1377
+ d << "Z "
1378
+ d
1379
+ else
1380
+ ""
1381
+ end
1382
+ end
1383
+
1384
+ def clip_attrs
1385
+ return {} unless @dc.clip_id
1386
+
1387
+ { "clip-path" => "url(#clip-#{@dc.clip_id})" }
1388
+ end
1389
+
1390
+ # LOGFONT_PANOSE face name lives at offset 28 within LOGFONT (after
1391
+ # 5-byte fixed prefix and the 23-byte full PANOSE bits in the
1392
+ # LOGFONT_PANOSE variant) — but CreateFontIndirectW's body starts
1393
+ # at the LOGFONT struct itself, where the face name is at offset 28.
1394
+ # The face name is 32 UTF-16LE chars (64 bytes) for LOGFONT_W.
1395
+ LOGFONT_FACE_NAME_OFFSET = 28
1396
+ LOGFONT_FACE_NAME_MAX_BYTES = 64
1397
+
1398
+ def parse_logfont_face_name(body, utf16:)
1399
+ return nil if body.bytesize < LOGFONT_FACE_NAME_OFFSET + 2
1400
+
1401
+ raw = body.bytes[LOGFONT_FACE_NAME_OFFSET, LOGFONT_FACE_NAME_MAX_BYTES]
1402
+ return nil unless raw
1403
+
1404
+ if utf16
1405
+ # libemf2svg calls U_Utf16leToUtf8 with the FULL 32-char (64-byte)
1406
+ # buffer — it does NOT stop at the null terminator. If any byte
1407
+ # pair in the buffer is not valid UTF-16LE (e.g. a lone surrogate
1408
+ # in the unused tail), iconv fails and returns NULL, which means
1409
+ # no font-family attribute is emitted. We replicate that bug.
1410
+ return nil unless valid_utf16le_pairs?(raw)
1411
+
1412
+ # libemf2svg's iconv then converts ALL 64 bytes (including nulls
1413
+ # and trailing junk) into UTF-8. fprintf("%s", ...) then truncates
1414
+ # at the first NUL byte. Replicate by encoding the full buffer
1415
+ # and truncating at the first NUL.
1416
+ decoded = raw.pack("C*").force_encoding("UTF-16LE").encode("UTF-8",
1417
+ undef: :replace,
1418
+ replace: "?")
1419
+ if (nul = decoded.index(""))
1420
+ decoded = decoded[0, nul]
1421
+ end
1422
+ decoded.empty? ? nil : decoded
1423
+ else
1424
+ raw.take_while { |b| !b.zero? }.pack("C*").force_encoding("CP1252")
1425
+ .encode("UTF-8", undef: :replace, replace: "?")
1426
+ end
1427
+ rescue StandardError
1428
+ nil
1429
+ end
1430
+
1431
+ # Replicates iconv's UTF-16LE validation: returns false if any 2-byte
1432
+ # pair is a lone surrogate (U+D800..U+DFFF). Buffer length must be even.
1433
+ def valid_utf16le_pairs?(bytes)
1434
+ return false unless bytes.size.even?
1435
+
1436
+ (0...bytes.size).step(2).each do |i|
1437
+ code = bytes[i] | (bytes[i + 1] << 8)
1438
+ return false if code >= 0xD800 && code <= 0xDFFF
1439
+ end
1440
+ true
1441
+ end
1442
+
1443
+ def emit_ext_text(wire, utf16:)
1444
+ record_bytes = wire.to_binary_s
1445
+ off_string = wire.off_string
1446
+ n_chars = wire.n_chars
1447
+ f_options = wire.f_options.to_i
1448
+
1449
+ text = if off_string && off_string.positive? && off_string < record_bytes.bytesize
1450
+ string_bytes = record_bytes[off_string, utf16 ? n_chars * 2 : n_chars]
1451
+ if string_bytes && !string_bytes.empty?
1452
+ if (f_options & GlyphIndexMapper::ETO_GLYPH_INDEX).positive?
1453
+ decode_glyph_indices(string_bytes, n_chars)
1454
+ elsif utf16
1455
+ string_bytes.force_encoding("UTF-16LE")
1456
+ .encode("UTF-8", undef: :replace, replace: "?")
1457
+ else
1458
+ cleaned = string_bytes.bytes.map { |b| b > 0x7F ? 0x20 : b }
1459
+ cleaned.pack("C*").force_encoding("UTF-8")
1460
+ end
1461
+ else
1462
+ ""
1463
+ end
1464
+ else
1465
+ ""
1466
+ end
1467
+
1468
+ # libemf2svg emits the <text> element even when the string is empty.
1469
+ x = cal_x(wire.ptl_reference.x)
1470
+ y = cal_y(wire.ptl_reference.y)
1471
+
1472
+ font = @dc.font
1473
+ font_height = scale_dim_x(font.height.to_i).abs
1474
+
1475
+ @builder.raw("<text ")
1476
+ @builder.raw(clip_path_attr_string)
1477
+ emit_text_style(font, font_height, x, y)
1478
+ @builder.raw(">")
1479
+ @builder.raw("<![CDATA[#{text}]]>")
1480
+ @builder.rawln("</text>")
1481
+ end
1482
+
1483
+ # Mirror libemf2svg's text_style_draw. Attributes are written in the
1484
+ # exact C order with the exact spacing, including the quirky
1485
+ # `style ="..."` (space before `=`).
1486
+ # NOTE: text_style_draw does NOT prefix with a space; the caller
1487
+ # (text_draw) emits the trailing space of `<text ` (and any trailing
1488
+ # space from clipset_draw) before this method runs.
1489
+ def emit_text_style(font, font_height, x, y)
1490
+ @builder.raw("font-family=\"#{font.face_name}\" ") if font.face_name
1491
+ @builder.raw("fill=\"#{rgb_hex(@dc.text_color)}\" ")
1492
+
1493
+ orientation = sf_y > 0 ? -1 : 1
1494
+ if font.escapement != 0
1495
+ # C's integer division truncates toward zero; Ruby's Integer#/ floors.
1496
+ # Use float division + truncate to match C exactly for negative values.
1497
+ rotation = (orientation.to_f * font.escapement / 10).truncate
1498
+ adj_y = y + (font_height * 0.9)
1499
+ @builder.raw("transform=\"rotate(#{rotation}, #{fmt(x)}, #{fmt(adj_y)}) translate(0, #{fmt(font_height * 0.9)})\" ")
1500
+ end
1501
+
1502
+ @builder.raw("font-style=\"italic\" ") if font.italic
1503
+ # Quirk: libemf2svg prints "style =\"...\"" with a space before `=`.
1504
+ @builder.raw("style =\"white-space:pre;\" ")
1505
+
1506
+ if font.underline && font.strikeout
1507
+ @builder.raw("text-decoration=\"line-through,underline\" ")
1508
+ elsif font.underline
1509
+ @builder.raw("text-decoration=\"underline\" ")
1510
+ elsif font.strikeout
1511
+ @builder.raw("text-decoration=\"line-through\" ")
1512
+ end
1513
+
1514
+ @builder.raw("font-weight=\"#{font.weight}\" ") if font.weight != 0
1515
+
1516
+ align = @dc.text_align
1517
+ # libemf2svg checks both U_TA_CENTER (0x06) AND U_TA_CENTER2 (0x04)
1518
+ # for "middle" anchor. Mask 0x06 doesn't include 0x04, so handle
1519
+ # both explicitly to match C.
1520
+ anchor = if (align & 0x06) == 0x06 || (align & 0x04) == 0x04
1521
+ "middle"
1522
+ elsif (align & 0x02) == 0x02
1523
+ "end"
1524
+ else
1525
+ "start"
1526
+ end
1527
+ @builder.raw("text-anchor=\"#{anchor}\" ")
1528
+
1529
+ # Vertical position: TA_BOTTOM (0x08) or TA_BASELINE (0x18) use Org.y;
1530
+ # otherwise add font_height * 0.9.
1531
+ y_pos = if (align & 0x18) == 0x18 || (align & 0x18) == 0x08
1532
+ y
1533
+ else
1534
+ y + (font_height * 0.9)
1535
+ end
1536
+ @builder.raw("x=\"#{fmt(x)}\" y=\"#{fmt(y_pos)}\" ")
1537
+ @builder.raw("font-size=\"#{fmt(font_height)}\" ")
1538
+ end
1539
+
1540
+ def emit_arc(wire, mode:)
1541
+ # Mirror libemf2svg's arc_draw. Uses int_el_rad to find the
1542
+ # intersection of ptlStart/ptlEnd with the ellipse boundary.
1543
+ # Path data: `M cur M startpoint A radii 0 large sweep endpoint [L center Z] [Z]`
1544
+ # Closes via endPathDraw (arc) or endFormDraw (chord/pie).
1545
+ rcl_left = wire.rcl_box.left
1546
+ rcl_top = wire.rcl_box.top
1547
+ rcl_right = wire.rcl_box.right
1548
+ rcl_bottom = wire.rcl_box.bottom
1549
+ radii_x = c_int_div(rcl_right - rcl_left, 2)
1550
+ radii_y = c_int_div(rcl_bottom - rcl_top, 2)
1551
+ return if radii_x.zero? || radii_y.zero?
1552
+
1553
+ start_pt = int_el_rad(wire.ptl_start, wire.rcl_box)
1554
+ end_pt = int_el_rad(wire.ptl_end, wire.rcl_box)
1555
+
1556
+ arcdir = @dc.arc_direction || 1
1557
+ sweep_flag = arcdir.positive? ? 1 : 0
1558
+ large_arc_flag = arcdir.positive? ? 1 : 0
1559
+
1560
+ d = +"M "
1561
+ d << fmt_xy(cal_x(@current_x.to_i), cal_y(@current_y.to_i))
1562
+ d << "M "
1563
+ d << fmt_xy(cal_x(start_pt[0]), cal_y(start_pt[1]))
1564
+ d << "A "
1565
+ d << fmt_xy(cal_x(radii_x), cal_y(radii_y))
1566
+ d << "0 "
1567
+ d << "#{large_arc_flag} #{sweep_flag} "
1568
+ d << fmt_xy(cal_x(end_pt[0]), cal_y(end_pt[1]))
1569
+
1570
+ case mode
1571
+ when :pie
1572
+ center_x = (rcl_right + rcl_left) / 2
1573
+ center_y = (rcl_bottom + rcl_top) / 2
1574
+ d << "L "
1575
+ d << fmt_xy(cal_x(center_x), cal_y(center_y))
1576
+ d << "Z "
1577
+ emit_path_element_closed(d)
1578
+ # C's arc_draw PIE case updates cur_x/cur_y to center via point_draw.
1579
+ @current_x = center_x
1580
+ @current_y = center_y
1581
+ when :chord
1582
+ d << "Z "
1583
+ emit_path_element_closed(d)
1584
+ # Chord: cur_x/cur_y stays at end point.
1585
+ @current_x = end_pt[0]
1586
+ @current_y = end_pt[1]
1587
+ else
1588
+ emit_path_element(d, closed: false, leading_fill_space: true)
1589
+ # Arc: cur_x/cur_y updated to end point (point_draw_d).
1590
+ @current_x = end_pt[0]
1591
+ @current_y = end_pt[1]
1592
+ end
1593
+ end
1594
+
1595
+ # Compute the intersection of a point with the ellipse boundary
1596
+ # (mirrors libemf2svg's int_el_rad). Returns [x, y].
1597
+ #
1598
+ # CRITICAL: C's int_el_rad uses INTEGER division on U_POINTL/U_RECTL
1599
+ # values. C integer division truncates toward zero; Ruby's Integer#/
1600
+ # floors. For negative sums (e.g. (-161 + -172) / 2), C gives -166
1601
+ # but Ruby gives -167 — a 1-pixel offset. We must replicate C's
1602
+ # truncation by converting to float and truncating.
1603
+ def int_el_rad(pt, rect)
1604
+ center_x = c_int_div(rect.right + rect.left, 2)
1605
+ center_y = c_int_div(rect.bottom + rect.top, 2)
1606
+ radii_x = c_int_div(rect.right - rect.left, 2)
1607
+ radii_y = c_int_div(rect.bottom - rect.top, 2)
1608
+ return [center_x, center_y] if radii_x.zero? || radii_y.zero?
1609
+
1610
+ pt_no_x = pt.x.to_f - center_x
1611
+ pt_no_y = pt.y.to_f - center_y
1612
+
1613
+ if pt_no_x.zero?
1614
+ return [center_x, ((pt_no_y.negative? ? -1 : 1) * radii_y) + center_y]
1615
+ end
1616
+ if pt_no_y.zero?
1617
+ return [((pt_no_x.negative? ? -1 : 1) * radii_x) + center_x, center_y]
1618
+ end
1619
+
1620
+ slope = pt_no_y / pt_no_x
1621
+ isect_x = ((pt_no_x.negative? ? -1 : 1) *
1622
+ Math.sqrt(1 / (((1 / radii_x.to_f)**2) + ((slope / radii_y.to_f)**2)))) +
1623
+ center_x
1624
+ isect_y = ((pt_no_y.negative? ? -1 : 1) *
1625
+ Math.sqrt(1 / (((1 / (slope * radii_x.to_f))**2) + ((1 / radii_y.to_f)**2)))) +
1626
+ center_y
1627
+ [isect_x, isect_y]
1628
+ end
1629
+
1630
+ # Closes a path element using endFormDraw pattern:
1631
+ # `<path d="..." [clipset] stroke fill [fallbacks] />\n`
1632
+ # Note the literal ` />` (with leading space) — endFormDraw uses
1633
+ # fprintf(" />\n") vs U_EMRPOLYGON_draw's fprintf("/>\n").
1634
+ def emit_path_element_closed(d_string)
1635
+ @builder.raw("<path ")
1636
+ @builder.raw(clip_path_attr_string)
1637
+ @builder.raw("d=\"")
1638
+ @builder.raw(d_string)
1639
+ @builder.raw("\" ")
1640
+ emit_stroke_draw
1641
+ emit_fill_draw
1642
+ emit_unfilled_unstroked
1643
+ @builder.raw(" />")
1644
+ @builder.newline
1645
+ end
1646
+
1647
+ def emit_poly_multi(counts, all_points, closed:, _point_size:)
1648
+ return if counts.empty? || all_points.empty?
1649
+
1650
+ # libemf2svg emits ALL sub-polygons in a single <path> element,
1651
+ # separated by Z M between sub-polygons. Each sub-polygon ends with
1652
+ # `Z `, then the next starts with `M `. The path closes with `Z" `
1653
+ # (single Z appended by U_EMRPOLYPOLYGON_draw).
1654
+ d = +""
1655
+ offset = 0
1656
+ last_pt = nil
1657
+ counts.each_with_index do |count, _idx|
1658
+ sub_points = all_points[offset, count]
1659
+ offset += count
1660
+ next unless sub_points && sub_points.size == count
1661
+
1662
+ d << "M "
1663
+ d << point_str(sub_points.first)
1664
+ sub_points[1..].each do |p|
1665
+ d << "L "
1666
+ d << point_str(p)
1667
+ end
1668
+ d << "Z " if closed
1669
+ last_pt = sub_points.last
1670
+ end
1671
+ return if d.empty?
1672
+
1673
+ if @in_path
1674
+ # Inside BeginPath..EndPath: append directly to open d-string.
1675
+ # PolyPolygon doesn't add Z (caller does at EndPath/FillPath/etc).
1676
+ @builder.raw(d)
1677
+ else
1678
+ # PolyPolygon (closed) vs PolyPolyline (open) have DIFFERENT
1679
+ # attribute orders in C:
1680
+ # PolyPolygon: fill_draw → stroke_draw → fallbacks
1681
+ # PolyPolyline: stroke_draw → unconditional `fill="none"`
1682
+ @builder.raw("<path ")
1683
+ @builder.raw(clip_path_attr_string)
1684
+ @builder.raw("d=\"")
1685
+ @builder.raw(d)
1686
+ @builder.raw("\" ")
1687
+ if closed
1688
+ emit_fill_draw
1689
+ emit_stroke_draw
1690
+ emit_unfilled_unstroked
1691
+ @builder.raw("/>")
1692
+ else
1693
+ emit_stroke_draw
1694
+ @builder.raw("fill=\"none\" />")
1695
+ end
1696
+ @builder.newline
1697
+ end
1698
+ # Mirror libemf2svg's point*_draw: the final emitted point becomes
1699
+ # the new current position for subsequent drawing records.
1700
+ if last_pt
1701
+ @current_x = last_pt.x
1702
+ @current_y = last_pt.y
1703
+ end
1704
+ end
1705
+
1706
+ def emit_poly_draw(points, types, _point_size:)
1707
+ return if points.empty? || types.empty?
1708
+
1709
+ pt_moveto = 1
1710
+ pt_lineto = 2
1711
+ pt_bezierto = 4
1712
+ pt_closefigure = 8
1713
+
1714
+ d = +""
1715
+ bezier_buf = []
1716
+ i = 0
1717
+ types.each_with_index do |t, _idx|
1718
+ pt = points[i]
1719
+ i += 1
1720
+ case t & 0x07
1721
+ when pt_moveto
1722
+ d << "M #{fmt(cal_x(pt.x))} #{fmt(cal_y(pt.y))} "
1723
+ when pt_lineto
1724
+ d << "L #{fmt(cal_x(pt.x))} #{fmt(cal_y(pt.y))} "
1725
+ when pt_bezierto
1726
+ bezier_buf << pt
1727
+ if bezier_buf.size == 3
1728
+ d << "C #{fmt(cal_x(bezier_buf[0].x))} #{fmt(cal_y(bezier_buf[0].y))} "
1729
+ d << "#{fmt(cal_x(bezier_buf[1].x))} #{fmt(cal_y(bezier_buf[1].y))} "
1730
+ d << "#{fmt(cal_x(bezier_buf[2].x))} #{fmt(cal_y(bezier_buf[2].y))} "
1731
+ bezier_buf.clear
1732
+ end
1733
+ end
1734
+ d << "Z " if (t & pt_closefigure).nonzero?
1735
+ end
1736
+ attrs = fill_attrs.merge(stroke_attrs).merge("d" => d)
1737
+ @builder.tag("path", attrs, single: true)
1738
+ end
1739
+
1740
+ def emit_image_from_dib(record_bytes, off_bmi, cb_bmi, off_bits, cb_bits, x:, y:, width:, height:)
1741
+ return if off_bmi.zero? || cb_bmi.zero?
1742
+
1743
+ bmi_bytes = record_bytes[off_bmi, cb_bmi]
1744
+ bits_bytes = record_bytes[off_bits, cb_bits] || +""
1745
+ dib_bytes = bmi_bytes.to_s + bits_bytes.to_s
1746
+ # Pass cb_bmi as bits_offset so the decoder reads pixels from the
1747
+ # actual start of the bitmap bits, not from header+palette (which
1748
+ # would misread if the BMI has trailing padding).
1749
+ result = DibDecoder.decode(dib_bytes, bits_offset: cb_bmi)
1750
+ return unless result
1751
+
1752
+ href = build_image_href(result)
1753
+ # libemf2svg's U_EMRSTRETCHDIBITS_draw uses point_cal for both size
1754
+ # (cDest) and position (Dest). Match that exactly so window/viewport
1755
+ # origins are applied to the dimensions too.
1756
+ geom = "width=\"#{fmt(cal_x(width))}\" height=\"#{fmt(cal_y(height))}\" x=\"#{fmt(cal_x(x))}\" y=\"#{fmt(cal_y(y))}\""
1757
+ @builder.raw("<image ")
1758
+ @builder.raw(geom)
1759
+ @builder.raw(" ")
1760
+ @builder.raw(clip_path_attr_string)
1761
+ @builder.raw("xlink:href=\"")
1762
+ @builder.raw(href)
1763
+ @builder.raw("\" ")
1764
+ @builder.raw("/>")
1765
+ @builder.newline
1766
+ end
1767
+
1768
+ def emit_image_from_body(wire, x, y, width, height)
1769
+ # For wire classes where we haven't decoded the BMI offsets:
1770
+ # try to find the bitmap data in the trailing body.
1771
+ body = if wire.is_a?(Emf::Emr::Binary::Records::StretchDIBits)
1772
+ wire.trailing.to_s
1773
+ else
1774
+ wire.body.to_s
1775
+ end
1776
+ return if body.bytesize < 40
1777
+
1778
+ result = DibDecoder.decode(body)
1779
+ return unless result
1780
+
1781
+ href = build_image_href(result)
1782
+ attrs = {
1783
+ "x" => x, "y" => maybe_flip_y(y),
1784
+ "width" => width, "height" => height,
1785
+ "xlink:href" => href, "preserveAspectRatio" => "none"
1786
+ }
1787
+ @builder.tag("image", attrs, single: true)
1788
+ end
1789
+
1790
+ def build_image_href(result)
1791
+ case result.color_type
1792
+ when :jpeg
1793
+ # libemf2svg uses "image/jpg" (not the standard "image/jpeg").
1794
+ # Also apply fmem modulo-3 padding so the base64 has 'AA' trailing
1795
+ # chars instead of '=' (the C base64_encode allocates length+3
1796
+ # for nulls and the partial-triple bytes default to 0).
1797
+ jpeg = result.pixels.to_s
1798
+ rem = jpeg.bytesize % 3
1799
+ jpeg << ("\x00" * ((3 - rem) % 3)) if rem != 0
1800
+ "data:image/jpg;base64,#{[jpeg].pack('m0')}"
1801
+ when :png
1802
+ "data:image/png;base64,#{[result.pixels].pack('m0')}"
1803
+ else
1804
+ png_bytes = PngEncoder.encode(result.width, result.height, result.pixels)
1805
+ "data:image/png;base64,#{[png_bytes].pack('m0')}"
1806
+ end
1807
+ end
1808
+
1809
+ # BitBlt with no source bitmap is used as a "fill rectangle with
1810
+ # current brush" operation. libemf2svg's U_EMRBITBLT_draw emits
1811
+ # different SVG depending on fill_mode:
1812
+ # BS_SOLID: <path style="fill:#rrggbb" d="M x,y L x+w,y L x+w,y+h L x,y+h Z" />
1813
+ # BS_MONOPATTERN: <path style="fill:url(#img-N-ref);" d="..." />
1814
+ # For other modes, emits nothing. Also emits nothing if dwRop == NOOP.
1815
+ # We replicate only the BS_SOLID path here.
1816
+ def emit_bitblt_solid_fill(wire)
1817
+ rop = wire.dw_rop.to_i
1818
+ return if rop == 0x00AA0029 # U_NOOP
1819
+
1820
+ brush = @dc.brush
1821
+ return unless brush.style == Brush::SOLID
1822
+
1823
+ style = format("fill:#%02x%02x%02x", brush.color.red, brush.color.green, brush.color.blue)
1824
+ size_x = scale_dim_x(wire.cx_dest.to_i)
1825
+ size_y = scale_dim_y(wire.cy_dest.to_i)
1826
+ pos_x = cal_x(wire.x_dest)
1827
+ pos_y = cal_y(wire.y_dest)
1828
+ @builder.raw("<path style=\"#{style}\"")
1829
+ @builder.raw(" d=\"M #{fmt(pos_x)},#{fmt(pos_y)} ")
1830
+ @builder.raw("L #{fmt(pos_x + size_x)},#{fmt(pos_y)} ")
1831
+ @builder.raw("L #{fmt(pos_x + size_x)},#{fmt(pos_y + size_y)} ")
1832
+ @builder.raw("L #{fmt(pos_x)},#{fmt(pos_y + size_y)} ")
1833
+ @builder.raw("Z\" />")
1834
+ end
1835
+
1836
+ def emit_path_polygon(points, closed:)
1837
+ return if points.empty?
1838
+
1839
+ if @in_path
1840
+ # Inside BeginPath..EndPath: append `M X,Y L X,Y ...` directly to
1841
+ # the open d-string (no element wrapping, no Z — Z is added by
1842
+ # CloseFigure or by the path closer at EndPath/FillPath/etc.).
1843
+ @builder.raw("M ")
1844
+ @builder.raw(point_str(points.first))
1845
+ points[1..].each do |p|
1846
+ @builder.raw("L ")
1847
+ @builder.raw(point_str(p))
1848
+ end
1849
+ # Mirror libemf2svg's point*_draw which updates cur_x,cur_y on
1850
+ # every emitted point — the LAST point becomes the new current
1851
+ # position, used by subsequent PolyBezier/LineTo/etc.
1852
+ @current_x = points.last.x
1853
+ @current_y = points.last.y
1854
+ return
1855
+ end
1856
+
1857
+ d = +"M "
1858
+ d << point_str(points.first)
1859
+ points[1..].each do |p|
1860
+ d << "L "
1861
+ d << point_str(p)
1862
+ end
1863
+ emit_path_element(d, closed: closed)
1864
+ @current_x = points.last.x
1865
+ @current_y = points.last.y
1866
+ end
1867
+
1868
+ # Emits a <path> element matching libemf2svg's exact byte layout:
1869
+ # <path [clip-path="..."] d="..."Z" [stroke attrs] [fill attrs] [...]/>\n
1870
+ # For closed paths the trailing Z is appended; for open paths it's omitted.
1871
+ # Stroke is emitted before fill (matching U_EMRPOLYGON_draw ordering).
1872
+ def emit_path_element(d_string, closed:, leading_fill_space: false)
1873
+ @builder.raw("<path ")
1874
+ @builder.raw(clip_path_attr_string)
1875
+ @builder.raw("d=\"")
1876
+ @builder.raw(d_string)
1877
+ @builder.raw(closed ? "Z\" " : "\" ")
1878
+ emit_stroke_draw
1879
+ if closed
1880
+ emit_fill_draw
1881
+ emit_unfilled_unstroked
1882
+ else
1883
+ # Open paths: no fill_draw call. libemf2svg emits `fill="none"` as
1884
+ # fallback. LineTo (endPathDraw) prefixes with a space; Polyline
1885
+ # does not.
1886
+ @builder.raw(" ") if leading_fill_space
1887
+ @builder.raw("fill=\"none\" ")
1888
+ @builder.raw("stroke=\"none\" ") unless @last_stroke_set
1889
+ end
1890
+ @builder.raw("/>")
1891
+ @builder.newline
1892
+ end
1893
+
1894
+ # Emits a shape element (rect/ellipse) matching libemf2svg byte layout:
1895
+ # <rect x=".." y=".." width=".." height=".." [rx ry] [fill] [stroke] [clip]/>\n
1896
+ # Fill is emitted before stroke (matching U_EMRRECTANGLE_draw ordering).
1897
+ def emit_shape_element(tag_name, geometry_str)
1898
+ @builder.raw("<")
1899
+ @builder.raw(tag_name)
1900
+ @builder.raw(" ")
1901
+ @builder.raw(geometry_str)
1902
+ @builder.raw(" ")
1903
+ emit_fill_draw
1904
+ emit_stroke_draw
1905
+ @builder.raw(clip_path_attr_string)
1906
+ emit_unfilled_unstroked
1907
+ @builder.raw("/>")
1908
+ @builder.newline
1909
+ end
1910
+
1911
+ # Mirror libemf2svg's "if (!filled) fill=\"none\"" / "if (!stroked) stroke=\"none\""
1912
+ # trailing fallbacks. Tracks state via @last_fill_set / @last_stroke_set.
1913
+ def emit_unfilled_unstroked
1914
+ @builder.raw("fill=\"none\" ") unless @last_fill_set
1915
+ @builder.raw("stroke=\"none\" ") unless @last_stroke_set
1916
+ end
1917
+
1918
+ # Format a single point as libemf2svg's point_draw would: "X,Y "
1919
+ def point_str(p)
1920
+ fmt_xy(cal_x(p.x), cal_y(p.y))
1921
+ end
1922
+
1923
+ # libemf2svg prints colors as `#RRGGBB` always (drops alpha/reserved).
1924
+ def rgb_hex(color)
1925
+ format("#%02X%02X%02X", color.red, color.green, color.blue)
1926
+ end
1927
+
1928
+ # Mirror libemf2svg's clipset_draw: emits " clip-path=\"url(#clip-N)\" "
1929
+ # only if a clip is currently set. Note the leading and trailing space.
1930
+ def clip_path_attr_string
1931
+ return "" unless @dc.clip_id
1932
+
1933
+ " clip-path=\"url(#clip-#{@dc.clip_id})\" "
1934
+ end
1935
+
1936
+ # Mirror libemf2svg's fill_draw. Emits fill-rule prefix (depending on
1937
+ # brush style — see note below) then the fill attribute. Sets
1938
+ # @last_fill_set so the caller can decide whether to add fallback
1939
+ # `fill="none"`.
1940
+ #
1941
+ # Quirk: in C, the first switch on fill_mode overlaps with brush style
1942
+ # constants — U_BS_NULL(1) matches U_ALTERNATE(1) → "evenodd", and
1943
+ # U_BS_HATCHED(2) matches U_WINDING(2) → "nonzero". Other brush styles
1944
+ # produce a single space. We replicate this exactly.
1945
+ def emit_fill_draw
1946
+ brush = @dc.brush
1947
+ @last_fill_set = false
1948
+ # C's fill_draw computes fill_rule from a switch that overlaps brush
1949
+ # style constants with poly-fill modes (BS_NULL==TA_ALTERNATE=1 →
1950
+ # "evenodd", BS_HATCHED==TA_WINDING=2 → "nonzero", else " "). The
1951
+ # fill_rule is ONLY emitted in the BS_SOLID branch of the second
1952
+ # switch — other branches ignore it. We replicate that exactly.
1953
+ fill_rule = case brush.style
1954
+ when Brush::NULL then 'fill-rule:"evenodd" '
1955
+ when Brush::HATCHED then 'fill-rule:"nonzero" '
1956
+ else " "
1957
+ end
1958
+ case brush.style
1959
+ when Brush::SOLID
1960
+ @builder.raw(fill_rule)
1961
+ @builder.raw(fill_color_attr(brush))
1962
+ @last_fill_set = true
1963
+ when Brush::NULL
1964
+ @builder.raw("fill=\"none\" ")
1965
+ @last_fill_set = true
1966
+ when Brush::MONOPATTERN
1967
+ @builder.raw("fill=\"#img-#{brush.image_id}-ref\" ")
1968
+ @last_fill_set = true
1969
+ else
1970
+ # BS_HATCHED, BS_PATTERN, etc. — C's default branch emits ONLY the
1971
+ # fill attr (fill_rule is computed but discarded).
1972
+ @builder.raw(fill_color_attr(brush))
1973
+ @last_fill_set = true
1974
+ end
1975
+ end
1976
+
1977
+ def fill_color_attr(brush)
1978
+ "fill=\"#{rgb_hex(brush.color)}\" "
1979
+ end
1980
+
1981
+ # Mirror libemf2svg's stroke_draw. Emits stroke attrs depending on pen
1982
+ # type (NULL/COSMETIC/GEOMETRIC) and dash style. Does NOT add a leading
1983
+ # space — the caller is responsible for spacing.
1984
+ def emit_stroke_draw
1985
+ pen = @dc.pen
1986
+ @last_stroke_set = false
1987
+ style = pen.style
1988
+
1989
+ if (style & 0xFF) == Pen::NULL
1990
+ if @dc.brush.style == Brush::NULL
1991
+ @builder.raw("stroke=\"none\" stroke-width=\"0.0\" ")
1992
+ else
1993
+ @builder.raw("stroke-width=\"1px\" stroke=\"#{rgb_hex(@dc.brush.color)}\" ")
1994
+ end
1995
+ @last_stroke_set = true
1996
+ return
1997
+ end
1998
+
1999
+ # Color then width (C order: color_stroke then width_stroke).
2000
+ # COSMETIC pen uses hardcoded width=1; GEOMETRIC uses pen.width.
2001
+ pen_type = style & 0x000F0000
2002
+ width_for_emit = pen_type == 0x00010000 ? pen.width : 1
2003
+ @builder.raw("stroke=\"#{rgb_hex(pen.color)}\" ")
2004
+ emit_width_stroke(width_for_emit)
2005
+ @last_stroke_set = true
2006
+
2007
+ # Dash style
2008
+ unit_stroke = (pen.width || 1) * @scaling
2009
+ dash_len = unit_stroke * 5
2010
+ dot_len = unit_stroke
2011
+ case style & 0xFF
2012
+ when Pen::DASH
2013
+ @builder.raw("stroke-dasharray=\"#{fmt(dash_len)},#{fmt(dash_len)}\" ")
2014
+ when Pen::DOT
2015
+ @builder.raw("stroke-dasharray=\"#{fmt(dot_len)},#{fmt(dot_len)}\" ")
2016
+ when Pen::DASHDOT
2017
+ @builder.raw("stroke-dasharray=\"#{fmt(dash_len)},#{fmt(dash_len)},#{fmt(dot_len)},#{fmt(dash_len)}\" ")
2018
+ when Pen::DASHDOTDOT
2019
+ @builder.raw("stroke-dasharray=\"#{fmt(dash_len)},#{fmt(dash_len)},#{fmt(dot_len)},#{fmt(dot_len)},#{fmt(dot_len)},#{fmt(dash_len)}\" ")
2020
+ end
2021
+
2022
+ # Line cap (bits 8-11)
2023
+ case style & 0x0F00
2024
+ when 0x0000 then @builder.raw(" stroke-linecap=\"round\" ") # PS_ENDCAP_ROUND default
2025
+ when 0x0100 then @builder.raw(" stroke-linecap=\"square\" ")
2026
+ when 0x0200 then @builder.raw(" stroke-linecap=\"butt\" ")
2027
+ end
2028
+
2029
+ # Line join (bits 12-15)
2030
+ case style & 0xF000
2031
+ when 0x0000 then @builder.raw(" stroke-linejoin=\"round\" ") # PS_JOIN_ROUND default
2032
+ when 0x1000 then @builder.raw(" stroke-linejoin=\"bevel\" ")
2033
+ when 0x2000
2034
+ @builder.raw(" stroke-linejoin=\"miter\" ")
2035
+ @builder.raw(" stroke-miterlimit=\"#{fmt(@scaling * @dc.miter_limit)}\" ") if @dc.miter_limit
2036
+ end
2037
+ end
2038
+
2039
+ # Mirror libemf2svg's width_stroke: tmp_w = scaleX(width); if (tmp_w/scaling)<1
2040
+ # emit "1px", else emit the scaled width.
2041
+ def emit_width_stroke(width)
2042
+ w = [width.to_i, 1].max
2043
+ tmp_w = scale_dim_x(w)
2044
+ if (tmp_w / @scaling) < 1.0
2045
+ @builder.raw("stroke-width=\"1px\" ")
2046
+ else
2047
+ @builder.raw("stroke-width=\"#{fmt(tmp_w)}\" ")
2048
+ end
2049
+ end
2050
+
2051
+ HATCH_PATTERNS = {
2052
+ 0 => [%w[M0,4 L8,4], []], # HS_HORIZONTAL
2053
+ 1 => [%w[M4,0 L4,8], []], # HS_VERTICAL
2054
+ 2 => [%w[M0,8 L8,0], []], # HS_FDIAGONAL
2055
+ 3 => [%w[M0,0 L8,8], []], # HS_BDIAGONAL
2056
+ 4 => [%w[M0,4 L8,4 M4,0 L4,8], []], # HS_CROSS
2057
+ 5 => [%w[M0,8 L8,0 M0,0 L8,8], []] # HS_DIAGCROSS
2058
+ }.freeze
2059
+
2060
+ def fill_attrs
2061
+ brush = @dc.brush
2062
+ return { "fill" => "none" }.merge(clip_attrs) if brush.null?
2063
+
2064
+ if brush.style == 2 # BS_HATCHED
2065
+ @hatch_counter ||= 0
2066
+ @hatch_counter += 1
2067
+ id = "hatch-#{@hatch_counter}"
2068
+ emit_hatch_pattern(id, brush.hatch, brush.color, @dc.bk_color, @dc.bk_mode)
2069
+ return { "fill" => "url(##{id})" }.merge(clip_attrs)
2070
+ end
2071
+
2072
+ { "fill" => rgb_hex(brush.color) }.merge(clip_attrs)
2073
+ end
2074
+
2075
+ def emit_hatch_pattern(id, hatch_style, fg_color, bk_color, bk_mode)
2076
+ lines = HATCH_PATTERNS[hatch_style] || HATCH_PATTERNS[0]
2077
+ bk = bk_mode == 2 ? rgb_hex(bk_color) : "none"
2078
+ fg = rgb_hex(fg_color)
2079
+ @builder.tag("defs") do
2080
+ @builder.tag("pattern", { "id" => id, "width" => 8, "height" => 8,
2081
+ "patternUnits" => "userSpaceOnUse" }) do
2082
+ @builder.tag("rect", { "width" => 8, "height" => 8, "fill" => bk }, single: true)
2083
+ lines.each do |d|
2084
+ @builder.tag("path", { "d" => d, "stroke" => fg, "stroke-width" => 1 }, single: true)
2085
+ end
2086
+ end
2087
+ end
2088
+ end
2089
+
2090
+ def stroke_attrs
2091
+ pen = @dc.pen
2092
+ style = pen.style
2093
+
2094
+ # NULL pen: libemf2svg falls back to 1px fill-color border
2095
+ # (not stroke="none") unless fill is also null
2096
+ if (style & 0xFF) == Pen::NULL
2097
+ return { "stroke" => "none", "stroke-width" => "0.0" }.merge(clip_attrs) if @dc.brush.null?
2098
+
2099
+ return { "stroke-width" => "1px",
2100
+ "stroke" => rgb_hex(@dc.brush.color) }.merge(clip_attrs)
2101
+
2102
+ end
2103
+
2104
+ w = [pen.width, 1].max
2105
+ attrs = { "stroke" => rgb_hex(pen.color), "stroke-width" => w }
2106
+
2107
+ # Line style (bits 0-7)
2108
+ dash_len = w * 5
2109
+ dot_len = w
2110
+ case style & 0xFF
2111
+ when Pen::DASH
2112
+ attrs["stroke-dasharray"] = "#{fmt(dash_len)},#{fmt(dash_len)}"
2113
+ when Pen::DOT
2114
+ attrs["stroke-dasharray"] = "#{fmt(dot_len)},#{fmt(dot_len)}"
2115
+ when Pen::DASHDOT
2116
+ attrs["stroke-dasharray"] = "#{fmt(dash_len)},#{fmt(dash_len)},#{fmt(dot_len)},#{fmt(dash_len)}"
2117
+ when Pen::DASHDOTDOT
2118
+ attrs["stroke-dasharray"] = "#{fmt(dash_len)},#{fmt(dash_len)},#{fmt(dot_len)},#{fmt(dot_len)},#{fmt(dot_len)},#{fmt(dash_len)}"
2119
+ end
2120
+
2121
+ # Line cap (bits 8-11)
2122
+ case style & 0x0F00
2123
+ when 0x0000 then attrs["stroke-linecap"] = "round" # PS_ENDCAP_ROUND default
2124
+ when 0x0100 then attrs["stroke-linecap"] = "square"
2125
+ when 0x0200 then attrs["stroke-linecap"] = "butt" # PS_ENDCAP_FLAT
2126
+ end
2127
+
2128
+ # Line join (bits 12-15)
2129
+ case style & 0xF000
2130
+ when 0x0000 then attrs["stroke-linejoin"] = "round" # PS_JOIN_ROUND default
2131
+ when 0x1000 then attrs["stroke-linejoin"] = "bevel"
2132
+ when 0x2000
2133
+ attrs["stroke-linejoin"] = "miter"
2134
+ attrs["stroke-miterlimit"] = fmt(@dc.miter_limit) if @dc.miter_limit
2135
+ end
2136
+
2137
+ attrs.merge(clip_attrs)
2138
+ end
2139
+
2140
+ def fill_rule_attr
2141
+ @dc.poly_fill_mode == 2 ? "nonzero" : "evenodd"
2142
+ end
2143
+
2144
+ # Scale factors matching libemf2svg's point_cal switch on MapMode.
2145
+ def sf_x
2146
+ case @dc.map_mode
2147
+ when 1 then 1.0
2148
+ when 2 then @px_per_mm * 0.1
2149
+ when 3 then @px_per_mm * 0.01
2150
+ when 4 then @px_per_mm * 0.01 * 25.4
2151
+ when 5 then @px_per_mm * 0.001 * 25.4
2152
+ when 6 then @px_per_mm / 1440.0 * 25.4
2153
+ when 7, 8
2154
+ if extents_set?
2155
+ @dc.viewport_ext[0].to_f / @dc.window_ext[0]
2156
+ else
2157
+ 1.0
2158
+ end
2159
+ else
2160
+ 1.0
2161
+ end
2162
+ end
2163
+
2164
+ def sf_y
2165
+ case @dc.map_mode
2166
+ when 1 then 1.0
2167
+ when 2 then @px_per_mm * 0.1 * -1
2168
+ when 3 then @px_per_mm * 0.01 * -1
2169
+ when 4 then @px_per_mm * 0.01 * 25.4 * -1
2170
+ when 5 then @px_per_mm * 0.001 * 25.4 * -1
2171
+ when 6 then @px_per_mm / 1440.0 * 25.4 * -1
2172
+ when 7 # ISOTROPIC: same as sf_x
2173
+ sf_x
2174
+ when 8 # ANISOTROPIC
2175
+ if extents_set?
2176
+ @dc.viewport_ext[1].to_f / @dc.window_ext[1]
2177
+ else
2178
+ @fix_y ? -1.0 : 1.0
2179
+ end
2180
+ else
2181
+ 1.0
2182
+ end
2183
+ end
2184
+
2185
+ def extents_set?
2186
+ @dc.window_ext_set? && @dc.viewport_ext_set?
2187
+ end
2188
+
2189
+ def maybe_flip_y(y)
2190
+ cal_y(y)
2191
+ end
2192
+
2193
+ def emit_transform_group(matrix)
2194
+ # Mirror libemf2svg's transform_draw: close previous transform group
2195
+ # before opening new one, emit `<g transform="matrix(...)">\n`,
2196
+ # with eDx/eDy passed through scaleX/scaleY (sign-preserving).
2197
+ # Skip emission while inside a BeginPath..EndPath block — transforms
2198
+ # are deferred to BeginPath/EndPath in C's two-pass analysis.
2199
+ return if @in_path
2200
+
2201
+ if @open_groups.positive?
2202
+ @builder.rawln("</g>")
2203
+ @open_groups -= 1
2204
+ end
2205
+ dx_scaled = matrix.dx * sf_x * @scaling
2206
+ dy_scaled = matrix.dy * sf_y * @scaling
2207
+ @builder.rawln("<g transform=\"matrix(#{fmt(matrix.m11)} #{fmt(matrix.m12)} #{fmt(matrix.m21)} #{fmt(matrix.m22)} #{fmt(dx_scaled)} #{fmt(dy_scaled)})\">")
2208
+ @open_groups += 1
2209
+ end
2210
+
2211
+ def multiply_matrix(a, b)
2212
+ Emf::Model::Geometry::Matrix.new(
2213
+ m11: (a.m11 * b.m11) + (a.m12 * b.m21),
2214
+ m12: (a.m11 * b.m12) + (a.m12 * b.m22),
2215
+ m21: (a.m21 * b.m11) + (a.m22 * b.m21),
2216
+ m22: (a.m21 * b.m12) + (a.m22 * b.m22),
2217
+ dx: (a.dx * b.m11) + (a.dy * b.m21) + b.dx,
2218
+ dy: (a.dx * b.m12) + (a.dy * b.m22) + b.dy
2219
+ )
2220
+ end
2221
+ end
2222
+ end
2223
+ end