emfsvg 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +86 -58
  3. data/exe/emfsvg +21 -2
  4. data/lib/emfsvg/dib_encoder.rb +67 -0
  5. data/lib/emfsvg/error.rb +2 -0
  6. data/lib/emfsvg/png_decoder.rb +23 -0
  7. data/lib/emfsvg/svg/attribute_parser.rb +31 -0
  8. data/lib/emfsvg/svg/clip_path_registry.rb +43 -0
  9. data/lib/emfsvg/svg/color.rb +105 -0
  10. data/lib/emfsvg/svg/document.rb +70 -0
  11. data/lib/emfsvg/svg/element.rb +66 -0
  12. data/lib/emfsvg/svg/elements/circle.rb +38 -0
  13. data/lib/emfsvg/svg/elements/clip_path.rb +128 -0
  14. data/lib/emfsvg/svg/elements/defs.rb +24 -0
  15. data/lib/emfsvg/svg/elements/ellipse.rb +35 -0
  16. data/lib/emfsvg/svg/elements/group.rb +33 -0
  17. data/lib/emfsvg/svg/elements/image.rb +65 -0
  18. data/lib/emfsvg/svg/elements/line.rb +35 -0
  19. data/lib/emfsvg/svg/elements/path.rb +29 -0
  20. data/lib/emfsvg/svg/elements/polygon.rb +29 -0
  21. data/lib/emfsvg/svg/elements/polyline.rb +29 -0
  22. data/lib/emfsvg/svg/elements/rect.rb +44 -0
  23. data/lib/emfsvg/svg/elements/stylable.rb +28 -0
  24. data/lib/emfsvg/svg/elements/text.rb +67 -0
  25. data/lib/emfsvg/svg/elements.rb +42 -0
  26. data/lib/emfsvg/svg/paint.rb +36 -0
  27. data/lib/emfsvg/svg/parser.rb +44 -0
  28. data/lib/emfsvg/svg/path_data/command.rb +15 -0
  29. data/lib/emfsvg/svg/path_data.rb +135 -0
  30. data/lib/emfsvg/svg/stroke.rb +107 -0
  31. data/lib/emfsvg/svg/transform_parser.rb +123 -0
  32. data/lib/emfsvg/svg.rb +25 -0
  33. data/lib/emfsvg/translation/context.rb +99 -0
  34. data/lib/emfsvg/translation/decimal_detector.rb +112 -0
  35. data/lib/emfsvg/translation/emf_renderer.rb +146 -0
  36. data/lib/emfsvg/translation/handler_registry.rb +38 -0
  37. data/lib/emfsvg/translation/handlers/circle_handler.rb +17 -0
  38. data/lib/emfsvg/translation/handlers/clip_path_handler.rb +16 -0
  39. data/lib/emfsvg/translation/handlers/defs_handler.rb +17 -0
  40. data/lib/emfsvg/translation/handlers/ellipse_handler.rb +28 -0
  41. data/lib/emfsvg/translation/handlers/group_handler.rb +40 -0
  42. data/lib/emfsvg/translation/handlers/image_handler.rb +94 -0
  43. data/lib/emfsvg/translation/handlers/line_handler.rb +30 -0
  44. data/lib/emfsvg/translation/handlers/path_flattener.rb +139 -0
  45. data/lib/emfsvg/translation/handlers/path_handler.rb +78 -0
  46. data/lib/emfsvg/translation/handlers/polygon_handler.rb +17 -0
  47. data/lib/emfsvg/translation/handlers/polyline_handler.rb +51 -0
  48. data/lib/emfsvg/translation/handlers/rect_handler.rb +43 -0
  49. data/lib/emfsvg/translation/handlers/shared_gdi.rb +97 -0
  50. data/lib/emfsvg/translation/handlers/text_handler.rb +137 -0
  51. data/lib/emfsvg/translation/handlers.rb +25 -0
  52. data/lib/emfsvg/translation/header_builder.rb +89 -0
  53. data/lib/emfsvg/translation/record_emitter.rb +36 -0
  54. data/lib/emfsvg/translation/scaler/fixed.rb +41 -0
  55. data/lib/emfsvg/translation/scaler/identity.rb +23 -0
  56. data/lib/emfsvg/translation/scaler.rb +19 -0
  57. data/lib/emfsvg/translation.rb +16 -0
  58. data/lib/emfsvg/version.rb +1 -1
  59. data/lib/emfsvg.rb +21 -0
  60. metadata +73 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4334821604725312749ae532acdd4d729ec4bbdde73897e097d9aedf5404b20e
