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.
@@ -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