emfsvg 0.1.0
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 +7 -0
- data/LICENSE.txt +24 -0
- data/README.adoc +104 -0
- data/exe/emfsvg +69 -0
- data/lib/emfsvg/bsd_rand.rb +29 -0
- data/lib/emfsvg/bsd_rand_sequence.txt +5000 -0
- data/lib/emfsvg/clip_region.rb +75 -0
- data/lib/emfsvg/compat.rb +134 -0
- data/lib/emfsvg/device_context.rb +240 -0
- data/lib/emfsvg/dib_decoder.rb +302 -0
- data/lib/emfsvg/error.rb +9 -0
- data/lib/emfsvg/format_helpers.rb +30 -0
- data/lib/emfsvg/glyph_index_mapper.rb +109 -0
- data/lib/emfsvg/object_table.rb +51 -0
- data/lib/emfsvg/options.rb +11 -0
- data/lib/emfsvg/png_encoder.rb +29 -0
- data/lib/emfsvg/renderer.rb +242 -0
- data/lib/emfsvg/svg_builder.rb +77 -0
- data/lib/emfsvg/transform_stack.rb +40 -0
- data/lib/emfsvg/version.rb +5 -0
- data/lib/emfsvg/visitors/emr_visitor.rb +2223 -0
- data/lib/emfsvg/visitors.rb +7 -0
- data/lib/emfsvg.rb +49 -0
- metadata +153 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Emfsvg
|
|
4
|
+
# Immutable clip region value object. Holds either a rectangular
|
|
5
|
+
# bounds (most common case) or a complex path (for SelectClipPath
|
|
6
|
+
# with arbitrary paths).
|
|
7
|
+
#
|
|
8
|
+
# Combine modes per MS-WMF 2.1.5:
|
|
9
|
+
# RGN_AND=1, RGN_OR=2, RGN_XOR=3, RGN_DIFF=4, RGN_COPY=5
|
|
10
|
+
class ClipRegion
|
|
11
|
+
RGN_AND = 1
|
|
12
|
+
RGN_OR = 2
|
|
13
|
+
RGN_XOR = 3
|
|
14
|
+
RGN_DIFF = 4
|
|
15
|
+
RGN_COPY = 5
|
|
16
|
+
|
|
17
|
+
def initialize(bounds: nil, path_d: nil)
|
|
18
|
+
@bounds = bounds
|
|
19
|
+
@path_d = path_d
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
attr_reader :bounds, :path_d
|
|
23
|
+
|
|
24
|
+
def empty?
|
|
25
|
+
bounds.nil? && path_d.nil?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# SVG element body for the clip shape: `<rect .../>` or `<path .../>`.
|
|
29
|
+
def to_svg_element(builder)
|
|
30
|
+
if path_d
|
|
31
|
+
builder.tag("path", { "d" => path_d }, single: true)
|
|
32
|
+
elsif bounds
|
|
33
|
+
builder.tag("rect", rect_attrs(bounds), single: true)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Combine with another region per the given mode.
|
|
38
|
+
# For MVP we approximate: only AND and COPY are handled precisely;
|
|
39
|
+
# OR/XOR/DIFF fall back to the new region's bounds.
|
|
40
|
+
def combine(other, mode)
|
|
41
|
+
case mode
|
|
42
|
+
when RGN_AND
|
|
43
|
+
return other if empty?
|
|
44
|
+
return self if other.empty?
|
|
45
|
+
|
|
46
|
+
ClipRegion.new(bounds: intersect_rects(bounds, other.bounds))
|
|
47
|
+
else # RGN_COPY, RGN_OR, RGN_XOR, RGN_DIFF — MVP: use new region
|
|
48
|
+
other
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def rect_attrs(rect)
|
|
55
|
+
{
|
|
56
|
+
"x" => rect.left,
|
|
57
|
+
"y" => rect.top,
|
|
58
|
+
"width" => rect.right - rect.left,
|
|
59
|
+
"height" => rect.bottom - rect.top
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def intersect_rects(a, b)
|
|
64
|
+
return b unless a
|
|
65
|
+
return a unless b
|
|
66
|
+
|
|
67
|
+
Emf::Model::Geometry::Rect.new(
|
|
68
|
+
left: [a.left, b.left].max,
|
|
69
|
+
top: [a.top, b.top].max,
|
|
70
|
+
right: [a.right, b.right].min,
|
|
71
|
+
bottom: [a.bottom, b.bottom].min
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Compares emfsvg's output against libemf2svg's reference output for the
|
|
4
|
+
# same EMF input. Used by spec/compat_with_libemf2svg_spec.rb.
|
|
5
|
+
#
|
|
6
|
+
# This is NOT a "must produce identical SVG" test — emfsvg is a clean-room
|
|
7
|
+
# rewrite that handles a subset of EMF records. It IS:
|
|
8
|
+
# - a structural-equivalence check for the records emfsvg handles
|
|
9
|
+
# - a quantification of the gap (compatibility ratio per fixture)
|
|
10
|
+
# - a regression guard so the ratio doesn't shrink over time
|
|
11
|
+
#
|
|
12
|
+
# Usage:
|
|
13
|
+
# Emfsvg::Compat::Comparator.call(fixtures_glob, ref_dir, out_dir)
|
|
14
|
+
#
|
|
15
|
+
# Requires the libemf2svg-conv binary (or our emf2svg_ref wrapper) at
|
|
16
|
+
# Emfsvg::Compat::LIBEMF2SVG_REF_PATH. The spec auto-skips if missing.
|
|
17
|
+
|
|
18
|
+
require "open3"
|
|
19
|
+
require "fileutils"
|
|
20
|
+
require "tmpdir"
|
|
21
|
+
require "rexml/document"
|
|
22
|
+
|
|
23
|
+
module Emfsvg
|
|
24
|
+
module Compat
|
|
25
|
+
REF_BINARY_DEFAULT = "/Users/mulgogi/src/claricle/libemf2svg/emf2svg_ref"
|
|
26
|
+
REF_LIB_DIR_DEFAULT = "/Users/mulgogi/src/claricle/libemf2svg/build"
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
def ref_binary_available?
|
|
31
|
+
path = ENV.fetch("EMF2SVG_REF_PATH", REF_BINARY_DEFAULT)
|
|
32
|
+
File.executable?(path)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Generate a reference SVG using libemf2svg. Returns the SVG bytes,
|
|
36
|
+
# or nil if generation failed.
|
|
37
|
+
def generate_reference(emf_path, output_path = nil)
|
|
38
|
+
binary = ENV.fetch("EMF2SVG_REF_PATH", REF_BINARY_DEFAULT)
|
|
39
|
+
lib_dir = ENV.fetch("EMF2SVG_REF_LIB_DIR", REF_LIB_DIR_DEFAULT)
|
|
40
|
+
env = { "DYLD_LIBRARY_PATH" => lib_dir }
|
|
41
|
+
output_path ||= "#{Dir.mktmpdir}/#{File.basename(emf_path, '.emf')}.svg"
|
|
42
|
+
_, _, status = Open3.capture3(env, binary, emf_path, output_path)
|
|
43
|
+
return nil unless status.success?
|
|
44
|
+
|
|
45
|
+
File.binread(output_path)
|
|
46
|
+
ensure
|
|
47
|
+
File.delete(output_path) if output_path && !block_given?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Generate emfsvg output. Requires the emfsvg gem to be loaded.
|
|
51
|
+
def generate_emfsvg(emf_path, options = {})
|
|
52
|
+
Emfsvg.from_file(emf_path, **options)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Normalise an SVG document for structural comparison:
|
|
56
|
+
# - parse to REXML
|
|
57
|
+
# - return the root element
|
|
58
|
+
def parse_svg(svg_string)
|
|
59
|
+
REXML::Document.new(svg_string).root
|
|
60
|
+
rescue REXML::ParseException
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Count drawing elements in an SVG. Returns a hash like
|
|
65
|
+
# { "rect" => 3, "path" => 17, "ellipse" => 2 }.
|
|
66
|
+
def count_elements(svg_root)
|
|
67
|
+
counts = Hash.new(0)
|
|
68
|
+
return counts unless svg_root
|
|
69
|
+
|
|
70
|
+
REXML::XPath.each(svg_root, "descendant-or-self::*") do |el|
|
|
71
|
+
counts[el.name] += 1
|
|
72
|
+
end
|
|
73
|
+
counts
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Per-fixture comparison result.
|
|
77
|
+
Result = Struct.new(:fixture, :ref_bytes, :emfsvg_bytes, :ref_elements,
|
|
78
|
+
:emfsvg_elements, keyword_init: true) do
|
|
79
|
+
def byte_ratio
|
|
80
|
+
ref_bytes.zero? ? 0.0 : (emfsvg_bytes.to_f / ref_bytes * 100).round(1)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def element_overlap
|
|
84
|
+
return 0.0 if emfsvg_elements.empty?
|
|
85
|
+
|
|
86
|
+
shared = emfsvg_elements.keys & ref_elements.keys
|
|
87
|
+
shared.sum { |k| [emfsvg_elements[k], ref_elements[k]].min }.to_f /
|
|
88
|
+
emfsvg_elements.values.sum
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def to_s
|
|
92
|
+
format("%-40s ref=%8d out=%7d (%5.1f%%) elements_overlap=%.1f",
|
|
93
|
+
fixture, ref_bytes, emfsvg_bytes, byte_ratio, element_overlap * 100)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Walk a fixtures glob, produce both outputs, return Result array.
|
|
98
|
+
def compare_glob(fixtures_glob)
|
|
99
|
+
results = []
|
|
100
|
+
Dir.glob(fixtures_glob).each do |emf|
|
|
101
|
+
ref_svg = generate_reference(emf)
|
|
102
|
+
next unless ref_svg
|
|
103
|
+
|
|
104
|
+
emfsvg_svg = begin
|
|
105
|
+
generate_emfsvg(emf)
|
|
106
|
+
rescue StandardError
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
next unless emfsvg_svg
|
|
110
|
+
|
|
111
|
+
results << Result.new(
|
|
112
|
+
fixture: File.basename(emf),
|
|
113
|
+
ref_bytes: ref_svg.bytesize,
|
|
114
|
+
emfsvg_bytes: emfsvg_svg.bytesize,
|
|
115
|
+
ref_elements: count_elements(parse_svg(ref_svg)),
|
|
116
|
+
emfsvg_elements: count_elements(parse_svg(emfsvg_svg))
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
results
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Summary across many results.
|
|
123
|
+
def summarize(results)
|
|
124
|
+
return "no results" if results.empty?
|
|
125
|
+
|
|
126
|
+
total_ref = results.sum(&:ref_bytes)
|
|
127
|
+
total_out = results.sum(&:emfsvg_bytes)
|
|
128
|
+
ratio = total_ref.zero? ? 0 : (total_out.to_f / total_ref * 100).round(1)
|
|
129
|
+
avg_overlap = results.sum(&:element_overlap) / results.size
|
|
130
|
+
format("fixtures=%d total_ref=%d total_out=%d byte_ratio=%.1f%% avg_element_overlap=%.1f%%",
|
|
131
|
+
results.size, total_ref, total_out, ratio, avg_overlap * 100)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Emfsvg
|
|
4
|
+
# Coordinate state that lives OUTSIDE the device-context stack in
|
|
5
|
+
# libemf2svg (MapMode, window/viewport extents and origins). The C
|
|
6
|
+
# code keeps these on drawingStates directly, NOT on
|
|
7
|
+
# EMF_DEVICE_CONTEXT, so SaveDC/RestoreDC never saves/restores them.
|
|
8
|
+
# They persist as global state for the duration of the render.
|
|
9
|
+
#
|
|
10
|
+
# This is a mutable struct shared by reference across every dup'd
|
|
11
|
+
# DeviceContext — when visit_set_map_mode changes the map mode, every
|
|
12
|
+
# DC observes the new value, even ones that were saved earlier.
|
|
13
|
+
class CoordinateState
|
|
14
|
+
attr_accessor :map_mode, :window_ext, :viewport_ext,
|
|
15
|
+
:window_org, :viewport_org,
|
|
16
|
+
:window_ext_set, :viewport_ext_set
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@map_mode = 1 # MM_TEXT
|
|
20
|
+
@window_ext = [0, 0]
|
|
21
|
+
@viewport_ext = [0, 0]
|
|
22
|
+
@window_org = [0, 0]
|
|
23
|
+
@viewport_org = [0, 0]
|
|
24
|
+
@window_ext_set = false
|
|
25
|
+
@viewport_ext_set = false
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Snapshot of GDI drawing state. Pushed onto TransformStack on SaveDC,
|
|
30
|
+
# popped on RestoreDC.
|
|
31
|
+
class DeviceContext
|
|
32
|
+
def initialize
|
|
33
|
+
@pen = Pen.default
|
|
34
|
+
@brush = Brush.default
|
|
35
|
+
@font = Font.default
|
|
36
|
+
@text_color = Emf::Model::Geometry::Color.black
|
|
37
|
+
@bk_color = Emf::Model::Geometry::Color.white
|
|
38
|
+
@bk_mode = 1 # TRANSPARENT
|
|
39
|
+
@poly_fill_mode = 1 # ALTERNATE (evenodd)
|
|
40
|
+
@text_align = 0
|
|
41
|
+
@stretch_mode = 1
|
|
42
|
+
@miter_limit = 10.0
|
|
43
|
+
@world_transform = Emf::Model::Geometry::Matrix.identity
|
|
44
|
+
@brush_origin = [0, 0]
|
|
45
|
+
@icm_mode = 0
|
|
46
|
+
@layout = 0
|
|
47
|
+
@mapper_flags = 0
|
|
48
|
+
@arc_direction = 0 # default; set by SetArcDirection (1=CW, -1=CCW in C)
|
|
49
|
+
@clip_region = nil
|
|
50
|
+
@clip_id = nil
|
|
51
|
+
# Coordinate state shared across all dup'd DCs (see CoordinateState).
|
|
52
|
+
@coordinate_state = CoordinateState.new
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
attr_accessor :pen, :brush, :font, :text_color, :bk_color, :bk_mode, :poly_fill_mode, :text_align, :stretch_mode, :miter_limit,
|
|
56
|
+
:world_transform, :brush_origin, :icm_mode, :layout, :mapper_flags, :arc_direction, :clip_region, :clip_id, :coordinate_state
|
|
57
|
+
|
|
58
|
+
def map_mode
|
|
59
|
+
coordinate_state.map_mode
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def map_mode=(value)
|
|
63
|
+
coordinate_state.map_mode = value
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def window_ext
|
|
67
|
+
coordinate_state.window_ext
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def window_ext=(value)
|
|
71
|
+
coordinate_state.window_ext = value
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def viewport_ext
|
|
75
|
+
coordinate_state.viewport_ext
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def viewport_ext=(value)
|
|
79
|
+
coordinate_state.viewport_ext = value
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def window_org
|
|
83
|
+
coordinate_state.window_org
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def window_org=(value)
|
|
87
|
+
coordinate_state.window_org = value
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def viewport_org
|
|
91
|
+
coordinate_state.viewport_org
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def viewport_org=(value)
|
|
95
|
+
coordinate_state.viewport_org = value
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def window_ext_set?
|
|
99
|
+
coordinate_state.window_ext_set
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def viewport_ext_set?
|
|
103
|
+
coordinate_state.viewport_ext_set
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def window_ext_set=(value)
|
|
107
|
+
coordinate_state.window_ext_set = value
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def viewport_ext_set=(value)
|
|
111
|
+
coordinate_state.viewport_ext_set = value
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def dup
|
|
115
|
+
other = DeviceContext.allocate
|
|
116
|
+
other.pen = pen.dup
|
|
117
|
+
other.brush = brush.dup
|
|
118
|
+
other.font = font.dup
|
|
119
|
+
other.text_color = text_color
|
|
120
|
+
other.bk_color = bk_color
|
|
121
|
+
other.bk_mode = bk_mode
|
|
122
|
+
other.poly_fill_mode = poly_fill_mode
|
|
123
|
+
other.text_align = text_align
|
|
124
|
+
other.stretch_mode = stretch_mode
|
|
125
|
+
other.miter_limit = miter_limit
|
|
126
|
+
other.world_transform = world_transform
|
|
127
|
+
other.brush_origin = brush_origin.dup
|
|
128
|
+
other.icm_mode = icm_mode
|
|
129
|
+
other.layout = layout
|
|
130
|
+
other.mapper_flags = mapper_flags
|
|
131
|
+
other.arc_direction = arc_direction
|
|
132
|
+
other.clip_region = clip_region
|
|
133
|
+
other.clip_id = clip_id
|
|
134
|
+
# CoordinateState is shared by reference — SaveDC/RestoreDC must
|
|
135
|
+
# not snapshot/restore the coordinate state per libemf2svg.
|
|
136
|
+
other.coordinate_state = coordinate_state
|
|
137
|
+
other
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
protected :coordinate_state=
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Pen value object: style, width, color.
|
|
144
|
+
class Pen
|
|
145
|
+
SOLID = 0
|
|
146
|
+
DASH = 1
|
|
147
|
+
DOT = 2
|
|
148
|
+
DASHDOT = 3
|
|
149
|
+
DASHDOTDOT = 4
|
|
150
|
+
NULL = 5
|
|
151
|
+
INSIDEFRAME = 6
|
|
152
|
+
|
|
153
|
+
def initialize(style: SOLID, width: 1, color: Emf::Model::Geometry::Color.black)
|
|
154
|
+
@style = style
|
|
155
|
+
@width = width
|
|
156
|
+
@color = color
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
attr_reader :style, :width, :color
|
|
160
|
+
|
|
161
|
+
def self.default
|
|
162
|
+
new
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def null?
|
|
166
|
+
style == NULL
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def dup
|
|
170
|
+
Pen.new(style: style, width: width, color: color)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Brush value object: style, color, hatch.
|
|
175
|
+
class Brush
|
|
176
|
+
SOLID = 0
|
|
177
|
+
NULL = 1
|
|
178
|
+
HATCHED = 2
|
|
179
|
+
PATTERN = 3
|
|
180
|
+
# MONOPATTERN: 8 (BS_MONOPATTERN / U_BS_MONOPATTERN in MS-WMF). Brush
|
|
181
|
+
# references a DIB image stored in the SVG <defs>; fill becomes
|
|
182
|
+
# `url(#img-N-ref)`. brush.image_id is the N.
|
|
183
|
+
MONOPATTERN = 8
|
|
184
|
+
|
|
185
|
+
def initialize(style: SOLID, color: Emf::Model::Geometry::Color.black, hatch: 0, image_id: nil)
|
|
186
|
+
@style = style
|
|
187
|
+
@color = color
|
|
188
|
+
@hatch = hatch
|
|
189
|
+
@image_id = image_id
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
attr_reader :style, :color, :hatch, :image_id
|
|
193
|
+
|
|
194
|
+
def self.default
|
|
195
|
+
new
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def null?
|
|
199
|
+
style == NULL
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def dup
|
|
203
|
+
Brush.new(style: style, color: color, hatch: hatch, image_id: image_id)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Font value object: minimal subset of LOGFONT for text rendering.
|
|
208
|
+
class Font
|
|
209
|
+
DEFAULT_HEIGHT = 0
|
|
210
|
+
|
|
211
|
+
def initialize(face_name: nil, height: DEFAULT_HEIGHT, weight: 0,
|
|
212
|
+
italic: false, underline: false, strikeout: false,
|
|
213
|
+
escapement: 0, charset: 0)
|
|
214
|
+
@face_name = face_name
|
|
215
|
+
@height = height
|
|
216
|
+
@weight = weight
|
|
217
|
+
@italic = italic
|
|
218
|
+
@underline = underline
|
|
219
|
+
@strikeout = strikeout
|
|
220
|
+
@escapement = escapement
|
|
221
|
+
@charset = charset
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
attr_reader :face_name, :height, :weight, :italic, :underline, :strikeout, :escapement, :charset
|
|
225
|
+
|
|
226
|
+
def self.default
|
|
227
|
+
new
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def bold?
|
|
231
|
+
weight >= 700
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def dup
|
|
235
|
+
Font.new(face_name: face_name, height: height, weight: weight,
|
|
236
|
+
italic: italic, underline: underline, strikeout: strikeout,
|
|
237
|
+
escapement: escapement, charset: charset)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|