4
- data.tar.gz: 077c491cd219b2f274036fb613353e0226ea150fa0cf70e0f544ae07f4b88598
3
+ metadata.gz: 5486d21c06edc95e8adcdd149befbac957e05eb18b3d4cb89a6ac556cf991f68
4
+ data.tar.gz: 423352d0507ab33950c3dbe22cfba957e76af5f48961dd77ab782b58714eaf75
5
5
  SHA512:
6
- metadata.gz: d18d84ed9d2d156aee6e89fc946c4d6c53c00678b7b627c5c995cc7fb72f7bb52780dfeb27ecc14047e5aa3906502e1ca05d2609c6556201685dbacaee9fc3db
7
- data.tar.gz: afb7470b2d7761102326ee7877cfa41c488966dadfde449abe91cdc332aead381b9f4a8bcef6a5bd582f7423d2336cbd982e1c213e5d210f38f336997ed2d720
6
+ metadata.gz: 2f2bdd7812cca71e2c160a8cba35a825c523678496db81176ba9f5f09aa0bbe5626e54d38aaaf8562b004c1a601c931a5a224a1c85a69d6ac770abbd617b9323
7
+ data.tar.gz: da3a81e8f1a56e7e213c25cb83621430c1f242b1b00edbf37d4c41c525ac5dc83b78342f095a2cafdca9fbc972c7625f983e9d4f07f55077eb0d2de9f79f58f6
data/README.adoc CHANGED
@@ -1,24 +1,39 @@
1
- = emfsvg: pure-Ruby EMF to SVG transformer
1
+ = emfsvg: pure-Ruby EMF SVG transformer
2
2
 
