emfsvg 0.1.2 → 0.1.4
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 +98 -14
- data/lib/emfsvg/device_context.rb +9 -6
- data/lib/emfsvg/dib_decoder.rb +12 -8
- data/lib/emfsvg/drawing_hint.rb +88 -0
- data/lib/emfsvg/error.rb +11 -0
- data/lib/emfsvg/format_helpers.rb +46 -2
- data/lib/emfsvg/glyph_index_mapper.rb +5 -6
- data/lib/emfsvg/object_table.rb +7 -15
- data/lib/emfsvg/record_access.rb +38 -0
- data/lib/emfsvg/renderer.rb +114 -55
- data/lib/emfsvg/svg/clip_path_registry.rb +287 -4
- data/lib/emfsvg/svg/color.rb +32 -9
- data/lib/emfsvg/svg/document.rb +7 -0
- data/lib/emfsvg/svg/element.rb +4 -4
- data/lib/emfsvg/svg/elements/clip_path.rb +89 -24
- data/lib/emfsvg/svg/elements/defs.rb +85 -4
- data/lib/emfsvg/svg/elements/group.rb +30 -4
- data/lib/emfsvg/svg/elements/path.rb +239 -3
- data/lib/emfsvg/svg/elements/rect.rb +5 -2
- data/lib/emfsvg/svg/elements/stylable.rb +41 -11
- data/lib/emfsvg/svg/elements/text.rb +101 -16
- data/lib/emfsvg/svg/elements.rb +1 -1
- data/lib/emfsvg/svg/paint.rb +8 -2
- data/lib/emfsvg/svg/parser.rb +15 -4
- data/lib/emfsvg/svg/path_data/command.rb +1 -1
- data/lib/emfsvg/svg/path_data.rb +47 -36
- data/lib/emfsvg/svg/stroke.rb +78 -12
- data/lib/emfsvg/svg.rb +1 -1
- data/lib/emfsvg/svg_builder.rb +38 -9
- data/lib/emfsvg/translation/context.rb +262 -10
- data/lib/emfsvg/translation/decimal_detector.rb +26 -8
- data/lib/emfsvg/translation/emf_renderer.rb +16 -12
- data/lib/emfsvg/translation/handler_registry.rb +16 -6
- data/lib/emfsvg/translation/handlers/circular_arc.rb +103 -0
- data/lib/emfsvg/translation/handlers/defs_handler.rb +33 -4
- data/lib/emfsvg/translation/handlers/ellipse_handler.rb +2 -2
- data/lib/emfsvg/translation/handlers/elliptical_arc.rb +125 -0
- data/lib/emfsvg/translation/handlers/group_handler.rb +7 -0
- data/lib/emfsvg/translation/handlers/image_handler.rb +53 -8
- data/lib/emfsvg/translation/handlers/line_handler.rb +2 -2
- data/lib/emfsvg/translation/handlers/mapped_arc.rb +41 -0
- data/lib/emfsvg/translation/handlers/mapped_circular_arc.rb +55 -0
- data/lib/emfsvg/translation/handlers/mapped_pie.rb +30 -0
- data/lib/emfsvg/translation/handlers/path_flattener.rb +70 -49
- data/lib/emfsvg/translation/handlers/path_handler.rb +169 -14
- data/lib/emfsvg/translation/handlers/polyline_handler.rb +2 -2
- data/lib/emfsvg/translation/handlers/rect_handler.rb +21 -5
- data/lib/emfsvg/translation/handlers/shared_gdi.rb +75 -12
- data/lib/emfsvg/translation/handlers/text_handler.rb +92 -29
- data/lib/emfsvg/translation/handlers.rb +5 -0
- data/lib/emfsvg/translation/header_builder.rb +20 -0
- data/lib/emfsvg/translation/local_origin.rb +40 -0
- data/lib/emfsvg/translation/record_emitter.rb +8 -5
- data/lib/emfsvg/translation/scaler/fixed.rb +8 -2
- data/lib/emfsvg/translation.rb +1 -0
- data/lib/emfsvg/version.rb +1 -1
- data/lib/emfsvg/visitors/emr_visitor.rb +197 -129
- data/lib/emfsvg.rb +49 -2
- metadata +24 -10
|
@@ -6,21 +6,51 @@ module Emfsvg
|
|
|
6
6
|
# Common presentation attribute parsing shared by every shape.
|
|
7
7
|
# Module function — call as `Stylable.parse_style(node)` from
|
|
8
8
|
# an Element subclass's `.from_node` factory.
|
|
9
|
+
#
|
|
10
|
+
# Paint and Stroke are immutable value objects, and a drawing repeats
|
|
11
|
+
# a handful of distinct fill/stroke combinations across thousands of
|
|
12
|
+
# elements, so one instance per distinct attribute set is shared
|
|
13
|
+
# instead of parsing and allocating the same objects for every
|
|
14
|
+
# element.
|
|
9
15
|
module Stylable
|
|
16
|
+
# Distinct attribute sets remembered; past this, parsing falls back
|
|
17
|
+
# to building a fresh pair, so a hostile document cannot grow it.
|
|
18
|
+
PAINT_CACHE_LIMIT = 1024
|
|
19
|
+
PAINT_CACHE_LOCK = Mutex.new
|
|
20
|
+
private_constant :PAINT_CACHE_LOCK, :PAINT_CACHE_LIMIT
|
|
21
|
+
|
|
22
|
+
@paint_cache = {}
|
|
23
|
+
|
|
10
24
|
module_function
|
|
11
25
|
|
|
12
26
|
def parse_style(node)
|
|
13
|
-
|
|
14
|
-
fill
|
|
15
|
-
stroke
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
27
|
+
fill, stroke = paints(
|
|
28
|
+
node["fill"], node["stroke"], node["stroke-width"],
|
|
29
|
+
node["stroke-dasharray"], node["stroke-linecap"], node["stroke-linejoin"],
|
|
30
|
+
node["stroke-miterlimit"]
|
|
31
|
+
)
|
|
32
|
+
{ fill: fill, stroke: stroke, clip_path: node["clip-path"] }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def paints(fill, stroke, width, dash_array, line_cap, line_join, miter_limit = nil)
|
|
36
|
+
key = [fill, stroke, width, dash_array, line_cap, line_join, miter_limit]
|
|
37
|
+
@paint_cache.fetch(key) do
|
|
38
|
+
fill_paint = Svg::Paint.from_fill(fill)
|
|
39
|
+
pair = [
|
|
40
|
+
fill_paint,
|
|
41
|
+
Svg::Stroke.parse(stroke: stroke, width: width, dash_array: dash_array,
|
|
42
|
+
line_cap: line_cap, line_join: line_join,
|
|
43
|
+
miter_limit: miter_limit, fill: fill_paint)
|
|
44
|
+
].freeze
|
|
45
|
+
remember(key.freeze, pair)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def remember(key, pair)
|
|
50
|
+
PAINT_CACHE_LOCK.synchronize do
|
|
51
|
+
@paint_cache[key] = pair if @paint_cache.size < PAINT_CACHE_LIMIT
|
|
52
|
+
end
|
|
53
|
+
pair
|
|
24
54
|
end
|
|
25
55
|
end
|
|
26
56
|
end
|
|
@@ -12,48 +12,133 @@ module Emfsvg
|
|
|
12
12
|
ELEMENT_NAME = "text"
|
|
13
13
|
Element.register("text", self)
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
:transform_matrix
|
|
15
|
+
# Recovered LOGFONT rotation and vertical alignment, separate from SVG affine transforms.
|
|
16
|
+
Rotation = Struct.new(:angle, :vertical_alignment, :escapement, :native_height, :native_y, keyword_init: true)
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
# The font attributes of a <text> element.
|
|
19
|
+
Font = Struct.new(:family, :point_size, :weight, :style, :rotation, keyword_init: true)
|
|
20
|
+
|
|
21
|
+
attr_reader :x, :y, :content, :font, :text_anchor, :fill, :stroke,
|
|
22
|
+
:clip_path, :transform_matrix, :empty_cdata_content, :canonical_text_rotation
|
|
23
|
+
|
|
24
|
+
def initialize(x:, y:, content:, font:, text_anchor:, fill:, stroke:, clip_path: nil,
|
|
25
|
+
transform_matrix: Emf::Model::Geometry::Matrix.identity,
|
|
26
|
+
empty_cdata_content: false, canonical_text_rotation: nil)
|
|
22
27
|
@x = x
|
|
23
28
|
@y = y
|
|
24
29
|
@content = content
|
|
25
|
-
@
|
|
26
|
-
@font_size = font_size
|
|
27
|
-
@font_weight = font_weight
|
|
28
|
-
@font_style = font_style
|
|
30
|
+
@font = font
|
|
29
31
|
@text_anchor = text_anchor
|
|
30
32
|
@fill = fill
|
|
31
33
|
@stroke = stroke
|
|
32
34
|
@clip_path = clip_path
|
|
33
35
|
@transform_matrix = transform_matrix
|
|
36
|
+
@empty_cdata_content = empty_cdata_content
|
|
37
|
+
@canonical_text_rotation = canonical_text_rotation
|
|
34
38
|
end
|
|
35
39
|
|
|
40
|
+
def font_family = font.family
|
|
41
|
+
|
|
42
|
+
def font_size = font.point_size
|
|
43
|
+
|
|
44
|
+
def font_weight = font.weight
|
|
45
|
+
|
|
46
|
+
def font_style = font.style
|
|
47
|
+
|
|
36
48
|
def transformed?
|
|
37
49
|
!transform_matrix.identity?
|
|
38
50
|
end
|
|
39
51
|
|
|
40
52
|
def self.from_node(node)
|
|
53
|
+
rotation = canonical_rotation(node)
|
|
41
54
|
new(
|
|
42
55
|
x: AttributeParser.float(node["x"]),
|
|
43
56
|
y: AttributeParser.float(node["y"]),
|
|
44
57
|
content: node.text,
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
58
|
+
empty_cdata_content: node.children.any? do |child|
|
|
59
|
+
child.is_a?(Leptris::XML::CDATA) && child.content.empty?
|
|
60
|
+
end,
|
|
61
|
+
font: Font.new(
|
|
62
|
+
family: node["font-family"],
|
|
63
|
+
point_size: AttributeParser.float(node["font-size"], default: 12.0),
|
|
64
|
+
weight: parse_weight(node["font-weight"]),
|
|
65
|
+
style: node["font-style"],
|
|
66
|
+
rotation: rotation&.angle
|
|
67
|
+
),
|
|
49
68
|
text_anchor: node["text-anchor"] || "start",
|
|
50
|
-
|
|
69
|
+
canonical_text_rotation: rotation,
|
|
70
|
+
transform_matrix: Svg::TransformParser.parse(rotation ? nil : node["transform"]),
|
|
51
71
|
**Stylable.parse_style(node)
|
|
52
72
|
)
|
|
53
73
|
end
|
|
54
74
|
|
|
75
|
+
# Canonical LOGFONT output independently rounds size, pivot and offset.
|
|
76
|
+
# Intersect their four-decimal source intervals instead of inferring an
|
|
77
|
+
# arbitrary affine transform. TA_TOP prints the pivot as the displayed y.
|
|
78
|
+
def self.canonical_rotation(node)
|
|
79
|
+
return if node["dx"] || node["dy"]
|
|
80
|
+
|
|
81
|
+
decimal = "(-?\\d+\\.\\d{4})"
|
|
82
|
+
pattern = /\Arotate\((0|-?[1-9]\d*), #{decimal}, #{decimal}\) translate\(0, #{decimal}\)\z/
|
|
83
|
+
match = node["transform"]&.match(pattern)
|
|
84
|
+
return unless match
|
|
85
|
+
|
|
86
|
+
angle = match[1].to_i
|
|
87
|
+
return unless angle.between?(-359, 359)
|
|
88
|
+
|
|
89
|
+
x, pivot, offset = match.captures.drop(1).map(&:to_r)
|
|
90
|
+
return unless x == AttributeParser.float(node["x"]).to_s.to_r
|
|
91
|
+
|
|
92
|
+
y = AttributeParser.float(node["y"]).to_s.to_r
|
|
93
|
+
height = AttributeParser.float(node["font-size"], default: 12.0).to_s.to_r
|
|
94
|
+
half_unit = Rational(1, 20_000)
|
|
95
|
+
low = [(height - half_unit) * Rational(9, 10), offset - half_unit].max
|
|
96
|
+
high = [(height + half_unit) * Rational(9, 10), offset + half_unit].min
|
|
97
|
+
return unless low < high
|
|
98
|
+
|
|
99
|
+
alignment = if pivot == y
|
|
100
|
+
height.zero? ? :baseline : :top
|
|
101
|
+
elsif [low, pivot - y - (2 * half_unit)].max < [high, pivot - y + (2 * half_unit)].min
|
|
102
|
+
:baseline
|
|
103
|
+
end
|
|
104
|
+
return unless alignment
|
|
105
|
+
|
|
106
|
+
native_height, native_y = recover_baseline(height, y, pivot, offset) if alignment == :baseline
|
|
107
|
+
Rotation.new(angle: angle, vertical_alignment: alignment, escapement: angle.zero? ? 1 : -angle * 10,
|
|
108
|
+
native_height: native_height, native_y: native_y)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# A four-decimal native grid cannot always reproduce independently rounded
|
|
112
|
+
# height, baseline and pivot. Choose an interior point of all four source
|
|
113
|
+
# intervals; the handler uses a finer native mapping only for this case.
|
|
114
|
+
def self.recover_baseline(height, y, pivot, offset)
|
|
115
|
+
# Match the mapped IEEE-754 values and C rounding used by the renderer.
|
|
116
|
+
mapped_height = (height * 10_000).round * 0.0001
|
|
117
|
+
mapped_y = (y * 10_000).round * 0.0001
|
|
118
|
+
return if FormatHelpers.fmt(mapped_y + (mapped_height * 0.9)) == FormatHelpers.fmt(pivot)
|
|
119
|
+
|
|
120
|
+
half = Rational(1, 20_000)
|
|
121
|
+
ratio = Rational(9, 10)
|
|
122
|
+
low = [height - half, (offset - half) / ratio, (pivot - y - (2 * half)) / ratio].max
|
|
123
|
+
high = [height + half, (offset + half) / ratio, (pivot - y + (2 * half)) / ratio].min
|
|
124
|
+
return unless low < high
|
|
125
|
+
|
|
126
|
+
native_height = ((low + high) / 2 * 1_000_000).round / 1_000_000.0
|
|
127
|
+
low_y = [y - half, pivot - half - (native_height.to_r * ratio)].max
|
|
128
|
+
high_y = [y + half, pivot + half - (native_height.to_r * ratio)].min
|
|
129
|
+
return unless low_y < high_y
|
|
130
|
+
|
|
131
|
+
native_y = ((low_y + high_y) / 2 * 1_000_000).round / 1_000_000.0
|
|
132
|
+
actual = [native_height, native_y, native_height * 0.9, native_y + (native_height * 0.9)]
|
|
133
|
+
expected = [height, y, offset, pivot].map { |value| FormatHelpers.fmt(value) }
|
|
134
|
+
return unless actual.map { |value| FormatHelpers.fmt(value) } == expected
|
|
135
|
+
|
|
136
|
+
[native_height, native_y]
|
|
137
|
+
end
|
|
138
|
+
|
|
55
139
|
def self.parse_weight(value)
|
|
56
|
-
return
|
|
140
|
+
return 0 if value.nil?
|
|
141
|
+
return 400 if value == "normal"
|
|
57
142
|
|
|
58
143
|
value == "bold" ? 700 : value.to_i
|
|
59
144
|
end
|
data/lib/emfsvg/svg/elements.rb
CHANGED
|
@@ -4,7 +4,7 @@ module Emfsvg
|
|
|
4
4
|
module Svg
|
|
5
5
|
# Subclasses of Svg::Element for the supported SVG shape vocabulary.
|
|
6
6
|
# Each subclass parses its typed attributes at construction time
|
|
7
|
-
# (no
|
|
7
|
+
# (no leptris node is retained). Register in Svg::Element's
|
|
8
8
|
# registry so Svg::Element.from_node dispatches correctly.
|
|
9
9
|
module Elements
|
|
10
10
|
autoload :Stylable, "emfsvg/svg/elements/stylable"
|
data/lib/emfsvg/svg/paint.rb
CHANGED
|
@@ -10,19 +10,25 @@ module Emfsvg
|
|
|
10
10
|
class Paint
|
|
11
11
|
SOLID = 0
|
|
12
12
|
NULL = 1
|
|
13
|
+
PATTERN = 3
|
|
13
14
|
|
|
14
|
-
def initialize(color:, style: SOLID)
|
|
15
|
+
def initialize(color:, style: SOLID, reference: nil)
|
|
15
16
|
@style = style
|
|
16
17
|
@color = color
|
|
18
|
+
@reference = reference
|
|
17
19
|
end
|
|
18
20
|
|
|
19
|
-
attr_reader :style, :color
|
|
21
|
+
attr_reader :style, :color, :reference
|
|
20
22
|
|
|
21
23
|
def null?
|
|
22
24
|
style == NULL || color.null?
|
|
23
25
|
end
|
|
24
26
|
|
|
25
27
|
def self.from_fill(value)
|
|
28
|
+
if (match = /\A(?:#(img-[1-9]\d*-ref)|url\(#(img-[1-9]\d*-ref)\))\z/.match(value.to_s))
|
|
29
|
+
return new(style: PATTERN, color: Color.parse("black"), reference: match[1] || match[2])
|
|
30
|
+
end
|
|
31
|
+
|
|
26
32
|
color = Color.parse(value)
|
|
27
33
|
new(style: color.null? ? NULL : SOLID, color: color)
|
|
28
34
|
end
|
data/lib/emfsvg/svg/parser.rb
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "leptris"
|
|
4
4
|
|
|
5
5
|
module Emfsvg
|
|
6
6
|
module Svg
|
|
7
7
|
# Parses an SVG document string into a Svg::Document value object.
|
|
8
|
-
#
|
|
8
|
+
# leptris is the only XML parser used in this gem.
|
|
9
9
|
#
|
|
10
10
|
# Width/height are coerced to floats (CSS units other than px are
|
|
11
11
|
# out of scope for v1; a numeric value is treated as user units).
|
|
@@ -14,18 +14,29 @@ module Emfsvg
|
|
|
14
14
|
module_function
|
|
15
15
|
|
|
16
16
|
def call(string)
|
|
17
|
-
doc =
|
|
17
|
+
doc = begin
|
|
18
|
+
Leptris::XML.parse(string, options: Leptris::XML::ParseOptions.noblanks)
|
|
19
|
+
rescue Leptris::XML::ParseError
|
|
20
|
+
raise ParseError, "not an SVG document: unparseable content"
|
|
21
|
+
end
|
|
18
22
|
root = doc.root
|
|
19
23
|
raise ParseError, "not an SVG document: missing root element" unless root
|
|
20
24
|
|
|
25
|
+
source = string.b
|
|
21
26
|
Document.new(
|
|
22
27
|
width: parse_dimension(root["width"]),
|
|
23
28
|
height: parse_dimension(root["height"]),
|
|
24
29
|
view_box: parse_view_box(root["viewBox"]),
|
|
25
|
-
root_element: Element.from_node(root)
|
|
30
|
+
root_element: Element.from_node(root) { |node| source_tag(source, node) }
|
|
26
31
|
)
|
|
27
32
|
end
|
|
28
33
|
|
|
34
|
+
# Node serialization normalizes spacing; the original start tag carries
|
|
35
|
+
# the canonical renderer's distinction between Polyline and LineTo.
|
|
36
|
+
def source_tag(string, node)
|
|
37
|
+
string.match(/\G<(?:path|rect)\b(?:[^"'>]|"[^"]*"|'[^']*')*>/m, node.byte_offset)&.to_s
|
|
38
|
+
end
|
|
39
|
+
|
|
29
40
|
def parse_dimension(value)
|
|
30
41
|
return 0.0 if value.nil? || value.empty?
|
|
31
42
|
|
|
@@ -7,7 +7,7 @@ module Emfsvg
|
|
|
7
7
|
# `letter` preserves case (absolute uppercase vs relative lowercase).
|
|
8
8
|
Command = Struct.new(:letter, :args, keyword_init: true) do
|
|
9
9
|
def absolute?
|
|
10
|
-
letter == letter.upcase
|
|
10
|
+
ARG_COUNTS.key?(letter) || (!LETTER_ARG_COUNTS.key?(letter) && letter == letter.upcase)
|
|
11
11
|
end
|
|
12
12
|
end
|
|
13
13
|
end
|
data/lib/emfsvg/svg/path_data.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "strscan"
|
|
4
|
+
|
|
3
5
|
module Emfsvg
|
|
4
6
|
module Svg
|
|
5
7
|
# Namespace for the SVG path-data parser. Pure parser, no EMF
|
|
@@ -14,6 +16,20 @@ module Emfsvg
|
|
|
14
16
|
"Q" => 4, "T" => 2, "A" => 7, "Z" => 0
|
|
15
17
|
}.freeze
|
|
16
18
|
|
|
19
|
+
# ARG_COUNTS keyed by the letter as written, so the parser does not
|
|
20
|
+
# upcase a fresh String per token to look it up.
|
|
21
|
+
LETTER_ARG_COUNTS = ARG_COUNTS.each_with_object({}) do |(letter, count), counts|
|
|
22
|
+
counts[letter] = count
|
|
23
|
+
counts[letter.downcase] = count
|
|
24
|
+
end.freeze
|
|
25
|
+
|
|
26
|
+
# Frozen command-letter Strings keyed by the Symbol the Scanner
|
|
27
|
+
# emits, shared by every Command rather than allocated per token.
|
|
28
|
+
LETTERS = LETTER_ARG_COUNTS.keys.to_h { |letter| [letter.to_sym, letter.freeze] }.freeze
|
|
29
|
+
|
|
30
|
+
# Shared by every zero-argument command (Z/z).
|
|
31
|
+
NO_ARGS = [].freeze
|
|
32
|
+
|
|
17
33
|
# Stateful parser walks the string once, emitting Commands as
|
|
18
34
|
# enough args accumulate for the current letter. Implicit repeats:
|
|
19
35
|
# subsequent arg groups after M/m are treated as L/l.
|
|
@@ -26,24 +42,24 @@ module Emfsvg
|
|
|
26
42
|
commands = []
|
|
27
43
|
scanner = Scanner.new(string)
|
|
28
44
|
current_letter = nil
|
|
29
|
-
args =
|
|
45
|
+
args = nil
|
|
30
46
|
|
|
31
47
|
while (token = scanner.next_token)
|
|
32
48
|
case token
|
|
33
49
|
when Symbol # command letter
|
|
34
50
|
flush_command(commands, current_letter, args) if current_letter && args.any?
|
|
35
|
-
current_letter = token.to_s
|
|
51
|
+
current_letter = LETTERS.fetch(token) { token.to_s }
|
|
36
52
|
args = []
|
|
37
53
|
# Zero-arg commands (Z/z) emit immediately.
|
|
38
|
-
if
|
|
39
|
-
emit_command(commands, current_letter,
|
|
54
|
+
if LETTER_ARG_COUNTS.fetch(current_letter, 1).zero?
|
|
55
|
+
emit_command(commands, current_letter, NO_ARGS)
|
|
40
56
|
current_letter = nil
|
|
41
57
|
end
|
|
42
58
|
when Numeric
|
|
43
59
|
raise FormatError, "path data begins with a number: #{string.inspect}" if current_letter.nil?
|
|
44
60
|
|
|
45
61
|
args << token
|
|
46
|
-
if args.size == ARG_COUNTS.fetch(current_letter.upcase)
|
|
62
|
+
if args.size == (LETTER_ARG_COUNTS[current_letter] || ARG_COUNTS.fetch(current_letter.upcase))
|
|
47
63
|
emit_command(commands, current_letter, args)
|
|
48
64
|
args = []
|
|
49
65
|
current_letter = implicit_repeat_letter(current_letter)
|
|
@@ -56,9 +72,9 @@ module Emfsvg
|
|
|
56
72
|
end
|
|
57
73
|
|
|
58
74
|
def emit_command(commands, letter, args)
|
|
59
|
-
raise FormatError, "unknown path command: #{letter}" unless
|
|
75
|
+
raise FormatError, "unknown path command: #{letter}" unless LETTER_ARG_COUNTS.key?(letter)
|
|
60
76
|
|
|
61
|
-
commands << Command.new(letter: letter, args: args
|
|
77
|
+
commands << Command.new(letter: letter, args: args)
|
|
62
78
|
end
|
|
63
79
|
|
|
64
80
|
def flush_command(_commands, letter, args)
|
|
@@ -86,48 +102,43 @@ module Emfsvg
|
|
|
86
102
|
# as Symbols (e.g. :M, :z) and numbers as Floats.
|
|
87
103
|
class Scanner
|
|
88
104
|
NUMBER_RE = /[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/
|
|
105
|
+
SEPARATORS_RE = /[ \t\n\r,]+/
|
|
106
|
+
|
|
107
|
+
# The Symbol for each command-letter byte, so a letter is read
|
|
108
|
+
# without allocating a String for it.
|
|
109
|
+
LETTER_SYMBOLS = (("A".."Z").to_a + ("a".."z").to_a).to_h { |letter| [letter.ord, letter.to_sym] }.freeze
|
|
110
|
+
private_constant :LETTER_SYMBOLS
|
|
89
111
|
|
|
90
112
|
def initialize(string)
|
|
91
113
|
@string = string
|
|
92
|
-
@
|
|
114
|
+
@scanner = StringScanner.new(string)
|
|
93
115
|
end
|
|
94
116
|
|
|
95
117
|
def next_token
|
|
96
|
-
|
|
97
|
-
return nil if @
|
|
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
|
|
118
|
+
@scanner.skip(SEPARATORS_RE)
|
|
119
|
+
return nil if @scanner.eos?
|
|
107
120
|
|
|
108
|
-
|
|
121
|
+
letter = LETTER_SYMBOLS[@string.getbyte(@scanner.pos)]
|
|
122
|
+
return read_number unless letter
|
|
109
123
|
|
|
110
|
-
|
|
111
|
-
|
|
124
|
+
@scanner.pos += 1
|
|
125
|
+
letter
|
|
112
126
|
end
|
|
113
127
|
|
|
114
|
-
|
|
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
|
|
128
|
+
private
|
|
124
129
|
|
|
130
|
+
# A number is normally right at the cursor. Anything else is
|
|
131
|
+
# skipped up to the next number, as the scan has always done.
|
|
125
132
|
def read_number
|
|
126
|
-
|
|
127
|
-
|
|
133
|
+
number = @scanner.scan(NUMBER_RE)
|
|
134
|
+
return number.to_f if number
|
|
135
|
+
|
|
136
|
+
position = @scanner.charpos
|
|
137
|
+
unless @scanner.skip_until(NUMBER_RE)
|
|
138
|
+
raise FormatError, "expected number at position #{position} in path data"
|
|
139
|
+
end
|
|
128
140
|
|
|
129
|
-
@
|
|
130
|
-
match.to_s.to_f
|
|
141
|
+
@scanner.matched.to_f
|
|
131
142
|
end
|
|
132
143
|
end
|
|
133
144
|
end
|
data/lib/emfsvg/svg/stroke.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Emfsvg
|
|
4
4
|
module Svg
|
|
5
5
|
# Parsed SVG stroke style: color, width, dash pattern, line cap,
|
|
6
|
-
# line join
|
|
6
|
+
# line join, and miter limit.
|
|
7
7
|
#
|
|
8
8
|
# Pen style bits (per MS-WMF 2.1.1.8 / emr_visitor.rb mirror):
|
|
9
9
|
#
|
|
@@ -21,61 +21,123 @@ module Emfsvg
|
|
|
21
21
|
END_CAP_BITS = { "butt" => 0x0200, "round" => 0x0000, "square" => 0x0100 }.freeze
|
|
22
22
|
LINE_JOIN_BITS = { "miter" => 0x2000, "round" => 0x0000, "bevel" => 0x1000 }.freeze
|
|
23
23
|
NULL_PEN_WIDTH_SENTINEL = "1px"
|
|
24
|
+
COSMETIC_DASH_PATTERNS = {
|
|
25
|
+
1 => [5, 5], 2 => [1, 1], 3 => [5, 5, 1, 5], 4 => [5, 5, 1, 1, 1, 5]
|
|
26
|
+
}.freeze
|
|
27
|
+
private_constant :COSMETIC_DASH_PATTERNS
|
|
24
28
|
|
|
25
|
-
attr_reader :paint, :width, :dash_array, :line_cap, :line_join, :raw_width,
|
|
26
|
-
:line_cap_explicit, :line_join_explicit
|
|
29
|
+
attr_reader :paint, :width, :dash_array, :line_cap, :line_join, :miter_limit, :raw_width,
|
|
30
|
+
:line_cap_explicit, :line_join_explicit, :null_pen_fallback, :fallback_brush, :canonical_cosmetic_dash
|
|
27
31
|
|
|
28
32
|
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
|
|
33
|
+
line_join: "round", miter_limit: 10.0, raw_width: nil,
|
|
34
|
+
line_cap_explicit: false, line_join_explicit: false,
|
|
35
|
+
null_pen_fallback: true, fallback_brush: nil)
|
|
31
36
|
@paint = paint
|
|
32
37
|
@width = width.to_f
|
|
33
38
|
@dash_array = dash_array
|
|
34
39
|
@line_cap = line_cap
|
|
35
40
|
@line_join = line_join
|
|
41
|
+
@miter_limit = miter_limit.to_f
|
|
36
42
|
@raw_width = raw_width
|
|
37
43
|
@line_cap_explicit = line_cap_explicit
|
|
38
44
|
@line_join_explicit = line_join_explicit
|
|
45
|
+
@null_pen_fallback = null_pen_fallback
|
|
46
|
+
@fallback_brush = fallback_brush
|
|
39
47
|
end
|
|
40
48
|
|
|
41
49
|
def null_pen_sentinel?
|
|
42
50
|
raw_width == NULL_PEN_WIDTH_SENTINEL &&
|
|
51
|
+
null_pen_fallback &&
|
|
43
52
|
!line_cap_explicit &&
|
|
44
53
|
!line_join_explicit
|
|
45
54
|
end
|
|
46
55
|
|
|
56
|
+
# Recover only the exact unfilled Polyline spelling emitted for these
|
|
57
|
+
# legacy cosmetic pens. Ordinary SVG shares the parsed paint cache.
|
|
58
|
+
def with_canonical_cosmetic_dash(source_tag)
|
|
59
|
+
return self unless %w[1.0000 1px].include?(raw_width) && !paint.null? && dash_array
|
|
60
|
+
|
|
61
|
+
number = "-?\\d+\\.\\d{4}"
|
|
62
|
+
point = "#{number},#{number}"
|
|
63
|
+
data = source_tag&.[](/\A<path d="(M #{point}(?: L #{point})+ )"/, 1)
|
|
64
|
+
color = source_tag&.[](/ stroke="(#[0-9A-F]{6})"/, 1)
|
|
65
|
+
return self unless data && color
|
|
66
|
+
|
|
67
|
+
lengths = dash_array.map { |length| format("%.4f", length) }.join(",")
|
|
68
|
+
expected = %(<path d="#{data}" stroke="#{color}" stroke-width="#{raw_width}" )
|
|
69
|
+
expected << %(stroke-dasharray="#{lengths}" stroke-linecap="round" stroke-linejoin="round" fill="none" />)
|
|
70
|
+
return self unless source_tag == expected
|
|
71
|
+
|
|
72
|
+
COSMETIC_DASH_PATTERNS.each do |style, pattern|
|
|
73
|
+
stored_width = dash_array.first / pattern.first
|
|
74
|
+
next unless stored_width >= 1 && stored_width == stored_width.to_i
|
|
75
|
+
next unless dash_array == pattern.map { |length| length * stored_width }
|
|
76
|
+
|
|
77
|
+
return dup.tap { |copy| copy.canonical_cosmetic_dash = [stored_width, style].freeze }
|
|
78
|
+
end
|
|
79
|
+
self
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
protected
|
|
83
|
+
|
|
84
|
+
attr_writer :canonical_cosmetic_dash
|
|
85
|
+
|
|
86
|
+
public
|
|
87
|
+
|
|
47
88
|
PS_GEOMETRIC = 0x00010000
|
|
48
89
|
|
|
90
|
+
# Cosmetic widths are device pixels, independent of logical coordinates.
|
|
91
|
+
def cosmetic_pixel_width?
|
|
92
|
+
raw_width == "1px" && !paint.null? && !null_pen_sentinel?
|
|
93
|
+
end
|
|
94
|
+
|
|
49
95
|
# Pen style bitmask (full 32 bits). Includes:
|
|
50
96
|
# * dash style (bits 0-7)
|
|
51
97
|
# * end-cap (bits 8-11)
|
|
52
98
|
# * line-join (bits 12-15)
|
|
53
|
-
# * pen type GEOMETRIC (bit 16) — set when width > 1
|
|
54
|
-
#
|
|
55
|
-
# instead of hardcoding 1.
|
|
99
|
+
# * pen type GEOMETRIC (bit 16) — set when width > 1 or an
|
|
100
|
+
# explicit unitless width of 1 must survive SVG round trips.
|
|
56
101
|
def pen_style
|
|
102
|
+
return canonical_cosmetic_dash[1] if canonical_cosmetic_dash
|
|
103
|
+
|
|
104
|
+
@pen_style ||= compute_pen_style
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def compute_pen_style
|
|
57
108
|
base = if paint.null? || null_pen_sentinel?
|
|
58
109
|
5 # PS_NULL
|
|
59
110
|
else
|
|
60
111
|
dash_style
|
|
61
112
|
end
|
|
62
|
-
type_bit = width > 1.0 && !paint.null? ? PS_GEOMETRIC : 0
|
|
113
|
+
type_bit = (width > 1.0 || explicit_unitless_one_width?) && !paint.null? ? PS_GEOMETRIC : 0
|
|
63
114
|
base | type_bit | END_CAP_BITS.fetch(line_cap, 0x0000) | LINE_JOIN_BITS.fetch(line_join, 0x0000)
|
|
64
115
|
end
|
|
116
|
+
private :compute_pen_style
|
|
65
117
|
|
|
66
|
-
def self.parse(stroke:, width:, dash_array:, line_cap:, line_join:)
|
|
118
|
+
def self.parse(stroke:, width:, dash_array:, line_cap:, line_join:, miter_limit: nil, fill: nil)
|
|
67
119
|
new(
|
|
68
120
|
paint: Paint.from_stroke(stroke),
|
|
69
121
|
width: parse_width(width),
|
|
70
122
|
dash_array: parse_dash_array(dash_array),
|
|
71
123
|
line_cap: line_cap || "round",
|
|
72
124
|
line_join: line_join || "round",
|
|
125
|
+
miter_limit: parse_miter_limit(miter_limit),
|
|
73
126
|
raw_width: width,
|
|
74
127
|
line_cap_explicit: !line_cap.nil?,
|
|
75
|
-
line_join_explicit: !line_join.nil
|
|
128
|
+
line_join_explicit: !line_join.nil?,
|
|
129
|
+
null_pen_fallback: fill.nil? || !fill.null?
|
|
76
130
|
)
|
|
77
131
|
end
|
|
78
132
|
|
|
133
|
+
def self.parse_miter_limit(value)
|
|
134
|
+
return 10.0 if value.nil? || value.strip.empty?
|
|
135
|
+
|
|
136
|
+
Float(value)
|
|
137
|
+
rescue ArgumentError
|
|
138
|
+
10.0
|
|
139
|
+
end
|
|
140
|
+
|
|
79
141
|
def self.parse_width(value)
|
|
80
142
|
return 1.0 if value.nil? || value.strip.empty?
|
|
81
143
|
|
|
@@ -96,11 +158,15 @@ module Emfsvg
|
|
|
96
158
|
first < (width * 2) ? 2 : 1 # PS_DOT=2, PS_DASH=1
|
|
97
159
|
end
|
|
98
160
|
|
|
161
|
+
def explicit_unitless_one_width?
|
|
162
|
+
raw_width.to_s.match?(/\A\s*1(?:\.0+)?\s*\z/)
|
|
163
|
+
end
|
|
164
|
+
|
|
99
165
|
def self.parse_dash_array(value)
|
|
100
166
|
return nil if value.nil? || value.strip == "none" || value.strip.empty?
|
|
101
167
|
|
|
102
168
|
parts = value.split(/[\s,]+/).map(&:to_f)
|
|
103
|
-
parts.empty? ? nil : parts
|
|
169
|
+
parts.empty? ? nil : parts.freeze
|
|
104
170
|
end
|
|
105
171
|
end
|
|
106
172
|
end
|
data/lib/emfsvg/svg.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Emfsvg
|
|
4
4
|
# Pure SVG domain model. No EMF knowledge lives here.
|
|
5
5
|
#
|
|
6
|
-
# Svg::Parser consumes a
|
|
6
|
+
# Svg::Parser consumes a leptris-parsed SVG document and produces
|
|
7
7
|
# Svg::Document / Svg::Element value objects. Translation layer
|
|
8
8
|
# (Emfsvg::Translation) consumes these.
|
|
9
9
|
module Svg
|