emfsvg 0.1.0 → 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.
- checksums.yaml +4 -4
- data/README.adoc +86 -58
- data/exe/emfsvg +21 -2
- data/lib/emfsvg/dib_encoder.rb +67 -0
- data/lib/emfsvg/error.rb +2 -0
- data/lib/emfsvg/png_decoder.rb +23 -0
- data/lib/emfsvg/svg/attribute_parser.rb +31 -0
- data/lib/emfsvg/svg/clip_path_registry.rb +43 -0
- data/lib/emfsvg/svg/color.rb +105 -0
- data/lib/emfsvg/svg/document.rb +70 -0
- data/lib/emfsvg/svg/element.rb +66 -0
- data/lib/emfsvg/svg/elements/circle.rb +38 -0
- data/lib/emfsvg/svg/elements/clip_path.rb +128 -0
- data/lib/emfsvg/svg/elements/defs.rb +24 -0
- data/lib/emfsvg/svg/elements/ellipse.rb +35 -0
- data/lib/emfsvg/svg/elements/group.rb +33 -0
- data/lib/emfsvg/svg/elements/image.rb +65 -0
- data/lib/emfsvg/svg/elements/line.rb +35 -0
- data/lib/emfsvg/svg/elements/path.rb +29 -0
- data/lib/emfsvg/svg/elements/polygon.rb +29 -0
- data/lib/emfsvg/svg/elements/polyline.rb +29 -0
- data/lib/emfsvg/svg/elements/rect.rb +44 -0
- data/lib/emfsvg/svg/elements/stylable.rb +28 -0
- data/lib/emfsvg/svg/elements/text.rb +67 -0
- data/lib/emfsvg/svg/elements.rb +42 -0
- data/lib/emfsvg/svg/paint.rb +36 -0
- data/lib/emfsvg/svg/parser.rb +44 -0
- data/lib/emfsvg/svg/path_data/command.rb +15 -0
- data/lib/emfsvg/svg/path_data.rb +135 -0
- data/lib/emfsvg/svg/stroke.rb +107 -0
- data/lib/emfsvg/svg/transform_parser.rb +123 -0
- data/lib/emfsvg/svg.rb +25 -0
- data/lib/emfsvg/translation/context.rb +99 -0
- data/lib/emfsvg/translation/decimal_detector.rb +112 -0
- data/lib/emfsvg/translation/emf_renderer.rb +146 -0
- data/lib/emfsvg/translation/handler_registry.rb +38 -0
- data/lib/emfsvg/translation/handlers/circle_handler.rb +17 -0
- data/lib/emfsvg/translation/handlers/clip_path_handler.rb +16 -0
- data/lib/emfsvg/translation/handlers/defs_handler.rb +17 -0
- data/lib/emfsvg/translation/handlers/ellipse_handler.rb +28 -0
- data/lib/emfsvg/translation/handlers/group_handler.rb +40 -0
- data/lib/emfsvg/translation/handlers/image_handler.rb +94 -0
- data/lib/emfsvg/translation/handlers/line_handler.rb +30 -0
- data/lib/emfsvg/translation/handlers/path_flattener.rb +139 -0
- data/lib/emfsvg/translation/handlers/path_handler.rb +78 -0
- data/lib/emfsvg/translation/handlers/polygon_handler.rb +17 -0
- data/lib/emfsvg/translation/handlers/polyline_handler.rb +51 -0
- data/lib/emfsvg/translation/handlers/rect_handler.rb +43 -0
- data/lib/emfsvg/translation/handlers/shared_gdi.rb +97 -0
- data/lib/emfsvg/translation/handlers/text_handler.rb +137 -0
- data/lib/emfsvg/translation/handlers.rb +25 -0
- data/lib/emfsvg/translation/header_builder.rb +89 -0
- data/lib/emfsvg/translation/record_emitter.rb +36 -0
- data/lib/emfsvg/translation/scaler/fixed.rb +41 -0
- data/lib/emfsvg/translation/scaler/identity.rb +23 -0
- data/lib/emfsvg/translation/scaler.rb +19 -0
- data/lib/emfsvg/translation.rb +16 -0
- data/lib/emfsvg/version.rb +1 -1
- data/lib/emfsvg.rb +21 -1
- metadata +62 -7
- data/lib/emfsvg/compat.rb +0 -134
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5486d21c06edc95e8adcdd149befbac957e05eb18b3d4cb89a6ac556cf991f68
|
|
4
|
+
data.tar.gz: 423352d0507ab33950c3dbe22cfba957e76af5f48961dd77ab782b58714eaf75
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2f2bdd7812cca71e2c160a8cba35a825c523678496db81176ba9f5f09aa0bbe5626e54d38aaaf8562b004c1a601c931a5a224a1c85a69d6ac770abbd617b9323
|
|
7
|
+
data.tar.gz: da3a81e8f1a56e7e213c25cb83621430c1f242b1b00edbf37d4c41c525ac5dc83b78342f095a2cafdca9fbc972c7625f983e9d4f07f55077eb0d2de9f79f58f6
|
data/README.adoc
CHANGED
|
@@ -1,24 +1,39 @@
|
|
|
1
|
-
= emfsvg: pure-Ruby EMF
|
|
1
|
+
= emfsvg: pure-Ruby EMF ↔ SVG transformer
|
|
2
2
|
|
|
3
|
-
`emfsvg` converts Microsoft Enhanced Metafile (EMF)
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
*
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
`
|
|
74
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
EMF
|
|
100
|
-
|
|
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
|
|
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
|
@@ -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
|