3
- `emfsvg` converts Microsoft Enhanced Metafile (EMF) documents to SVG
4
- using the [`emf`](https://github.com/claricle/emf) gem's domain model.
5
- It is a clean-room Ruby replacement for the FFI wrapper around the
6
- GPLv2 `libemf2svg` C library.
3
+ `emfsvg` converts between Microsoft Enhanced Metafile (EMF) and SVG
4
+ in both directions. It is a clean-room Ruby replacement for the FFI
5
+ wrapper around the GPLv2 `libemf2svg` C library.
6
+
7
+ == Status
8
+
9
+ * **EMF → SVG**: 208/208 fixtures (100%) semantically match libemf2svg.
10
+ Verified with the lossy `SvgMatcher` at 0.1px tolerance.
11
+ 79/208 are byte-identical to libemf2svg's output.
12
+ * **SVG → EMF**: functional for shapes, transforms, clips, text, and
13
+ images. Synthetic SVGs round-trip byte-equal through
14
+ `SVG → EMF → SVG`. The lossy matcher and golden-master tooling
15
+ are in place for iterative fidelity improvement.
7
16
 
8
17
  == Synopsis
9
18
 
10
19
  require "emfsvg"
11
20
 
21
+ # EMF → SVG
12
22
  svg = Emfsvg.from_file("image.emf")
13
23
  svg = Emfsvg.from_bytes(emf_bytes, width: 400, height: 300)
14
- metafile = Emf.parse(emf_bytes)
15
- svg = Emfsvg.to_svg(metafile, namespace: "svg")
24
+
25
+ # SVG EMF
26
+ emf = Emfsvg.from_svg_file("drawing.svg")
27
+ emf = Emfsvg.from_svg(svg_string)
16
28
 
17
29
  == Command-line tool
18
30
 
19
31
  emfsvg convert INPUT.emf OUTPUT.svg [options]
32
+ emfsvg to-emf INPUT.svg OUTPUT.emf
33
+ emfsvg version
34
+ emfsvg help
20
35
 
21
- Options:
36
+ Options (convert):
22
37
 
23
38
  -w, --width PIXELS Override viewport width
24
39
  -h, --height PIXELS Override viewport height
@@ -28,50 +43,50 @@ Options:
28
43
 
29
44
  == Architecture
30
45
 
31
- `emfsvg` adds a renderer layer on top of `emf`'s domain model:
32
-
33
- Emf::Model::Metafile
34
- v
35
- Emfsvg::Renderer
36
- v
37
- Emfsvg::Visitors::EmrVisitor
38
- v
39
- Emfsvg::SvgBuilder (XML)
40
- v
41
- Emfsvg::DeviceContext (GDI state)
42
- v
43
- Emfsvg::ObjectTable (1-indexed pen/brush/font handles)
44
-
45
- The renderer walks the metafile's records, dispatching each to a
46
- `visit_<record_type>` method on the visitor. State changes (pen,
47
- brush, transform, clip) flow through the `DeviceContext`; persistent
48
- objects are stored in the `ObjectTable`.
49
-
50
- === Handlers
51
-
52
- The visitor handles a useful subset of EMF records out of the box:
53
-
54
- * *Primitives*: `Rectangle`, `Ellipse`, `RoundRect`, `LineTo`,
55
- `MoveToEx`, `Arc` (stub), `Polygon`, `Polyline` + 16-bit variants
56
- * *Paths*: `BeginPath`/`EndPath`/`CloseFigure`/`FillPath`/`StrokePath`/
57
- `StrokeAndFillPath`/`AbortPath`
58
- * *State*: `SetTextColor`, `SetBkColor`, `SetBkMode`, `SetMapMode`,
59
- `SetRop2`, `SetPolyFillMode`, `SetTextAlign`, `SetStretchBltMode`
60
- * *Transform*: `SetWorldTransform`, `ModifyWorldTransform`
61
- * *Objects*: `CreatePen`, `CreateBrushIndirect`, `SelectObject`,
62
- `DeleteObject`, `SaveDc`, `RestoreDc`
63
- * *Misc*: `SetMiterLimit`, `SetWindowExtEx`, `SetViewportExtEx`
64
-
65
- Unsupported record types fall through silently (verbose mode logs
66
- them).
67
-
68
- === Sparx/Wine Y-repair
69
-
70
- EMFs from Sparx Enterprise Architect under Wine have only the Y anchor
71
- negated. The detector (`rclBounds.top * rclBounds.bottom < 0`) and the
72
- per-coordinate repair live in `Emfsvg::Renderer` and
73
- `Emfsvg::Visitors::EmrVisitor#maybe_flip_y`, matching the behaviour
74
- of the C library.
46
+ [cols="1,4"]
47
+ |===
48
+ | EMF → SVG | `Emf::Model::Metafile` → `Emfsvg::Renderer` → `Emfsvg::Visitors::EmrVisitor` → `Emfsvg::SvgBuilder`
49
+ | SVG → EMF | SVG bytes → Nokogiri → `Emfsvg::Svg::Document` → `Emfsvg::Translation::EmfRenderer` → `Emf::Model::Metafile` → `Emf.serialize`
50
+ |===
51
+
52
+ Both directions share:
53
+
54
+ * `Emfsvg::DeviceContext` / `ObjectTable` / `TransformStack` — GDI state
55
+ * `Emf::*` — the `emf` gem's wire record classes + serializer
56
+ * `Emfsvg::SvgMatcher` (spec/support) — lossy semantic SVG comparison
57
+
58
+ === SVG EMF handler registry (OCP)
59
+
60
+ Adding a new SVG element type means writing a handler class and
61
+ registering it no existing handler code changes:
62
+
63
+ # lib/emfsvg/translation/handlers/my_element_handler.rb
64
+ class MyElementHandler
65
+ include SharedGdi
66
+ def self.call(element, context)
67
+ # emit EMF records via context.emit(...)
68
+ end
69
+ end
70
+
71
+ # Register in EmfRenderer.register_default_handlers!
72
+ DEFAULT_REGISTRY.register(Svg::Elements::MyElement, Handlers::MyElementHandler)
73
+
74
+ === Lossy SVG matcher
75
+
76
+ `Emfsvg::SvgMatcher` compares two SVG documents semantically:
77
+
78
+ * Float tolerance (configurable, default 1.0px)
79
+ * Auto-generated ID canonicalisation (`clip-N`, `img-N`)
80
+ * libemf2svg typo normalisation (`0.00 00` `0.0000`)
81
+ * `<defs>` hoisting + multiset comparison
82
+ * `<g transform><X/></g>` ≡ `<X transform/>` flattening
83
+
84
+ Used by:
85
+
86
+ * `spec/round_trip_spec.rb` SVG→EMF→SVG round-trip verification
87
+ * `spec/fidelity_regression_spec.rb` EMF→SVG fidelity vs libemf2svg
88
+ * `scripts/run_round_trip.rb` full golden-master round-trip scan
89
+ * `scripts/run_emf_svg_compare.rb` full fidelity scan vs libemf2svg
75
90
 
76
91
  == Installation
77
92
 
@@ -92,12 +107,25 @@ Requires Ruby >= 3.1.
92
107
  bundle exec rspec
93
108
  bundle exec rubocop
94
109
 
95
- == Status
96
-
97
- EMF-to-SVG renders the common subset of records from the EMF
98
- fixtures. Bitmap records (StretchDIBits, AlphaBlend, etc.) and
99
- EMF+ drawing records are not yet rendered. Corrupted inputs are
100
- resilient through the `emf` parser's hardened error handling.
110
+ === Developer scripts
111
+
112
+ [cols="1,3"]
113
+ |===
114
+ | `scripts/run_emf_svg_compare.rb [limit] [tolerance]` | EMF→SVG fidelity vs libemf2svg (208 fixtures, 100% pass at 0.1px)
115
+ | `scripts/run_round_trip.rb [limit] [tolerance]` | SVG→EMF→SVG round-trip scan
116
+ | `scripts/diff_fixture.rb <fixture.svg>` | Element-by-element diff between input and round-trip output
117
+ | `scripts/diagnose_round_trip.rb [limit]` | Per-fixture failure reasons for round-trip scan
118
+ | `scripts/regenerate_golden.rb` | Regenerate libemf2svg + emfsvg golden baselines
119
+ | `scripts/run_compat_check.rb` | Legacy compat matrix vs libemf2svg
120
+ |===
121
+
122
+ == Dependencies
123
+
124
+ * `emf` (~> 0.1) — EMF binary parser, domain model, and serializer
125
+ * `nokogiri` (~> 1.16) — SVG parser
126
+ * `fontisan` — cmap parsing for glyph-index text
127
+ * `libpng` (~> 1.6) — PNG encode/decode for image round-trip
128
+ * `base64`, `bigdecimal` — stdlib
101
129
 
102
130
  == License
103
131
 
data/exe/emfsvg CHANGED
@@ -13,6 +13,7 @@ module EmfsvgCLI
13
13
  command = argv.shift
14
14
  case command
15
15
  when "convert" then convert(argv)
16
+ when "to-emf" then to_emf(argv)
16
17
  when "version", "-V", "--version"
17
18
  puts "emfsvg #{Emfsvg::VERSION}"
18
19
  when "help", "-h", "--help", nil
@@ -47,16 +48,34 @@ module EmfsvgCLI
47
48
  warn "Wrote #{output} (#{svg.bytesize} bytes)"
48
49
  end
49
50
 
51
+ def to_emf(argv)
52
+ parser = OptionParser.new do |opts|
53
+ opts.banner = "Usage: emfsvg to-emf INPUT.svg OUTPUT.emf"
54
+ end
55
+
56
+ args = parser.parse(argv)
57
+ if args.length != 2
58
+ warn parser
59
+ exit 2
60
+ end
61
+
62
+ input, output = args
63
+ emf = Emfsvg.from_svg_file(input)
64
+ File.binwrite(output, emf)
65
+ warn "Wrote #{output} (#{emf.bytesize} bytes)"
66
+ end
67
+
50
68
  def print_help
51
69
  puts <<~HELP
52
- emfsvg #{Emfsvg::VERSION} — EMF to SVG transformer
70
+ emfsvg #{Emfsvg::VERSION} — EMF <-> SVG transformer
53
71
 
54
72
  Usage:
55
73
  emfsvg convert INPUT.emf OUTPUT.svg [options]
74
+ emfsvg to-emf INPUT.svg OUTPUT.emf
56
75
  emfsvg version
57
76
  emfsvg help
58
77
 
59
- Options:
78
+ Options (convert):
60
79
  -w, --width PIXELS Override viewport width
61
80
  -h, --height PIXELS Override viewport height
62
81
  -n, --namespace NAME XML namespace prefix (e.g. svg)
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emfsvg
4
+ # Encode RGBA pixels + dimensions to Windows DIB (BITMAPINFOHEADER +
5
+ # pixel array) suitable for embedding in EMR_STRETCHDIBITS and
6
+ # related records. The inverse of emfsvg's DibDecoder.
7
+ #
8
+ # Output layout (BI_RGB, 32-bpp, bottom-up):
9
+ # * BITMAPINFOHEADER (40 bytes)
10
+ # * pixel array (BGRA per pixel, row-major from bottom to top)
11
+ module DibEncoder
12
+ BITMAPINFOHEADER_SIZE = 40
13
+ BI_RGB = 0
14
+
15
+ module_function
16
+
17
+ def encode(width, height, rgba_bytes)
18
+ return "" if width <= 0 || height <= 0
19
+ return "" if rgba_bytes.nil? || rgba_bytes.bytesize < (width * height * 4)
20
+
21
+ header = build_header(width, height)
22
+ pixels = bgra_pixel_array(width, height, rgba_bytes)
23
+ header + pixels
24
+ end
25
+
26
+ def build_header(width, height)
27
+ pixel_bytes = width * height * 4
28
+ [
29
+ BITMAPINFOHEADER_SIZE, # biSize (uint32)
30
+ width, # biWidth (int32)
31
+ height, # biHeight (int32, positive = bottom-up)
32
+ 1, # biPlanes (uint16)
33
+ 32, # biBitCount (uint16)
34
+ BI_RGB, # biCompression (uint32)
35
+ pixel_bytes, # biSizeImage (uint32)
36
+ 2835, # biXPelsPerMeter (~72 DPI)
37
+ 2835, # biYPelsPerMeter
38
+ 0, # biClrUsed
39
+ 0 # biClrImportant
40
+ ].pack("Vl<l<vvVVVVVV")
41
+ end
42
+
43
+ # Convert RGBA → BGRA (the byte order DIB expects), keeping the
44
+ # bottom-up row order (rows reversed).
45
+ def bgra_pixel_array(width, height, rgba_bytes)
46
+ row_size = width * 4
47
+ out = String.new capacity: width * height * 4
48
+ (0...height).to_a.reverse_each do |src_row|
49
+ row_start = src_row * row_size
50
+ row_bytes = rgba_bytes.byteslice(row_start, row_size)
51
+ out << swap_rb_in_row(row_bytes)
52
+ end
53
+ out
54
+ end
55
+
56
+ def swap_rb_in_row(row_bytes)
57
+ bytes = row_bytes.dup
58
+ (0...bytes.bytesize).step(4).each do |i|
59
+ r = bytes.getbyte(i)
60
+ b = bytes.getbyte(i + 2)
61
+ bytes.setbyte(i, b)
62
+ bytes.setbyte(i + 2, r)
63
+ end
64
+ bytes
65
+ end
66
+ end
67
+ end
data/lib/emfsvg/error.rb CHANGED
@@ -5,5 +5,7 @@ module Emfsvg
5
5
 
6
6
  class FormatError < Error; end
7
7
 
8
+ class ParseError < Error; end
9
+
8
10
  class RenderError < Error; end
9
11
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "libpng"
4
+
5
+ module Emfsvg
6
+ # Decode PNG bytes to RGBA pixels. Wraps the libpng simplified API.
7
+ # Returns a Result with width, height, and a packed RGBA pixel buffer.
8
+ module PngDecoder
9
+ Result = Struct.new(:width, :height, :pixels, keyword_init: true)
10
+
11
+ module_function
12
+
13
+ def decode(png_bytes)
14
+ return nil if png_bytes.nil? || png_bytes.empty?
15
+
16
+ decoded = Libpng.decode(png_bytes, pixel_format: "RGBA")
17
+ Result.new(width: decoded.width, height: decoded.height,
18
+ pixels: decoded.pixels)
19
+ rescue Libpng::Error
20
+ nil
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emfsvg
4
+ module Svg
5
+ # Helpers for parsing common SVG attribute formats (numbers, points,
6
+ # path data). Used by Element subclasses.
7
+ module AttributeParser
8
+ module_function
9
+
10
+ def float(value, default: 0.0)
11
+ return default if value.nil? || value.strip.empty?
12
+
13
+ match = value.strip.match(/\A(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/)
14
+ match ? match[1].to_f : default
15
+ end
16
+
17
+ def integer(value, default: 0)
18
+ float(value, default: default.to_f).to_i
19
+ end
20
+
21
+ # Parse a points list like "1,2 3,4 5,6" into an array of [x, y]
22
+ # pairs.
23
+ def points(value)
24
+ return [] if value.nil? || value.strip.empty?
25
+
26
+ nums = value.strip.split(/[\s,]+/).map(&:to_f)
27
+ nums.each_slice(2).to_a
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emfsvg
4
+ module Svg
5
+ # Resolves `clip-path="url(#X)"` references to ClipPath value
6
+ # objects. Built once per Svg::Document by walking the <defs>
7
+ # section; queried by the translation layer when emitting clip
8
+ # records for a shape.
9
+ class ClipPathRegistry
10
+ URL_RE = /\Aurl\(\s*#([^)\s]+)\s*\)\z/
11
+
12
+ def initialize
13
+ @clip_paths = {}
14
+ end
15
+
16
+ def register(clip_path)
17
+ @clip_paths[clip_path.id] = clip_path
18
+ end
19
+
20
+ def lookup(clip_path_attr)
21
+ return nil if clip_path_attr.nil?
22
+
23
+ match = clip_path_attr.match(URL_RE)
24
+ return nil unless match
25
+
26
+ @clip_paths[match[1]]
27
+ end
28
+
29
+ def self.from_document(document)
30
+ registry = new
31
+ collect_clip_paths(document.root_element, registry)
32
+ registry
33
+ end
34
+
35
+ def self.collect_clip_paths(element, registry)
36
+ return unless element
37
+
38
+ registry.register(element) if element.is_a?(Elements::ClipPath)
39
+ element.children.each { |c| collect_clip_paths(c, registry) }
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emfsvg
4
+ module Svg
5
+ # SVG color value object. Holds an RGB triplet (0..255 each) or
6
+ # represents a "null" color (fill="none" / stroke="none").
7
+ #
8
+ # Parses CSS color syntax:
9
+ # * #RGB / #RRGGBB hex
10
+ # * rgb(r, g, b) functional
11
+ # * CSS Level 1+2 named colors (17 basic)
12
+ #
13
+ # Anything else raises Emfsvg::FormatError. The translation layer
14
+ # relies on a normalized RGB triplet — no transparency, no currentColor.
15
+ class Color
16
+ attr_reader :red, :green, :blue
17
+
18
+ def initialize(red:, green:, blue:)
19
+ @red = clamp(red)
20
+ @green = clamp(green)
21
+ @blue = clamp(blue)
22
+ end
23
+
24
+ def null?
25
+ false
26
+ end
27
+
28
+ def to_emf_wire
29
+ Emf::Model::Geometry::Color.new(red: red, green: green, blue: blue)
30
+ end
31
+
32
+ def ==(other)
33
+ other.is_a?(self.class) && red == other.red && green == other.green && blue == other.blue
34
+ end
35
+ alias eql? ==
36
+
37
+ def hash
38
+ [self.class, red, green, blue].hash
39
+ end
40
+
41
+ NULL = NullColor = Class.new do
42
+ def null? = true
43
+ def red = 0
44
+ def green = 0
45
+ def blue = 0
46
+ def to_emf_wire = Emf::Model::Geometry::Color.new(red: 0, green: 0, blue: 0)
47
+ def ==(other) = other.is_a?(self.class)
48
+ alias_method :eql?, :==
49
+ def hash = NullColor.hash
50
+ end.new.freeze
51
+
52
+ NAMED_COLORS = {
53
+ "black" => [0x00, 0x00, 0x00],
54
+ "silver" => [0xC0, 0xC0, 0xC0],
55
+ "gray" => [0x80, 0x80, 0x80],
56
+ "grey" => [0x80, 0x80, 0x80],
57
+ "white" => [0xFF, 0xFF, 0xFF],
58
+ "maroon" => [0x80, 0x00, 0x00],
59
+ "red" => [0xFF, 0x00, 0x00],
60
+ "purple" => [0x80, 0x00, 0x80],
61
+ "fuchsia" => [0xFF, 0x00, 0xFF],
62
+ "magenta" => [0xFF, 0x00, 0xFF],
63
+ "green" => [0x00, 0x80, 0x00],
64
+ "lime" => [0x00, 0xFF, 0x00],
65
+ "olive" => [0x80, 0x80, 0x00],
66
+ "yellow" => [0xFF, 0xFF, 0x00],
67
+ "navy" => [0x00, 0x00, 0x80],
68
+ "blue" => [0x00, 0x00, 0xFF],
69
+ "teal" => [0x00, 0x80, 0x80],
70
+ "aqua" => [0x00, 0xFF, 0xFF],
71
+ "cyan" => [0x00, 0xFF, 0xFF]
72
+ }.freeze
73
+
74
+ def self.parse(value)
75
+ return NULL if value.nil? || value.strip == "none" || value.strip.empty?
76
+
77
+ v = value.strip
78
+ if (m = v.match(/\A#([0-9a-fA-F]{3})\z/))
79
+ expand_short_hex(m[1])
80
+ elsif (m = v.match(/\A#([0-9a-fA-F]{6})\z/))
81
+ new(red: m[1][0, 2].to_i(16), green: m[1][2, 2].to_i(16), blue: m[1][4, 2].to_i(16))
82
+ elsif (m = v.match(/\Argb\(\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*\)\z/i))
83
+ new(red: m[1].to_i, green: m[2].to_i, blue: m[3].to_i)
84
+ elsif NAMED_COLORS.key?(v.downcase)
85
+ r, g, b = NAMED_COLORS[v.downcase]
86
+ new(red: r, green: g, blue: b)
87
+ else
88
+ raise FormatError, "unsupported SVG color: #{value.inspect}"
89
+ end
90
+ end
91
+
92
+ def self.expand_short_hex(digits)
93
+ new(red: (digits[0] * 2).to_i(16),
94
+ green: (digits[1] * 2).to_i(16),
95
+ blue: (digits[2] * 2).to_i(16))
96
+ end
97
+
98
+ private
99
+
100
+ def clamp(byte)
101
+ byte.to_i.clamp(0, 255)
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emfsvg
4
+ module Svg
5
+ # Root of a parsed SVG document. Carries the viewport geometry from
6
+ # the outer <svg width height viewBox> element plus the (lazily
7
+ # parsed) child element tree.
8
+ #
9
+ # emfsvg's EMF→SVG renderer always wraps content in
10
+ # `<g transform="translate(-bounds.left, -bounds.top)">`. For
11
+ # byte-clean round-trip, the SVG→EMF side detects this "header
12
+ # translate group" and uses it to recover the EMF bounds origin
13
+ # instead of emitting a redundant SetWorldTransform.
14
+ class Document
15
+ def initialize(width:, height:, root_element:, view_box: nil)
16
+ @width = width
17
+ @height = height
18
+ @view_box = view_box
19
+ @root_element = root_element
20
+ end
21
+
22
+ attr_reader :width, :height, :view_box, :root_element
23
+
24
+ # The Group that represents the emfsvg-emitted header translate,
25
+ # or nil if no such group is present. This group's contents are
26
+ # dispatched directly (without the wrapping translate); the
27
+ # translate feeds the EMF header bounds instead.
28
+ def header_translate_group
29
+ candidate = first_child
30
+ return nil unless candidate.is_a?(Elements::Group)
31
+
32
+ matrix = candidate.transform_matrix
33
+ return nil unless matrix
34
+ return nil unless translate_only?(matrix)
35
+
36
+ candidate
37
+ end
38
+
39
+ # A matrix equivalent to a pure translate: identity 2×2 portion
40
+ # (modulo floating-point rounding).
41
+ IDENTITY_TOLERANCE = 1e-9
42
+
43
+ def translate_only?(matrix)
44
+ (matrix.m11 - 1.0).abs < IDENTITY_TOLERANCE &&
45
+ (matrix.m22 - 1.0).abs < IDENTITY_TOLERANCE &&
46
+ matrix.m12.abs < IDENTITY_TOLERANCE &&
47
+ matrix.m21.abs < IDENTITY_TOLERANCE
48
+ end
49
+
50
+ # [min_x, min_y, w, h] for the EMF bounds.
51
+ def bounds
52
+ return view_box if view_box
53
+
54
+ group = header_translate_group
55
+ if group
56
+ matrix = group.transform_matrix
57
+ return [-matrix.dx, -matrix.dy, width, height]
58
+ end
59
+
60
+ [0, 0, width, height]
61
+ end
62
+
63
+ private
64
+
65
+ def first_child
66
+ root_element.is_a?(Element) ? root_element.children.first : nil
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emfsvg
4
+ module Svg
5
+ # Base value object for an SVG element. Subclasses (Svg::Elements::*)
6
+ # carry typed semantic fields; the base provides a uniform interface
7
+ # for the translation layer's dispatch.
8
+ #
9
+ # Element instances are immutable: built once from a Nokogiri node
10
+ # via the .from_node factory. The node is not retained.
11
+ class Element
12
+ ELEMENT_NAME = "abstract"
13
+
14
+ def element_name
15
+ self.class::ELEMENT_NAME
16
+ end
17
+
18
+ def children
19
+ []
20
+ end
21
+
22
+ # All elements expose `clip_path` (nil by default) so the SharedGdi
23
+ # module can treat every element uniformly. Subclasses with the
24
+ # attribute override via attr_reader.
25
+ def clip_path
26
+ nil
27
+ end
28
+
29
+ # Registry: SVG element name -> Element subclass. OCP: register
30
+ # new subclasses via Element.register("rect", Elements::Rect)
31
+ # instead of editing a switch.
32
+ @registry = {}
33
+
34
+ class << self
35
+ attr_reader :registry
36
+
37
+ def register(element_name, subclass)
38
+ @registry[element_name] = subclass
39
+ end
40
+
41
+ def from_node(node)
42
+ subclass = @registry[node.name]
43
+ return subclass.from_node(node) if subclass
44
+
45
+ OpenElement.new(name: node.name, children: node.element_children.map do |c|
46
+ from_node(c)
47
+ end)
48
+ end
49
+ end
50
+ end
51
+
52
+ # Fallback for unknown SVG element types. Carries the element name
53
+ # so handlers can skip it or warn; carries children so traversal
54
+ # still descends into known subtrees.
55
+ class OpenElement < Element
56
+ ELEMENT_NAME = "open"
57
+
58
+ attr_reader :name, :children
59
+
60
+ def initialize(name:, children: [])
61
+ @name = name
62
+ @children = children.freeze
63
+ end
64
+ end
65
+ end
66
+ end