emfsvg 0.1.3 → 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 +3 -3
- 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/paint.rb +8 -2
- data/lib/emfsvg/svg/parser.rb +8 -1
- 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_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
data/lib/emfsvg/renderer.rb
CHANGED
|
@@ -17,6 +17,30 @@ module Emfsvg
|
|
|
17
17
|
# 7. </svg>
|
|
18
18
|
class Renderer
|
|
19
19
|
MAX_OUTPUT_BYTES = 500 * 1024 * 1024
|
|
20
|
+
# Downstream collection policy, independent of parser input/array limits.
|
|
21
|
+
MAX_RECORDS = 1_000_000
|
|
22
|
+
|
|
23
|
+
# Records that set pathDrawn=true in libemf2svg's U_emf_onerec_analyse
|
|
24
|
+
# pre-pass. Used to distinguish wtBefore from wtAfter.
|
|
25
|
+
DRAWING_PATH_RECORDS = %w[
|
|
26
|
+
PolyBezier Polygon Polyline PolyBezierTo PolylineTo PolyPolyline PolyPolygon
|
|
27
|
+
SetWindowExtEx LineTo ArcTo PolyDraw
|
|
28
|
+
PolyBezier16 Polygon16 Polyline16 PolyBezierTo16 PolylineTo16
|
|
29
|
+
PolyPolyline16 PolyPolygon16 PolyDraw16
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
# Unqualified wire class name => the role a record plays in build_path_analysis.
|
|
33
|
+
PATH_RECORD_KINDS = {
|
|
34
|
+
"BeginPath" => :begin_path,
|
|
35
|
+
"EndPath" => :end_path,
|
|
36
|
+
"AbortPath" => :abort_path,
|
|
37
|
+
"FillPath" => :fill_path,
|
|
38
|
+
"StrokePath" => :stroke_path,
|
|
39
|
+
"StrokeAndFillPath" => :stroke_and_fill_path,
|
|
40
|
+
"SetWorldTransform" => :set_world_transform,
|
|
41
|
+
"ModifyWorldTransform" => :modify_world_transform,
|
|
42
|
+
**DRAWING_PATH_RECORDS.to_h { |name| [name, :drawing] }
|
|
43
|
+
}.freeze
|
|
20
44
|
|
|
21
45
|
def self.call(metafile, options)
|
|
22
46
|
new(metafile, options).call
|
|
@@ -25,12 +49,12 @@ module Emfsvg
|
|
|
25
49
|
def initialize(metafile, options)
|
|
26
50
|
@metafile = metafile
|
|
27
51
|
@options = options
|
|
52
|
+
compute_header_params
|
|
28
53
|
@builder = SvgBuilder.new
|
|
29
54
|
@dc = DeviceContext.new
|
|
30
55
|
@object_table = ObjectTable.new
|
|
31
56
|
@transform_stack = TransformStack.new
|
|
32
57
|
BsdRand.reset
|
|
33
|
-
compute_header_params
|
|
34
58
|
end
|
|
35
59
|
|
|
36
60
|
def call
|
|
@@ -40,10 +64,7 @@ module Emfsvg
|
|
|
40
64
|
visit_records
|
|
41
65
|
end
|
|
42
66
|
end
|
|
43
|
-
|
|
44
|
-
raise RenderError, "SVG output exceeds #{MAX_OUTPUT_BYTES} bytes" if result.bytesize > MAX_OUTPUT_BYTES
|
|
45
|
-
|
|
46
|
-
result
|
|
67
|
+
@builder.to_s
|
|
47
68
|
end
|
|
48
69
|
|
|
49
70
|
private
|
|
@@ -52,8 +73,34 @@ module Emfsvg
|
|
|
52
73
|
|
|
53
74
|
def compute_header_params
|
|
54
75
|
b = @metafile.header.bounds
|
|
55
|
-
|
|
76
|
+
validate_header_dimensions!(b)
|
|
77
|
+
@fix_broken_y = (b.top.negative? && b.bottom.positive?) || (b.top.positive? && b.bottom.negative?)
|
|
78
|
+
|
|
79
|
+
compute_output_dimensions(b)
|
|
80
|
+
|
|
81
|
+
bounds_width = (b.right - b.left).abs
|
|
82
|
+
@scaling = bounds_width.zero? ? 1.0 : @img_width / bounds_width.to_f
|
|
83
|
+
@ref_x = b.left.to_f
|
|
84
|
+
@ref_y = b.top.to_f
|
|
85
|
+
|
|
86
|
+
dev = @metafile.header.device_pixels
|
|
87
|
+
mm = @metafile.header.device_mm
|
|
88
|
+
@px_per_mm = mm.cx.zero? ? 1.0 : dev.cx.to_f / mm.cx
|
|
89
|
+
|
|
90
|
+
@open_groups = 0
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def validate_header_dimensions!(b)
|
|
94
|
+
[b.left, b.top, b.right, b.bottom].each do |coordinate|
|
|
95
|
+
raise RenderError, "Header coordinates must be finite" unless coordinate.to_f.finite?
|
|
96
|
+
end
|
|
97
|
+
validate_dimension!((b.right - b.left).abs.to_f, "Header width")
|
|
98
|
+
validate_dimension!((b.bottom - b.top).abs.to_f, "Header height")
|
|
99
|
+
validate_dimension!(@options.width.to_f, "Output width") if @options.width
|
|
100
|
+
validate_dimension!(@options.height.to_f, "Output height") if @options.height
|
|
101
|
+
end
|
|
56
102
|
|
|
103
|
+
def compute_output_dimensions(b)
|
|
57
104
|
ratio_xy = (b.right - b.left).to_f / (b.bottom - b.top)
|
|
58
105
|
|
|
59
106
|
if @options.width && @options.height
|
|
@@ -76,16 +123,14 @@ module Emfsvg
|
|
|
76
123
|
@img_height = (b.bottom - b.top).abs.to_f
|
|
77
124
|
end
|
|
78
125
|
|
|
79
|
-
|
|
80
|
-
@
|
|
81
|
-
|
|
82
|
-
@ref_y = b.top.to_f
|
|
126
|
+
validate_dimension!(@img_width, "Output width")
|
|
127
|
+
validate_dimension!(@img_height, "Output height")
|
|
128
|
+
end
|
|
83
129
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
@px_per_mm = mm.cx.zero? ? 1.0 : dev.cx.to_f / mm.cx
|
|
130
|
+
def validate_dimension!(value, label)
|
|
131
|
+
return if value.finite? && value.positive?
|
|
87
132
|
|
|
88
|
-
|
|
133
|
+
raise RenderError, "#{label} must be finite and positive"
|
|
89
134
|
end
|
|
90
135
|
|
|
91
136
|
def emit_xml_declaration
|
|
@@ -131,13 +176,13 @@ module Emfsvg
|
|
|
131
176
|
end
|
|
132
177
|
|
|
133
178
|
def visit_records
|
|
134
|
-
records =
|
|
179
|
+
records = bounded_records
|
|
135
180
|
path_actions, path_transforms = build_path_analysis(records)
|
|
136
181
|
visitor = Visitors::EmrVisitor.new(
|
|
137
182
|
@builder, @dc, @object_table, @transform_stack,
|
|
138
183
|
@metafile.header.bounds, @fix_broken_y, @options,
|
|
139
|
-
scaling: @scaling, ref_x: @ref_x, ref_y: @ref_y,
|
|
140
|
-
|
|
184
|
+
frame: Visitors::EmrVisitor::Frame.new(scaling: @scaling, ref_x: @ref_x, ref_y: @ref_y,
|
|
185
|
+
px_per_mm: @px_per_mm),
|
|
141
186
|
path_actions: path_actions,
|
|
142
187
|
path_transforms: path_transforms
|
|
143
188
|
)
|
|
@@ -149,6 +194,17 @@ module Emfsvg
|
|
|
149
194
|
@open_groups = visitor.open_group_count
|
|
150
195
|
end
|
|
151
196
|
|
|
197
|
+
def bounded_records
|
|
198
|
+
records = []
|
|
199
|
+
@metafile.each do |record|
|
|
200
|
+
records << record
|
|
201
|
+
next unless records.length > MAX_RECORDS
|
|
202
|
+
|
|
203
|
+
raise ResourceLimitError.new(resource: :records, limit: MAX_RECORDS, actual: records.length)
|
|
204
|
+
end
|
|
205
|
+
records
|
|
206
|
+
end
|
|
207
|
+
|
|
152
208
|
# Pre-scan records to build two maps keyed by BeginPath record index:
|
|
153
209
|
# path_actions: { idx => :fill|:stroke|:stroke_fill|:end|:abort }
|
|
154
210
|
# path_transforms: { idx => { wt_before: PathTransform?, wt_after: PathTransform? } }
|
|
@@ -162,7 +218,8 @@ module Emfsvg
|
|
|
162
218
|
# SetWorldTransform (just assign) or 1/2/3 for ModifyWorldTransform
|
|
163
219
|
# (MWT_IDENTITY/LEFTMULTIPLY/RIGHTMULTIPLY).
|
|
164
220
|
def build_path_analysis(records)
|
|
165
|
-
|
|
221
|
+
kinds = record_kinds(records)
|
|
222
|
+
actions = path_actions_for(kinds)
|
|
166
223
|
transforms = {}
|
|
167
224
|
in_path = false
|
|
168
225
|
path_drawn = false
|
|
@@ -170,31 +227,30 @@ module Emfsvg
|
|
|
170
227
|
wt_before = nil
|
|
171
228
|
wt_after = nil
|
|
172
229
|
records.each_with_index do |r, i|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
when "BeginPath"
|
|
176
|
-
actions[i] = find_path_action(records, i)
|
|
230
|
+
case kinds[i]
|
|
231
|
+
when :begin_path
|
|
177
232
|
cur_begin = i
|
|
178
233
|
wt_before = nil
|
|
179
234
|
wt_after = nil
|
|
180
235
|
in_path = true
|
|
181
236
|
path_drawn = false
|
|
182
|
-
when
|
|
237
|
+
when :end_path, :abort_path
|
|
183
238
|
transforms[cur_begin] = { wt_before: wt_before, wt_after: wt_after } if cur_begin
|
|
184
239
|
in_path = false
|
|
185
240
|
path_drawn = false
|
|
186
241
|
cur_begin = nil
|
|
187
|
-
when
|
|
242
|
+
when :set_world_transform
|
|
188
243
|
next unless in_path
|
|
189
244
|
|
|
190
|
-
pt = path_transform_for(r
|
|
245
|
+
pt = path_transform_for(path_fields(r), 0)
|
|
191
246
|
path_drawn ? (wt_before = pt) : (wt_after = pt)
|
|
192
|
-
when
|
|
247
|
+
when :modify_world_transform
|
|
193
248
|
next unless in_path
|
|
194
249
|
|
|
195
|
-
|
|
250
|
+
fields = path_fields(r)
|
|
251
|
+
pt = path_transform_for(fields, fields.modify_mode)
|
|
196
252
|
path_drawn ? (wt_before = pt) : (wt_after = pt)
|
|
197
|
-
when
|
|
253
|
+
when :drawing
|
|
198
254
|
path_drawn = true if in_path
|
|
199
255
|
end
|
|
200
256
|
end
|
|
@@ -203,40 +259,43 @@ module Emfsvg
|
|
|
203
259
|
|
|
204
260
|
PathTransform = Struct.new(:matrix, :mode, keyword_init: true)
|
|
205
261
|
|
|
262
|
+
def path_fields(record)
|
|
263
|
+
RecordAccess.fields(record, RecordAccess.wire_class(record))
|
|
264
|
+
end
|
|
265
|
+
|
|
206
266
|
def path_transform_for(wire, mode)
|
|
207
267
|
matrix = Emf::Model::Geometry::Matrix.from_wire(wire.xform)
|
|
208
268
|
PathTransform.new(matrix: matrix, mode: mode)
|
|
209
269
|
end
|
|
210
270
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
when "FillPath"
|
|
219
|
-
return :fill
|
|
220
|
-
when "StrokePath"
|
|
221
|
-
return :stroke
|
|
222
|
-
when "StrokeAndFillPath"
|
|
223
|
-
return :stroke_fill
|
|
224
|
-
when "AbortPath"
|
|
225
|
-
return :abort
|
|
226
|
-
when "BeginPath"
|
|
227
|
-
return :end
|
|
228
|
-
end
|
|
271
|
+
# Path-analysis kind per record (see PATH_RECORD_KINDS; nil when the
|
|
272
|
+
# record is irrelevant to the analysis), computed once per wire class.
|
|
273
|
+
def record_kinds(records)
|
|
274
|
+
cache = {}
|
|
275
|
+
records.map do |r|
|
|
276
|
+
klass = RecordAccess.wire_class(r)
|
|
277
|
+
cache.fetch(klass) { cache[klass] = PATH_RECORD_KINDS[klass.name.split("::").last] }
|
|
229
278
|
end
|
|
230
|
-
action
|
|
231
279
|
end
|
|
232
280
|
|
|
233
|
-
#
|
|
234
|
-
#
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
281
|
+
# Resolve each BeginPath's action in one backward pass. The action is set
|
|
282
|
+
# by the first Fill/Stroke/StrokeAndFill/AbortPath after the BeginPath;
|
|
283
|
+
# another BeginPath first, or none at all, leaves it :end.
|
|
284
|
+
def path_actions_for(kinds)
|
|
285
|
+
actions = {}
|
|
286
|
+
following = :end
|
|
287
|
+
(kinds.size - 1).downto(0) do |i|
|
|
288
|
+
case kinds[i]
|
|
289
|
+
when :begin_path
|
|
290
|
+
actions[i] = following
|
|
291
|
+
following = :end
|
|
292
|
+
when :fill_path then following = :fill
|
|
293
|
+
when :stroke_path then following = :stroke
|
|
294
|
+
when :stroke_and_fill_path then following = :stroke_fill
|
|
295
|
+
when :abort_path then following = :abort
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
actions
|
|
299
|
+
end
|
|
241
300
|
end
|
|
242
301
|
end
|
|
@@ -11,24 +11,48 @@ module Emfsvg
|
|
|
11
11
|
|
|
12
12
|
def initialize
|
|
13
13
|
@clip_paths = {}
|
|
14
|
+
@references = {}
|
|
15
|
+
@resolved = {}
|
|
16
|
+
@exports = {}
|
|
17
|
+
@imports = {}
|
|
18
|
+
@selected = {}
|
|
19
|
+
@unused_selected = {}
|
|
20
|
+
@interval_starts = {}
|
|
21
|
+
@interval_ends = {}
|
|
22
|
+
@interval_groups = {}
|
|
14
23
|
end
|
|
15
24
|
|
|
16
25
|
def register(clip_path)
|
|
26
|
+
@resolved.clear
|
|
17
27
|
@clip_paths[clip_path.id] = clip_path
|
|
18
28
|
end
|
|
19
29
|
|
|
30
|
+
def referenced?(id)
|
|
31
|
+
@references.key?(id)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def reference(clip_path_attr)
|
|
35
|
+
match = clip_path_attr&.match(URL_RE)
|
|
36
|
+
@references[match[1]] = @references.fetch(match[1], 0) + 1 if match
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Shapes reference the same few clips over and over, so each
|
|
40
|
+
# distinct attribute value is resolved once (until the next
|
|
41
|
+
# registration).
|
|
20
42
|
def lookup(clip_path_attr)
|
|
21
43
|
return nil if clip_path_attr.nil?
|
|
22
44
|
|
|
23
|
-
|
|
24
|
-
return nil unless match
|
|
25
|
-
|
|
26
|
-
@clip_paths[match[1]]
|
|
45
|
+
@resolved.fetch(clip_path_attr) { @resolved[clip_path_attr] = resolve(clip_path_attr) }
|
|
27
46
|
end
|
|
28
47
|
|
|
29
48
|
def self.from_document(document)
|
|
30
49
|
registry = new
|
|
31
50
|
collect_clip_paths(document.root_element, registry)
|
|
51
|
+
registry.plan_sibling_clips(document.root_element)
|
|
52
|
+
registry.plan_selected_clips(document.root_element)
|
|
53
|
+
registry.plan_clip_intervals(document.header_translate_group)
|
|
54
|
+
registry.plan_text_clip_intervals(document.root_element)
|
|
55
|
+
registry.plan_pending_clips(document.root_element, document.header_translate_group)
|
|
32
56
|
registry
|
|
33
57
|
end
|
|
34
58
|
|
|
@@ -36,8 +60,267 @@ module Emfsvg
|
|
|
36
60
|
return unless element
|
|
37
61
|
|
|
38
62
|
registry.register(element) if element.is_a?(Elements::ClipPath)
|
|
63
|
+
registry.reference(element.clip_path) if element.respond_to?(:clip_path)
|
|
39
64
|
element.children.each { |c| collect_clip_paths(c, registry) }
|
|
40
65
|
end
|
|
66
|
+
|
|
67
|
+
def exported_clip(group)
|
|
68
|
+
@exports[group]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def imported_clip(group)
|
|
72
|
+
@imports[group]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def selected_clip(group)
|
|
76
|
+
@selected[group]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# A pending native path and its definition must agree exactly, and every
|
|
80
|
+
# remaining child must reference it. RestoreDC then bounds its lifetime.
|
|
81
|
+
def plan_selected_clips(group)
|
|
82
|
+
return unless group
|
|
83
|
+
|
|
84
|
+
group.children.each { |child| plan_selected_clips(child) }
|
|
85
|
+
clip = selected_group_clip(group)
|
|
86
|
+
@selected[group] = clip if clip
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# A canonical identity group containing only a definition can carry its
|
|
90
|
+
# clip into the immediately following canonical drawing group. The sole
|
|
91
|
+
# reference bounds the lifetime; Context also requires a known-empty clip.
|
|
92
|
+
def plan_sibling_clips(parent)
|
|
93
|
+
return unless parent
|
|
94
|
+
|
|
95
|
+
parent.children.each { |child| plan_sibling_clips(child) }
|
|
96
|
+
return unless parent.is_a?(Elements::Group) && parent.transform_matrix.identity?
|
|
97
|
+
|
|
98
|
+
parent.children.each_cons(2) do |source, target|
|
|
99
|
+
clip = sibling_clip(source, target)
|
|
100
|
+
next unless clip
|
|
101
|
+
|
|
102
|
+
@exports[source] = clip
|
|
103
|
+
@imports[target] = clip
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def interval_group?(element)
|
|
108
|
+
@interval_groups.key?(element)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def interval_start(element)
|
|
112
|
+
@interval_starts[element]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def interval_end(element)
|
|
116
|
+
@interval_ends[element]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def unused_selected_clip?(clip)
|
|
120
|
+
@unused_selected.key?(clip)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# A definition immediately following its exact pending native path can
|
|
124
|
+
# select that path. Exclusive adjacent image or path uses bound its lifetime;
|
|
125
|
+
# an unused selection is reset before any later drawing.
|
|
126
|
+
def plan_pending_clips(parent, header)
|
|
127
|
+
return unless parent
|
|
128
|
+
|
|
129
|
+
parent.children.each { |child| plan_pending_clips(child, header) }
|
|
130
|
+
return unless parent.equal?(header) ||
|
|
131
|
+
(parent.is_a?(Elements::Group) && parent.canonical_matrix? && parent.transform_matrix.identity?)
|
|
132
|
+
|
|
133
|
+
parent.children.each_cons(2).with_index do |(path, defs), index|
|
|
134
|
+
next unless defs.is_a?(Elements::Defs) && defs.children.size == 1
|
|
135
|
+
|
|
136
|
+
clip = defs.children.first
|
|
137
|
+
next unless clip.is_a?(Elements::ClipPath) && matching_pending_clip?(path, clip)
|
|
138
|
+
|
|
139
|
+
if referenced?(clip.id)
|
|
140
|
+
plan_pending_draw_interval(parent.children.drop(index + 2), defs, clip)
|
|
141
|
+
else
|
|
142
|
+
@unused_selected[clip] = true
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# A canonical rectangle may surround nested clips and then be reused.
|
|
148
|
+
# Only flat, fully explicit built-in drawings participate. Unused defs
|
|
149
|
+
# and text transformed under a different clip remain boundaries.
|
|
150
|
+
def plan_clip_intervals(parent)
|
|
151
|
+
return unless parent
|
|
152
|
+
|
|
153
|
+
parent.children.grep(Elements::Group).each do |group|
|
|
154
|
+
plan_clip_intervals(group) if group.canonical_matrix?
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
children = parent.children
|
|
158
|
+
positions = children.each_index.group_by { |index| lookup(children[index].clip_path) }
|
|
159
|
+
occupied = []
|
|
160
|
+
candidates = positions.reject { |clip, indices| !clip || indices.size < 2 }
|
|
161
|
+
candidates.sort_by { |_, indices| -(indices.last - indices.first) }.each do |clip, indices|
|
|
162
|
+
range = indices.first..indices.last
|
|
163
|
+
next unless compatible_interval?(range, occupied, children, clip)
|
|
164
|
+
next unless clip.unused_rectangle_bounds
|
|
165
|
+
next unless children[range].all? { |child| interval_child?(child, clip) }
|
|
166
|
+
|
|
167
|
+
@interval_starts[children[range.begin]] = clip
|
|
168
|
+
@interval_ends[children[range.end]] = clip
|
|
169
|
+
occupied << range
|
|
170
|
+
@interval_groups[parent] = true
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Canonical text draws can share a clip across a painted native path,
|
|
175
|
+
# whose renderer spelling omits the clipping attribute. The exact three
|
|
176
|
+
# drawing sequence and exclusive references bound this scope.
|
|
177
|
+
def plan_text_clip_intervals(parent)
|
|
178
|
+
return unless parent
|
|
179
|
+
|
|
180
|
+
parent.children.each { |child| plan_text_clip_intervals(child) }
|
|
181
|
+
return unless parent.is_a?(Elements::Group) && parent.canonical_matrix?
|
|
182
|
+
|
|
183
|
+
parent.children.each_cons(5) do |previous, defs, first, path, last|
|
|
184
|
+
clip = text_interval_clip(previous, defs, first, path, last)
|
|
185
|
+
next unless clip
|
|
186
|
+
|
|
187
|
+
# The general rectangle planner may already start this same interval
|
|
188
|
+
# at its first text. Move that start to the definition instead of
|
|
189
|
+
# opening two native scopes with only one shared end.
|
|
190
|
+
@interval_starts.delete(first) if @interval_starts[first].equal?(clip)
|
|
191
|
+
@interval_starts[defs] = clip
|
|
192
|
+
@interval_ends[last] = clip
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
private
|
|
197
|
+
|
|
198
|
+
def plan_pending_draw_interval(children, defs, clip)
|
|
199
|
+
draws = children.take_while do |draw|
|
|
200
|
+
supported = draw.instance_of?(Elements::Image) ||
|
|
201
|
+
(draw.instance_of?(Elements::Path) && draw.stroke && draw.fill &&
|
|
202
|
+
draw.path_record_kind != :end_path)
|
|
203
|
+
supported && lookup(draw.clip_path).equal?(clip)
|
|
204
|
+
end
|
|
205
|
+
return if draws.empty? || @references[clip.id] != draws.size
|
|
206
|
+
|
|
207
|
+
@interval_starts[defs] = clip
|
|
208
|
+
@interval_ends[draws.last] = clip
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Crossing ranges cannot form native scopes. A nested range contains
|
|
212
|
+
# only its own clip, so its final RestoreDC returns to the outer scope
|
|
213
|
+
# even for renderers which retain restored snapshots.
|
|
214
|
+
def compatible_interval?(range, occupied, children, clip)
|
|
215
|
+
return false if occupied.any? do |prior|
|
|
216
|
+
prior.cover?(range.begin) != prior.cover?(range.end)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
parents = occupied.count { |prior| prior.cover?(range.begin) && prior.cover?(range.end) }
|
|
220
|
+
uniform = children[range].all? { |child| child.is_a?(Elements::Defs) || lookup(child.clip_path).equal?(clip) }
|
|
221
|
+
return !uniform if parents.zero?
|
|
222
|
+
|
|
223
|
+
parents == 1 && uniform
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def interval_child?(child, outer)
|
|
227
|
+
return interval_definitions?(child) if child.is_a?(Elements::Defs)
|
|
228
|
+
|
|
229
|
+
classes = [Elements::Path, Elements::Rect, Elements::Ellipse, Elements::Line,
|
|
230
|
+
Elements::Polyline, Elements::Polygon, Elements::Text, Elements::Image]
|
|
231
|
+
return false unless classes.include?(child.class)
|
|
232
|
+
return false if child.is_a?(Elements::Text) && child.transformed? && !lookup(child.clip_path).equal?(outer)
|
|
233
|
+
return true if child.is_a?(Elements::Path) && !child.clip_path && child.stroke&.null_pen_fallback
|
|
234
|
+
|
|
235
|
+
bounds = lookup(child.clip_path)&.unused_rectangle_bounds
|
|
236
|
+
return false unless bounds
|
|
237
|
+
|
|
238
|
+
left, top, right, bottom = outer.unused_rectangle_bounds
|
|
239
|
+
bounds[0] >= left && bounds[1] >= top && bounds[2] <= right && bounds[3] <= bottom
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def interval_definitions?(defs)
|
|
243
|
+
# Creating an unselected canonical brush does not change the active clip.
|
|
244
|
+
return true if defs.unused_pattern
|
|
245
|
+
|
|
246
|
+
defs.children.all? do |clip|
|
|
247
|
+
clip.is_a?(Elements::ClipPath) && clip.unused_rectangle_bounds && referenced?(clip.id)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def text_interval_clip(previous, defs, first, path, last)
|
|
252
|
+
return unless defs.is_a?(Elements::Defs) && defs.children.size == 1
|
|
253
|
+
|
|
254
|
+
clip = defs.children.first
|
|
255
|
+
return unless clip.is_a?(Elements::ClipPath) && @references[clip.id] == 2
|
|
256
|
+
return unless [first, last].all? do |text|
|
|
257
|
+
text.instance_of?(Elements::Text) && !text.transformed? && lookup(text.clip_path).equal?(clip)
|
|
258
|
+
end
|
|
259
|
+
return unless path.instance_of?(Elements::Path) && !path.clip_path
|
|
260
|
+
return unless path.path_record_kind == :stroke_and_fill_path
|
|
261
|
+
return clip if clip.unused_rectangle_bounds
|
|
262
|
+
|
|
263
|
+
clip if matching_pending_clip?(previous, clip)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def matching_pending_clip?(path, clip)
|
|
267
|
+
clip.selected_path_data && path.instance_of?(Elements::Path) &&
|
|
268
|
+
path.path_record_kind == :end_path && !path.clip_path && matching_clip_path?(path, clip)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def selected_group_clip(group)
|
|
272
|
+
return unless group.is_a?(Elements::Group) && group.canonical_matrix? && group.transform_matrix.identity?
|
|
273
|
+
|
|
274
|
+
unused, path, defs, *draws = group.children
|
|
275
|
+
return unless unused_clip_definitions?(unused)
|
|
276
|
+
return unless path.is_a?(Elements::Path) && path.path_record_kind == :end_path && !path.clip_path
|
|
277
|
+
return unless defs.is_a?(Elements::Defs) && defs.children.size == 1 && !draws.empty?
|
|
278
|
+
|
|
279
|
+
clip = defs.children.first
|
|
280
|
+
return unless clip.is_a?(Elements::ClipPath) && clip.selected_path_data
|
|
281
|
+
return unless matching_clip_path?(path, clip)
|
|
282
|
+
return unless exclusive_clip_draws?(draws, clip)
|
|
283
|
+
|
|
284
|
+
clip
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def exclusive_clip_draws?(draws, clip)
|
|
288
|
+
@references[clip.id] == draws.size && draws.all? do |draw|
|
|
289
|
+
draw.is_a?(Elements::Path) && lookup(draw.clip_path).equal?(clip)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def unused_clip_definitions?(defs)
|
|
294
|
+
defs.is_a?(Elements::Defs) && !defs.children.empty? && defs.children.all? do |child|
|
|
295
|
+
child.is_a?(Elements::ClipPath) && child.unused_rectangle_bounds && !referenced?(child.id)
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def matching_clip_path?(path, clip)
|
|
300
|
+
commands = Svg::PathData::Parser.parse(clip.selected_path_data)
|
|
301
|
+
path.commands.map { |cmd| [cmd.letter, cmd.args] } == commands.map { |cmd| [cmd.letter, cmd.args] }
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def sibling_clip(source, target)
|
|
305
|
+
return unless source.is_a?(Elements::Group) && source.canonical_matrix? && source.transform_matrix.identity?
|
|
306
|
+
return unless target.is_a?(Elements::Group) && target.canonical_matrix?
|
|
307
|
+
return unless source.children.size == 1 && target.children.size == 1
|
|
308
|
+
|
|
309
|
+
defs = source.children.first
|
|
310
|
+
path = target.children.first
|
|
311
|
+
return unless defs.is_a?(Elements::Defs) && defs.children.size == 1 && path.is_a?(Elements::Path)
|
|
312
|
+
|
|
313
|
+
clip = defs.children.first
|
|
314
|
+
return unless clip.is_a?(Elements::ClipPath) && clip.unused_rectangle_bounds
|
|
315
|
+
return unless @references[clip.id] == 1 && lookup(path.clip_path).equal?(clip)
|
|
316
|
+
|
|
317
|
+
clip
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def resolve(clip_path_attr)
|
|
321
|
+
match = clip_path_attr.match(URL_RE)
|
|
322
|
+
match && @clip_paths[match[1]]
|
|
323
|
+
end
|
|
41
324
|
end
|
|
42
325
|
end
|
|
43
326
|
end
|
data/lib/emfsvg/svg/color.rb
CHANGED
|
@@ -29,6 +29,19 @@ module Emfsvg
|
|
|
29
29
|
Emf::Model::Geometry::Color.new(red: red, green: green, blue: blue)
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
# The ColorRef reserved byte emfsvg has always written for a colour.
|
|
33
|
+
WIRE_RESERVED = 255
|
|
34
|
+
|
|
35
|
+
# Attribute-hash form of #to_emf_wire(...).to_wire: a record field
|
|
36
|
+
# accepts it and builds the ColorRef in place, without first
|
|
37
|
+
# constructing a model colour and a wire object that the record
|
|
38
|
+
# would only copy.
|
|
39
|
+
#
|
|
40
|
+
# A Color never changes, so the one frozen Hash is returned each time.
|
|
41
|
+
def wire_attrs
|
|
42
|
+
@wire_attrs ||= { red: red, green: green, blue: blue, reserved: WIRE_RESERVED }.freeze
|
|
43
|
+
end
|
|
44
|
+
|
|
32
45
|
def ==(other)
|
|
33
46
|
other.is_a?(self.class) && red == other.red && green == other.green && blue == other.blue
|
|
34
47
|
end
|
|
@@ -38,12 +51,16 @@ module Emfsvg
|
|
|
38
51
|
[self.class, red, green, blue].hash
|
|
39
52
|
end
|
|
40
53
|
|
|
54
|
+
NULL_WIRE_ATTRS = { red: 0, green: 0, blue: 0, reserved: WIRE_RESERVED }.freeze
|
|
55
|
+
private_constant :NULL_WIRE_ATTRS
|
|
56
|
+
|
|
41
57
|
NULL = NullColor = Class.new do
|
|
42
58
|
def null? = true
|
|
43
59
|
def red = 0
|
|
44
60
|
def green = 0
|
|
45
61
|
def blue = 0
|
|
46
62
|
def to_emf_wire = Emf::Model::Geometry::Color.new(red: 0, green: 0, blue: 0)
|
|
63
|
+
def wire_attrs = NULL_WIRE_ATTRS
|
|
47
64
|
def ==(other) = other.is_a?(self.class)
|
|
48
65
|
alias_method :eql?, :==
|
|
49
66
|
def hash = NullColor.hash
|
|
@@ -71,19 +88,25 @@ module Emfsvg
|
|
|
71
88
|
"cyan" => [0x00, 0xFF, 0xFF]
|
|
72
89
|
}.freeze
|
|
73
90
|
|
|
91
|
+
HEX3_RE = /\A#\h{3}\z/
|
|
92
|
+
HEX6_RE = /\A#\h{6}\z/
|
|
93
|
+
RGB_RE = /\Argb\(\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*\)\z/i
|
|
94
|
+
|
|
74
95
|
def self.parse(value)
|
|
75
|
-
return NULL if value.nil?
|
|
96
|
+
return NULL if value.nil?
|
|
76
97
|
|
|
77
98
|
v = value.strip
|
|
78
|
-
if
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
elsif
|
|
99
|
+
return NULL if v.empty? || v == "none"
|
|
100
|
+
|
|
101
|
+
if v.match?(HEX3_RE)
|
|
102
|
+
expand_short_hex(v[1, 3])
|
|
103
|
+
elsif v.match?(HEX6_RE)
|
|
104
|
+
rgb = v[1, 6].to_i(16)
|
|
105
|
+
new(red: rgb >> 16, green: (rgb >> 8) & 0xFF, blue: rgb & 0xFF)
|
|
106
|
+
elsif (m = RGB_RE.match(v))
|
|
83
107
|
new(red: m[1].to_i, green: m[2].to_i, blue: m[3].to_i)
|
|
84
|
-
elsif NAMED_COLORS
|
|
85
|
-
|
|
86
|
-
new(red: r, green: g, blue: b)
|
|
108
|
+
elsif (rgb = NAMED_COLORS[v.downcase])
|
|
109
|
+
new(red: rgb[0], green: rgb[1], blue: rgb[2])
|
|
87
110
|
else
|
|
88
111
|
raise FormatError, "unsupported SVG color: #{value.inspect}"
|
|
89
112
|
end
|
data/lib/emfsvg/svg/document.rb
CHANGED
|
@@ -36,6 +36,13 @@ module Emfsvg
|
|
|
36
36
|
candidate
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
# True when the header translate group carries the legacy broken-Y
|
|
40
|
+
# marker (see Elements::Group::LEGACY_BROKEN_Y_TRANSFORM).
|
|
41
|
+
def legacy_broken_y_header?
|
|
42
|
+
group = header_translate_group
|
|
43
|
+
!group.nil? && group.legacy_broken_y?
|
|
44
|
+
end
|
|
45
|
+
|
|
39
46
|
# A matrix equivalent to a pure translate: identity 2×2 portion
|
|
40
47
|
# (modulo floating-point rounding).
|
|
41
48
|
IDENTITY_TOLERANCE = 1e-9
|