@copylee/dsh-office 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +104 -0
- package/cordis.patch.yml +4 -0
- package/helper/OfficeHelper.cs +1293 -0
- package/lib/client.js +534 -0
- package/lib/index.js +6417 -0
- package/lib/types/client/accent.d.ts +39 -0
- package/lib/types/client/index.d.ts +39 -0
- package/lib/types/client/nav-icon.d.ts +12 -0
- package/lib/types/format.d.ts +88 -0
- package/lib/types/helper-client.d.ts +31 -0
- package/lib/types/index.d.ts +12 -0
- package/lib/types/prompt.d.ts +2 -0
- package/lib/types/settings.d.ts +16 -0
- package/lib/types/tools.d.ts +24 -0
- package/package.json +122 -0
|
@@ -0,0 +1,1293 @@
|
|
|
1
|
+
// dsh-office helper: drives the running Word / Excel / PowerPoint through COM.
|
|
2
|
+
// One JSON command per stdin line, one JSON reply per stdout line.
|
|
3
|
+
// Must stay C# 5 (compiled at runtime by the in-box .NET Framework csc.exe).
|
|
4
|
+
using System;
|
|
5
|
+
using System.Collections;
|
|
6
|
+
using System.Collections.Generic;
|
|
7
|
+
using System.Drawing;
|
|
8
|
+
using System.Drawing.Imaging;
|
|
9
|
+
using System.IO;
|
|
10
|
+
using System.Runtime.InteropServices;
|
|
11
|
+
using System.Text;
|
|
12
|
+
using System.Threading;
|
|
13
|
+
using System.Web.Script.Serialization;
|
|
14
|
+
using System.Windows.Forms;
|
|
15
|
+
|
|
16
|
+
class Fail : Exception
|
|
17
|
+
{
|
|
18
|
+
public string Code;
|
|
19
|
+
public Fail(string code, string message) : base(message) { Code = code; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/// Arguments of a command or of one edit operation.
|
|
23
|
+
class Bag
|
|
24
|
+
{
|
|
25
|
+
readonly Dictionary<string, object> d;
|
|
26
|
+
readonly HashSet<string> read = new HashSet<string>();
|
|
27
|
+
/// Fields that were given but that nothing looked at: a misnamed field must not pass silently.
|
|
28
|
+
public string Unread()
|
|
29
|
+
{
|
|
30
|
+
List<string> names = new List<string>();
|
|
31
|
+
foreach (string key in d.Keys) if (!read.Contains(key)) names.Add(key);
|
|
32
|
+
return string.Join(", ", names.ToArray());
|
|
33
|
+
}
|
|
34
|
+
public Bag(object source)
|
|
35
|
+
{
|
|
36
|
+
d = source as Dictionary<string, object>;
|
|
37
|
+
if (d == null) throw new Fail("BAD_ARGS", "Expected an object.");
|
|
38
|
+
}
|
|
39
|
+
public bool Has(string key) { return Raw(key) != null; }
|
|
40
|
+
public object Raw(string key) { object v; read.Add(key); return d.TryGetValue(key, out v) ? v : null; }
|
|
41
|
+
public string Str(string key, string fallback)
|
|
42
|
+
{
|
|
43
|
+
object v = Raw(key);
|
|
44
|
+
return v == null ? fallback : Convert.ToString(v, System.Globalization.CultureInfo.InvariantCulture);
|
|
45
|
+
}
|
|
46
|
+
public string Need(string key)
|
|
47
|
+
{
|
|
48
|
+
string v = Str(key, null);
|
|
49
|
+
if (v == null) throw new Fail("BAD_ARGS", "\"" + key + "\" is required.");
|
|
50
|
+
return v;
|
|
51
|
+
}
|
|
52
|
+
public int Int(string key, int fallback)
|
|
53
|
+
{
|
|
54
|
+
object v = Raw(key);
|
|
55
|
+
if (v == null) return fallback;
|
|
56
|
+
try { return Convert.ToInt32(v, System.Globalization.CultureInfo.InvariantCulture); }
|
|
57
|
+
catch { throw new Fail("BAD_ARGS", "\"" + key + "\" must be a whole number."); }
|
|
58
|
+
}
|
|
59
|
+
public double Num(string key, double fallback)
|
|
60
|
+
{
|
|
61
|
+
object v = Raw(key);
|
|
62
|
+
if (v == null) return fallback;
|
|
63
|
+
try { return Convert.ToDouble(v, System.Globalization.CultureInfo.InvariantCulture); }
|
|
64
|
+
catch { throw new Fail("BAD_ARGS", "\"" + key + "\" must be a number."); }
|
|
65
|
+
}
|
|
66
|
+
public bool Flag(string key, bool fallback)
|
|
67
|
+
{
|
|
68
|
+
object v = Raw(key);
|
|
69
|
+
return v is bool ? (bool)v : fallback;
|
|
70
|
+
}
|
|
71
|
+
public IList List(string key)
|
|
72
|
+
{
|
|
73
|
+
object v = Raw(key);
|
|
74
|
+
if (v == null) return null;
|
|
75
|
+
IList list = v as IList;
|
|
76
|
+
if (list == null || v is string) { ArrayList one = new ArrayList(); one.Add(v); return one; }
|
|
77
|
+
return list;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
[ComImport, Guid("00000016-0000-0000-C000-000000000046"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
82
|
+
interface IOleMessageFilter
|
|
83
|
+
{
|
|
84
|
+
[PreserveSig] int HandleInComingCall(int dwCallType, IntPtr hTaskCaller, int dwTickCount, IntPtr lpInterfaceInfo);
|
|
85
|
+
[PreserveSig] int RetryRejectedCall(IntPtr hTaskCallee, int dwTickCount, int dwRejectType);
|
|
86
|
+
[PreserveSig] int MessagePending(IntPtr hTaskCallee, int dwTickCount, int dwPendingType);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/// Office rejects calls while the user has a dialog open or is typing: wait for it a few seconds.
|
|
90
|
+
class MessageFilter : IOleMessageFilter
|
|
91
|
+
{
|
|
92
|
+
[DllImport("ole32.dll")]
|
|
93
|
+
static extern int CoRegisterMessageFilter(IOleMessageFilter newFilter, out IOleMessageFilter oldFilter);
|
|
94
|
+
public static void Register() { IOleMessageFilter old; CoRegisterMessageFilter(new MessageFilter(), out old); }
|
|
95
|
+
public int HandleInComingCall(int dwCallType, IntPtr hTaskCaller, int dwTickCount, IntPtr lpInterfaceInfo) { return 0; }
|
|
96
|
+
public int RetryRejectedCall(IntPtr hTaskCallee, int dwTickCount, int dwRejectType)
|
|
97
|
+
{
|
|
98
|
+
return dwRejectType == 2 && dwTickCount < 6000 ? 150 : -1;
|
|
99
|
+
}
|
|
100
|
+
public int MessagePending(IntPtr hTaskCallee, int dwTickCount, int dwPendingType) { return 2; }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
static class Program
|
|
104
|
+
{
|
|
105
|
+
static readonly JavaScriptSerializer Json = new JavaScriptSerializer();
|
|
106
|
+
static TextWriter Out;
|
|
107
|
+
|
|
108
|
+
[STAThread]
|
|
109
|
+
static void Main()
|
|
110
|
+
{
|
|
111
|
+
Json.MaxJsonLength = int.MaxValue;
|
|
112
|
+
Json.RecursionLimit = 64;
|
|
113
|
+
Out = new StreamWriter(Console.OpenStandardOutput(), new UTF8Encoding(false));
|
|
114
|
+
TextReader input = new StreamReader(Console.OpenStandardInput(), new UTF8Encoding(false));
|
|
115
|
+
MessageFilter.Register();
|
|
116
|
+
Send(new Dictionary<string, object> { { "event", "ready" } });
|
|
117
|
+
string line;
|
|
118
|
+
while ((line = input.ReadLine()) != null)
|
|
119
|
+
{
|
|
120
|
+
if (line.Trim().Length == 0) continue;
|
|
121
|
+
object id = null;
|
|
122
|
+
Dictionary<string, object> reply = new Dictionary<string, object>();
|
|
123
|
+
try
|
|
124
|
+
{
|
|
125
|
+
Bag args = new Bag(Json.DeserializeObject(line));
|
|
126
|
+
id = args.Raw("id");
|
|
127
|
+
reply["result"] = Run(args.Need("cmd"), args);
|
|
128
|
+
reply["ok"] = true;
|
|
129
|
+
}
|
|
130
|
+
catch (Exception error)
|
|
131
|
+
{
|
|
132
|
+
string code;
|
|
133
|
+
reply["ok"] = false;
|
|
134
|
+
reply["error"] = Describe(error, out code);
|
|
135
|
+
reply["code"] = code;
|
|
136
|
+
}
|
|
137
|
+
reply["id"] = id;
|
|
138
|
+
Send(reply);
|
|
139
|
+
// Let go of every COM object: a reference kept here would keep a closed Office app alive, hidden.
|
|
140
|
+
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); GC.WaitForPendingFinalizers();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
static void Send(object message) { Out.WriteLine(Json.Serialize(message)); Out.Flush(); }
|
|
145
|
+
|
|
146
|
+
static string Describe(Exception error, out string code)
|
|
147
|
+
{
|
|
148
|
+
while (error is System.Reflection.TargetInvocationException && error.InnerException != null) error = error.InnerException;
|
|
149
|
+
Fail fail = error as Fail;
|
|
150
|
+
if (fail != null) { code = fail.Code; return fail.Message; }
|
|
151
|
+
COMException com = error as COMException;
|
|
152
|
+
if (com != null)
|
|
153
|
+
{
|
|
154
|
+
uint hr = unchecked((uint)com.ErrorCode);
|
|
155
|
+
if (hr == 0x80010001 || hr == 0x8001010A || hr == 0x800AC472)
|
|
156
|
+
{
|
|
157
|
+
code = "BUSY";
|
|
158
|
+
return "The Office app is busy: the user is typing in a cell or has a dialog open. Nothing was changed by this call; try again in a moment.";
|
|
159
|
+
}
|
|
160
|
+
code = "OFFICE_ERROR";
|
|
161
|
+
return com.Message.Trim() + " (0x" + hr.ToString("X8") + ")";
|
|
162
|
+
}
|
|
163
|
+
code = "ERROR";
|
|
164
|
+
return error.Message;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ───────────────────────── applications and documents ─────────────────────────
|
|
168
|
+
|
|
169
|
+
static string ProgId(string kind)
|
|
170
|
+
{
|
|
171
|
+
if (kind == "word") return "Word.Application";
|
|
172
|
+
if (kind == "excel") return "Excel.Application";
|
|
173
|
+
if (kind == "ppt") return "PowerPoint.Application";
|
|
174
|
+
throw new Fail("BAD_ARGS", "Unknown app \"" + kind + "\": use word, excel or ppt.");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
static string AppName(string kind) { return kind == "word" ? "Word" : kind == "excel" ? "Excel" : "PowerPoint"; }
|
|
178
|
+
|
|
179
|
+
static string KindOfPath(string path)
|
|
180
|
+
{
|
|
181
|
+
string ext = Path.GetExtension(path).ToLowerInvariant();
|
|
182
|
+
if (ext == ".docx" || ext == ".doc" || ext == ".docm" || ext == ".rtf" || ext == ".dotx") return "word";
|
|
183
|
+
if (ext == ".xlsx" || ext == ".xls" || ext == ".xlsm" || ext == ".csv" || ext == ".xlsb") return "excel";
|
|
184
|
+
if (ext == ".pptx" || ext == ".ppt" || ext == ".pptm") return "ppt";
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
static string Kind(Bag a)
|
|
189
|
+
{
|
|
190
|
+
string kind = a.Str("app", null);
|
|
191
|
+
if (kind != null) { ProgId(kind); return kind; }
|
|
192
|
+
string doc = a.Str("doc", a.Str("path", null));
|
|
193
|
+
string guess = doc == null ? null : KindOfPath(doc);
|
|
194
|
+
if (guess == null) throw new Fail("BAD_ARGS", "Say which app: \"app\" must be word, excel or ppt (it cannot be told from the file name).");
|
|
195
|
+
return guess;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
static dynamic Running(string kind)
|
|
199
|
+
{
|
|
200
|
+
try { return Marshal.GetActiveObject(ProgId(kind)); }
|
|
201
|
+
catch (COMException) { return null; }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
static dynamic App(string kind, bool start)
|
|
205
|
+
{
|
|
206
|
+
dynamic app = Running(kind);
|
|
207
|
+
if (app != null) return app;
|
|
208
|
+
if (!start) throw new Fail("NOT_RUNNING", AppName(kind) + " is not running. Open the file with office_open first.");
|
|
209
|
+
Type type = Type.GetTypeFromProgID(ProgId(kind));
|
|
210
|
+
if (type == null) throw new Fail("NOT_INSTALLED", AppName(kind) + " is not installed on this computer.");
|
|
211
|
+
app = Activator.CreateInstance(type);
|
|
212
|
+
// Started by automation, Excel would quit once we let go of it; hand it to the user.
|
|
213
|
+
if (kind == "excel") { try { app.UserControl = true; } catch (Exception) { } }
|
|
214
|
+
return app;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
static void Show(string kind, dynamic app)
|
|
218
|
+
{
|
|
219
|
+
if (kind == "ppt") app.Visible = -1; else app.Visible = true;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
static dynamic Docs(string kind, dynamic app)
|
|
223
|
+
{
|
|
224
|
+
return kind == "word" ? app.Documents : kind == "excel" ? app.Workbooks : app.Presentations;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
static dynamic Active(string kind, dynamic app)
|
|
228
|
+
{
|
|
229
|
+
try
|
|
230
|
+
{
|
|
231
|
+
if ((int)Docs(kind, app).Count == 0) return null;
|
|
232
|
+
return kind == "word" ? app.ActiveDocument : kind == "excel" ? app.ActiveWorkbook : app.ActivePresentation;
|
|
233
|
+
}
|
|
234
|
+
catch (COMException) { return null; }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
static bool SamePath(string a, string b)
|
|
238
|
+
{
|
|
239
|
+
try { return string.Equals(Path.GetFullPath(a), Path.GetFullPath(b), StringComparison.OrdinalIgnoreCase); }
|
|
240
|
+
catch (Exception) { return false; }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
static dynamic Find(string kind, dynamic app, string doc)
|
|
244
|
+
{
|
|
245
|
+
foreach (dynamic item in Docs(kind, app))
|
|
246
|
+
{
|
|
247
|
+
string name = (string)item.Name, full = (string)item.FullName;
|
|
248
|
+
if (string.Equals(name, doc, StringComparison.OrdinalIgnoreCase)) return item;
|
|
249
|
+
if (Path.IsPathRooted(doc) && full.IndexOf("://", StringComparison.Ordinal) < 0 && Path.IsPathRooted(full) && SamePath(full, doc)) return item;
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/// The document a command is about: named by "doc" (path or window name), else the active one.
|
|
255
|
+
static dynamic Doc(string kind, dynamic app, Bag a)
|
|
256
|
+
{
|
|
257
|
+
string doc = a.Str("doc", null);
|
|
258
|
+
dynamic found = doc == null ? Active(kind, app) : Find(kind, app, doc);
|
|
259
|
+
if (found == null)
|
|
260
|
+
{
|
|
261
|
+
throw new Fail("NO_DOCUMENT", doc == null
|
|
262
|
+
? "No document is open in " + AppName(kind) + ". Open one with office_open."
|
|
263
|
+
: "\"" + doc + "\" is not open in " + AppName(kind) + ". Open it with office_open, or call office_status to see what is open.");
|
|
264
|
+
}
|
|
265
|
+
return found;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
static bool Truthy(object value)
|
|
269
|
+
{
|
|
270
|
+
if (value is bool) return (bool)value;
|
|
271
|
+
try { return Convert.ToInt32(value) != 0; } catch (Exception) { return false; }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
static Dictionary<string, object> Info(string kind, dynamic doc, dynamic active)
|
|
275
|
+
{
|
|
276
|
+
Dictionary<string, object> info = new Dictionary<string, object>();
|
|
277
|
+
string full = (string)doc.FullName;
|
|
278
|
+
info["app"] = kind;
|
|
279
|
+
info["name"] = (string)doc.Name;
|
|
280
|
+
info["path"] = ((string)doc.Path).Length == 0 ? null : full;
|
|
281
|
+
info["saved"] = Truthy(doc.Saved);
|
|
282
|
+
info["active"] = active != null && (string)active.FullName == full;
|
|
283
|
+
try { info["readOnly"] = Truthy(doc.ReadOnly); } catch (Exception) { }
|
|
284
|
+
return info;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/// Excel refuses every call while a cell is being edited: wait for it briefly rather than fail half-way through a batch.
|
|
288
|
+
static void WaitReady(string kind, dynamic app)
|
|
289
|
+
{
|
|
290
|
+
if (kind != "excel") return;
|
|
291
|
+
for (int i = 0; i < 25; i++)
|
|
292
|
+
{
|
|
293
|
+
try { if ((bool)app.Ready) return; } catch (COMException) { }
|
|
294
|
+
Thread.Sleep(200);
|
|
295
|
+
}
|
|
296
|
+
throw new Fail("BUSY", "Excel is busy: the user is typing in a cell or has a dialog open. Nothing was changed; try again in a moment.");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ───────────────────────── commands ─────────────────────────
|
|
300
|
+
|
|
301
|
+
static object Run(string cmd, Bag a)
|
|
302
|
+
{
|
|
303
|
+
if (cmd == "ping") return "pong";
|
|
304
|
+
if (cmd == "status") return Status();
|
|
305
|
+
string kind = Kind(a);
|
|
306
|
+
if (cmd == "open") return Open(kind, a);
|
|
307
|
+
if (cmd == "quit")
|
|
308
|
+
{
|
|
309
|
+
dynamic idle = Running(kind);
|
|
310
|
+
if (idle == null) return "not running";
|
|
311
|
+
if ((int)Docs(kind, idle).Count > 0) throw new Fail("BAD_ARGS", AppName(kind) + " still has documents open; it is left running.");
|
|
312
|
+
idle.Quit();
|
|
313
|
+
return "quit";
|
|
314
|
+
}
|
|
315
|
+
dynamic app = App(kind, false);
|
|
316
|
+
WaitReady(kind, app);
|
|
317
|
+
dynamic doc = Doc(kind, app, a);
|
|
318
|
+
if (cmd == "read") return kind == "word" ? WordRead(doc, a) : kind == "excel" ? ExcelRead(doc, a) : PptRead(doc, a);
|
|
319
|
+
if (cmd == "edit") return Edit(kind, app, doc, a);
|
|
320
|
+
if (cmd == "render") return Render(kind, app, doc, a);
|
|
321
|
+
if (cmd == "save") return Save(kind, app, doc, a);
|
|
322
|
+
if (cmd == "close") return Close(kind, doc, a);
|
|
323
|
+
if (cmd == "undo")
|
|
324
|
+
{
|
|
325
|
+
if (kind != "word") throw new Fail("BAD_ARGS", "Only Word keeps the agent's edits on its undo stack.");
|
|
326
|
+
return (bool)doc.Undo(Math.Max(1, a.Int("times", 1))) ? "undone" : "nothing to undo";
|
|
327
|
+
}
|
|
328
|
+
throw new Fail("BAD_ARGS", "Unknown command \"" + cmd + "\".");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
static object Status()
|
|
332
|
+
{
|
|
333
|
+
List<object> apps = new List<object>();
|
|
334
|
+
foreach (string kind in new string[] { "word", "excel", "ppt" })
|
|
335
|
+
{
|
|
336
|
+
Dictionary<string, object> entry = new Dictionary<string, object>();
|
|
337
|
+
entry["app"] = kind;
|
|
338
|
+
entry["installed"] = Type.GetTypeFromProgID(ProgId(kind)) != null;
|
|
339
|
+
List<object> open = new List<object>();
|
|
340
|
+
dynamic app = Running(kind);
|
|
341
|
+
entry["running"] = app != null;
|
|
342
|
+
if (app != null)
|
|
343
|
+
{
|
|
344
|
+
try
|
|
345
|
+
{
|
|
346
|
+
dynamic active = Active(kind, app);
|
|
347
|
+
foreach (dynamic doc in Docs(kind, app)) open.Add(Info(kind, doc, active));
|
|
348
|
+
entry["version"] = Convert.ToString(app.Version);
|
|
349
|
+
}
|
|
350
|
+
catch (COMException error) { string code; entry["error"] = Describe(error, out code); }
|
|
351
|
+
}
|
|
352
|
+
entry["documents"] = open;
|
|
353
|
+
apps.Add(entry);
|
|
354
|
+
}
|
|
355
|
+
return apps;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
static object Open(string kind, Bag a)
|
|
359
|
+
{
|
|
360
|
+
string path = a.Str("path", null);
|
|
361
|
+
if (path != null)
|
|
362
|
+
{
|
|
363
|
+
if (!Path.IsPathRooted(path)) throw new Fail("BAD_ARGS", "\"path\" must be an absolute path.");
|
|
364
|
+
path = Path.GetFullPath(path);
|
|
365
|
+
}
|
|
366
|
+
dynamic app = App(kind, true);
|
|
367
|
+
Show(kind, app);
|
|
368
|
+
WaitReady(kind, app);
|
|
369
|
+
dynamic doc = path == null ? null : Find(kind, app, path);
|
|
370
|
+
string how = "attached";
|
|
371
|
+
if (doc == null)
|
|
372
|
+
{
|
|
373
|
+
dynamic docs = Docs(kind, app);
|
|
374
|
+
if (path != null && File.Exists(path))
|
|
375
|
+
{
|
|
376
|
+
doc = kind == "ppt" ? docs.Open(path, 0, 0, -1) : docs.Open(path);
|
|
377
|
+
how = "opened";
|
|
378
|
+
}
|
|
379
|
+
else
|
|
380
|
+
{
|
|
381
|
+
if (path != null && !Directory.Exists(Path.GetDirectoryName(path))) throw new Fail("BAD_ARGS", "The folder of \"" + path + "\" does not exist.");
|
|
382
|
+
doc = kind == "ppt" ? docs.Add(-1) : docs.Add();
|
|
383
|
+
how = "created";
|
|
384
|
+
if (path != null) SaveAs(kind, app, doc, path);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (a.Flag("show", true))
|
|
388
|
+
{
|
|
389
|
+
try
|
|
390
|
+
{
|
|
391
|
+
if (kind == "ppt") doc.Windows[1].Activate(); else doc.Activate();
|
|
392
|
+
if (kind == "word") app.Activate();
|
|
393
|
+
}
|
|
394
|
+
catch (COMException) { }
|
|
395
|
+
}
|
|
396
|
+
Dictionary<string, object> info = Info(kind, doc, Active(kind, app));
|
|
397
|
+
info["how"] = how;
|
|
398
|
+
return info;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
static void SaveAs(string kind, dynamic app, dynamic doc, string path)
|
|
402
|
+
{
|
|
403
|
+
string ext = Path.GetExtension(path).ToLowerInvariant();
|
|
404
|
+
if (kind == "word")
|
|
405
|
+
{
|
|
406
|
+
if (ext == ".pdf") doc.ExportAsFixedFormat(path, 17);
|
|
407
|
+
else doc.SaveAs2(path, ext == ".doc" ? 0 : ext == ".rtf" ? 6 : ext == ".docm" ? 13 : 12);
|
|
408
|
+
}
|
|
409
|
+
else if (kind == "excel")
|
|
410
|
+
{
|
|
411
|
+
if (ext == ".pdf") { doc.ExportAsFixedFormat(0, path); return; }
|
|
412
|
+
bool alerts = (bool)app.DisplayAlerts;
|
|
413
|
+
app.DisplayAlerts = false;
|
|
414
|
+
try { doc.SaveAs(path, ext == ".xls" ? 56 : ext == ".xlsm" ? 52 : ext == ".csv" ? 6 : ext == ".xlsb" ? 50 : 51); }
|
|
415
|
+
finally { app.DisplayAlerts = alerts; }
|
|
416
|
+
}
|
|
417
|
+
else
|
|
418
|
+
{
|
|
419
|
+
if (ext == ".pdf") doc.SaveAs(path, 32); else doc.SaveAs(path);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
static object Save(string kind, dynamic app, dynamic doc, Bag a)
|
|
424
|
+
{
|
|
425
|
+
string path = a.Str("path", null);
|
|
426
|
+
Dictionary<string, object> result = new Dictionary<string, object>();
|
|
427
|
+
if (path == null)
|
|
428
|
+
{
|
|
429
|
+
if (((string)doc.Path).Length == 0) throw new Fail("NEEDS_PATH", "This document has never been saved: give \"path\" to say where.");
|
|
430
|
+
doc.Save();
|
|
431
|
+
result["path"] = (string)doc.FullName;
|
|
432
|
+
}
|
|
433
|
+
else
|
|
434
|
+
{
|
|
435
|
+
if (!Path.IsPathRooted(path)) throw new Fail("BAD_ARGS", "\"path\" must be an absolute path.");
|
|
436
|
+
path = Path.GetFullPath(path);
|
|
437
|
+
if (!Directory.Exists(Path.GetDirectoryName(path))) throw new Fail("BAD_ARGS", "The folder of \"" + path + "\" does not exist.");
|
|
438
|
+
SaveAs(kind, app, doc, path);
|
|
439
|
+
result["path"] = path;
|
|
440
|
+
}
|
|
441
|
+
result["bytes"] = new FileInfo((string)result["path"]).Length;
|
|
442
|
+
return result;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
static object Close(string kind, dynamic doc, Bag a)
|
|
446
|
+
{
|
|
447
|
+
bool save = a.Flag("save", false);
|
|
448
|
+
if (kind == "word") doc.Close(save ? -1 : 0);
|
|
449
|
+
else if (kind == "excel") doc.Close(save);
|
|
450
|
+
else { if (save) doc.Save(); else doc.Saved = -1; doc.Close(); }
|
|
451
|
+
return "closed";
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ───────────────────────── shared helpers ─────────────────────────
|
|
455
|
+
|
|
456
|
+
static string Clean(string text)
|
|
457
|
+
{
|
|
458
|
+
if (text == null) return "";
|
|
459
|
+
StringBuilder sb = new StringBuilder(text.Length);
|
|
460
|
+
foreach (char c in text)
|
|
461
|
+
{
|
|
462
|
+
if (c == '\r' || c == '\a' || c == '\f') continue;
|
|
463
|
+
if (c == '\v' || c == '\n') { sb.Append('\n'); continue; }
|
|
464
|
+
if (c < ' ' && c != '\t') continue;
|
|
465
|
+
sb.Append(c);
|
|
466
|
+
}
|
|
467
|
+
return sb.ToString();
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
static string Clip(string text, int max)
|
|
471
|
+
{
|
|
472
|
+
return text.Length <= max ? text : text.Substring(0, max) + "…(" + text.Length + " chars)";
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
static string Squash(string text)
|
|
476
|
+
{
|
|
477
|
+
StringBuilder sb = new StringBuilder();
|
|
478
|
+
foreach (char c in text) if (!char.IsWhiteSpace(c)) sb.Append(c);
|
|
479
|
+
return sb.ToString();
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/// "#RRGGBB" to the BGR integer Office uses.
|
|
483
|
+
static int Bgr(string color)
|
|
484
|
+
{
|
|
485
|
+
string hex = color.Trim().TrimStart('#');
|
|
486
|
+
if (hex.Length == 3) hex = "" + hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
|
487
|
+
int rgb;
|
|
488
|
+
if (hex.Length != 6 || !int.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out rgb)) throw new Fail("BAD_ARGS", "Colour \"" + color + "\" must look like #RRGGBB.");
|
|
489
|
+
return ((rgb & 0xFF) << 16) | (rgb & 0xFF00) | ((rgb >> 16) & 0xFF);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
static string Lines(object value)
|
|
493
|
+
{
|
|
494
|
+
IList list = value as IList;
|
|
495
|
+
string text;
|
|
496
|
+
if (list != null && !(value is string))
|
|
497
|
+
{
|
|
498
|
+
List<string> parts = new List<string>();
|
|
499
|
+
foreach (object item in list) parts.Add(Convert.ToString(item));
|
|
500
|
+
text = string.Join("\r", parts.ToArray());
|
|
501
|
+
}
|
|
502
|
+
else text = Convert.ToString(value);
|
|
503
|
+
return text.Replace("\r\n", "\r").Replace("\n", "\r");
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ───────────────────────── Word ─────────────────────────
|
|
507
|
+
|
|
508
|
+
static readonly Dictionary<string, int> WordStyles = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
|
509
|
+
{
|
|
510
|
+
{ "Normal", -1 }, { "Heading 1", -2 }, { "Heading 2", -3 }, { "Heading 3", -4 }, { "Heading 4", -5 }, { "Heading 5", -6 },
|
|
511
|
+
{ "Heading 6", -7 }, { "Title", -63 }, { "Subtitle", -75 }, { "List Bullet", -49 }, { "List Number", -50 },
|
|
512
|
+
{ "Quote", -181 }, { "Caption", -35 }, { "Body Text", -67 },
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
static void SetStyle(dynamic range, string style)
|
|
516
|
+
{
|
|
517
|
+
int builtin;
|
|
518
|
+
try
|
|
519
|
+
{
|
|
520
|
+
if (WordStyles.TryGetValue(style, out builtin)) range.Style = builtin; else range.Style = style;
|
|
521
|
+
}
|
|
522
|
+
catch (COMException)
|
|
523
|
+
{
|
|
524
|
+
throw new Fail("STYLE_MISSING", "This document has no style \"" + style + "\". Built-in names that always work: " + string.Join(", ", new List<string>(WordStyles.Keys).ToArray()) + ".");
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
static dynamic Para(dynamic doc, int index, string expect)
|
|
529
|
+
{
|
|
530
|
+
int total = (int)doc.Paragraphs.Count;
|
|
531
|
+
if (index < 1 || index > total) throw new Fail("ANCHOR_MISSING", "There is no paragraph " + index + " (the document has " + total + "). Call office_read again.");
|
|
532
|
+
dynamic p = doc.Paragraphs[index];
|
|
533
|
+
if (expect != null)
|
|
534
|
+
{
|
|
535
|
+
string text = Squash(Clean((string)p.Range.Text)), want = Squash(expect);
|
|
536
|
+
if (want.Length == 0 ? text.Length > 0 : !text.StartsWith(want, StringComparison.Ordinal))
|
|
537
|
+
{
|
|
538
|
+
throw new Fail("ANCHOR_MISMATCH", "Paragraph " + index + " now starts with \"" + Clip(Clean((string)p.Range.Text), 40) + "\", not " + (want.Length == 0 ? "empty" : "\"" + Clip(expect, 40) + "\"") + ". The document was edited since you read it: call office_read again and retry from this operation.");
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
return p;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
static object WordRead(dynamic doc, Bag a)
|
|
545
|
+
{
|
|
546
|
+
Dictionary<string, object> result = new Dictionary<string, object>();
|
|
547
|
+
int total = (int)doc.Paragraphs.Count, tables = (int)doc.Tables.Count;
|
|
548
|
+
result["paragraphs"] = total;
|
|
549
|
+
result["tables"] = tables;
|
|
550
|
+
try { result["pages"] = (int)doc.ComputeStatistics(2); } catch (COMException) { }
|
|
551
|
+
if (a.Has("table"))
|
|
552
|
+
{
|
|
553
|
+
int n = a.Int("table", 1);
|
|
554
|
+
if (n < 1 || n > tables) throw new Fail("ANCHOR_MISSING", "There is no table " + n + " (the document has " + tables + ").");
|
|
555
|
+
dynamic table = doc.Tables[n];
|
|
556
|
+
int rows = (int)table.Rows.Count, cols = (int)table.Columns.Count;
|
|
557
|
+
List<object> grid = new List<object>();
|
|
558
|
+
for (int r = 1; r <= rows; r++)
|
|
559
|
+
{
|
|
560
|
+
List<object> row = new List<object>();
|
|
561
|
+
for (int c = 1; c <= cols; c++)
|
|
562
|
+
{
|
|
563
|
+
try { row.Add(Clean((string)table.Cell(r, c).Range.Text)); }
|
|
564
|
+
catch (COMException) { row.Add(null); }
|
|
565
|
+
}
|
|
566
|
+
grid.Add(row);
|
|
567
|
+
}
|
|
568
|
+
result["table"] = n;
|
|
569
|
+
result["cells"] = grid;
|
|
570
|
+
return result;
|
|
571
|
+
}
|
|
572
|
+
int from = Math.Max(1, a.Int("from", 1)), count = Math.Max(1, Math.Min(400, a.Int("count", 120)));
|
|
573
|
+
bool full = a.Flag("full", false);
|
|
574
|
+
List<object> items = new List<object>();
|
|
575
|
+
int shown = from - 1;
|
|
576
|
+
string titleStyle = null;
|
|
577
|
+
try { titleStyle = (string)doc.Styles[-63].NameLocal; } catch (Exception) { }
|
|
578
|
+
if (from <= total)
|
|
579
|
+
{
|
|
580
|
+
dynamic p = doc.Paragraphs[from];
|
|
581
|
+
for (int i = from; i < from + count && i <= total && p != null; i++)
|
|
582
|
+
{
|
|
583
|
+
Dictionary<string, object> item = new Dictionary<string, object>();
|
|
584
|
+
string text = Clean((string)p.Range.Text);
|
|
585
|
+
item["i"] = i;
|
|
586
|
+
item["text"] = full ? text : Clip(text, 100);
|
|
587
|
+
string style = null;
|
|
588
|
+
try { style = (string)p.Style.NameLocal; } catch (Exception) { }
|
|
589
|
+
if (style != null && style == titleStyle) item["style"] = "Title";
|
|
590
|
+
else
|
|
591
|
+
{
|
|
592
|
+
if (style != null) item["style"] = style;
|
|
593
|
+
try { int level = (int)p.OutlineLevel; if (level < 10) item["level"] = level; } catch (Exception) { }
|
|
594
|
+
}
|
|
595
|
+
bool inTable = false;
|
|
596
|
+
try { inTable = Truthy(p.Range.Information[12]); } catch (Exception) { }
|
|
597
|
+
if (inTable)
|
|
598
|
+
{
|
|
599
|
+
// A table is one entry, not one per cell: its cells are read with "table".
|
|
600
|
+
dynamic table = p.Range.Tables[1];
|
|
601
|
+
int end = (int)table.Range.End, inside = (int)table.Range.Paragraphs.Count;
|
|
602
|
+
item.Clear();
|
|
603
|
+
item["i"] = i;
|
|
604
|
+
item["to"] = i + inside - 1;
|
|
605
|
+
item["table"] = (int)doc.Range(0, end).Tables.Count;
|
|
606
|
+
item["size"] = (int)table.Rows.Count + "x" + (int)table.Columns.Count;
|
|
607
|
+
items.Add(item);
|
|
608
|
+
i += inside - 1;
|
|
609
|
+
p = i < total ? doc.Range(end, end).Paragraphs[1] : null;
|
|
610
|
+
shown = i;
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
items.Add(item);
|
|
614
|
+
shown = i;
|
|
615
|
+
p = p.Next();
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
result["from"] = from;
|
|
619
|
+
result["items"] = items;
|
|
620
|
+
if (shown < total) result["more"] = "Paragraphs " + (shown + 1) + "–" + total + " not shown; read them with \"from\".";
|
|
621
|
+
return result;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
static dynamic WordRange(dynamic doc, Bag op)
|
|
625
|
+
{
|
|
626
|
+
dynamic scope;
|
|
627
|
+
if (op.Has("para"))
|
|
628
|
+
{
|
|
629
|
+
dynamic first = Para(doc, op.Int("para", 1), op.Str("expect", null));
|
|
630
|
+
dynamic last = op.Has("to") ? Para(doc, op.Int("to", 1), null) : first;
|
|
631
|
+
scope = doc.Range(first.Range.Start, last.Range.End);
|
|
632
|
+
}
|
|
633
|
+
else scope = doc.Content;
|
|
634
|
+
if (!op.Has("find")) return scope;
|
|
635
|
+
string find = op.Need("find");
|
|
636
|
+
dynamic match = scope.Duplicate;
|
|
637
|
+
if (!(bool)match.Find.Execute(FindText: find, MatchCase: true, Forward: true, Wrap: 0)) throw new Fail("NOT_FOUND", "The text \"" + Clip(find, 60) + "\" was not found there.");
|
|
638
|
+
return match;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
static void WordFormat(dynamic range, Bag op)
|
|
642
|
+
{
|
|
643
|
+
if (op.Has("style")) SetStyle(range, op.Need("style"));
|
|
644
|
+
if (op.Has("font")) { string font = op.Need("font"); range.Font.Name = font; try { range.Font.NameFarEast = font; } catch (COMException) { } }
|
|
645
|
+
if (op.Has("size")) range.Font.Size = (float)op.Num("size", 12);
|
|
646
|
+
if (op.Has("bold")) range.Font.Bold = op.Flag("bold", false) ? 1 : 0;
|
|
647
|
+
if (op.Has("italic")) range.Font.Italic = op.Flag("italic", false) ? 1 : 0;
|
|
648
|
+
if (op.Has("underline")) range.Font.Underline = op.Flag("underline", false) ? 1 : 0;
|
|
649
|
+
if (op.Has("color")) range.Font.Color = Bgr(op.Need("color"));
|
|
650
|
+
if (op.Has("align"))
|
|
651
|
+
{
|
|
652
|
+
string align = op.Need("align");
|
|
653
|
+
range.ParagraphFormat.Alignment = align == "center" ? 1 : align == "right" ? 2 : align == "justify" ? 3 : 0;
|
|
654
|
+
}
|
|
655
|
+
if (op.Has("firstLineIndent")) range.ParagraphFormat.FirstLineIndent = (float)op.Num("firstLineIndent", 0);
|
|
656
|
+
if (op.Has("spaceBefore")) range.ParagraphFormat.SpaceBefore = (float)op.Num("spaceBefore", 0);
|
|
657
|
+
if (op.Has("spaceAfter")) range.ParagraphFormat.SpaceAfter = (float)op.Num("spaceAfter", 0);
|
|
658
|
+
if (op.Has("lineSpacing")) { range.ParagraphFormat.LineSpacingRule = 5; range.ParagraphFormat.LineSpacing = (float)(op.Num("lineSpacing", 1) * 12); }
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/// A new empty paragraph where an insert operation points: after / before paragraph "para", or at the start / end.
|
|
662
|
+
static dynamic NewParagraph(dynamic doc, Bag op)
|
|
663
|
+
{
|
|
664
|
+
string where = op.Str("where", op.Has("para") ? "after" : "end");
|
|
665
|
+
// A new, empty document: its one empty paragraph is where the text goes, wherever the operation points.
|
|
666
|
+
if ((int)doc.Paragraphs.Count == 1 && (string)doc.Paragraphs[1].Range.Text == "\r") return doc.Paragraphs[1];
|
|
667
|
+
if (where == "end")
|
|
668
|
+
{
|
|
669
|
+
dynamic last = doc.Paragraphs.Last;
|
|
670
|
+
if ((string)last.Range.Text == "\r" && !Truthy(last.Range.Information[12])) return last;
|
|
671
|
+
return After(doc, last);
|
|
672
|
+
}
|
|
673
|
+
dynamic anchor = where == "start" ? doc.Paragraphs[1] : Para(doc, op.Int("para", 1), op.Str("expect", null));
|
|
674
|
+
if (where == "after") return After(doc, anchor);
|
|
675
|
+
if (where != "before" && where != "start") throw new Fail("BAD_ARGS", "\"where\" must be after, before, start or end.");
|
|
676
|
+
int start = (int)anchor.Range.Start;
|
|
677
|
+
anchor.Range.InsertParagraphBefore();
|
|
678
|
+
return doc.Range(start, start).Paragraphs[1];
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/// A new empty paragraph right after the given one.
|
|
682
|
+
static dynamic After(dynamic doc, dynamic paragraph)
|
|
683
|
+
{
|
|
684
|
+
int end = (int)paragraph.Range.End;
|
|
685
|
+
paragraph.Range.InsertParagraphAfter();
|
|
686
|
+
return doc.Range(end, end).Paragraphs[1];
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/// The number of a paragraph, counted from the start of the document.
|
|
690
|
+
static int Index(dynamic doc, dynamic paragraph)
|
|
691
|
+
{
|
|
692
|
+
return (int)doc.Range(0, paragraph.Range.End).Paragraphs.Count;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
static void Plain(dynamic paragraph, string style)
|
|
696
|
+
{
|
|
697
|
+
SetStyle(paragraph.Range, style);
|
|
698
|
+
try { paragraph.Range.Font.Reset(); paragraph.Range.ParagraphFormat.Reset(); } catch (COMException) { }
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
static string WordOp(dynamic doc, string type, Bag op)
|
|
702
|
+
{
|
|
703
|
+
if (type == "replace_text")
|
|
704
|
+
{
|
|
705
|
+
string find = op.Need("find"), replace = Lines(op.Raw("replace") ?? "");
|
|
706
|
+
bool all = op.Flag("all", true), matchCase = op.Flag("matchCase", true);
|
|
707
|
+
dynamic range = doc.Content;
|
|
708
|
+
int count = 0;
|
|
709
|
+
while (count < 5000 && (bool)range.Find.Execute(FindText: find, MatchCase: matchCase, Forward: true, Wrap: 0))
|
|
710
|
+
{
|
|
711
|
+
range.Text = replace;
|
|
712
|
+
range.Collapse(0);
|
|
713
|
+
count++;
|
|
714
|
+
if (!all) break;
|
|
715
|
+
}
|
|
716
|
+
if (count == 0) throw new Fail("NOT_FOUND", "The text \"" + Clip(find, 60) + "\" does not occur in the document.");
|
|
717
|
+
return "replaced " + count;
|
|
718
|
+
}
|
|
719
|
+
if (type == "insert_paragraphs")
|
|
720
|
+
{
|
|
721
|
+
IList items = op.List("items");
|
|
722
|
+
if (items == null) { items = new ArrayList(); items.Add(new Dictionary<string, object> { { "text", op.Need("text") } }); }
|
|
723
|
+
dynamic current = null;
|
|
724
|
+
int made = 0;
|
|
725
|
+
foreach (object raw in items)
|
|
726
|
+
{
|
|
727
|
+
Bag item = raw is string ? new Bag(new Dictionary<string, object> { { "text", raw } }) : new Bag(raw);
|
|
728
|
+
foreach (string text in Lines(item.Raw("text") ?? "").Split('\r'))
|
|
729
|
+
{
|
|
730
|
+
dynamic p;
|
|
731
|
+
if (current == null) p = NewParagraph(doc, op);
|
|
732
|
+
else p = After(doc, current);
|
|
733
|
+
Plain(p, item.Str("style", op.Str("style", "Normal")));
|
|
734
|
+
if (text.Length > 0) p.Range.InsertBefore(text);
|
|
735
|
+
WordFormat(p.Range, StyleLess(op));
|
|
736
|
+
WordFormat(p.Range, StyleLess(item));
|
|
737
|
+
current = p;
|
|
738
|
+
made++;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
int lastIndex = Index(doc, current);
|
|
742
|
+
return "inserted " + made + " paragraph(s), now paragraphs " + (lastIndex - made + 1) + "–" + lastIndex + " of " + (int)doc.Paragraphs.Count;
|
|
743
|
+
}
|
|
744
|
+
if ((type == "set_text" || (type == "delete_range" && op.Has("para"))) && op.Raw("expect") == null)
|
|
745
|
+
{
|
|
746
|
+
throw new Fail("BAD_ARGS", type + " needs \"expect\": the first words of paragraph " + op.Int("para", 0) + " as you last read it (\"\" for an empty paragraph), so that the wrong paragraph is never changed.");
|
|
747
|
+
}
|
|
748
|
+
if (type == "set_text")
|
|
749
|
+
{
|
|
750
|
+
dynamic p = Para(doc, op.Int("para", 0), op.Str("expect", null));
|
|
751
|
+
dynamic range = p.Range;
|
|
752
|
+
range.MoveEnd(1, -1);
|
|
753
|
+
range.Text = Lines(op.Raw("text") ?? "");
|
|
754
|
+
return "paragraph " + op.Int("para", 0) + " rewritten";
|
|
755
|
+
}
|
|
756
|
+
if (type == "format_text" || type == "set_style")
|
|
757
|
+
{
|
|
758
|
+
if (type == "set_style") op.Need("style");
|
|
759
|
+
WordFormat(WordRange(doc, op), op);
|
|
760
|
+
return "formatted";
|
|
761
|
+
}
|
|
762
|
+
if (type == "delete_range")
|
|
763
|
+
{
|
|
764
|
+
if (!op.Has("para") && !op.Has("find")) throw new Fail("BAD_ARGS", "Say what to delete with \"para\" (and \"to\") or \"find\".");
|
|
765
|
+
WordRange(doc, op).Delete();
|
|
766
|
+
return "deleted, " + (int)doc.Paragraphs.Count + " paragraphs now";
|
|
767
|
+
}
|
|
768
|
+
if (type == "insert_table")
|
|
769
|
+
{
|
|
770
|
+
IList data = op.List("data");
|
|
771
|
+
int rows = op.Int("rows", data == null ? 2 : data.Count), cols = op.Int("cols", 0);
|
|
772
|
+
if (data != null) foreach (object row in data) { IList cells = row as IList; if (cells != null) cols = Math.Max(cols, cells.Count); }
|
|
773
|
+
if (rows < 1 || cols < 1) throw new Fail("BAD_ARGS", "Give \"data\" (rows of cells) or \"rows\" and \"cols\".");
|
|
774
|
+
dynamic p = NewParagraph(doc, op);
|
|
775
|
+
Plain(p, "Normal");
|
|
776
|
+
dynamic table = doc.Tables.Add(p.Range, rows, cols);
|
|
777
|
+
// The cells take the formatting of the paragraph that follows (a heading, say): make them plain.
|
|
778
|
+
SetStyle(table.Range, "Normal");
|
|
779
|
+
try
|
|
780
|
+
{
|
|
781
|
+
table.Range.Font.Reset(); table.Range.ParagraphFormat.Reset();
|
|
782
|
+
table.Range.ParagraphFormat.CharacterUnitFirstLineIndent = 0; table.Range.ParagraphFormat.FirstLineIndent = 0;
|
|
783
|
+
}
|
|
784
|
+
catch (COMException) { }
|
|
785
|
+
table.Borders.Enable = 1;
|
|
786
|
+
if (data != null)
|
|
787
|
+
{
|
|
788
|
+
for (int r = 0; r < data.Count && r < rows; r++)
|
|
789
|
+
{
|
|
790
|
+
IList cells = data[r] as IList;
|
|
791
|
+
if (cells == null) continue;
|
|
792
|
+
for (int c = 0; c < cells.Count; c++) if (cells[c] != null) table.Cell(r + 1, c + 1).Range.Text = Convert.ToString(cells[c]);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
if (op.Flag("header", true)) table.Rows[1].Range.Font.Bold = 1;
|
|
796
|
+
int tableEnd = (int)doc.Range(0, table.Range.End).Paragraphs.Count;
|
|
797
|
+
return "table " + (int)doc.Range(0, table.Range.End).Tables.Count + " inserted (" + rows + "×" + cols + "), now paragraphs " + (tableEnd - (int)table.Range.Paragraphs.Count + 1) + "–" + tableEnd + " of " + (int)doc.Paragraphs.Count;
|
|
798
|
+
}
|
|
799
|
+
if (type == "set_cell")
|
|
800
|
+
{
|
|
801
|
+
int n = op.Int("table", 1);
|
|
802
|
+
if (n < 1 || n > (int)doc.Tables.Count) throw new Fail("ANCHOR_MISSING", "There is no table " + n + ".");
|
|
803
|
+
doc.Tables[n].Cell(op.Int("row", 1), op.Int("col", 1)).Range.Text = Lines(op.Raw("text") ?? "");
|
|
804
|
+
return "cell set";
|
|
805
|
+
}
|
|
806
|
+
if (type == "insert_image")
|
|
807
|
+
{
|
|
808
|
+
string path = op.Need("path");
|
|
809
|
+
if (!File.Exists(path)) throw new Fail("BAD_ARGS", "Image \"" + path + "\" does not exist.");
|
|
810
|
+
dynamic p = NewParagraph(doc, op);
|
|
811
|
+
Plain(p, "Normal");
|
|
812
|
+
dynamic picture = p.Range.InlineShapes.AddPicture(FileName: path, LinkToFile: false, SaveWithDocument: true);
|
|
813
|
+
if (op.Has("width")) { picture.LockAspectRatio = -1; picture.Width = (float)op.Num("width", 300); }
|
|
814
|
+
WordFormat(p.Range, StyleLess(op));
|
|
815
|
+
return "image inserted";
|
|
816
|
+
}
|
|
817
|
+
throw new Fail("BAD_ARGS", "Unknown Word operation \"" + type + "\".");
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
static Bag StyleLess(Bag source)
|
|
821
|
+
{
|
|
822
|
+
Dictionary<string, object> copy = new Dictionary<string, object>();
|
|
823
|
+
foreach (string key in new string[] { "font", "size", "bold", "italic", "underline", "color", "align", "firstLineIndent", "spaceBefore", "spaceAfter", "lineSpacing" })
|
|
824
|
+
if (source.Has(key)) copy[key] = source.Raw(key);
|
|
825
|
+
return new Bag(copy);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// ───────────────────────── Excel ─────────────────────────
|
|
829
|
+
|
|
830
|
+
static dynamic Sheet(dynamic book, Bag op)
|
|
831
|
+
{
|
|
832
|
+
string name = op.Str("sheet", null);
|
|
833
|
+
try
|
|
834
|
+
{
|
|
835
|
+
if (name == null) return book.ActiveSheet;
|
|
836
|
+
int index;
|
|
837
|
+
return int.TryParse(name, out index) ? book.Worksheets[index] : book.Worksheets[name];
|
|
838
|
+
}
|
|
839
|
+
catch (COMException)
|
|
840
|
+
{
|
|
841
|
+
List<string> names = new List<string>();
|
|
842
|
+
foreach (dynamic sheet in book.Worksheets) names.Add((string)sheet.Name);
|
|
843
|
+
throw new Fail("ANCHOR_MISSING", "There is no sheet \"" + name + "\". Sheets: " + string.Join(", ", names.ToArray()) + ".");
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
static dynamic Cells(dynamic sheet, string address)
|
|
848
|
+
{
|
|
849
|
+
try { return sheet.Range[address]; }
|
|
850
|
+
catch (COMException) { throw new Fail("BAD_ARGS", "\"" + address + "\" is not a valid range (use A1 style, e.g. B2:D10)."); }
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
static string Column(int column)
|
|
854
|
+
{
|
|
855
|
+
string name = "";
|
|
856
|
+
while (column > 0) { int rem = (column - 1) % 26; name = (char)('A' + rem) + name; column = (column - 1) / 26; }
|
|
857
|
+
return name;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
static object CellValue(object value)
|
|
861
|
+
{
|
|
862
|
+
if (value is int)
|
|
863
|
+
{
|
|
864
|
+
switch ((int)value)
|
|
865
|
+
{
|
|
866
|
+
case -2146826281: return "#DIV/0!";
|
|
867
|
+
case -2146826246: return "#N/A";
|
|
868
|
+
case -2146826259: return "#NAME?";
|
|
869
|
+
case -2146826288: return "#NULL!";
|
|
870
|
+
case -2146826252: return "#NUM!";
|
|
871
|
+
case -2146826265: return "#REF!";
|
|
872
|
+
case -2146826273: return "#VALUE!";
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
return value;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
static object ExcelRead(dynamic book, Bag a)
|
|
879
|
+
{
|
|
880
|
+
Dictionary<string, object> result = new Dictionary<string, object>();
|
|
881
|
+
List<object> sheets = new List<object>();
|
|
882
|
+
foreach (dynamic ws in book.Worksheets)
|
|
883
|
+
{
|
|
884
|
+
Dictionary<string, object> entry = new Dictionary<string, object>();
|
|
885
|
+
entry["name"] = (string)ws.Name;
|
|
886
|
+
dynamic used = ws.UsedRange;
|
|
887
|
+
entry["used"] = (string)used.Address[false, false];
|
|
888
|
+
int charts = (int)ws.ChartObjects().Count;
|
|
889
|
+
if (charts > 0) entry["charts"] = charts;
|
|
890
|
+
sheets.Add(entry);
|
|
891
|
+
}
|
|
892
|
+
result["sheets"] = sheets;
|
|
893
|
+
dynamic sheet = Sheet(book, a);
|
|
894
|
+
result["sheet"] = (string)sheet.Name;
|
|
895
|
+
dynamic range = a.Has("range") ? Cells(sheet, a.Need("range")) : sheet.UsedRange;
|
|
896
|
+
int rows = (int)range.Rows.Count, cols = (int)range.Columns.Count;
|
|
897
|
+
int maxRows = a.Has("range") ? 2000 : 60, maxCols = a.Has("range") ? 100 : 26;
|
|
898
|
+
if (rows > maxRows || cols > maxCols)
|
|
899
|
+
{
|
|
900
|
+
result["clipped"] = "Showing the first " + Math.Min(rows, maxRows) + " rows × " + Math.Min(cols, maxCols) + " columns of " + rows + " × " + cols + "; pass \"range\" for more.";
|
|
901
|
+
rows = Math.Min(rows, maxRows); cols = Math.Min(cols, maxCols);
|
|
902
|
+
range = range.Resize[rows, cols];
|
|
903
|
+
}
|
|
904
|
+
result["range"] = (string)range.Address[false, false];
|
|
905
|
+
object values = range.Value2, formulas = range.Formula;
|
|
906
|
+
int row0 = (int)range.Row, col0 = (int)range.Column;
|
|
907
|
+
List<object> grid = new List<object>();
|
|
908
|
+
List<object> formulaList = new List<object>();
|
|
909
|
+
object[,] v = values as object[,], f = formulas as object[,];
|
|
910
|
+
for (int r = 1; r <= rows; r++)
|
|
911
|
+
{
|
|
912
|
+
List<object> line = new List<object>();
|
|
913
|
+
for (int c = 1; c <= cols; c++)
|
|
914
|
+
{
|
|
915
|
+
line.Add(CellValue(v == null ? values : v[r, c]));
|
|
916
|
+
string formula = Convert.ToString(f == null ? formulas : f[r, c]);
|
|
917
|
+
if (formula.StartsWith("=", StringComparison.Ordinal))
|
|
918
|
+
{
|
|
919
|
+
formulaList.Add(new Dictionary<string, object> { { "cell", Column(col0 + c - 1) + (row0 + r - 1) }, { "formula", formula } });
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
grid.Add(line);
|
|
923
|
+
}
|
|
924
|
+
result["values"] = grid;
|
|
925
|
+
if (formulaList.Count > 0) result["formulas"] = formulaList;
|
|
926
|
+
return result;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
static object Scalar(object value)
|
|
930
|
+
{
|
|
931
|
+
if (value is decimal) return Convert.ToDouble(value);
|
|
932
|
+
return value;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
static void ExcelFormat(dynamic range, Bag op)
|
|
936
|
+
{
|
|
937
|
+
if (op.Has("font")) range.Font.Name = op.Need("font");
|
|
938
|
+
if (op.Has("size")) range.Font.Size = op.Num("size", 11);
|
|
939
|
+
if (op.Has("bold")) range.Font.Bold = op.Flag("bold", false);
|
|
940
|
+
if (op.Has("italic")) range.Font.Italic = op.Flag("italic", false);
|
|
941
|
+
if (op.Has("color")) range.Font.Color = Bgr(op.Need("color"));
|
|
942
|
+
if (op.Has("fill")) range.Interior.Color = Bgr(op.Need("fill"));
|
|
943
|
+
if (op.Has("numberFormat")) range.NumberFormat = op.Need("numberFormat");
|
|
944
|
+
if (op.Has("align"))
|
|
945
|
+
{
|
|
946
|
+
string align = op.Need("align");
|
|
947
|
+
range.HorizontalAlignment = align == "center" ? -4108 : align == "right" ? -4152 : -4131;
|
|
948
|
+
}
|
|
949
|
+
if (op.Has("wrap")) range.WrapText = op.Flag("wrap", false);
|
|
950
|
+
if (op.Flag("border", false)) range.Borders.LineStyle = 1;
|
|
951
|
+
if (op.Flag("merge", false)) range.Merge();
|
|
952
|
+
if (op.Has("columnWidth")) range.ColumnWidth = op.Num("columnWidth", 10);
|
|
953
|
+
if (op.Has("rowHeight")) range.RowHeight = op.Num("rowHeight", 15);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
static string ExcelOp(dynamic app, dynamic book, string type, Bag op)
|
|
957
|
+
{
|
|
958
|
+
if (type == "add_sheet")
|
|
959
|
+
{
|
|
960
|
+
dynamic sheets = book.Worksheets;
|
|
961
|
+
dynamic sheet = sheets.Add(After: sheets[(int)sheets.Count]);
|
|
962
|
+
if (op.Has("name")) sheet.Name = op.Need("name");
|
|
963
|
+
return "sheet \"" + (string)sheet.Name + "\" added";
|
|
964
|
+
}
|
|
965
|
+
dynamic ws = Sheet(book, op);
|
|
966
|
+
if (type == "write_range")
|
|
967
|
+
{
|
|
968
|
+
IList rows = op.List("values");
|
|
969
|
+
if (rows == null || rows.Count == 0) throw new Fail("BAD_ARGS", "\"values\" must be rows of cells, e.g. [[\"Name\",\"Qty\"],[\"A\",3]].");
|
|
970
|
+
int width = 1;
|
|
971
|
+
foreach (object row in rows) { IList cells = row as IList; if (cells != null && !(row is string)) width = Math.Max(width, cells.Count); }
|
|
972
|
+
object[,] grid = new object[rows.Count, width];
|
|
973
|
+
for (int r = 0; r < rows.Count; r++)
|
|
974
|
+
{
|
|
975
|
+
IList cells = rows[r] as IList;
|
|
976
|
+
if (cells == null || rows[r] is string) { grid[r, 0] = Scalar(rows[r]); continue; }
|
|
977
|
+
for (int c = 0; c < cells.Count; c++) grid[r, c] = Scalar(cells[c]);
|
|
978
|
+
}
|
|
979
|
+
dynamic target = Cells(ws, op.Str("range", "A1")).Cells[1, 1].Resize[rows.Count, width];
|
|
980
|
+
target.Formula = grid;
|
|
981
|
+
ExcelFormat(target, op);
|
|
982
|
+
return "wrote " + (string)target.Address[false, false];
|
|
983
|
+
}
|
|
984
|
+
if (type == "format_range") { ExcelFormat(Cells(ws, op.Need("range")), op); return "formatted"; }
|
|
985
|
+
if (type == "autofit")
|
|
986
|
+
{
|
|
987
|
+
dynamic range = op.Has("range") ? Cells(ws, op.Need("range")) : ws.UsedRange;
|
|
988
|
+
range.EntireColumn.AutoFit();
|
|
989
|
+
return "columns fitted";
|
|
990
|
+
}
|
|
991
|
+
if (type == "rename_sheet") { ws.Name = op.Need("name"); return "renamed"; }
|
|
992
|
+
if (type == "delete_sheet")
|
|
993
|
+
{
|
|
994
|
+
bool alerts = (bool)app.DisplayAlerts;
|
|
995
|
+
app.DisplayAlerts = false;
|
|
996
|
+
try { ws.Delete(); } finally { app.DisplayAlerts = alerts; }
|
|
997
|
+
return "sheet deleted";
|
|
998
|
+
}
|
|
999
|
+
if (type == "insert_rows" || type == "delete_rows")
|
|
1000
|
+
{
|
|
1001
|
+
int row = op.Int("row", 0), count = Math.Max(1, op.Int("count", 1));
|
|
1002
|
+
if (row < 1) throw new Fail("BAD_ARGS", "\"row\" is required (1-based).");
|
|
1003
|
+
dynamic rows = ws.Range[row + ":" + (row + count - 1)];
|
|
1004
|
+
if (type == "insert_rows") rows.Insert(); else rows.Delete();
|
|
1005
|
+
return (type == "insert_rows" ? "inserted " : "deleted ") + count + " row(s)";
|
|
1006
|
+
}
|
|
1007
|
+
if (type == "add_chart")
|
|
1008
|
+
{
|
|
1009
|
+
dynamic source = Cells(ws, op.Need("range"));
|
|
1010
|
+
dynamic anchor = Cells(ws, op.Str("at", "H2"));
|
|
1011
|
+
dynamic holder = ws.ChartObjects().Add((double)anchor.Left, (double)anchor.Top, op.Num("width", 420), op.Num("height", 260));
|
|
1012
|
+
dynamic chart = holder.Chart;
|
|
1013
|
+
chart.SetSourceData(source);
|
|
1014
|
+
string kind = op.Str("chart", "column");
|
|
1015
|
+
chart.ChartType = kind == "bar" ? 57 : kind == "line" ? 65 : kind == "pie" ? 5 : kind == "scatter" ? -4169 : kind == "area" ? 1 : 51;
|
|
1016
|
+
if (op.Has("title")) { chart.HasTitle = true; chart.ChartTitle.Text = op.Need("title"); }
|
|
1017
|
+
if (op.Has("name")) holder.Name = op.Need("name");
|
|
1018
|
+
return "chart \"" + (string)holder.Name + "\" added";
|
|
1019
|
+
}
|
|
1020
|
+
throw new Fail("BAD_ARGS", "Unknown Excel operation \"" + type + "\".");
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// ───────────────────────── PowerPoint ─────────────────────────
|
|
1024
|
+
|
|
1025
|
+
static dynamic Slide(dynamic deck, Bag op)
|
|
1026
|
+
{
|
|
1027
|
+
int index = op.Int("slide", 0), total = (int)deck.Slides.Count;
|
|
1028
|
+
if (index < 1 || index > total) throw new Fail("ANCHOR_MISSING", "There is no slide " + index + " (the deck has " + total + ").");
|
|
1029
|
+
return deck.Slides[index];
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
static dynamic Shape(dynamic slide, string key)
|
|
1033
|
+
{
|
|
1034
|
+
try
|
|
1035
|
+
{
|
|
1036
|
+
if (key == "title") return slide.Shapes.Title;
|
|
1037
|
+
if (key == "body") return slide.Shapes.Placeholders[2];
|
|
1038
|
+
int index;
|
|
1039
|
+
return int.TryParse(key, out index) ? slide.Shapes[index] : slide.Shapes[key];
|
|
1040
|
+
}
|
|
1041
|
+
catch (COMException)
|
|
1042
|
+
{
|
|
1043
|
+
List<string> names = new List<string>();
|
|
1044
|
+
foreach (dynamic shape in slide.Shapes) names.Add((string)shape.Name);
|
|
1045
|
+
throw new Fail("ANCHOR_MISSING", "Slide " + (int)slide.SlideIndex + " has no shape \"" + key + "\". Shapes: " + (names.Count == 0 ? "(none)" : string.Join(", ", names.ToArray())) + ".");
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
static string ShapeType(int type)
|
|
1050
|
+
{
|
|
1051
|
+
switch (type)
|
|
1052
|
+
{
|
|
1053
|
+
case 1: return "shape";
|
|
1054
|
+
case 3: return "chart";
|
|
1055
|
+
case 6: return "group";
|
|
1056
|
+
case 9: return "line";
|
|
1057
|
+
case 13: return "picture";
|
|
1058
|
+
case 14: return "placeholder";
|
|
1059
|
+
case 17: return "textbox";
|
|
1060
|
+
case 19: return "table";
|
|
1061
|
+
default: return "type" + type;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
static object PptRead(dynamic deck, Bag a)
|
|
1066
|
+
{
|
|
1067
|
+
Dictionary<string, object> result = new Dictionary<string, object>();
|
|
1068
|
+
result["slides"] = (int)deck.Slides.Count;
|
|
1069
|
+
result["width"] = Math.Round((double)deck.PageSetup.SlideWidth, 1);
|
|
1070
|
+
result["height"] = Math.Round((double)deck.PageSetup.SlideHeight, 1);
|
|
1071
|
+
bool full = a.Flag("full", false);
|
|
1072
|
+
int only = a.Int("slide", 0);
|
|
1073
|
+
List<object> slides = new List<object>();
|
|
1074
|
+
foreach (dynamic slide in deck.Slides)
|
|
1075
|
+
{
|
|
1076
|
+
int index = (int)slide.SlideIndex;
|
|
1077
|
+
if (only > 0 && index != only) continue;
|
|
1078
|
+
Dictionary<string, object> entry = new Dictionary<string, object>();
|
|
1079
|
+
entry["slide"] = index;
|
|
1080
|
+
try { entry["layout"] = (string)slide.CustomLayout.Name; } catch (Exception) { }
|
|
1081
|
+
List<object> shapes = new List<object>();
|
|
1082
|
+
foreach (dynamic shape in slide.Shapes)
|
|
1083
|
+
{
|
|
1084
|
+
Dictionary<string, object> item = new Dictionary<string, object>();
|
|
1085
|
+
item["name"] = (string)shape.Name;
|
|
1086
|
+
item["type"] = ShapeType((int)shape.Type);
|
|
1087
|
+
item["box"] = new double[] { Math.Round((double)shape.Left), Math.Round((double)shape.Top), Math.Round((double)shape.Width), Math.Round((double)shape.Height) };
|
|
1088
|
+
try
|
|
1089
|
+
{
|
|
1090
|
+
if (Truthy(shape.HasTextFrame) && Truthy(shape.TextFrame.HasText))
|
|
1091
|
+
{
|
|
1092
|
+
string text = ((string)shape.TextFrame.TextRange.Text).Replace('\r', '\n').Replace('\v', '\n');
|
|
1093
|
+
item["text"] = full ? text : Clip(text, 200);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
catch (Exception) { }
|
|
1097
|
+
shapes.Add(item);
|
|
1098
|
+
}
|
|
1099
|
+
entry["shapes"] = shapes;
|
|
1100
|
+
slides.Add(entry);
|
|
1101
|
+
}
|
|
1102
|
+
result["items"] = slides;
|
|
1103
|
+
return result;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
static void PptText(dynamic shape, Bag op)
|
|
1107
|
+
{
|
|
1108
|
+
if (op.Has("text")) shape.TextFrame.TextRange.Text = Lines(op.Raw("text"));
|
|
1109
|
+
if (!(op.Has("font") || op.Has("size") || op.Has("bold") || op.Has("italic") || op.Has("color") || op.Has("align"))) return;
|
|
1110
|
+
dynamic range = shape.TextFrame.TextRange;
|
|
1111
|
+
if (op.Has("font")) { string font = op.Need("font"); range.Font.Name = font; try { range.Font.NameFarEast = font; } catch (COMException) { } }
|
|
1112
|
+
if (op.Has("size")) range.Font.Size = (float)op.Num("size", 18);
|
|
1113
|
+
if (op.Has("bold")) range.Font.Bold = op.Flag("bold", false) ? -1 : 0;
|
|
1114
|
+
if (op.Has("italic")) range.Font.Italic = op.Flag("italic", false) ? -1 : 0;
|
|
1115
|
+
if (op.Has("color")) range.Font.Color.RGB = Bgr(op.Need("color"));
|
|
1116
|
+
if (op.Has("align"))
|
|
1117
|
+
{
|
|
1118
|
+
string align = op.Need("align");
|
|
1119
|
+
range.ParagraphFormat.Alignment = align == "center" ? 2 : align == "right" ? 3 : 1;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
static void PptBox(dynamic shape, Bag op)
|
|
1124
|
+
{
|
|
1125
|
+
if (op.Has("left")) shape.Left = (float)op.Num("left", 0);
|
|
1126
|
+
if (op.Has("top")) shape.Top = (float)op.Num("top", 0);
|
|
1127
|
+
if (op.Has("width")) shape.Width = (float)op.Num("width", 100);
|
|
1128
|
+
if (op.Has("height")) shape.Height = (float)op.Num("height", 100);
|
|
1129
|
+
if (op.Has("fill")) { shape.Fill.Visible = -1; shape.Fill.Solid(); shape.Fill.ForeColor.RGB = Bgr(op.Need("fill")); }
|
|
1130
|
+
if (op.Has("name")) shape.Name = op.Need("name");
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
static readonly Dictionary<string, int> Layouts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
|
1134
|
+
{
|
|
1135
|
+
{ "title", 1 }, { "title_content", 2 }, { "two_content", 29 }, { "title_only", 11 }, { "blank", 12 }, { "section", 33 }, { "comparison", 34 },
|
|
1136
|
+
};
|
|
1137
|
+
|
|
1138
|
+
static string PptOp(dynamic deck, string type, Bag op)
|
|
1139
|
+
{
|
|
1140
|
+
if (type == "add_slide")
|
|
1141
|
+
{
|
|
1142
|
+
int total = (int)deck.Slides.Count, index = op.Int("at", total + 1), layout;
|
|
1143
|
+
if (index < 1 || index > total + 1) throw new Fail("BAD_ARGS", "\"at\" must be between 1 and " + (total + 1) + ".");
|
|
1144
|
+
string name = op.Str("layout", "title_content");
|
|
1145
|
+
if (!Layouts.TryGetValue(name, out layout)) throw new Fail("BAD_ARGS", "Unknown layout \"" + name + "\": use " + string.Join(", ", new List<string>(Layouts.Keys).ToArray()) + ".");
|
|
1146
|
+
dynamic slide = deck.Slides.Add(index, layout);
|
|
1147
|
+
if (op.Has("title")) slide.Shapes.Title.TextFrame.TextRange.Text = Lines(op.Raw("title"));
|
|
1148
|
+
if (op.Has("body")) slide.Shapes.Placeholders[2].TextFrame.TextRange.Text = Lines(op.Raw("body"));
|
|
1149
|
+
if (op.Has("notes")) slide.NotesPage.Shapes.Placeholders[2].TextFrame.TextRange.Text = Lines(op.Raw("notes"));
|
|
1150
|
+
return "slide " + index + " added";
|
|
1151
|
+
}
|
|
1152
|
+
dynamic target = Slide(deck, op);
|
|
1153
|
+
if (type == "delete_slide") { target.Delete(); return "slide deleted"; }
|
|
1154
|
+
if (type == "move_slide") { target.MoveTo(op.Int("to", 1)); return "slide moved to " + op.Int("to", 1); }
|
|
1155
|
+
if (type == "set_notes") { target.NotesPage.Shapes.Placeholders[2].TextFrame.TextRange.Text = Lines(op.Raw("text") ?? ""); return "notes set"; }
|
|
1156
|
+
if (type == "add_textbox")
|
|
1157
|
+
{
|
|
1158
|
+
dynamic box = target.Shapes.AddTextbox(1, (float)op.Num("left", 60), (float)op.Num("top", 60), (float)op.Num("width", 400), (float)op.Num("height", 60));
|
|
1159
|
+
PptText(box, op);
|
|
1160
|
+
if (op.Has("fill") || op.Has("name")) PptBox(box, new Bag(new Dictionary<string, object> { { "fill", op.Raw("fill") }, { "name", op.Raw("name") } }));
|
|
1161
|
+
return "textbox \"" + (string)box.Name + "\" added";
|
|
1162
|
+
}
|
|
1163
|
+
if (type == "add_image")
|
|
1164
|
+
{
|
|
1165
|
+
string path = op.Need("path");
|
|
1166
|
+
if (!File.Exists(path)) throw new Fail("BAD_ARGS", "Image \"" + path + "\" does not exist.");
|
|
1167
|
+
dynamic picture = target.Shapes.AddPicture(path, 0, -1, (float)op.Num("left", 60), (float)op.Num("top", 60), -1, -1);
|
|
1168
|
+
if (op.Has("width") || op.Has("height"))
|
|
1169
|
+
{
|
|
1170
|
+
picture.LockAspectRatio = op.Has("width") && op.Has("height") ? 0 : -1;
|
|
1171
|
+
if (op.Has("width")) picture.Width = (float)op.Num("width", 100);
|
|
1172
|
+
if (op.Has("height")) picture.Height = (float)op.Num("height", 100);
|
|
1173
|
+
}
|
|
1174
|
+
if (op.Has("name")) picture.Name = op.Need("name");
|
|
1175
|
+
return "picture \"" + (string)picture.Name + "\" added";
|
|
1176
|
+
}
|
|
1177
|
+
dynamic shape = Shape(target, op.Need("shape"));
|
|
1178
|
+
if (type == "set_text") { op.Need("text"); PptText(shape, op); return "text set"; }
|
|
1179
|
+
if (type == "set_shape") { PptBox(shape, op); PptText(shape, op); return "shape updated"; }
|
|
1180
|
+
if (type == "delete_shape") { shape.Delete(); return "shape deleted"; }
|
|
1181
|
+
throw new Fail("BAD_ARGS", "Unknown PowerPoint operation \"" + type + "\".");
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// ───────────────────────── edit batches ─────────────────────────
|
|
1185
|
+
|
|
1186
|
+
static object Edit(string kind, dynamic app, dynamic doc, Bag a)
|
|
1187
|
+
{
|
|
1188
|
+
IList ops = a.List("ops");
|
|
1189
|
+
if (ops == null || ops.Count == 0) throw new Fail("BAD_ARGS", "\"ops\" must list at least one operation.");
|
|
1190
|
+
List<object> done = new List<object>();
|
|
1191
|
+
Dictionary<string, object> result = new Dictionary<string, object>();
|
|
1192
|
+
dynamic undo = null;
|
|
1193
|
+
// One undo step for the whole batch: the user takes it back with a single Ctrl+Z.
|
|
1194
|
+
if (kind == "word") { try { undo = app.UndoRecord; undo.StartCustomRecord("AI edit"); } catch (COMException) { undo = null; } }
|
|
1195
|
+
try
|
|
1196
|
+
{
|
|
1197
|
+
for (int i = 0; i < ops.Count; i++)
|
|
1198
|
+
{
|
|
1199
|
+
string type = "?";
|
|
1200
|
+
try
|
|
1201
|
+
{
|
|
1202
|
+
Bag op = new Bag(ops[i]);
|
|
1203
|
+
type = op.Need("op");
|
|
1204
|
+
string line = kind == "word" ? WordOp(doc, type, op) : kind == "excel" ? ExcelOp(app, doc, type, op) : PptOp(doc, type, op);
|
|
1205
|
+
string ignored = op.Unread();
|
|
1206
|
+
done.Add(ignored.Length == 0 ? line : line + " — IGNORED field(s) " + ignored + ": " + type + " does not take them, check the operation list");
|
|
1207
|
+
}
|
|
1208
|
+
catch (Exception error)
|
|
1209
|
+
{
|
|
1210
|
+
string code;
|
|
1211
|
+
Dictionary<string, object> failed = new Dictionary<string, object>();
|
|
1212
|
+
failed["index"] = i;
|
|
1213
|
+
failed["op"] = type;
|
|
1214
|
+
failed["error"] = Describe(error, out code);
|
|
1215
|
+
failed["code"] = code;
|
|
1216
|
+
result["failed"] = failed;
|
|
1217
|
+
break;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
finally
|
|
1222
|
+
{
|
|
1223
|
+
if (undo != null) { try { undo.EndCustomRecord(); } catch (COMException) { } }
|
|
1224
|
+
}
|
|
1225
|
+
result["done"] = done;
|
|
1226
|
+
result["total"] = ops.Count;
|
|
1227
|
+
return result;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// ───────────────────────── rendering ─────────────────────────
|
|
1231
|
+
|
|
1232
|
+
static object Render(string kind, dynamic app, dynamic doc, Bag a)
|
|
1233
|
+
{
|
|
1234
|
+
string path = a.Need("out");
|
|
1235
|
+
int width = Math.Max(320, Math.Min(2400, a.Int("width", 1100)));
|
|
1236
|
+
Dictionary<string, object> result = new Dictionary<string, object>();
|
|
1237
|
+
if (kind == "ppt")
|
|
1238
|
+
{
|
|
1239
|
+
dynamic slide = Slide(doc, a);
|
|
1240
|
+
double ratio = (double)doc.PageSetup.SlideHeight / (double)doc.PageSetup.SlideWidth;
|
|
1241
|
+
slide.Export(path, "PNG", width, (int)Math.Round(width * ratio));
|
|
1242
|
+
result["what"] = "slide " + a.Int("slide", 0);
|
|
1243
|
+
}
|
|
1244
|
+
else if (kind == "word")
|
|
1245
|
+
{
|
|
1246
|
+
int page = Math.Max(1, a.Int("page", 1));
|
|
1247
|
+
dynamic pages = doc.Windows[1].Panes[1].Pages;
|
|
1248
|
+
int total = (int)pages.Count;
|
|
1249
|
+
if (page > total) throw new Fail("ANCHOR_MISSING", "There is no page " + page + " (the document has " + total + ").");
|
|
1250
|
+
byte[] bits = (byte[])pages[page].EnhMetaFileBits;
|
|
1251
|
+
using (MemoryStream stream = new MemoryStream(bits))
|
|
1252
|
+
using (Metafile meta = new Metafile(stream))
|
|
1253
|
+
{
|
|
1254
|
+
int height = (int)Math.Round(width * (double)meta.Height / meta.Width);
|
|
1255
|
+
using (Bitmap bitmap = new Bitmap(width, height))
|
|
1256
|
+
{
|
|
1257
|
+
using (Graphics g = Graphics.FromImage(bitmap))
|
|
1258
|
+
{
|
|
1259
|
+
g.Clear(Color.White);
|
|
1260
|
+
g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
|
|
1261
|
+
g.DrawImage(meta, 0, 0, width, height);
|
|
1262
|
+
}
|
|
1263
|
+
bitmap.Save(path, ImageFormat.Png);
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
result["what"] = "page " + page + " of " + total;
|
|
1267
|
+
}
|
|
1268
|
+
else
|
|
1269
|
+
{
|
|
1270
|
+
dynamic sheet = Sheet(doc, a);
|
|
1271
|
+
dynamic range = a.Has("range") ? Cells(sheet, a.Need("range")) : sheet.UsedRange;
|
|
1272
|
+
// Excel can only hand a picture of cells over through the clipboard: put the user's text back afterwards.
|
|
1273
|
+
string text = null;
|
|
1274
|
+
try { if (Clipboard.ContainsText()) text = Clipboard.GetText(); } catch (Exception) { }
|
|
1275
|
+
Image image = null;
|
|
1276
|
+
try
|
|
1277
|
+
{
|
|
1278
|
+
range.CopyPicture(1, 2);
|
|
1279
|
+
for (int i = 0; i < 10 && image == null; i++) { image = Clipboard.GetImage(); if (image == null) Thread.Sleep(100); }
|
|
1280
|
+
}
|
|
1281
|
+
finally
|
|
1282
|
+
{
|
|
1283
|
+
try { if (text != null) Clipboard.SetText(text); else Clipboard.Clear(); } catch (Exception) { }
|
|
1284
|
+
}
|
|
1285
|
+
if (image == null) throw new Fail("OFFICE_ERROR", "Excel did not produce a picture of that range.");
|
|
1286
|
+
using (image) image.Save(path, ImageFormat.Png);
|
|
1287
|
+
result["what"] = (string)sheet.Name + "!" + (string)range.Address[false, false];
|
|
1288
|
+
}
|
|
1289
|
+
result["path"] = path;
|
|
1290
|
+
using (Image saved = Image.FromFile(path)) { result["width"] = saved.Width; result["height"] = saved.Height; }
|
|
1291
|
+
return result;
|
|
1292
|
+
}
|
|
1293
|
+
}
|