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,353 @@
1
+ require_relative 'errors'
2
+
3
+ module WineOLE
4
+ # COM events for one object, reached as `proxy.ole_events`.
5
+ #
6
+ # Registering a callback is the only thing a caller does. Everything under
7
+ # it is DERIVED from that. The bridge-side subscription and the COM Advise
8
+ # beneath it belong to THIS object: they are put up by its first callback
9
+ # and taken back down by the last one removed from it. The sink on the
10
+ # connection and the dispatcher thread belong to the CONNECTION, so they
11
+ # follow the same rule one level up -- up with the first callback anywhere
12
+ # on the client, down with the last one anywhere on it (see Dispatcher).
13
+ # Making any of them a separate control would allow the state where a
14
+ # callback is registered and the event never arrives, with nothing to show
15
+ # for it.
16
+ #
17
+ # That invariant is a claim about ORDER as much as about bookkeeping, so
18
+ # `on`, `off` and `close` hold @wire_mutex across "decide, then tell the
19
+ # bridge". Deciding under one lock and writing the wire outside it lets two
20
+ # threads reach the bridge in the opposite order to the one they decided
21
+ # in: a subscribe landing after the unsubscribe that was meant to follow it
22
+ # leaves a registered callback whose event can never arrive, and the mirror
23
+ # case leaves the bridge advised with nothing to deliver to. Measured, on
24
+ # the code before this lock existed: wire order subscribe, subscribe,
25
+ # unsubscribe with one callback still registered.
26
+ #
27
+ # Callbacks run on one dispatcher thread PER CONNECTION, in arrival order,
28
+ # one at a time -- so two objects on one client never run their callbacks
29
+ # concurrently, and a caller needs no lock between them. That thread is not
30
+ # here: it belongs to the connection, and this object attaches itself to it
31
+ # for as long as it has a registration (see Dispatcher). They may call COM
32
+ # freely -- that is the point -- because the client's reader thread is a
33
+ # different thread and stays free to read the response. A slow callback
34
+ # delays the events behind it, on every object on the connection.
35
+ #
36
+ # The dispatcher thread survives anything a callback or a malformed frame
37
+ # can throw; see Dispatcher.run.
38
+ class Events
39
+ # NOT a Struct, and not for tidiness: a Struct has value equality, so two
40
+ # subscriptions with the same name, block and flag are `==` without being
41
+ # the same registration -- registering one proc twice for an event is
42
+ # enough. `off(first)` would then remove the second one too (Array#delete
43
+ # removes every match), unsubscribe, and leave the caller with a
44
+ # Subscription that never fires again and nothing said about it. A
45
+ # registration is the thing itself, so identity is its equality.
46
+ #
47
+ # `args` is per callback, but the wire has one flag per event: what goes
48
+ # on it is the union (see `effective_args`).
49
+ class Subscription
50
+ attr_reader :name, :block, :args
51
+
52
+ def initialize(name, block, args)
53
+ @name = name
54
+ @block = block
55
+ @args = args
56
+ end
57
+ end
58
+
59
+ def initialize(client, handle)
60
+ @client = client
61
+ @handle = handle
62
+ # A plain Hash, deliberately not `Hash.new { |h, k| h[k] = [] }`: with a
63
+ # default block, merely LOOKING an event name up writes a permanent key
64
+ # -- an arriving event nobody subscribed to, or an `off` for a name that
65
+ # was never `on`, would grow this table for the life of the connection.
66
+ # A name is added when a callback is registered for it and removed when
67
+ # the last one goes.
68
+ @subs = {}
69
+ @mutex = Mutex.new
70
+ # Held across the whole of `on`/`off`/`close` -- the decision AND the
71
+ # call that carries it out -- and always acquired BEFORE @mutex, never
72
+ # while holding it. @mutex is therefore never held across a wire round
73
+ # trip, which is what keeps `deliver`'s brief acquisition of it off the
74
+ # bridge's response time. A callback calling `off` from the dispatcher
75
+ # waits for at most one round trip and cannot deadlock: the thread it
76
+ # waits for needs nothing from the dispatcher to finish.
77
+ #
78
+ # The connection's Dispatcher has a mutex of its own, and it is taken
79
+ # under this one and never under @mutex: the order is @wire_mutex ->
80
+ # Dispatcher#@mutex (in `arm`/`disarm`) and @wire_mutex -> @mutex
81
+ # (everywhere else), so those two are never held together. The
82
+ # Dispatcher takes none of these, and drops its own before it calls
83
+ # `deliver`, which is what lets a callback register on another object.
84
+ @wire_mutex = Mutex.new
85
+ @on_error = nil
86
+ # Whether this object currently has a place on the connection's
87
+ # dispatcher. It stands where @sink used to: the dispatcher and its
88
+ # sink are the connection's now, shared with every other object on it,
89
+ # so "is it up?" is no longer a question this object can answer by
90
+ # looking at what it owns. Read and written under @wire_mutex only --
91
+ # `arm` and `disarm` are both called with it held -- which is what
92
+ # keeps `disarm` idempotent, and it has to be: it is reached from
93
+ # `off`'s ensure, from `close`, and from `on`'s rescue, on an object
94
+ # that may never have been armed at all.
95
+ @attached = false
96
+ end
97
+
98
+ # `args: false` tells the bridge not to mint handles for this event's
99
+ # object arguments. The callback is then called with no arguments at all
100
+ # -- measured: a block written `|sheet, range|` gets nil for both. Worth
101
+ # it for a high-frequency event you only want to count.
102
+ #
103
+ # The bridge holds ONE flag per event, so what goes on the wire is the
104
+ # union: arguments are minted while any callback for that event wants
105
+ # them. Registering an `args: true` callback next to an `args: false` one
106
+ # therefore re-subscribes rather than leaving the first registration's
107
+ # flag standing -- measured on Excel before this was here: the second
108
+ # callback was handed nil, having asked for the objects, and nothing said
109
+ # so. A callback that asked for `args: false` and gets them anyway
110
+ # because a sibling wanted them is the harmless direction of the same
111
+ # trade; it can ignore them.
112
+ def on(name, args: true, &block)
113
+ raise ArgumentError, 'on needs a block -- there is nothing to call otherwise' unless block
114
+
115
+ sub = Subscription.new(name, block, args)
116
+ @wire_mutex.synchronize do
117
+ # Before the subscribe, never after: the bridge advises the COM
118
+ # source as the subscribe is handled, so an event can be on its way
119
+ # back before the call returns. Arming afterwards would drop it.
120
+ arm
121
+ wanted = @mutex.synchronize do
122
+ before = effective_args(name)
123
+ (@subs[name] ||= []) << sub
124
+ after = effective_args(name)
125
+ after == before ? nil : after
126
+ end
127
+ begin
128
+ @client.call('subscribe', {handle: @handle, event: name, args: wanted}) unless wanted.nil?
129
+ rescue StandardError
130
+ # A subscribe the bridge refused -- an object that is not an event
131
+ # source is the ordinary case -- must not leave the callback
132
+ # registered. Keeping it would produce exactly the state this class
133
+ # exists to make unreachable: a callback that is never called, with
134
+ # the error already raised and gone.
135
+ disarm if @mutex.synchronize { drop(name, sub); @subs.empty? }
136
+ raise
137
+ end
138
+ end
139
+ sub
140
+ end
141
+
142
+ # Takes either a name (every callback for it) or one Subscription.
143
+ def off(name_or_sub)
144
+ @wire_mutex.synchronize do
145
+ sub = name_or_sub.is_a?(Subscription) ? name_or_sub : nil
146
+ name = sub ? sub.name : name_or_sub.to_s
147
+ before, after, empty = @mutex.synchronize do
148
+ was = effective_args(name)
149
+ drop(name, sub)
150
+ [was, effective_args(name), @subs.empty?]
151
+ end
152
+ # Nothing was registered for this event, so nothing was derived from
153
+ # it either. `after.nil?` alone cannot tell "the last callback just
154
+ # went" from "there was never one", and an unsubscribe for a
155
+ # subscription that does not exist is a round trip that says nothing.
156
+ next if before.nil?
157
+
158
+ begin
159
+ if after.nil?
160
+ # The last callback for this event is gone, so the subscription
161
+ # that only existed to feed it goes too -- and with the last name
162
+ # for the object, the COM Advise underneath it.
163
+ @client.call('unsubscribe', {handle: @handle, event: name})
164
+ elsif after != before
165
+ # Callbacks remain, but the one that wanted arguments was among
166
+ # those removed: stop paying for handles nobody asked for.
167
+ @client.call('subscribe', {handle: @handle, event: name, args: after})
168
+ end
169
+ ensure
170
+ # In an ensure because the registry has already been emptied above:
171
+ # if the unsubscribe raised (a connection that has just gone is the
172
+ # ordinary case) the local half must still come down, or this
173
+ # object keeps a dispatcher thread and a sink entry on the client
174
+ # for the life of the connection with nothing left to deliver.
175
+ disarm if empty
176
+ end
177
+ end
178
+ self
179
+ end
180
+
181
+ # Every callback forgotten, every subscription and Advise released, the
182
+ # dispatcher stopped and the sink taken off the connection.
183
+ #
184
+ # `off`-ing the last callback does all of this already -- that is the
185
+ # derivation this class is built on, and it is why there is no `close`
186
+ # you are REQUIRED to call. This is the bulk form, for a caller who does
187
+ # not want to remember which names it registered. `on` afterwards works
188
+ # exactly as the first one did: the object arms itself again.
189
+ def close
190
+ @wire_mutex.synchronize do
191
+ @mutex.synchronize { @subs.keys }.each do |name|
192
+ @mutex.synchronize { drop(name, nil) }
193
+ begin
194
+ @client.call('unsubscribe', {handle: @handle, event: name})
195
+ rescue StandardError
196
+ # A connection that has already gone has already unadvised
197
+ # everything on it. Unlike `off`, this is not reported: `close`
198
+ # is what a caller reaches for when it is done, and the local
199
+ # half it exists to release comes down either way.
200
+ nil
201
+ end
202
+ end
203
+ disarm
204
+ end
205
+ self
206
+ end
207
+
208
+ # ONE error handler per object, last writer wins -- deliberately not
209
+ # `on`'s append. An error handler is not a subscription: it has no
210
+ # arguments to negotiate, nothing is derived from it, and there is
211
+ # nothing for a second one to add that the first cannot do. That it
212
+ # returns `self` rather than a Subscription is the same statement, and
213
+ # there is no `off_error` for the same reason -- `on_error { }` replaces
214
+ # it with one that does nothing.
215
+ def on_error(&block)
216
+ @mutex.synchronize { @on_error = block }
217
+ self
218
+ end
219
+
220
+ # Tests only. Production code never needs any of these, because callbacks
221
+ # are the delivery mechanism.
222
+ #
223
+ # The queue and the thread these two ask about belong to the connection
224
+ # now, so both are the Dispatcher's answers. Kept here as delegators
225
+ # because a test that has an `Events` and asks whether ITS callbacks are
226
+ # still being delivered is asking the right question of the right object
227
+ # -- and because the answer is the same one it always was.
228
+ def stopped_for_test?(seconds)
229
+ @client.dispatcher.stopped_for_test?(seconds)
230
+ end
231
+
232
+ def drain_for_test(seconds = 5)
233
+ @client.dispatcher.drain_for_test(seconds)
234
+ self
235
+ end
236
+
237
+ def registered_names_for_test
238
+ @mutex.synchronize { @subs.keys }
239
+ end
240
+
241
+ private
242
+
243
+ # Takes this object's place on the connection: the frames for its handle
244
+ # start being routed to it, and the connection's dispatcher thread and
245
+ # sink go up if this is the first object to ask for them. Derived from
246
+ # there being a callback at all, so an `ole_events` a caller merely
247
+ # touched costs nothing anywhere. Called from `on` under @wire_mutex,
248
+ # which is what serializes it against `disarm`.
249
+ def arm
250
+ return if @attached
251
+
252
+ @client.dispatcher.attach(@handle, self)
253
+ @attached = true
254
+ end
255
+
256
+ # The other half of `arm`, once the last callback is gone: a registered
257
+ # callback is the only reason for any of this to exist. Without it an
258
+ # Events that has had every callback removed still holds a place on the
259
+ # connection's dispatcher and an entry the reader walks for every frame
260
+ # on it -- measured: 50 proxies that registered one callback and removed
261
+ # it left 51 live threads and 50 sink entries, all of them until the
262
+ # connection closed. The thread and the sink themselves only go with the
263
+ # LAST object to leave; that decision belongs to the Dispatcher, which is
264
+ # the only thing that knows whether any other object still has callbacks.
265
+ def disarm
266
+ return unless @attached
267
+
268
+ @attached = false
269
+ # By identity: the Dispatcher removes this object's routing entry and
270
+ # nobody else's.
271
+ @client.dispatcher.detach(@handle, self)
272
+ end
273
+
274
+ # What the wire flag for `name` should be: nil when no callback is
275
+ # registered for it at all, otherwise true if any of them wants the
276
+ # event's object arguments minted. Call it under @mutex.
277
+ def effective_args(name)
278
+ subs = @subs[name]
279
+ return nil if subs.nil? || subs.empty?
280
+
281
+ subs.any? { |sub| sub.args }
282
+ end
283
+
284
+ # Removes one subscription, or every callback for `name` when `sub` is
285
+ # nil, and takes the name out of the table when nothing is left for it.
286
+ # Call it under @mutex.
287
+ #
288
+ # `equal?`, not `==`: `off` removes the registration it was handed and no
289
+ # other. See Subscription.
290
+ def drop(name, sub)
291
+ list = @subs[name]
292
+ return if list.nil?
293
+
294
+ sub.nil? ? list.clear : list.reject! { |s| s.equal?(sub) }
295
+ @subs.delete(name) if list.empty?
296
+ end
297
+
298
+ # One frame, to the callbacks registered for its name. Called by the
299
+ # connection's Dispatcher, which routed it here by handle, and private
300
+ # for the reason the whole class exists: a registered callback is the
301
+ # only way in, and a public `deliver` would say otherwise. The frame's
302
+ # argument handles are given back by the Dispatcher's `route`, in an
303
+ # ensure that covers a frame this raised on as well as one that reached
304
+ # nobody at all.
305
+ def deliver(frame)
306
+ subs = @mutex.synchronize { (@subs[frame['event']] || []).dup }
307
+ args = build_args(frame['args'])
308
+ subs.each do |sub|
309
+ begin
310
+ sub.block.call(*args)
311
+ rescue Exception => e # rubocop:disable Lint/RescueException
312
+ # Everything, for the reason Dispatcher.run catches everything: this
313
+ # thread is the whole delivery mechanism, and a callback raising
314
+ # something outside StandardError must not take the next callback,
315
+ # the next event and every later release down with it.
316
+ report(e, frame)
317
+ end
318
+ end
319
+ end
320
+
321
+ def build_args(raw)
322
+ return [] if raw.nil?
323
+
324
+ # The same decode an invoke's result goes through, so an event argument
325
+ # that is an object arrives as a Proxy and one that is a date arrives
326
+ # as a Time -- a second, private copy of that walk here is how those
327
+ # two answers drift apart.
328
+ raw.map { |v| Proxy.decode(@client, v) }
329
+ end
330
+
331
+ # Never raises. It is called from the dispatcher's own rescue, so an
332
+ # exception out of here is the one thing that could still end the thread.
333
+ def report(err, frame)
334
+ handler = @mutex.synchronize { @on_error }
335
+ event = frame.is_a?(Hash) ? frame['event'] : nil
336
+ begin
337
+ if handler
338
+ handler.call(err, frame)
339
+ else
340
+ warn "wineole: #{event} callback raised #{err.class}: #{err.message}"
341
+ end
342
+ rescue Exception => e # rubocop:disable Lint/RescueException
343
+ # An on_error that itself raises must not recurse, and must not be
344
+ # able to kill the dispatcher either -- its own rescue was
345
+ # StandardError once, and an on_error raising past that was a third
346
+ # way to lose every later event in silence.
347
+ warn "wineole: on_error raised #{e.class} while reporting #{err.class}"
348
+ end
349
+ rescue Exception # rubocop:disable Lint/RescueException
350
+ nil # even $stderr being gone must not end the dispatcher
351
+ end
352
+ end
353
+ end
@@ -0,0 +1,67 @@
1
+ module WineOLE
2
+ module MSOffice
3
+ # Parses the addressing DSL this wrapper inherits from msoffice.rb:
4
+ #
5
+ # "[Book1]Sheet1!A1:B2" "[:new]" ":new!" ":first!A1" "A1:B2"
6
+ #
7
+ # Touches no COM: it is a pure string parser, so it can be exercised
8
+ # without Excel running.
9
+ #
10
+ # The patterns come from msoffice.rb, which encodes Excel's actual grid
11
+ # limits (IV/65536 for Excel 11, XFD/1048576 for 12+). Both column
12
+ # patterns were checked against every column up to their limit and are
13
+ # exact -- and they are the harder half.
14
+ #
15
+ # The two row patterns needed a fix, carried here. Every nested digit
16
+ # range started at 1 where it should start at 0: the leading digit's
17
+ # "no leading zero" rule was carried down to positions the digits above
18
+ # had already constrained. That dropped row 65530 outright, and 11,111
19
+ # rows of the 1048576 grid. Both are exhaustively verified now, and
20
+ # test_every_row_in_the_grid_parses is what keeps them that way.
21
+ class Address
22
+ PTN_A_IV = /[A-H]?[A-Z]|I[A-V]/i
23
+ PTN_ABS_A_IV = /\$?#{PTN_A_IV}/
24
+ PTN_1_65536 = /[1-9]\d{,3}|[1-5]\d{4}|6(?:[0-4]\d{3}|5(?:[0-4]\d{2}|5(?:[0-2]\d|3[0-6])))/
25
+ PTN_ABS_1_65536 = /\$?#{PTN_1_65536}/
26
+ PTN_ABS_A1_IV65536 = /#{PTN_ABS_A_IV}#{PTN_ABS_1_65536}/
27
+ PTN_RANGE_LOCAL_XL11 = /(?<range>(?:#{PTN_ABS_A1_IV65536}:)?#{PTN_ABS_A1_IV65536}|#{PTN_ABS_A_IV}:#{PTN_ABS_A_IV}|#{PTN_ABS_1_65536}:#{PTN_ABS_1_65536})/
28
+ PTN_A_XFD = /[A-W]?[A-Z]{1,2}|X(?:[A-E][A-Z]|F[A-D])/i
29
+ PTN_ABS_A_XFD = /\$?#{PTN_A_XFD}/
30
+ PTN_1_1048576 = /[1-9]\d{,5}|10(?:[0-3]\d{4}|4(?:[0-7]\d{3}|8(?:[0-4]\d{2}|5(?:[0-6]\d|7[0-6]))))/
31
+ PTN_ABS_1_1048576 = /\$?#{PTN_1_1048576}/
32
+ PTN_ABS_A1_XFD1048576 = /#{PTN_ABS_A_XFD}#{PTN_ABS_1_1048576}/
33
+ PTN_RANGE_LOCAL_XL12 = /(?<range>(?:#{PTN_ABS_A1_XFD1048576}:)?#{PTN_ABS_A1_XFD1048576}|#{PTN_ABS_A_XFD}:#{PTN_ABS_A_XFD}|#{PTN_ABS_1_1048576}:#{PTN_ABS_1_1048576})/
34
+ PTN_WORKBOOK = /\[(?<workbook>(?i::new)|[^\[\]]*)\]/
35
+ PTN_WORKBOOK_WORKSHEET = /(?<worksheet_quote>'?)#{PTN_WORKBOOK}?(?<worksheet>(?i::(?:new|first|last))|(?:[^\[\]\\\:\*\'][^\[\]\\\:\*]*)?)\k<worksheet_quote>!/
36
+ PTN_XL11 = /\A(?:#{PTN_WORKBOOK}|#{PTN_WORKBOOK_WORKSHEET}?#{PTN_RANGE_LOCAL_XL11}?)\z/
37
+ PTN_XL12 = /\A(?:#{PTN_WORKBOOK}|#{PTN_WORKBOOK_WORKSHEET}?#{PTN_RANGE_LOCAL_XL12}?)\z/
38
+
39
+ attr_reader :workbook, :worksheet, :range
40
+
41
+ # Returns nil when the string is not an address at all, so a caller can
42
+ # fall back to treating it as a raw sheet name -- which is what
43
+ # msoffice.rb did.
44
+ def self.parse(str, excel_version)
45
+ pattern = excel_version.to_f >= 12 ? PTN_XL12 : PTN_XL11
46
+ m = pattern.match(str)
47
+ return nil if m.nil? || m.to_s.empty?
48
+
49
+ new(workbook: m[:workbook], worksheet: m[:worksheet], range: m[:range])
50
+ end
51
+
52
+ def initialize(workbook:, worksheet:, range:)
53
+ @workbook = workbook
54
+ @worksheet = worksheet
55
+ @range = range
56
+ end
57
+
58
+ # Whether this address names a range of cells. An address that stops at
59
+ # a sheet or a book is a lookup, not an assignment target -- see the
60
+ # spec on why `xl["Sheet1!"] = 0` filling a whole sheet is a hazard
61
+ # rather than a convenience.
62
+ def range?
63
+ !@range.nil?
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,114 @@
1
+ require 'tmpdir'
2
+ require_relative '../proxy'
3
+ require_relative 'passthrough'
4
+ require_relative 'paths'
5
+ require_relative 'sheet'
6
+ require_relative 'vba_api'
7
+ require_relative 'forms'
8
+
9
+ module WineOLE
10
+ module MSOffice
11
+ # Wraps a COM Workbook.
12
+ #
13
+ # Name note (measured against a live Excel 11): `sheet`, `each_sheet`,
14
+ # `save_as` and `local_path` are all free -- COM answers
15
+ # DISP_E_UNKNOWNNAME for each. `close` is not: COM resolves member names
16
+ # case-insensitively, and `Workbook.Close` already exists. It is a
17
+ # deliberate shadow anyway (see #close below). `local_file` is free by
18
+ # the same construction as `save_as`: COM does not strip underscores
19
+ # when matching names, so it never collides with `Workbook.FullName`.
20
+ class Book
21
+ include Passthrough
22
+
23
+ # `convert_paths` is the caller's opt-out; whether converting is even
24
+ # meaningful is a separate question (a remote bridge's own filesystem
25
+ # means nothing to this machine). Combining them once here means a
26
+ # caller cannot talk a remote bridge into converting by passing
27
+ # `convert_paths: true` -- Paths.convertible? still says no.
28
+ def initialize(proxy, client:, version:, convert_paths: true)
29
+ @ole = proxy
30
+ @version = version
31
+ @convert_paths = convert_paths && Paths.convertible?(client: client)
32
+ end
33
+
34
+ # Worksheets, not Sheets: Sheets also includes chart sheets, which
35
+ # this wrapper's Sheet class does not model.
36
+ def sheet(name_or_index)
37
+ Sheet.new(@ole.Worksheets.Item(name_or_index), version: @version)
38
+ end
39
+
40
+ def each_sheet
41
+ return to_enum(:each_sheet) unless block_given?
42
+
43
+ worksheets = @ole.Worksheets
44
+ (1..worksheets.Count).each do |i|
45
+ yield Sheet.new(worksheets.Item(i), version: @version)
46
+ end
47
+ end
48
+
49
+ # Takes only the path. A caller needing FileFormat and the rest of
50
+ # COM's SaveAs arguments uses the passthrough `book.SaveAs(...)`.
51
+ def save_as(path)
52
+ target = @convert_paths ? Paths.to_wine(path) : path
53
+ @ole.SaveAs(target)
54
+ end
55
+
56
+ # COM's Workbook.Path is the *containing folder*, not the file --
57
+ # local_path deliberately names the same thing this wrapper's way, in
58
+ # Linux form. The file's own path is #local_file.
59
+ #
60
+ # An unsaved book's Path is "" -- Paths.to_local returns that
61
+ # unchanged without shelling out to winepath, so this never runs it
62
+ # for nothing.
63
+ def local_path
64
+ @convert_paths ? Paths.to_local(@ole.Path) : @ole.Path
65
+ end
66
+
67
+ # The file's own path, in Linux form -- what local_path is not.
68
+ # Gated by the same @convert_paths (loopback-only) rule as
69
+ # local_path; calling Paths.to_local(book.FullName) directly instead
70
+ # skips that gate and runs a local winepath over what may be a
71
+ # *remote* bridge's Wine path, silently producing a path that refers
72
+ # to a filesystem this machine does not have (Spec Sec 4.7: a wrong
73
+ # conversion that happens silently is worse than one that visibly
74
+ # does not happen).
75
+ #
76
+ # Measured against a live Excel 11: FullName is the file
77
+ # (`Z:\tmp\wineole_item_probe.xls` where Path is the folder,
78
+ # `Z:\tmp`); an unsaved book's FullName is the bare in-memory name
79
+ # ("Book1"), matching its Path of "".
80
+ def local_file
81
+ @convert_paths ? Paths.to_local(@ole.FullName) : @ole.FullName
82
+ end
83
+
84
+ # A deliberate shadow of COM's Workbook.Close. Close with no
85
+ # SaveChanges argument can raise a modal save-changes prompt, which
86
+ # under Wine is a hang; close(save: false) turns that hazard into an
87
+ # explicit parameter instead. The raw member stays reachable as
88
+ # `book.Close(...)` (exact PascalCase) and as `book.ole.Close(...)`.
89
+ def close(save: false)
90
+ @ole.Close(save)
91
+ end
92
+
93
+ # This workbook's VBA surface: blocks, components, import and export.
94
+ # `book.vba.write(code, name: 'helpers')` is the common call; see
95
+ # BookVBA for the block-vs-component split and for where code has to
96
+ # live to be callable at all.
97
+ #
98
+ # Name note: `vba` is a bare lowercase word, so it would shadow a COM
99
+ # member spelled the same. Workbook has none -- the member it has is
100
+ # `VBProject`, still reachable as `book.vba.project`.
101
+ def vba
102
+ @vba ||= BookVBA.new(@ole, convert_paths: @convert_paths)
103
+ end
104
+
105
+ # This workbook's UserForms: `forms.add('AppForm')`, `forms['AppForm']`.
106
+ #
107
+ # Name note (M0, measured against Excel 11): `forms` is free on
108
+ # Workbook.
109
+ def forms
110
+ @forms ||= Forms.new(self)
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,87 @@
1
+ module WineOLE
2
+ module MSOffice
3
+ # '#RRGGBB' <-> Excel's colour integer.
4
+ #
5
+ # Excel stores a colour as BGR, not RGB: measured by making Excel name
6
+ # the colour itself, `Interior.Color = 255` reports ColorIndex 3 (red)
7
+ # and `= 0xFF0000` reports ColorIndex 5 (blue). So the obvious
8
+ # `'#FF0000'.delete('#').to_i(16)` produces blue, silently.
9
+ #
10
+ # Public, and returning a plain Integer, on purpose. The wrapper's own
11
+ # keys are not the only place a colour is written -- the passthrough
12
+ # reaches Interior.Color, Font.Color, Borders.Color, Tab.Color and
13
+ # anything else COM has, and that surface is unbounded by design. One
14
+ # function that returns an Integer covers all of it without the protocol
15
+ # or Proxy#encode knowing anything about colours.
16
+ module Color
17
+ HEX = /\A#?(?:\h{3}|\h{6})\z/.freeze
18
+ private_constant :HEX
19
+
20
+ # '#RRGGBB' | '#RGB' | [r, g, b] -> Excel's integer.
21
+ def self.[](value)
22
+ r, g, b = rgb(value)
23
+ r | (g << 8) | (b << 16)
24
+ end
25
+
26
+ # Excel's integer (or the Float it actually hands back) -> '#RRGGBB'.
27
+ def self.to_hex(value)
28
+ unless value.is_a?(::Numeric)
29
+ raise ArgumentError, "expected a number from COM, got #{value.inspect}"
30
+ end
31
+
32
+ # Checked before converting: Float::INFINITY#to_i raises
33
+ # FloatDomainError and Complex#to_i raises RangeError, neither of
34
+ # which is this module's ArgumentError. Range#cover? (as
35
+ # Format.size uses via SIZE_RANGE) rather than #between?, because
36
+ # Complex does not include Comparable -- #between? raises
37
+ # NoMethodError on it outright, where #cover? just answers false.
38
+ unless (0..0xFFFFFF).cover?(value)
39
+ raise ArgumentError,
40
+ "expected a colour in 0..0xFFFFFF, got #{value.inspect}. " \
41
+ "Excel's ColorIndex is a different property from Color -- its " \
42
+ 'values (-4105 automatic, -4142 none) are not colours and cannot ' \
43
+ 'be converted here'
44
+ end
45
+ n = value.to_i
46
+ ::Kernel.format('#%02X%02X%02X', n & 0xFF, (n >> 8) & 0xFF, (n >> 16) & 0xFF)
47
+ end
48
+
49
+ def self.rgb(value)
50
+ case value
51
+ when ::String then from_hex(value)
52
+ when ::Array then from_array(value)
53
+ else
54
+ raise ArgumentError,
55
+ "expected '#RRGGBB', '#RGB' or [r, g, b], got #{value.inspect}"
56
+ end
57
+ end
58
+ private_class_method :rgb
59
+
60
+ def self.from_hex(value)
61
+ # Checked before parsing: String#to_i(16) reads garbage as 0 rather
62
+ # than complaining, so '#GGGGGG' would silently become black.
63
+ unless value.match?(HEX)
64
+ raise ArgumentError,
65
+ "expected '#RRGGBB' or '#RGB', got #{value.inspect}"
66
+ end
67
+
68
+ s = value.delete_prefix('#')
69
+ s = s.chars.map { |c| c * 2 }.join if s.length == 3
70
+ [s[0, 2], s[2, 2], s[4, 2]].map { |h| h.to_i(16) }
71
+ end
72
+ private_class_method :from_hex
73
+
74
+ def self.from_array(value)
75
+ ok = value.length == 3 &&
76
+ value.all? { |c| c.is_a?(::Integer) && (0..255).cover?(c) }
77
+ unless ok
78
+ raise ArgumentError,
79
+ "expected [r, g, b] with three integers in 0..255, got #{value.inspect}"
80
+ end
81
+
82
+ value
83
+ end
84
+ private_class_method :from_array
85
+ end
86
+ end
87
+ end