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,163 @@
1
+ require_relative '../proxy'
2
+ require_relative '../errors'
3
+ require_relative 'passthrough'
4
+ require_relative 'vba_api'
5
+ require_relative 'controls'
6
+
7
+ module WineOLE
8
+ module MSOffice
9
+ # `book.forms`: the UserForms of a workbook. A UserForm is a VBComponent
10
+ # of type 3 with a Designer; the form itself exists only while VBA has
11
+ # it loaded, as its default instance. The wrapper reaches that instance
12
+ # through four generated procedures in its own module, because a
13
+ # UserForm's default instance has no COM name a client can ask for.
14
+ #
15
+ # Name note (M0): `forms` is free on Workbook.
16
+ class Forms
17
+ USERFORM_TYPE = 3
18
+
19
+ def initialize(book)
20
+ @book = book
21
+ end
22
+
23
+ # The name is checked here as well as in add_component, because it
24
+ # also becomes part of four procedure names.
25
+ def add(name)
26
+ Controls.check_name!(name)
27
+ component = @book.vba.add_component(name, kind: :form)
28
+ begin
29
+ Form.new(name, component, @book)
30
+ rescue StandardError
31
+ # Form.new's helper write failed after the component was already
32
+ # created; take the component back out rather than leaving it
33
+ # behind with no helper to show or unload it.
34
+ @book.vba.remove_component(name)
35
+ raise
36
+ end
37
+ end
38
+
39
+ # Re-bind an existing UserForm (one made earlier, or one in a workbook
40
+ # that was opened). nil for a missing name or a component that is not
41
+ # a UserForm. AccessDenied from `project` passes through.
42
+ #
43
+ # Only the lookup is rescued, deliberately: Form.new writes the helper
44
+ # block, and that write can be refused (a locked project, a refused
45
+ # AddFromString). Reporting a real failure as "not found" would send
46
+ # the caller looking for a form that is right there.
47
+ def [](name)
48
+ component = begin
49
+ found = @book.vba.project.VBComponents.Item(name)
50
+ found if found.Type == USERFORM_TYPE
51
+ rescue WineOLE::RemoteError
52
+ nil
53
+ end
54
+ return nil if component.nil?
55
+
56
+ Form.new(name, component, @book)
57
+ end
58
+
59
+ # The helper block. `Show 0` is modeless, and it is the only form the
60
+ # wrapper offers: a modal Show blocks Excel's message loop, and with
61
+ # it the bridge -- measured, the bridge freezes until the form closes.
62
+ def self.helper(name)
63
+ <<~VBA
64
+ Function WineOLE_Form_#{name}() As Object
65
+ Set WineOLE_Form_#{name} = #{name}
66
+ End Function
67
+ Sub WineOLE_Show_#{name}()
68
+ #{name}.Show 0
69
+ End Sub
70
+ Sub WineOLE_Hide_#{name}()
71
+ #{name}.Hide
72
+ End Sub
73
+ Sub WineOLE_Unload_#{name}()
74
+ Unload #{name}
75
+ End Sub
76
+ VBA
77
+ end
78
+ end
79
+
80
+ # One UserForm. `ole` is the Designer (the design-time form: Caption,
81
+ # Width, Height, and the Controls that `controls` wraps). `instance` is
82
+ # the loaded form -- the object that shows, hides and fires events.
83
+ #
84
+ # Name note (M0): `name`, `component`, `ole`, `instance`, `show`,
85
+ # `hide`, `unload` measured free on the Designer; `controls` is the
86
+ # deliberate shadow.
87
+ class Form
88
+ include Passthrough
89
+
90
+ attr_reader :name, :component
91
+
92
+ # Writing the helper on every construction (add and re-bind alike) is
93
+ # what makes a form in a reopened workbook showable without the
94
+ # caller remembering to do anything; write is an upsert.
95
+ def initialize(name, component, book)
96
+ @name = name
97
+ @component = component
98
+ @book = book
99
+ @ole = component.Designer
100
+ @runtime = {}
101
+ @instance = nil
102
+ @book.vba.write(Forms.helper(name), name: "form_#{name}")
103
+ end
104
+
105
+ def controls
106
+ @controls ||= UserFormControls.new(self, @book.vba)
107
+ end
108
+
109
+ # The default instance. Referencing it loads the form if it is not
110
+ # loaded (VBA auto-instantiation), so `shown?` before `show` answers
111
+ # false and leaves the form loaded but hidden. Cached until `unload`.
112
+ def instance
113
+ @instance ||= run('Form')
114
+ end
115
+
116
+ # The live counterpart of a design-time control, by name. Cached so
117
+ # that `on` and the `off` that undoes it meet the same Events.
118
+ def runtime_control(control_name)
119
+ @runtime[control_name] ||= instance.Controls.Item(control_name)
120
+ end
121
+
122
+ # Modeless, always. Returns as soon as the form is on screen; the
123
+ # bridge stays responsive, which is what lets events reach Ruby.
124
+ def show
125
+ run('Show')
126
+ self
127
+ end
128
+
129
+ # Through VBA, like show and unload: the extender's Hide answers
130
+ # GetIDsOfNames but refuses every out-of-process Invoke (measured,
131
+ # DISP_E_MEMBERNOTFOUND whatever the flags), while Visible reads fine.
132
+ def hide
133
+ run('Hide')
134
+ self
135
+ end
136
+
137
+ def shown?
138
+ instance.Visible ? true : false
139
+ end
140
+
141
+ # Unloading destroys the runtime controls, and with them every event
142
+ # connection Ruby holds on them, so those are closed first -- on our
143
+ # side, deliberately, rather than left to fail when the object is
144
+ # gone. The next `instance` loads a fresh form.
145
+ def unload
146
+ @runtime.each_value { |control| control.ole_events.close }
147
+ @instance&.ole_events&.close
148
+ @runtime.clear
149
+ @instance = nil
150
+ run('Unload')
151
+ self
152
+ end
153
+
154
+ private
155
+
156
+ # Qualified with the workbook name so the right book's procedure runs
157
+ # when several are open (M2 measured this form working on Excel 11).
158
+ def run(verb)
159
+ @book.ole.Application.Run("'#{@book.ole.Name}'!WineOLE_#{verb}_#{@name}")
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,46 @@
1
+ require_relative '../proxy'
2
+
3
+ module WineOLE
4
+ module MSOffice
5
+ # Shared by every wrapper around a COM object that otherwise passes
6
+ # unknown methods straight through to it: the `ole` reader, plus
7
+ # `method_missing`/`respond_to_missing?`. `Range`, `Sheet`, `Book`,
8
+ # `Control` and `Form` each need exactly these three members; `Control`
9
+ # overrides `passthrough_target`; extracted here so they are written
10
+ # once instead of copied three times.
11
+ #
12
+ # Deliberately does not define `initialize` -- the including classes
13
+ # take different constructor arguments (`Range.new(proxy)` vs.
14
+ # `Sheet.new(proxy, version:)` vs. `Book.new(proxy, client:, version:,
15
+ # convert_paths:)`), so each sets `@ole` itself.
16
+ module Passthrough
17
+ # The underlying Proxy, for reaching COM explicitly.
18
+ attr_reader :ole
19
+
20
+ def method_missing(name, *args, &block)
21
+ return super if Proxy::IMPLICIT_CONVERSIONS.include?(name)
22
+
23
+ passthrough_target.public_send(name, *args, &block)
24
+ end
25
+
26
+ def respond_to_missing?(name, include_private = false)
27
+ # Anything not named here is a COM member as far as this class is
28
+ # concerned -- the same stance Proxy takes, and for the same reason:
29
+ # Ruby probes to_ary, to_str, coerce and friends behind the scenes,
30
+ # and answering `true` to those makes e.g. `puts obj` try a
31
+ # conversion that ends in NoMethodError from inside the interpreter.
32
+ # Reuse Proxy's list rather than keeping a second copy of it.
33
+ !Proxy::IMPLICIT_CONVERSIONS.include?(name)
34
+ end
35
+
36
+ private
37
+
38
+ # Where unknown methods go. `@ole` for every wrapper except `Control`,
39
+ # which forwards to the object that has Caption and Value rather than
40
+ # to the OLEObject host around it (see Control's class comment).
41
+ def passthrough_target
42
+ @ole
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,62 @@
1
+ require 'English'
2
+ require_relative '../client'
3
+
4
+ module WineOLE
5
+ module MSOffice
6
+ # Linux <-> Wine path conversion, and the question of whether converting
7
+ # is meaningful at all.
8
+ #
9
+ # It is meaningful only when the client and the bridge are on one machine
10
+ # looking at one Wine prefix. Convert when that is not true and you get a
11
+ # path that silently refers to some other machine's filesystem.
12
+ module Paths
13
+ # Z:\..., C:/..., \\server\share\...
14
+ WINDOWS_SHAPED = %r{\A(?:[A-Za-z]:[\\/]|\\\\)}.freeze
15
+
16
+ # Deliberately keyed off the same loopback test the bridge uses to
17
+ # decide whether a token is required. If the two definitions of "local"
18
+ # drifted apart, a connection could be remote for authentication and
19
+ # local for paths at once.
20
+ #
21
+ # A host's own NIC address counts as remote. Enumerating local
22
+ # interfaces to notice otherwise would be more code, more edge cases
23
+ # (containers, NAT, temporary IPv6 addresses), and would reintroduce
24
+ # exactly that split.
25
+ def self.convertible?(client:, windows: Client::WINDOWS)
26
+ return false if windows # already Windows paths, and no winepath here
27
+
28
+ client.loopback?
29
+ end
30
+
31
+ # Linux path -> Wine path. Returns the argument unchanged when it
32
+ # already looks like a Windows path, and when winepath is unavailable
33
+ # or fails.
34
+ #
35
+ # Failing to convert is not fatal -- the caller can write a Windows
36
+ # path themselves, and will see that they need to. Raising here would
37
+ # turn a recoverable inconvenience into a stopped script.
38
+ def self.to_wine(path)
39
+ return path if path.to_s.match?(WINDOWS_SHAPED)
40
+
41
+ run_winepath('-w', path) || path
42
+ end
43
+
44
+ # Wine path -> Linux path. Same failure stance.
45
+ def self.to_local(path)
46
+ return path unless path.to_s.match?(WINDOWS_SHAPED)
47
+
48
+ run_winepath('-u', path) || path
49
+ end
50
+
51
+ def self.run_winepath(flag, path)
52
+ out = IO.popen(['winepath', flag, path.to_s], err: File::NULL, &:read)
53
+ return nil unless $CHILD_STATUS&.success?
54
+
55
+ out.strip.empty? ? nil : out.strip
56
+ rescue SystemCallError, IOError
57
+ nil
58
+ end
59
+ private_class_method :run_winepath
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,149 @@
1
+ require_relative '../proxy'
2
+ require_relative 'passthrough'
3
+ require_relative 'format'
4
+
5
+ module WineOLE
6
+ module MSOffice
7
+ # Wraps a COM Range. Adds exactly four methods plus `ole`; everything
8
+ # else falls through to COM.
9
+ #
10
+ # The restraint is deliberate. COM's Range already exposes Rows, Columns,
11
+ # Cells, Item, Areas, Find, Sort, Merge, Table and many more, and COM
12
+ # resolves names case-insensitively -- so every lowercase single word this
13
+ # class defines covers a COM call that already worked. `to_a`, `write`,
14
+ # `fill`, `format` and `ole` were each checked against a live Range and
15
+ # found absent.
16
+ class Range
17
+ include Passthrough
18
+
19
+ def initialize(proxy)
20
+ @ole = proxy
21
+ end
22
+
23
+ # Always a two-dimensional Array, whatever the range's size.
24
+ #
25
+ # Excel's own `Value` returns a bare scalar for a one-cell range --
26
+ # consistently, whether it was addressed as "A1" or "A1:A1" -- so
27
+ # generic code that does not know the size has to branch. This is that
28
+ # branch, written once.
29
+ #
30
+ # Returns a plain Array on purpose: Ruby's own vocabulary then covers
31
+ # the rest (`rows.transpose` for columns, `rows.flatten` for values),
32
+ # so this class does not have to grow `each_row`, `each_column` or
33
+ # `flatten` and risk shadowing more COM members.
34
+ def to_a
35
+ v = @ole.Value
36
+ v.is_a?(::Array) ? v : [[v]]
37
+ end
38
+
39
+ # Write, refusing anything that does not fit.
40
+ #
41
+ # Excel's own assignment corrupts silently in three ways (all measured):
42
+ # a flat array written to a column replicates its first element down
43
+ # every cell, too few values leave #N/A behind, and too many are
44
+ # truncated. None of those raise, and none are visible without looking
45
+ # at the sheet.
46
+ def write(value)
47
+ @ole.Value = shaped(value)
48
+ end
49
+
50
+ # Write, adapting the value to the range: replicate along a dimension
51
+ # the argument does not have, truncate or pad along one it does.
52
+ #
53
+ # Total -- every input has a defined result, no exceptions -- but note
54
+ # that it reproduces Excel's own column trap by construction: a flat
55
+ # array is a row, so filling an Nx1 column with [1,2,3] puts 1 in every
56
+ # cell. That is why `write` and not `fill` is what `sheet[addr] = x`
57
+ # uses; reach for this one deliberately.
58
+ def fill(value)
59
+ nrows = row_count
60
+ ncols = column_count
61
+ @ole.Value =
62
+ case value
63
+ when ::Array
64
+ if value.first.is_a?(::Array)
65
+ (0...nrows).map { |r| row = value[r] || []; (0...ncols).map { |c| row[c] } }
66
+ else
67
+ one = (0...ncols).map { |c| value[c] }
68
+ ::Array.new(nrows) { one.dup }
69
+ end
70
+ else
71
+ ::Array.new(nrows) { ::Array.new(ncols) { value } }
72
+ end
73
+ end
74
+
75
+ # Apply formatting. Keys are documented on WineOLE::MSOffice::Format.
76
+ #
77
+ # An absent key leaves that attribute alone; `false` turns it off.
78
+ # That third state is why this takes keyword arguments rather than
79
+ # being a chain of verbs -- and it keeps this class's additions to one
80
+ # name, which matters because COM resolves names case-insensitively
81
+ # and every lowercase word here covers a COM member of the same
82
+ # spelling. `format` was measured free on a live Range.
83
+ #
84
+ # Note for anyone editing this class: defining `format` shadows
85
+ # Kernel#format inside these instance methods, so string formatting
86
+ # here must be written as `::Kernel.format(...)`.
87
+ def format(**opts)
88
+ Format.apply(@ole, opts)
89
+ self
90
+ end
91
+
92
+ private
93
+
94
+ def row_count
95
+ @ole.Rows.Count
96
+ end
97
+
98
+ def column_count
99
+ @ole.Columns.Count
100
+ end
101
+
102
+ def shaped(value)
103
+ return value unless value.is_a?(::Array)
104
+
105
+ nrows = row_count
106
+ ncols = column_count
107
+
108
+ if value.first.is_a?(::Array)
109
+ unless value.all? { |r| r.is_a?(::Array) }
110
+ raise ArgumentError,
111
+ "range is #{nrows}x#{ncols}, but the value mixes rows and scalars"
112
+ end
113
+ widths = value.map(&:length).uniq
114
+ if widths.length > 1
115
+ raise ArgumentError,
116
+ "range is #{nrows}x#{ncols}, but the value has ragged rows " \
117
+ "(#{widths.join(', ')} elements)"
118
+ end
119
+ if value.any? { |r| r.any? { |c| c.is_a?(::Array) } }
120
+ raise ArgumentError,
121
+ "range is #{nrows}x#{ncols}, but the value nests more than two deep"
122
+ end
123
+ unless value.length == nrows && widths.first == ncols
124
+ raise ArgumentError,
125
+ "range is #{nrows}x#{ncols}, but the value is " \
126
+ "#{value.length}x#{widths.first}"
127
+ end
128
+ value
129
+ else
130
+ if value.any? { |v| v.is_a?(::Array) } # rubocop:disable Style/IfInsideElse
131
+ raise ArgumentError,
132
+ "range is #{nrows}x#{ncols}, but the value mixes scalars and rows"
133
+ end
134
+ if nrows > 1 && ncols > 1
135
+ raise ArgumentError,
136
+ "range is #{nrows}x#{ncols}; a flat array only fits a single " \
137
+ 'row or column -- pass rows, or use fill'
138
+ end
139
+ expected = nrows > 1 ? nrows : ncols
140
+ unless value.length == expected
141
+ raise ArgumentError,
142
+ "range is #{nrows}x#{ncols}, but the value has #{value.length} elements"
143
+ end
144
+ nrows > 1 ? value.map { |v| [v] } : [value]
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,101 @@
1
+ require_relative '../proxy'
2
+ require_relative 'passthrough'
3
+ require_relative 'address'
4
+ require_relative 'range'
5
+ require_relative 'vba_api'
6
+ require_relative 'controls'
7
+
8
+ module WineOLE
9
+ module MSOffice
10
+ # Wraps a COM Worksheet. `[]`/`[]=` address it through the same `Address`
11
+ # parser the whole wrapper uses, so `sheet['A1:B2']` and
12
+ # `sheet[row, col]` both hand back a `Range`; everything else falls
13
+ # through to COM.
14
+ class Sheet
15
+ include Passthrough
16
+
17
+ def initialize(proxy, version:)
18
+ @ole = proxy
19
+ @version = version
20
+ end
21
+
22
+ def [](*keys)
23
+ range_for(keys)
24
+ end
25
+
26
+ # Delegates to Range#write, never #fill: write raises on a value that
27
+ # does not fit the range rather than replicating or padding it, which
28
+ # is the behaviour an assignment through []= should have.
29
+ def []=(*args)
30
+ value = args.pop
31
+ range_for(args).write(value)
32
+ end
33
+
34
+ # This worksheet's VBA surface. Blocks only -- a worksheet's module
35
+ # cannot be created or deleted, so SheetVBA has no component methods
36
+ # rather than having ones that always fail.
37
+ def vba
38
+ @vba ||= SheetVBA.new(@ole)
39
+ end
40
+
41
+ # The Forms-toolbar controls on this sheet: `form_controls.add(:button,
42
+ # name: 'Go', at: 'B2')`. See FormControls for what they can and
43
+ # cannot do; `activex` is the family whose events reach Ruby.
44
+ #
45
+ # Name note (M0, measured against Excel 11): `form_controls` and
46
+ # `activex` are both free on Worksheet.
47
+ def form_controls
48
+ @form_controls ||= FormControls.new(self)
49
+ end
50
+
51
+ # The ActiveX controls on this sheet, MSForms or any registered
52
+ # ProgID: `activex.add(:command_button, name: 'Go', at: 'B2')`.
53
+ def activex
54
+ @activex ||= ActiveXControls.new(self)
55
+ end
56
+
57
+ private
58
+
59
+ def range_for(keys)
60
+ return Range.new(@ole.Cells(*keys)) if cell_reference?(keys)
61
+
62
+ unless keys.length == 1 && keys.first.is_a?(::String)
63
+ raise ArgumentError, "unsupported sheet index #{keys.inspect}"
64
+ end
65
+
66
+ str = keys.first
67
+ addr = Address.parse(str, @version)
68
+
69
+ # An address that parses but stops at a sheet or a book (or does
70
+ # not parse at all) is a lookup, not something with cells to read
71
+ # or write -- see the spec on why `xl["Sheet1!"] = 0` filling a
72
+ # whole sheet is a hazard rather than a convenience (Spec §4.5).
73
+ if addr.nil? || !addr.range?
74
+ raise ArgumentError, "#{str.inspect} has no range"
75
+ end
76
+
77
+ # This object is one sheet. An address that names a different
78
+ # workbook or worksheet would silently reach past it -- writing to
79
+ # the sheet the caller *named* instead of the one they *have* is
80
+ # exactly the class of silent wrong-target write this wrapper
81
+ # exists to prevent.
82
+ if named?(addr.workbook) || named?(addr.worksheet)
83
+ raise ArgumentError,
84
+ "#{str.inspect} names another workbook or worksheet; a Sheet " \
85
+ 'addresses its own cells only -- reach another sheet through ' \
86
+ 'the Excel object that owns it'
87
+ end
88
+
89
+ Range.new(@ole.Range(addr.range))
90
+ end
91
+
92
+ def cell_reference?(keys)
93
+ keys.length == 2 && keys.all? { |k| k.is_a?(::Integer) }
94
+ end
95
+
96
+ def named?(part)
97
+ !part.nil? && !part.empty?
98
+ end
99
+ end
100
+ end
101
+ end