wineole 0.1.0 → 0.2.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 +4 -4
- data/bin/wineole-vba +45 -0
- data/lib/wineole/client.rb +454 -20
- data/lib/wineole/dispatcher.rb +534 -0
- data/lib/wineole/errors.rb +6 -0
- data/lib/wineole/events.rb +353 -0
- data/lib/wineole/msoffice/address.rb +67 -0
- data/lib/wineole/msoffice/book.rb +114 -0
- data/lib/wineole/msoffice/color.rb +87 -0
- data/lib/wineole/msoffice/controls.rb +450 -0
- data/lib/wineole/msoffice/excel.rb +311 -0
- data/lib/wineole/msoffice/format.rb +362 -0
- data/lib/wineole/msoffice/forms.rb +163 -0
- data/lib/wineole/msoffice/passthrough.rb +46 -0
- data/lib/wineole/msoffice/paths.rb +62 -0
- data/lib/wineole/msoffice/range.rb +149 -0
- data/lib/wineole/msoffice/sheet.rb +101 -0
- data/lib/wineole/msoffice/vba.rb +199 -0
- data/lib/wineole/msoffice/vba_api.rb +366 -0
- data/lib/wineole/msoffice/vba_block.rb +144 -0
- data/lib/wineole/msoffice.rb +28 -0
- data/lib/wineole/proxy.rb +126 -15
- data/lib/wineole.rb +10 -1
- data/wineole-bridge-dist/aarch64-pc-windows-gnullvm/wineole-bridge.exe +0 -0
- data/wineole-bridge-dist/i686-pc-windows-gnu/wineole-bridge.exe +0 -0
- data/wineole-bridge-dist/x86_64-pc-windows-gnu/wineole-bridge.exe +0 -0
- metadata +21 -2
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
require_relative '../../wineole'
|
|
2
|
+
require_relative '../proxy'
|
|
3
|
+
require_relative 'passthrough'
|
|
4
|
+
require_relative 'address'
|
|
5
|
+
require_relative 'range'
|
|
6
|
+
require_relative 'sheet'
|
|
7
|
+
require_relative 'book'
|
|
8
|
+
|
|
9
|
+
module WineOLE
|
|
10
|
+
module MSOffice
|
|
11
|
+
# Wraps a COM Excel.Application: the lifecycle (.create / .connect /
|
|
12
|
+
# .connect_or_create / .run) and the entry point of the addressing DSL
|
|
13
|
+
# that Address / Sheet / Book / Range build on.
|
|
14
|
+
#
|
|
15
|
+
# `version` is deliberately not defined -- see the comment on @version
|
|
16
|
+
# in #initialize.
|
|
17
|
+
#
|
|
18
|
+
# `run` here is a *class* method (Excel.run), never an instance one:
|
|
19
|
+
# COM's own Application.Run (the macro runner) is real -- measured on a
|
|
20
|
+
# live Excel 11, `xl.run` answers DISP_E_EXCEPTION, meaning COM tried
|
|
21
|
+
# to actually invoke it rather than reporting an unknown name. A class
|
|
22
|
+
# method lives in a completely separate namespace from an instance
|
|
23
|
+
# method of the same spelling, so `Excel.run` shadows nothing; adding
|
|
24
|
+
# an *instance* `run` would.
|
|
25
|
+
class Excel
|
|
26
|
+
include Passthrough
|
|
27
|
+
|
|
28
|
+
APPLICATION = 'Excel.Application'.freeze
|
|
29
|
+
|
|
30
|
+
# What the bridge runs on the way out of an auto-created instance,
|
|
31
|
+
# once its last user releases the root: suppress prompts, then quit.
|
|
32
|
+
# Declared here, once, and handed to the bridge at construction time
|
|
33
|
+
# (via Client#create/connect/connect_or_create's `cleanup:` kwarg)
|
|
34
|
+
# rather than run from Ruby -- the bridge is the only party that knows
|
|
35
|
+
# when the LAST user of a shared root has let go of it, so it is the
|
|
36
|
+
# only party that can decide whether these steps should run at all.
|
|
37
|
+
CLEANUP_STEPS = { steps: [['DisplayAlerts=', false], ['Quit']] }.freeze
|
|
38
|
+
|
|
39
|
+
def self.create(client: WineOLE.default_client, convert_paths: true)
|
|
40
|
+
new(client.create(APPLICATION, cleanup: CLEANUP_STEPS), client: client, convert_paths: convert_paths)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Declares the same steps .create does. That is correct, not an
|
|
44
|
+
# oversight: the steps are a property of this instance, but they only
|
|
45
|
+
# ever RUN when the bridge's record is auto_created, which is false
|
|
46
|
+
# here unless connect_or_create's own fallback is what created it. A
|
|
47
|
+
# human's Excel reached via .connect is never auto_created, so Quit
|
|
48
|
+
# never fires for it -- this class used to enforce that with an
|
|
49
|
+
# `ole_created?` check of its own; the bridge enforces it now, one
|
|
50
|
+
# layer down.
|
|
51
|
+
def self.connect(client: WineOLE.default_client, convert_paths: true)
|
|
52
|
+
new(client.connect(APPLICATION, cleanup: CLEANUP_STEPS), client: client, convert_paths: convert_paths)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.connect_or_create(client: WineOLE.default_client, convert_paths: true)
|
|
56
|
+
new(client.connect_or_create(APPLICATION, cleanup: CLEANUP_STEPS), client: client, convert_paths: convert_paths)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Runs the block with an Excel application, then releases it.
|
|
60
|
+
#
|
|
61
|
+
# Deciding whether that release actually quits Excel is no longer this
|
|
62
|
+
# method's job. create/connect/connect_or_create declare CLEANUP_STEPS
|
|
63
|
+
# at construction time, and the bridge is the one that knows when the
|
|
64
|
+
# LAST user of an auto-created root lets go of it -- that is when it
|
|
65
|
+
# runs those steps. Attaching to an Excel somebody already had open
|
|
66
|
+
# (.connect) is never auto-created, so those steps never fire for it;
|
|
67
|
+
# quitting it out from under them would throw away their unsaved work.
|
|
68
|
+
def self.run(mode = :connect_or_create, **options)
|
|
69
|
+
xl = case mode
|
|
70
|
+
when :create then create(**options)
|
|
71
|
+
when :connect then connect(**options)
|
|
72
|
+
when :connect_or_create then connect_or_create(**options)
|
|
73
|
+
else raise ArgumentError, "unknown mode #{mode.inspect}"
|
|
74
|
+
end
|
|
75
|
+
begin
|
|
76
|
+
yield xl
|
|
77
|
+
ensure
|
|
78
|
+
xl.ole_release
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def initialize(proxy, client:, convert_paths: true)
|
|
83
|
+
@ole = proxy
|
|
84
|
+
@client = client
|
|
85
|
+
@convert_paths = convert_paths
|
|
86
|
+
# COM resolves names case-insensitively, so `xl.version` already
|
|
87
|
+
# reaches COM's own Version and returns e.g. "11.0" -- measured.
|
|
88
|
+
# Defining a `version` method here would only shadow that with
|
|
89
|
+
# something that does the same thing, so this class deliberately
|
|
90
|
+
# does not have one. Captured once here, rather than read fresh
|
|
91
|
+
# per lookup, so every Sheet/Book this object builds sees one
|
|
92
|
+
# stable answer without a round trip each time.
|
|
93
|
+
@version = @ole.Version
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Release the underlying Application. On the last user of an auto-created
|
|
97
|
+
# instance this is what quits Excel (the bridge runs DisplayAlerts=false
|
|
98
|
+
# then Quit); on a connected instance it simply detaches. Public because
|
|
99
|
+
# `run` is not the only way to get one of these, and a caller managing
|
|
100
|
+
# the lifecycle by hand needs the same call available.
|
|
101
|
+
def ole_release
|
|
102
|
+
@ole.ole_release
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Keep this Excel running after this program leaves -- e.g. a report left
|
|
106
|
+
# on screen for a human. Revokes the bridge's permission to quit it.
|
|
107
|
+
def leave_open
|
|
108
|
+
@ole.ole_leave_open
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def [](*keys)
|
|
112
|
+
lookup(keys)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Resolves the same way #[] does; raises unless that resolves all the
|
|
116
|
+
# way to a Range. `xl["Sheet1!"] = 0` silently filling a whole sheet
|
|
117
|
+
# is exactly the hazard this guards against -- a typo that stops at a
|
|
118
|
+
# sheet or a book is otherwise indistinguishable from that "fill
|
|
119
|
+
# everything" request (Spec Sec 4.5).
|
|
120
|
+
def []=(*args)
|
|
121
|
+
value = args.pop
|
|
122
|
+
target = lookup(args)
|
|
123
|
+
unless target.is_a?(Range)
|
|
124
|
+
raise ArgumentError,
|
|
125
|
+
"#{args.inspect} does not resolve to a range -- assignment needs an address " \
|
|
126
|
+
'with an explicit range; a bare sheet or workbook lookup is get-only'
|
|
127
|
+
end
|
|
128
|
+
target.write(value)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# COM's Application has no Show/Hide (measured: both answer
|
|
132
|
+
# DISP_E_UNKNOWNNAME), so defining these does not shadow a working
|
|
133
|
+
# COM call the way a single-word name with a real COM counterpart
|
|
134
|
+
# would (Sec 4.6).
|
|
135
|
+
def show
|
|
136
|
+
@ole.Visible = true
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def hide
|
|
140
|
+
@ole.Visible = false
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Suppresses Excel's save/overwrite/etc. modal prompts for the
|
|
144
|
+
# duration of the block, then restores whatever DisplayAlerts was
|
|
145
|
+
# set to beforehand -- not a hardcoded true. msoffice.rb's own
|
|
146
|
+
# version restores a hardcoded true in its ensure, which would
|
|
147
|
+
# silently turn a caller's own `DisplayAlerts = false` back on the
|
|
148
|
+
# moment this method returns; reading the value first instead means
|
|
149
|
+
# an outer caller who had already turned it off keeps it off.
|
|
150
|
+
#
|
|
151
|
+
# The underscore makes the name safe regardless of whether COM has a
|
|
152
|
+
# same-named member (Sec 4.6): `no_alert` would not resolve to
|
|
153
|
+
# anything on Application even as a single word, since COM does not
|
|
154
|
+
# strip underscores when matching names.
|
|
155
|
+
def no_alert
|
|
156
|
+
raise ArgumentError, 'no_alert needs a block -- the flag is restored when it returns' unless block_given?
|
|
157
|
+
|
|
158
|
+
previous = @ole.DisplayAlerts
|
|
159
|
+
begin
|
|
160
|
+
@ole.DisplayAlerts = false
|
|
161
|
+
yield
|
|
162
|
+
ensure
|
|
163
|
+
restore(:DisplayAlerts=, previous)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Same restore-what-was-there discipline as #no_alert, for screen
|
|
168
|
+
# redraws instead of alert dialogs.
|
|
169
|
+
def no_update
|
|
170
|
+
raise ArgumentError, 'no_update needs a block -- the flag is restored when it returns' unless block_given?
|
|
171
|
+
|
|
172
|
+
previous = @ole.ScreenUpdating
|
|
173
|
+
begin
|
|
174
|
+
@ole.ScreenUpdating = false
|
|
175
|
+
yield
|
|
176
|
+
ensure
|
|
177
|
+
restore(:ScreenUpdating=, previous)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
private
|
|
182
|
+
|
|
183
|
+
# Put a flag back, and never let failing to do so become the caller's
|
|
184
|
+
# problem.
|
|
185
|
+
#
|
|
186
|
+
# An exception raised inside `ensure` REPLACES whatever the block
|
|
187
|
+
# raised. A failed restore would therefore destroy the caller's own
|
|
188
|
+
# error and report the cleanup instead.
|
|
189
|
+
#
|
|
190
|
+
# And the ordinary reason a restore fails is that the block ended the
|
|
191
|
+
# very thing being restored: an Application that quits or disconnects
|
|
192
|
+
# mid-block (whether from a caller's own `no_alert { xl.Quit }`, or
|
|
193
|
+
# from underneath this process entirely) leaves nothing for `no_alert`'s
|
|
194
|
+
# ensure to put DisplayAlerts back on. There is no state left to
|
|
195
|
+
# restore, and nothing worth telling anyone.
|
|
196
|
+
#
|
|
197
|
+
# Swallowed unconditionally rather than only for a "the object is
|
|
198
|
+
# gone" error: over this bridge that arrives as a RemoteError wrapping
|
|
199
|
+
# an HRESULT string (0x800706BE is a normal transient right after
|
|
200
|
+
# Quit), so telling the two apart means matching on those strings --
|
|
201
|
+
# brittle, and beside the point, since raising out of `ensure` is
|
|
202
|
+
# wrong even when the failure really is transient.
|
|
203
|
+
def restore(setter, value)
|
|
204
|
+
@ole.public_send(setter, value)
|
|
205
|
+
rescue StandardError
|
|
206
|
+
nil
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def lookup(keys)
|
|
210
|
+
return cell_range(*keys) if keys.length == 2 && keys.all? { |k| k.is_a?(::Integer) }
|
|
211
|
+
|
|
212
|
+
unless keys.length == 1 && keys.first.is_a?(::String)
|
|
213
|
+
raise ArgumentError, "unsupported index #{keys.inspect}"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
str = keys.first
|
|
217
|
+
addr = Address.parse(str, @version)
|
|
218
|
+
|
|
219
|
+
# Not an address at all -- msoffice.rb's own fallback: treat the
|
|
220
|
+
# whole string as a raw worksheet name. This is exactly why
|
|
221
|
+
# Address.parse returns nil instead of raising.
|
|
222
|
+
return Sheet.new(@ole.Worksheets.Item(str), version: @version) if addr.nil?
|
|
223
|
+
|
|
224
|
+
resolve(addr, str)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# xl[row, col]: Cells on the active sheet. Goes through #resolve's
|
|
228
|
+
# same no-Select path (via the Sheet it builds), one Application
|
|
229
|
+
# round trip for ActiveSheet plus whatever Sheet#[] costs.
|
|
230
|
+
def cell_range(row, col)
|
|
231
|
+
Sheet.new(active_worksheet(@ole, "xl[#{row}, #{col}]"), version: @version)[row, col]
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Resolves a parsed Address against this Application.
|
|
235
|
+
#
|
|
236
|
+
# Deliberately never calls Select. msoffice.rb has to, because it
|
|
237
|
+
# reaches a range through Application.Range, which addresses
|
|
238
|
+
# whatever sheet happens to be active. Resolving against the
|
|
239
|
+
# worksheet object itself needs no active state, so a read here does
|
|
240
|
+
# not mutate the caller's selection as a side effect, and it costs
|
|
241
|
+
# one fewer round trip. Measured against a live Excel 11:
|
|
242
|
+
# `book.Worksheets.Item(1).Range('A1').Value` left ActiveSheet
|
|
243
|
+
# exactly where it was, both before and after.
|
|
244
|
+
def resolve(addr, str)
|
|
245
|
+
workbook_ole = addr.workbook.nil? ? nil : resolve_workbook(addr.workbook, str)
|
|
246
|
+
|
|
247
|
+
worksheet_part = addr.worksheet
|
|
248
|
+
# A bare range ("A1:B2", no "!") names no worksheet at all -- it
|
|
249
|
+
# still needs one to be looked up against, so it implicitly means
|
|
250
|
+
# the active sheet, the same way xl[row, col] does.
|
|
251
|
+
worksheet_part = '' if worksheet_part.nil? && addr.range?
|
|
252
|
+
|
|
253
|
+
if worksheet_part
|
|
254
|
+
worksheet_ole = resolve_worksheet(worksheet_part, workbook_ole || @ole, str)
|
|
255
|
+
sheet = Sheet.new(worksheet_ole, version: @version)
|
|
256
|
+
return addr.range? ? sheet[addr.range] : sheet
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# The grammar only leaves worksheet_part nil when addr is the bare
|
|
260
|
+
# "[workbook]" form -- Address#worksheet is nil and Address#range?
|
|
261
|
+
# is false together only there -- so workbook_ole is always set by
|
|
262
|
+
# this point (Sec 4.5, get-only for a lookup with no range).
|
|
263
|
+
Book.new(workbook_ole, client: @client, version: @version, convert_paths: @convert_paths)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def resolve_workbook(part, str)
|
|
267
|
+
if part.empty?
|
|
268
|
+
active_workbook(str)
|
|
269
|
+
elsif part.casecmp?(':new')
|
|
270
|
+
@ole.Workbooks.Add
|
|
271
|
+
else
|
|
272
|
+
@ole.Workbooks.Item(part)
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def resolve_worksheet(part, container, str)
|
|
277
|
+
if part.empty?
|
|
278
|
+
active_worksheet(container, str)
|
|
279
|
+
elsif part.casecmp?(':new')
|
|
280
|
+
worksheets = container.Worksheets
|
|
281
|
+
worksheets.Add(After: worksheets.Item(worksheets.Count))
|
|
282
|
+
elsif part.casecmp?(':first')
|
|
283
|
+
container.Worksheets.Item(1)
|
|
284
|
+
elsif part.casecmp?(':last')
|
|
285
|
+
worksheets = container.Worksheets
|
|
286
|
+
worksheets.Item(worksheets.Count)
|
|
287
|
+
elsif part.match?(/\A\d+\z/)
|
|
288
|
+
container.Worksheets.Item(part.to_i)
|
|
289
|
+
else
|
|
290
|
+
container.Worksheets.Item(part)
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# ActiveWorkbook/ActiveSheet are nil on a fresh Excel with nothing
|
|
295
|
+
# open yet (measured: Workbooks.Count == 0 makes both nil). Without
|
|
296
|
+
# this check that turns into a NoMethodError raised from deep inside
|
|
297
|
+
# this class instead of something a caller can act on.
|
|
298
|
+
#
|
|
299
|
+
# RuntimeError rather than ArgumentError: the address string itself
|
|
300
|
+
# was fine -- it is the application's current state (no open
|
|
301
|
+
# workbook/sheet) that cannot satisfy it.
|
|
302
|
+
def active_workbook(str)
|
|
303
|
+
@ole.ActiveWorkbook or raise RuntimeError, "no active workbook -- #{str.inspect} needs one open"
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def active_worksheet(container, str)
|
|
307
|
+
container.ActiveSheet or raise RuntimeError, "no active worksheet -- #{str.inspect} needs one open"
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
require_relative 'color'
|
|
2
|
+
|
|
3
|
+
module WineOLE
|
|
4
|
+
module MSOffice
|
|
5
|
+
# The one place that knows how a format key maps onto a COM property.
|
|
6
|
+
#
|
|
7
|
+
# Keyword arguments rather than a chain, because formatting needs three
|
|
8
|
+
# states and a chain has two: an absent key means "leave it alone", and
|
|
9
|
+
# `false` means "explicitly turn it off". Those are different operations
|
|
10
|
+
# and both are ordinary. The implementation therefore asks `key?` and
|
|
11
|
+
# never leans on `opts[:bold]` being nil.
|
|
12
|
+
#
|
|
13
|
+
# Every number here was measured against a live Excel 11 rather than
|
|
14
|
+
# recalled; see the spec's table.
|
|
15
|
+
module Format
|
|
16
|
+
UNDERLINE = {none: -4142, single: 2, double: -4119}.freeze
|
|
17
|
+
ALIGN = {general: 1, left: -4131, center: -4108, right: -4152, justify: -4130}.freeze
|
|
18
|
+
VALIGN = {top: -4160, center: -4108, bottom: -4107}.freeze
|
|
19
|
+
private_constant :UNDERLINE, :ALIGN, :VALIGN
|
|
20
|
+
|
|
21
|
+
XL_NONE = -4142
|
|
22
|
+
XL_AUTOMATIC = -4105
|
|
23
|
+
XL_GENERAL_FORMAT_NAME = 26
|
|
24
|
+
private_constant :XL_NONE, :XL_AUTOMATIC, :XL_GENERAL_FORMAT_NAME
|
|
25
|
+
|
|
26
|
+
FONT_KEYS = %i[bold italic underline size color].freeze
|
|
27
|
+
INTERIOR_KEYS = %i[background].freeze
|
|
28
|
+
RANGE_KEYS = %i[align valign wrap number_format].freeze
|
|
29
|
+
KEYS = (FONT_KEYS + INTERIOR_KEYS + RANGE_KEYS + %i[border]).freeze
|
|
30
|
+
private_constant :FONT_KEYS, :INTERIOR_KEYS, :RANGE_KEYS, :KEYS
|
|
31
|
+
|
|
32
|
+
# One knob for the caller, two properties for Excel. Excel keeps the
|
|
33
|
+
# line pattern (LineStyle) and its thickness (Weight) apart, which
|
|
34
|
+
# means `:thin` and `:dash` live in different properties even though a
|
|
35
|
+
# caller thinks of both as "what the line looks like". Measured pairs:
|
|
36
|
+
BORDER_STYLE = {
|
|
37
|
+
none: {line: -4142, weight: nil},
|
|
38
|
+
hairline: {line: 1, weight: 1},
|
|
39
|
+
thin: {line: 1, weight: 2},
|
|
40
|
+
medium: {line: 1, weight: -4138},
|
|
41
|
+
thick: {line: 1, weight: 4},
|
|
42
|
+
dash: {line: -4115, weight: 2},
|
|
43
|
+
dot: {line: -4118, weight: 2},
|
|
44
|
+
}.freeze
|
|
45
|
+
private_constant :BORDER_STYLE
|
|
46
|
+
|
|
47
|
+
EDGE = {left: 7, top: 8, bottom: 9, right: 10, inside_v: 11, inside_h: 12}.freeze
|
|
48
|
+
OUTLINE_EDGES = %i[left top bottom right].freeze
|
|
49
|
+
ALL_EDGES = %i[left top bottom right inside_v inside_h].freeze
|
|
50
|
+
BORDER_HASH_KEYS = %i[edges style color].freeze
|
|
51
|
+
private_constant :EDGE, :OUTLINE_EDGES, :ALL_EDGES, :BORDER_HASH_KEYS
|
|
52
|
+
|
|
53
|
+
# Two passes on purpose. Everything is validated and converted into the
|
|
54
|
+
# values COM wants *before* the first write, so a bad key or a bad
|
|
55
|
+
# value leaves the range exactly as it was. Validating as it went would
|
|
56
|
+
# mean `format(bold: true, align: :middle)` raises with the range
|
|
57
|
+
# already bold -- and a caller who sees an exception reasonably reads
|
|
58
|
+
# it as "nothing happened".
|
|
59
|
+
#
|
|
60
|
+
# `translate` may read from COM (`:general` asks the Application for
|
|
61
|
+
# the local name of the General format). A read changes nothing, so
|
|
62
|
+
# the invariant that matters -- no write before validation finishes --
|
|
63
|
+
# still holds.
|
|
64
|
+
def self.apply(ole, opts)
|
|
65
|
+
reject_unknown_keys(opts)
|
|
66
|
+
opts = opts.reject { |_k, v| v.nil? } # nil means "not specified"
|
|
67
|
+
font, interior, range, border = translate(ole, opts)
|
|
68
|
+
|
|
69
|
+
# Each `ole.Font` is its own round trip, so it is fetched once and
|
|
70
|
+
# only when there is something to write to it.
|
|
71
|
+
write_to(ole.Font, font) unless font.empty?
|
|
72
|
+
write_to(ole.Interior, interior) unless interior.empty?
|
|
73
|
+
write_to(ole, range)
|
|
74
|
+
write_border(ole, border) unless border.nil?
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.write_to(target, assignments)
|
|
79
|
+
assignments.each { |setter, value| target.public_send(setter, value) }
|
|
80
|
+
end
|
|
81
|
+
private_class_method :write_to
|
|
82
|
+
|
|
83
|
+
# Returns four values: three lists of [setter, value] -- for Font, for
|
|
84
|
+
# Interior and for the Range itself -- plus a border plan (or nil) for
|
|
85
|
+
# `write_border`, which does not fit the [setter, value] shape because
|
|
86
|
+
# it may need both a bulk assignment and a per-edge loop. Raises
|
|
87
|
+
# rather than returning anything partial.
|
|
88
|
+
def self.translate(ole, opts)
|
|
89
|
+
font = []
|
|
90
|
+
interior = []
|
|
91
|
+
range = []
|
|
92
|
+
|
|
93
|
+
font << [:Bold=, boolean(opts[:bold], :bold)] if opts.key?(:bold)
|
|
94
|
+
font << [:Italic=, boolean(opts[:italic], :italic)] if opts.key?(:italic)
|
|
95
|
+
font << [:Underline=, underline(opts[:underline])] if opts.key?(:underline)
|
|
96
|
+
font << [:Size=, size(opts[:size])] if opts.key?(:size)
|
|
97
|
+
if opts.key?(:color)
|
|
98
|
+
font << if opts[:color] == false
|
|
99
|
+
[:ColorIndex=, XL_AUTOMATIC]
|
|
100
|
+
else
|
|
101
|
+
[:Color=, colour(opts[:color], :color)]
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
if opts.key?(:background)
|
|
106
|
+
interior << if opts[:background] == false
|
|
107
|
+
# Not `Color = white`: measured, a cleared cell and a
|
|
108
|
+
# white-painted cell both report Color 16777215, but
|
|
109
|
+
# the painted one keeps ColorIndex 2 and Pattern 1 --
|
|
110
|
+
# it is still filled, prints as a fill, and hides
|
|
111
|
+
# gridlines.
|
|
112
|
+
[:ColorIndex=, XL_NONE]
|
|
113
|
+
else
|
|
114
|
+
[:Color=, colour(opts[:background], :background)]
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
range << [:HorizontalAlignment=, fetch(ALIGN, opts[:align], :align)] if opts.key?(:align)
|
|
119
|
+
range << [:VerticalAlignment=, fetch(VALIGN, opts[:valign], :valign)] if opts.key?(:valign)
|
|
120
|
+
range << [:WrapText=, boolean(opts[:wrap], :wrap)] if opts.key?(:wrap)
|
|
121
|
+
if opts.key?(:number_format)
|
|
122
|
+
range << [:NumberFormat=, number_format(ole, opts[:number_format])]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
border = opts.key?(:border) ? translate_border(opts[:border]) : nil
|
|
126
|
+
|
|
127
|
+
[font, interior, range, border]
|
|
128
|
+
end
|
|
129
|
+
private_class_method :translate
|
|
130
|
+
|
|
131
|
+
# Validates and resolves everything; touches no COM.
|
|
132
|
+
def self.translate_border(spec)
|
|
133
|
+
spec = normalize_border(spec)
|
|
134
|
+
style = fetch(BORDER_STYLE, spec[:style], 'border style')
|
|
135
|
+
# Named line_colour, not colour: a local called `colour` would shadow
|
|
136
|
+
# the method of that name for the rest of this body.
|
|
137
|
+
line_colour = spec.key?(:color) ? colour(spec[:color], 'border color') : nil
|
|
138
|
+
|
|
139
|
+
{indexes: expand_edges(spec[:edges]), line: style[:line],
|
|
140
|
+
weight: style[:weight], colour: line_colour}
|
|
141
|
+
end
|
|
142
|
+
private_class_method :translate_border
|
|
143
|
+
|
|
144
|
+
# `ole.Borders` is a round trip like Font and Interior: fetched once,
|
|
145
|
+
# with Item() called off it -- except when every edge is being set, in
|
|
146
|
+
# which case an assignment straight to the Borders collection replaces
|
|
147
|
+
# the whole per-edge loop.
|
|
148
|
+
#
|
|
149
|
+
# Measured against a live Excel on a multi-cell range, an assignment on
|
|
150
|
+
# Borders itself reaches all six edges -- including inside_v and
|
|
151
|
+
# inside_h -- in one COM call each: LineStyle, Weight and Color each
|
|
152
|
+
# touch all six for the price of one round trip. 2.3 ms against 10.0 ms
|
|
153
|
+
# for the per-edge Item() loop below. That is exactly why it must never
|
|
154
|
+
# be used for :outline: it would silently draw the inside edges too.
|
|
155
|
+
# Keyed off the resolved index set rather than the :all symbol, so an
|
|
156
|
+
# explicit list of all six edges gets the fast path as well.
|
|
157
|
+
ALL_EDGE_INDEXES = ALL_EDGES.map { |name| EDGE.fetch(name) }.sort.freeze
|
|
158
|
+
private_constant :ALL_EDGE_INDEXES
|
|
159
|
+
|
|
160
|
+
def self.write_border(ole, plan)
|
|
161
|
+
indexes = plan[:indexes].uniq
|
|
162
|
+
return if indexes.empty?
|
|
163
|
+
|
|
164
|
+
borders = ole.Borders
|
|
165
|
+
|
|
166
|
+
if indexes.sort == ALL_EDGE_INDEXES
|
|
167
|
+
borders.LineStyle = plan[:line]
|
|
168
|
+
return if plan[:weight].nil?
|
|
169
|
+
|
|
170
|
+
borders.Weight = plan[:weight]
|
|
171
|
+
borders.Color = plan[:colour] unless plan[:colour].nil?
|
|
172
|
+
return
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
indexes.each do |index|
|
|
176
|
+
edge = borders.Item(index)
|
|
177
|
+
edge.LineStyle = plan[:line]
|
|
178
|
+
# Nothing to weigh or colour when the line is being removed.
|
|
179
|
+
next if plan[:weight].nil?
|
|
180
|
+
|
|
181
|
+
edge.Weight = plan[:weight]
|
|
182
|
+
edge.Color = plan[:colour] unless plan[:colour].nil?
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
private_class_method :write_border
|
|
186
|
+
|
|
187
|
+
def self.normalize_border(spec)
|
|
188
|
+
hash = case spec
|
|
189
|
+
when false then {edges: :all, style: :none}
|
|
190
|
+
when ::Symbol then {edges: spec, style: :thin}
|
|
191
|
+
when ::Array then {edges: spec, style: :thin}
|
|
192
|
+
when ::Hash then spec
|
|
193
|
+
else
|
|
194
|
+
raise ArgumentError,
|
|
195
|
+
'border: expected :all, :outline, an edge name, an array of edge ' \
|
|
196
|
+
"names, false, or a hash, got #{spec.inspect}"
|
|
197
|
+
end
|
|
198
|
+
# Checked against the hash as given, before nils are dropped -- same
|
|
199
|
+
# order as `apply`'s top-level keys, so a misspelled key with a nil
|
|
200
|
+
# value is still caught rather than silently absorbed.
|
|
201
|
+
unknown = hash.keys - BORDER_HASH_KEYS
|
|
202
|
+
unless unknown.empty?
|
|
203
|
+
raise ArgumentError,
|
|
204
|
+
"border: unknown key#{'s' if unknown.length > 1} " \
|
|
205
|
+
"#{unknown.map(&:inspect).join(', ')} -- known keys are #{BORDER_HASH_KEYS.join(', ')}"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# nil means "not specified" everywhere else in this module (`apply`
|
|
209
|
+
# drops nil top-level values before anything is validated); a caller
|
|
210
|
+
# writing `style: nil` explicitly is asking for the same thing as
|
|
211
|
+
# omitting `style`, not for an override that beats the default.
|
|
212
|
+
{style: :thin}.merge(hash.compact)
|
|
213
|
+
end
|
|
214
|
+
private_class_method :normalize_border
|
|
215
|
+
|
|
216
|
+
def self.expand_edges(edges)
|
|
217
|
+
names = case edges
|
|
218
|
+
# Only a hash form reaches here without edges: having been
|
|
219
|
+
# filled in -- the shorthands (:all, an edge name, an array,
|
|
220
|
+
# false) all set it themselves in normalize_border. Naming
|
|
221
|
+
# the missing key beats bad_edges_message's "got nil", which
|
|
222
|
+
# reads as a value the caller never wrote.
|
|
223
|
+
when nil
|
|
224
|
+
raise ArgumentError,
|
|
225
|
+
'border: a hash needs an edges: key, e.g. ' \
|
|
226
|
+
'border: {edges: :all, style: :thick}'
|
|
227
|
+
when :all then ALL_EDGES
|
|
228
|
+
when :outline then OUTLINE_EDGES
|
|
229
|
+
when ::Symbol then [edges]
|
|
230
|
+
# :all and :outline expand here too, not just on their own --
|
|
231
|
+
# the error message below promises "an array of those", and
|
|
232
|
+
# "those" includes the shorthands. This also makes something
|
|
233
|
+
# like [:outline, :inside_h] expressible.
|
|
234
|
+
when ::Array
|
|
235
|
+
edges.flat_map do |edge|
|
|
236
|
+
case edge
|
|
237
|
+
when :all then ALL_EDGES
|
|
238
|
+
when :outline then OUTLINE_EDGES
|
|
239
|
+
else [edge]
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
else raise ArgumentError, bad_edges_message(edges)
|
|
243
|
+
end
|
|
244
|
+
names.map do |name|
|
|
245
|
+
EDGE.fetch(name) { raise ArgumentError, bad_edges_message(name) }
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
private_class_method :expand_edges
|
|
249
|
+
|
|
250
|
+
# One message for both failures, and it names the shorthands as well as
|
|
251
|
+
# the edges -- someone who typed :diagonal needs to learn that :all and
|
|
252
|
+
# :outline exist, which a bare list of EDGE's keys would not tell them.
|
|
253
|
+
def self.bad_edges_message(value)
|
|
254
|
+
"border: expected :all, :outline, one of " \
|
|
255
|
+
"#{EDGE.keys.map(&:inspect).join(', ')}, or an array of those, " \
|
|
256
|
+
"got #{value.inspect}"
|
|
257
|
+
end
|
|
258
|
+
private_class_method :bad_edges_message
|
|
259
|
+
|
|
260
|
+
# Before any COM call, so a typo leaves the sheet exactly as it was
|
|
261
|
+
# rather than half-formatted.
|
|
262
|
+
def self.reject_unknown_keys(opts)
|
|
263
|
+
unknown = opts.keys - KEYS
|
|
264
|
+
return if unknown.empty?
|
|
265
|
+
|
|
266
|
+
raise ArgumentError,
|
|
267
|
+
"unknown format key#{'s' if unknown.length > 1} " \
|
|
268
|
+
"#{unknown.map(&:inspect).join(', ')} -- known keys are #{KEYS.join(', ')}"
|
|
269
|
+
end
|
|
270
|
+
private_class_method :reject_unknown_keys
|
|
271
|
+
|
|
272
|
+
# A nil never reaches here -- `apply` drops nil values first, because
|
|
273
|
+
# nil means "I have no value for this", which is the same thing as not
|
|
274
|
+
# passing the key. That matters: measured, assigning nil to a COM
|
|
275
|
+
# boolean property sets it to *false*, so a nil reaching COM would
|
|
276
|
+
# silently un-bold a range whose caller simply did not know.
|
|
277
|
+
def self.boolean(value, key)
|
|
278
|
+
return value if value == true || value == false
|
|
279
|
+
|
|
280
|
+
raise ArgumentError,
|
|
281
|
+
"#{key}: expected true or false, got #{value.inspect}. " \
|
|
282
|
+
'Omit the key entirely to leave this attribute alone'
|
|
283
|
+
end
|
|
284
|
+
private_class_method :boolean
|
|
285
|
+
|
|
286
|
+
# Excel's own font size range. Outside it, COM fails with a message
|
|
287
|
+
# about the Font class rather than about the number.
|
|
288
|
+
SIZE_RANGE = (1..409).freeze
|
|
289
|
+
private_constant :SIZE_RANGE
|
|
290
|
+
|
|
291
|
+
def self.size(value)
|
|
292
|
+
unless value.is_a?(::Numeric) && SIZE_RANGE.cover?(value)
|
|
293
|
+
raise ArgumentError,
|
|
294
|
+
"size: expected a number in 1..409 (Excel's own range), got #{value.inspect}"
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
value
|
|
298
|
+
end
|
|
299
|
+
private_class_method :size
|
|
300
|
+
|
|
301
|
+
def self.underline(value)
|
|
302
|
+
case value
|
|
303
|
+
when true then UNDERLINE.fetch(:single)
|
|
304
|
+
when false then UNDERLINE.fetch(:none)
|
|
305
|
+
else fetch(UNDERLINE, value, :underline)
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
private_class_method :underline
|
|
309
|
+
|
|
310
|
+
# A raw COM colour integer is ambiguous here: 255 could mean the
|
|
311
|
+
# caller's #0000FF or Excel's own value for red. Refuse rather than
|
|
312
|
+
# guess -- the same stance `write` takes on a wrong-shaped array.
|
|
313
|
+
def self.colour(value, key)
|
|
314
|
+
if value.is_a?(::Numeric)
|
|
315
|
+
raise ArgumentError,
|
|
316
|
+
"#{key}: expected '#RRGGBB', got the number #{value.inspect}. " \
|
|
317
|
+
'A raw COM colour is ambiguous here -- pass a hex string, or use ' \
|
|
318
|
+
'WineOLE::MSOffice::Color[...] with .ole to reach COM directly'
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
begin
|
|
322
|
+
Color[value]
|
|
323
|
+
rescue ArgumentError => e
|
|
324
|
+
raise ArgumentError, "#{key}: #{e.message}"
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
private_class_method :colour
|
|
328
|
+
|
|
329
|
+
# 'General' is the one format code that cannot be written: measured, it
|
|
330
|
+
# fails outright on a localized Excel, where the format has a
|
|
331
|
+
# translated name instead -- and that translated spelling is not
|
|
332
|
+
# portable either. Application.International(26) returns whichever
|
|
333
|
+
# one this Excel wants.
|
|
334
|
+
def self.number_format(ole, value)
|
|
335
|
+
case value
|
|
336
|
+
when :general then ole.Application.International(XL_GENERAL_FORMAT_NAME)
|
|
337
|
+
when :text then '@'
|
|
338
|
+
when ::String
|
|
339
|
+
if value.casecmp?('General')
|
|
340
|
+
ole.Application.International(XL_GENERAL_FORMAT_NAME)
|
|
341
|
+
else
|
|
342
|
+
value
|
|
343
|
+
end
|
|
344
|
+
else
|
|
345
|
+
raise ArgumentError,
|
|
346
|
+
"number_format: expected a format code string, :general or :text, " \
|
|
347
|
+
"got #{value.inspect}"
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
private_class_method :number_format
|
|
351
|
+
|
|
352
|
+
def self.fetch(table, value, key)
|
|
353
|
+
table.fetch(value) do
|
|
354
|
+
raise ArgumentError,
|
|
355
|
+
"#{key}: expected one of #{table.keys.map(&:inspect).join(', ')}, " \
|
|
356
|
+
"got #{value.inspect}"
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
private_class_method :fetch
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
end
|