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.
- 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 -0
- metadata +73 -3
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
|
|
5
|
+
module Emfsvg
|
|
6
|
+
module Svg
|
|
7
|
+
# Parses an SVG document string into a Svg::Document value object.
|
|
8
|
+
# Nokogiri is the only XML/HTML parser used in this gem.
|
|
9
|
+
#
|
|
10
|
+
# Width/height are coerced to floats (CSS units other than px are
|
|
11
|
+
# out of scope for v1; a numeric value is treated as user units).
|
|
12
|
+
# viewBox is parsed into a 4-tuple [min_x, min_y, width, height].
|
|
13
|
+
module Parser
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def call(string)
|
|
17
|
+
doc = Nokogiri::XML(string, &:noblanks)
|
|
18
|
+
root = doc.root
|
|
19
|
+
raise ParseError, "not an SVG document: missing root element" unless root
|
|
20
|
+
|
|
21
|
+
Document.new(
|
|
22
|
+
width: parse_dimension(root["width"]),
|
|
23
|
+
height: parse_dimension(root["height"]),
|
|
24
|
+
view_box: parse_view_box(root["viewBox"]),
|
|
25
|
+
root_element: Element.from_node(root)
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def parse_dimension(value)
|
|
30
|
+
return 0.0 if value.nil? || value.empty?
|
|
31
|
+
|
|
32
|
+
match = value.match(/\A(-?\d+(?:\.\d+)?)/)
|
|
33
|
+
match ? match[1].to_f : 0.0
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def parse_view_box(value)
|
|
37
|
+
return nil if value.nil? || value.empty?
|
|
38
|
+
|
|
39
|
+
parts = value.split(/[\s,]+/).map(&:to_f)
|
|
40
|
+
parts.length == 4 ? parts : nil
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Emfsvg
|
|
4
|
+
module Svg
|
|
5
|
+
module PathData
|
|
6
|
+
# One parsed SVG path command (e.g. "M 1 2" or "L 3 4").
|
|
7
|
+
# `letter` preserves case (absolute uppercase vs relative lowercase).
|
|
8
|
+
Command = Struct.new(:letter, :args, keyword_init: true) do
|
|
9
|
+
def absolute?
|
|
10
|
+
letter == letter.upcase
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Emfsvg
|
|
4
|
+
module Svg
|
|
5
|
+
# Namespace for the SVG path-data parser. Pure parser, no EMF
|
|
6
|
+
# concerns. The translation layer (Phase 03 path handler) consumes
|
|
7
|
+
# the resulting Command list.
|
|
8
|
+
module PathData
|
|
9
|
+
autoload :Command, "emfsvg/svg/path_data/command"
|
|
10
|
+
|
|
11
|
+
# Arg count per command letter. M repeats as L (handled in parser).
|
|
12
|
+
ARG_COUNTS = {
|
|
13
|
+
"M" => 2, "L" => 2, "H" => 1, "V" => 1, "C" => 6, "S" => 4,
|
|
14
|
+
"Q" => 4, "T" => 2, "A" => 7, "Z" => 0
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
# Stateful parser walks the string once, emitting Commands as
|
|
18
|
+
# enough args accumulate for the current letter. Implicit repeats:
|
|
19
|
+
# subsequent arg groups after M/m are treated as L/l.
|
|
20
|
+
module Parser
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def parse(string)
|
|
24
|
+
return [] if string.nil? || string.empty?
|
|
25
|
+
|
|
26
|
+
commands = []
|
|
27
|
+
scanner = Scanner.new(string)
|
|
28
|
+
current_letter = nil
|
|
29
|
+
args = []
|
|
30
|
+
|
|
31
|
+
while (token = scanner.next_token)
|
|
32
|
+
case token
|
|
33
|
+
when Symbol # command letter
|
|
34
|
+
flush_command(commands, current_letter, args) if current_letter && args.any?
|
|
35
|
+
current_letter = token.to_s
|
|
36
|
+
args = []
|
|
37
|
+
# Zero-arg commands (Z/z) emit immediately.
|
|
38
|
+
if ARG_COUNTS.fetch(current_letter.upcase, 1).zero?
|
|
39
|
+
emit_command(commands, current_letter, [])
|
|
40
|
+
current_letter = nil
|
|
41
|
+
end
|
|
42
|
+
when Numeric
|
|
43
|
+
raise FormatError, "path data begins with a number: #{string.inspect}" if current_letter.nil?
|
|
44
|
+
|
|
45
|
+
args << token
|
|
46
|
+
if args.size == ARG_COUNTS.fetch(current_letter.upcase)
|
|
47
|
+
emit_command(commands, current_letter, args)
|
|
48
|
+
args = []
|
|
49
|
+
current_letter = implicit_repeat_letter(current_letter)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
flush_command(commands, current_letter, args) if current_letter && args.any?
|
|
55
|
+
commands
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def emit_command(commands, letter, args)
|
|
59
|
+
raise FormatError, "unknown path command: #{letter}" unless ARG_COUNTS.key?(letter.upcase)
|
|
60
|
+
|
|
61
|
+
commands << Command.new(letter: letter, args: args.dup)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def flush_command(_commands, letter, args)
|
|
65
|
+
return unless letter
|
|
66
|
+
return if args.empty?
|
|
67
|
+
|
|
68
|
+
expected = ARG_COUNTS.fetch(letter.upcase, 0)
|
|
69
|
+
return if args.size == expected
|
|
70
|
+
|
|
71
|
+
raise FormatError, "path command #{letter} expected #{expected} args, got #{args.size}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# M/m repeats as L/l per SVG spec; all other commands repeat as
|
|
75
|
+
# themselves.
|
|
76
|
+
def implicit_repeat_letter(letter)
|
|
77
|
+
case letter
|
|
78
|
+
when "M" then "L"
|
|
79
|
+
when "m" then "l"
|
|
80
|
+
else letter
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Character-stream tokenizer for path data. Emits command letters
|
|
86
|
+
# as Symbols (e.g. :M, :z) and numbers as Floats.
|
|
87
|
+
class Scanner
|
|
88
|
+
NUMBER_RE = /[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/
|
|
89
|
+
|
|
90
|
+
def initialize(string)
|
|
91
|
+
@string = string
|
|
92
|
+
@pos = 0
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def next_token
|
|
96
|
+
skip_separators
|
|
97
|
+
return nil if @pos >= @string.length
|
|
98
|
+
|
|
99
|
+
char = @string[@pos]
|
|
100
|
+
if command_letter?(char)
|
|
101
|
+
@pos += 1
|
|
102
|
+
char.to_sym
|
|
103
|
+
else
|
|
104
|
+
read_number
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def skip_separators
|
|
111
|
+
@pos += 1 while @pos < @string.length && separator?(@string[@pos])
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def separator?(char)
|
|
115
|
+
case char
|
|
116
|
+
when " ", "\t", "\n", "\r", "," then true
|
|
117
|
+
else false
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def command_letter?(char)
|
|
122
|
+
("A".."Z").cover?(char) || ("a".."z").cover?(char)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def read_number
|
|
126
|
+
match = @string.match(NUMBER_RE, @pos)
|
|
127
|
+
raise FormatError, "expected number at position #{@pos} in path data" unless match
|
|
128
|
+
|
|
129
|
+
@pos = match.end(0)
|
|
130
|
+
match.to_s.to_f
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Emfsvg
|
|
4
|
+
module Svg
|
|
5
|
+
# Parsed SVG stroke style: color, width, dash pattern, line cap,
|
|
6
|
+
# line join. Maps directly to the EMF LOGPEN / EXTLOGPEN32 fields.
|
|
7
|
+
#
|
|
8
|
+
# Pen style bits (per MS-WMF 2.1.1.8 / emr_visitor.rb mirror):
|
|
9
|
+
#
|
|
10
|
+
# PS_SOLID=0, PS_DASH=1, PS_DOT=2, PS_DASHDOT=3, PS_DASHDOTDOT=4,
|
|
11
|
+
# PS_NULL=5
|
|
12
|
+
#
|
|
13
|
+
# bits 8-11: PS_ENDCAP_ROUND=0x0000, _SQUARE=0x0100, _FLAT=0x0200
|
|
14
|
+
# bits 12-15: PS_JOIN_ROUND=0x0000, _BEVEL=0x1000, _MITER=0x2000
|
|
15
|
+
#
|
|
16
|
+
# emfsvg's EMF→SVG renderer emits `stroke-width="1px"` (with the px
|
|
17
|
+
# suffix) AND no linecap/linejoin attrs as the fallback for a NULL
|
|
18
|
+
# pen with a solid brush. When the SVG→EMF side sees that exact
|
|
19
|
+
# form, it round-trips back to a NULL pen to preserve the rendering.
|
|
20
|
+
class Stroke
|
|
21
|
+
END_CAP_BITS = { "butt" => 0x0200, "round" => 0x0000, "square" => 0x0100 }.freeze
|
|
22
|
+
LINE_JOIN_BITS = { "miter" => 0x2000, "round" => 0x0000, "bevel" => 0x1000 }.freeze
|
|
23
|
+
NULL_PEN_WIDTH_SENTINEL = "1px"
|
|
24
|
+
|
|
25
|
+
attr_reader :paint, :width, :dash_array, :line_cap, :line_join, :raw_width,
|
|
26
|
+
:line_cap_explicit, :line_join_explicit
|
|
27
|
+
|
|
28
|
+
def initialize(paint:, width: 1.0, dash_array: nil, line_cap: "round",
|
|
29
|
+
line_join: "round", raw_width: nil,
|
|
30
|
+
line_cap_explicit: false, line_join_explicit: false)
|
|
31
|
+
@paint = paint
|
|
32
|
+
@width = width.to_f
|
|
33
|
+
@dash_array = dash_array
|
|
34
|
+
@line_cap = line_cap
|
|
35
|
+
@line_join = line_join
|
|
36
|
+
@raw_width = raw_width
|
|
37
|
+
@line_cap_explicit = line_cap_explicit
|
|
38
|
+
@line_join_explicit = line_join_explicit
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def null_pen_sentinel?
|
|
42
|
+
raw_width == NULL_PEN_WIDTH_SENTINEL &&
|
|
43
|
+
!line_cap_explicit &&
|
|
44
|
+
!line_join_explicit
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
PS_GEOMETRIC = 0x00010000
|
|
48
|
+
|
|
49
|
+
# Pen style bitmask (full 32 bits). Includes:
|
|
50
|
+
# * dash style (bits 0-7)
|
|
51
|
+
# * end-cap (bits 8-11)
|
|
52
|
+
# * line-join (bits 12-15)
|
|
53
|
+
# * pen type GEOMETRIC (bit 16) — set when width > 1, so
|
|
54
|
+
# emfsvg's EMF→SVG renderer uses the actual pen.width
|
|
55
|
+
# instead of hardcoding 1.
|
|
56
|
+
def pen_style
|
|
57
|
+
base = if paint.null? || null_pen_sentinel?
|
|
58
|
+
5 # PS_NULL
|
|
59
|
+
else
|
|
60
|
+
dash_style
|
|
61
|
+
end
|
|
62
|
+
type_bit = width > 1.0 && !paint.null? ? PS_GEOMETRIC : 0
|
|
63
|
+
base | type_bit | END_CAP_BITS.fetch(line_cap, 0x0000) | LINE_JOIN_BITS.fetch(line_join, 0x0000)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.parse(stroke:, width:, dash_array:, line_cap:, line_join:)
|
|
67
|
+
new(
|
|
68
|
+
paint: Paint.from_stroke(stroke),
|
|
69
|
+
width: parse_width(width),
|
|
70
|
+
dash_array: parse_dash_array(dash_array),
|
|
71
|
+
line_cap: line_cap || "round",
|
|
72
|
+
line_join: line_join || "round",
|
|
73
|
+
raw_width: width,
|
|
74
|
+
line_cap_explicit: !line_cap.nil?,
|
|
75
|
+
line_join_explicit: !line_join.nil?
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.parse_width(value)
|
|
80
|
+
return 1.0 if value.nil? || value.strip.empty?
|
|
81
|
+
|
|
82
|
+
match = value.match(/\A(-?\d+(?:\.\d+)?)/)
|
|
83
|
+
match ? match[1].to_f : 1.0
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# SVG dasharray is a comma/space separated list of lengths; "none"
|
|
87
|
+
# means solid. We map the first length to a discrete dash style:
|
|
88
|
+
# a single length matching roughly the width → PS_DOT, longer →
|
|
89
|
+
# PS_DASH. Anything more complex is approximated as PS_DASH for v1.
|
|
90
|
+
def dash_style
|
|
91
|
+
return 0 if dash_array.nil? || dash_array.empty?
|
|
92
|
+
|
|
93
|
+
first = dash_array.first
|
|
94
|
+
return 0 if first.zero?
|
|
95
|
+
|
|
96
|
+
first < (width * 2) ? 2 : 1 # PS_DOT=2, PS_DASH=1
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.parse_dash_array(value)
|
|
100
|
+
return nil if value.nil? || value.strip == "none" || value.strip.empty?
|
|
101
|
+
|
|
102
|
+
parts = value.split(/[\s,]+/).map(&:to_f)
|
|
103
|
+
parts.empty? ? nil : parts
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Emfsvg
|
|
4
|
+
module Svg
|
|
5
|
+
# Parse SVG `transform="..."` attribute syntax into a single
|
|
6
|
+
# `Emf::Model::Geometry::Matrix`. Handles translate(), scale(),
|
|
7
|
+
# rotate(), matrix(), skewX(), skewY().
|
|
8
|
+
#
|
|
9
|
+
# Convention: the emf gem's Matrix uses EMF's row-vector layout
|
|
10
|
+
# (v_new = v * M) — same as emfsvg's existing emr_visitor multiply.
|
|
11
|
+
# SVG `transform="A B"` means "apply A first, then B to the result"
|
|
12
|
+
# in row-vector terms, which equals the matrix product A * B.
|
|
13
|
+
module TransformParser
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
TRANSFORM_FN_RE = /([a-zA-Z]+)\s*\(([^)]*)\)/
|
|
17
|
+
|
|
18
|
+
def parse(string)
|
|
19
|
+
return Emf::Model::Geometry::Matrix.identity if string.nil? || string.strip.empty?
|
|
20
|
+
|
|
21
|
+
# Row-vector convention: v_new = v * M. SVG `transform="A B"`
|
|
22
|
+
# applies B first then A to local coords, which in row-vector
|
|
23
|
+
# equals the product B * A. Scanning left-to-right we compose
|
|
24
|
+
# on the LEFT so each new function pre-multiplies the accumulator.
|
|
25
|
+
string.scan(TRANSFORM_FN_RE).inject(Emf::Model::Geometry::Matrix.identity) do |composed, (name, args_str)|
|
|
26
|
+
args = args_str.split(/[\s,]+/).map(&:to_f)
|
|
27
|
+
fn_matrix = build_function(name.downcase, args)
|
|
28
|
+
multiply(fn_matrix, composed)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def build_function(name, args)
|
|
33
|
+
case name
|
|
34
|
+
when "matrix" then from_matrix_args(args)
|
|
35
|
+
when "translate" then translate_matrix(*args)
|
|
36
|
+
when "scale" then scale_matrix(*args)
|
|
37
|
+
when "rotate" then rotate_matrix(*args)
|
|
38
|
+
when "skewx" then skewx_matrix(args.first)
|
|
39
|
+
when "skewy" then skewy_matrix(args.first)
|
|
40
|
+
else
|
|
41
|
+
raise FormatError, "unknown SVG transform function: #{name}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def from_matrix_args(args)
|
|
46
|
+
raise FormatError, "matrix() requires 6 args" if args.size != 6
|
|
47
|
+
|
|
48
|
+
Emf::Model::Geometry::Matrix.new(
|
|
49
|
+
m11: args[0], m12: args[1], m21: args[2], m22: args[3],
|
|
50
|
+
dx: args[4], dy: args[5]
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def translate_matrix(*args)
|
|
55
|
+
tx = args.fetch(0, 0.0)
|
|
56
|
+
ty = args.fetch(1, 0.0)
|
|
57
|
+
Emf::Model::Geometry::Matrix.new(
|
|
58
|
+
m11: 1.0, m12: 0.0, m21: 0.0, m22: 1.0,
|
|
59
|
+
dx: tx, dy: ty
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def scale_matrix(*args)
|
|
64
|
+
sx = args.fetch(0, 1.0)
|
|
65
|
+
sy = args.fetch(1, sx)
|
|
66
|
+
Emf::Model::Geometry::Matrix.new(
|
|
67
|
+
m11: sx, m12: 0.0, m21: 0.0, m22: sy,
|
|
68
|
+
dx: 0.0, dy: 0.0
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# SVG rotate(angle) is visually clockwise (Y grows downward).
|
|
73
|
+
# With the emf gem's row-vector matrix layout, the equivalent
|
|
74
|
+
# transform applied around (cx, cy) is: T(-cx,-cy) * R * T(cx,cy).
|
|
75
|
+
# (The SVG spec text writes it as T(cx,cy) * R * T(-cx,-cy), but
|
|
76
|
+
# that's column-vector notation — row-vector reverses the order.)
|
|
77
|
+
def rotate_matrix(*args)
|
|
78
|
+
deg = args.fetch(0, 0.0)
|
|
79
|
+
cx = args.fetch(1, 0.0)
|
|
80
|
+
cy = args.fetch(2, 0.0)
|
|
81
|
+
rad = deg * Math::PI / 180.0
|
|
82
|
+
cos = Math.cos(rad)
|
|
83
|
+
sin = Math.sin(rad)
|
|
84
|
+
rot = Emf::Model::Geometry::Matrix.new(
|
|
85
|
+
m11: cos, m12: sin, m21: -sin, m22: cos,
|
|
86
|
+
dx: 0.0, dy: 0.0
|
|
87
|
+
)
|
|
88
|
+
return rot if cx.zero? && cy.zero?
|
|
89
|
+
|
|
90
|
+
multiply(multiply(translate_matrix(-cx, -cy), rot), translate_matrix(cx, cy))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def skewx_matrix(deg)
|
|
94
|
+
rad = deg * Math::PI / 180.0
|
|
95
|
+
Emf::Model::Geometry::Matrix.new(
|
|
96
|
+
m11: 1.0, m12: 0.0, m21: Math.tan(rad), m22: 1.0,
|
|
97
|
+
dx: 0.0, dy: 0.0
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def skewy_matrix(deg)
|
|
102
|
+
rad = deg * Math::PI / 180.0
|
|
103
|
+
Emf::Model::Geometry::Matrix.new(
|
|
104
|
+
m11: 1.0, m12: Math.tan(rad), m21: 0.0, m22: 1.0,
|
|
105
|
+
dx: 0.0, dy: 0.0
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Row-vector matrix multiply: v * (a * b) = (v * a) * b. Matches
|
|
110
|
+
# emfsvg's emr_visitor#multiply_matrix formula.
|
|
111
|
+
def multiply(a, b)
|
|
112
|
+
Emf::Model::Geometry::Matrix.new(
|
|
113
|
+
m11: (a.m11 * b.m11) + (a.m12 * b.m21),
|
|
114
|
+
m12: (a.m11 * b.m12) + (a.m12 * b.m22),
|
|
115
|
+
m21: (a.m21 * b.m11) + (a.m22 * b.m21),
|
|
116
|
+
m22: (a.m21 * b.m12) + (a.m22 * b.m22),
|
|
117
|
+
dx: (a.dx * b.m11) + (a.dy * b.m21) + b.dx,
|
|
118
|
+
dy: (a.dx * b.m12) + (a.dy * b.m22) + b.dy
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
data/lib/emfsvg/svg.rb
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Emfsvg
|
|
4
|
+
# Pure SVG domain model. No EMF knowledge lives here.
|
|
5
|
+
#
|
|
6
|
+
# Svg::Parser consumes a Nokogiri-parsed SVG document and produces
|
|
7
|
+
# Svg::Document / Svg::Element value objects. Translation layer
|
|
8
|
+
# (Emfsvg::Translation) consumes these.
|
|
9
|
+
module Svg
|
|
10
|
+
autoload :Document, "emfsvg/svg/document"
|
|
11
|
+
autoload :Element, "emfsvg/svg/element"
|
|
12
|
+
autoload :OpenElement, "emfsvg/svg/element"
|
|
13
|
+
autoload :Parser, "emfsvg/svg/parser"
|
|
14
|
+
autoload :AttributeParser, "emfsvg/svg/attribute_parser"
|
|
15
|
+
autoload :Color, "emfsvg/svg/color"
|
|
16
|
+
autoload :Paint, "emfsvg/svg/paint"
|
|
17
|
+
autoload :Stroke, "emfsvg/svg/stroke"
|
|
18
|
+
autoload :PathData, "emfsvg/svg/path_data"
|
|
19
|
+
autoload :TransformParser, "emfsvg/svg/transform_parser"
|
|
20
|
+
autoload :ClipPathRegistry, "emfsvg/svg/clip_path_registry"
|
|
21
|
+
autoload :Elements, "emfsvg/svg/elements"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
require "emfsvg/svg/elements"
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "emf"
|
|
4
|
+
|
|
5
|
+
module Emfsvg
|
|
6
|
+
module Translation
|
|
7
|
+
# Mutable translation state carried through the element tree walk.
|
|
8
|
+
#
|
|
9
|
+
# Holds the GDI device context (pen/brush/font/transform), the
|
|
10
|
+
# 1-indexed object table, the SaveDC/RestoreDC stack, the
|
|
11
|
+
# coordinate scaler, and the output record list. All handlers
|
|
12
|
+
# receive a Context instance and operate on it functionally.
|
|
13
|
+
#
|
|
14
|
+
# Context also carries a reference to the HandlerRegistry so that
|
|
15
|
+
# GroupHandler (and other container handlers) can recurse into
|
|
16
|
+
# their children uniformly.
|
|
17
|
+
class Context
|
|
18
|
+
attr_reader :device_context, :object_table, :transform_stack, :emitter,
|
|
19
|
+
:handler_registry, :clip_path_registry, :scaler
|
|
20
|
+
|
|
21
|
+
def initialize(handler_registry: EmfRenderer::DEFAULT_REGISTRY,
|
|
22
|
+
clip_path_registry: nil,
|
|
23
|
+
scaler: Scaler::Identity.new)
|
|
24
|
+
@device_context = DeviceContext.new
|
|
25
|
+
@object_table = ObjectTable.new
|
|
26
|
+
@transform_stack = TransformStack.new
|
|
27
|
+
@emitter = RecordEmitter.new
|
|
28
|
+
@handler_registry = handler_registry
|
|
29
|
+
@clip_path_registry = clip_path_registry
|
|
30
|
+
@scaler = scaler
|
|
31
|
+
@next_handle = 1
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Allocate the next 1-indexed GDI object handle. Always increments
|
|
35
|
+
# — no reuse in v1 (see TODO.roadmap/09-coalescing-optimization.adoc).
|
|
36
|
+
def allocate_handle
|
|
37
|
+
handle = @next_handle
|
|
38
|
+
@next_handle += 1
|
|
39
|
+
handle
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def emit(wire_class, **attrs)
|
|
43
|
+
@emitter.emit(wire_class, **attrs)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def records
|
|
47
|
+
@emitter.records
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Dispatch an element through the registry. Container handlers
|
|
51
|
+
# (e.g. GroupHandler) call this to recurse into their children.
|
|
52
|
+
def dispatch(element)
|
|
53
|
+
handler = @handler_registry.handler_for(element)
|
|
54
|
+
handler&.call(element, self)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# ---- Coordinate-scaled wire helpers (DRY: handlers route all
|
|
58
|
+
# coords through these so the scaler affects everything) ----
|
|
59
|
+
|
|
60
|
+
def point_l(x, y)
|
|
61
|
+
Emf::Binary::Types::PointL.new(x: scale_int(x), y: scale_int(y))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def rect_l(left, top, right, bottom)
|
|
65
|
+
Emf::Binary::Types::RectL.new(
|
|
66
|
+
left: scale_int(left), top: scale_int(top),
|
|
67
|
+
right: scale_int(right), bottom: scale_int(bottom)
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def size_l(cx, cy)
|
|
72
|
+
Emf::Binary::Types::SizeL.new(cx: scale_int(cx), cy: scale_int(cy))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Convert a Matrix to its wire form, scaling the translation
|
|
76
|
+
# (dx/dy) by the scaler's factor. The 2x2 rotation/scale
|
|
77
|
+
# coefficients are NOT scaled — they're dimensionless ratios
|
|
78
|
+
# and survive any isometric map-mode change intact.
|
|
79
|
+
def xform_wire_from(matrix)
|
|
80
|
+
factor = @scaler.factor
|
|
81
|
+
return matrix.to_wire if factor == 1
|
|
82
|
+
|
|
83
|
+
scaled = Emf::Model::Geometry::Matrix.new(
|
|
84
|
+
m11: matrix.m11, m12: matrix.m12,
|
|
85
|
+
m21: matrix.m21, m22: matrix.m22,
|
|
86
|
+
dx: matrix.dx * factor,
|
|
87
|
+
dy: matrix.dy * factor
|
|
88
|
+
)
|
|
89
|
+
scaled.to_wire
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def scale_int(value)
|
|
95
|
+
@scaler.to_int(value)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Emfsvg
|
|
4
|
+
module Translation
|
|
5
|
+
# Walks an Svg::Document tree to decide whether any coordinate
|
|
6
|
+
# attribute has a non-integer value. If yes, the renderer installs
|
|
7
|
+
# `Scaler::Fixed` and emits MapMode scaling records so the
|
|
8
|
+
# decimals survive the int32 truncation in EMF.
|
|
9
|
+
#
|
|
10
|
+
# Also detects overflow: if any decimal coord is so large that
|
|
11
|
+
# scaling would overflow int32, the renderer falls back to
|
|
12
|
+
# Identity (accepting the truncation for that fixture).
|
|
13
|
+
module DecimalDetector
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def needs_scaling?(document)
|
|
17
|
+
any_decimal?(document.root_element)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def safe_for_fixed_scale?(document, scaler)
|
|
21
|
+
!any_unsafe?(document.root_element, scaler)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# ---- private ----
|
|
25
|
+
|
|
26
|
+
def any_decimal?(element)
|
|
27
|
+
return false unless element
|
|
28
|
+
|
|
29
|
+
element_attrs = decimal_attrs_for(element)
|
|
30
|
+
return true if element_attrs.any? { |attr| has_decimal?(element.send(attr)) }
|
|
31
|
+
|
|
32
|
+
path = path_commands(element)
|
|
33
|
+
return true if path && path.any? { |cmd| cmd.args.any? { |a| a.is_a?(Float) && a != a.to_i } }
|
|
34
|
+
|
|
35
|
+
element.children.any? { |c| any_decimal?(c) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def any_unsafe?(element, scaler)
|
|
39
|
+
return false unless element
|
|
40
|
+
|
|
41
|
+
element_attrs = decimal_attrs_for(element)
|
|
42
|
+
return true if element_attrs.any? { |attr| unsafe_value?(element.send(attr), scaler) }
|
|
43
|
+
|
|
44
|
+
path = path_commands(element)
|
|
45
|
+
return true if path && path.any? { |cmd| cmd.args.any? { |a| scaler.unsafe?(a) } }
|
|
46
|
+
|
|
47
|
+
element.children.any? { |c| any_unsafe?(c, scaler) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Coordinate-bearing attributes per element class. Returns [] for
|
|
51
|
+
# elements with no coords (Group, Defs, etc).
|
|
52
|
+
def decimal_attrs_for(element)
|
|
53
|
+
case element
|
|
54
|
+
when Svg::Elements::Rect then %i[x y width height rx ry]
|
|
55
|
+
when Svg::Elements::Ellipse then %i[cx cy rx ry]
|
|
56
|
+
when Svg::Elements::Circle then %i[cx cy r]
|
|
57
|
+
when Svg::Elements::Line then %i[x1 y1 x2 y2]
|
|
58
|
+
when Svg::Elements::Polyline, Svg::Elements::Polygon then [:points]
|
|
59
|
+
when Svg::Elements::Path then [:commands]
|
|
60
|
+
when Svg::Elements::Text then %i[x y font_size]
|
|
61
|
+
when Svg::Elements::Image then %i[x y width height]
|
|
62
|
+
else []
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def path_commands(element)
|
|
67
|
+
return nil unless element.is_a?(Svg::Elements::Path)
|
|
68
|
+
|
|
69
|
+
element.commands
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# `value` may be: Float, Integer, Array of [x,y] pairs, Array of
|
|
73
|
+
# PathData::Command, or nil.
|
|
74
|
+
def has_decimal?(value)
|
|
75
|
+
return false if value.nil?
|
|
76
|
+
|
|
77
|
+
case value
|
|
78
|
+
when Float then !value.finite? || (value != value.to_i)
|
|
79
|
+
when Integer then false
|
|
80
|
+
when Array
|
|
81
|
+
value.any? { |v| pair_or_command?(v) ? has_decimal?(v) : v.is_a?(Float) && v != v.to_i }
|
|
82
|
+
when String then value.match?(/\.\d/)
|
|
83
|
+
else false
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def pair_or_command?(value)
|
|
88
|
+
value.is_a?(Array) || value.is_a?(Svg::PathData::Command)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def unsafe_value?(value, scaler)
|
|
92
|
+
return false if value.nil?
|
|
93
|
+
|
|
94
|
+
case value
|
|
95
|
+
when Float then scaler.unsafe?(value)
|
|
96
|
+
when Integer then false
|
|
97
|
+
when Array then value.any? { |v| unsafe_value?(v, scaler) }
|
|
98
|
+
when String then extract_floats(value).any? { |f| scaler.unsafe?(f) }
|
|
99
|
+
else false
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def path_unsafe?(path_str, scaler)
|
|
104
|
+
extract_floats(path_str).any? { |f| scaler.unsafe?(f) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def extract_floats(str)
|
|
108
|
+
str.to_s.scan(/-?\d+(?:\.\d+)?/).map(&:to_f)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|