@copylee/dsh-office 0.1.0 → 0.2.1

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.
@@ -100,8 +100,158 @@ class MessageFilter : IOleMessageFilter
100
100
  public int MessagePending(IntPtr hTaskCallee, int dwTickCount, int dwPendingType) { return 2; }
101
101
  }
102
102
 
103
+
104
+ /// A small always-on-top card shown while the agent edits: what it is doing, and two switches the user
105
+ /// can flip at any moment (follow the edits / write like typing). It never takes the focus.
106
+ class Card : Form
107
+ {
108
+ static Card instance;
109
+ static readonly ManualResetEvent Ready = new ManualResetEvent(false);
110
+ /// Set once the user has flipped a switch here: from then on the card, not the settings page, decides.
111
+ public static volatile bool FollowChosen, TypingChosen;
112
+
113
+ string line = "";
114
+ DateTime shown = DateTime.MinValue;
115
+ readonly float scale;
116
+ Rectangle followBox, typingBox;
117
+ Point grip;
118
+ bool pressed, dragged;
119
+ readonly System.Windows.Forms.Timer timer = new System.Windows.Forms.Timer();
120
+
121
+ [DllImport("user32.dll")] static extern bool ShowWindow(IntPtr handle, int command);
122
+
123
+ protected override bool ShowWithoutActivation { get { return true; } }
124
+
125
+ protected override CreateParams CreateParams
126
+ {
127
+ get
128
+ {
129
+ CreateParams p = base.CreateParams;
130
+ p.ExStyle |= 0x08000000 | 0x00000080 | 0x00000008; // no-activate, tool window, topmost
131
+ return p;
132
+ }
133
+ }
134
+
135
+ Card()
136
+ {
137
+ FormBorderStyle = FormBorderStyle.None;
138
+ ShowInTaskbar = false;
139
+ StartPosition = FormStartPosition.Manual;
140
+ TopMost = true;
141
+ DoubleBuffered = true;
142
+ BackColor = Color.FromArgb(34, 34, 38);
143
+ Opacity = 0.94;
144
+ using (Graphics g = CreateGraphics()) scale = g.DpiX / 96f;
145
+ Size = new Size(S(330), S(38));
146
+ Rectangle area = Screen.PrimaryScreen.WorkingArea;
147
+ Location = new Point(area.Right - Width - S(18), area.Bottom - Height - S(18));
148
+ using (System.Drawing.Drawing2D.GraphicsPath path = Rounded(new Rectangle(0, 0, Width, Height), S(10))) Region = new Region(path);
149
+ timer.Interval = 500;
150
+ timer.Tick += delegate { if (Visible && (DateTime.UtcNow - shown).TotalSeconds > 6) Hide(); };
151
+ timer.Start();
152
+ }
153
+
154
+ int S(int value) { return (int)Math.Round(value * scale); }
155
+
156
+ static System.Drawing.Drawing2D.GraphicsPath Rounded(Rectangle r, int radius)
157
+ {
158
+ System.Drawing.Drawing2D.GraphicsPath path = new System.Drawing.Drawing2D.GraphicsPath();
159
+ int d = radius * 2;
160
+ path.AddArc(r.X, r.Y, d, d, 180, 90);
161
+ path.AddArc(r.Right - d, r.Y, d, d, 270, 90);
162
+ path.AddArc(r.Right - d, r.Bottom - d, d, d, 0, 90);
163
+ path.AddArc(r.X, r.Bottom - d, d, d, 90, 90);
164
+ path.CloseFigure();
165
+ return path;
166
+ }
167
+
168
+ void Chip(Graphics g, Font font, string label, bool on, ref int right, out Rectangle box)
169
+ {
170
+ Size size = TextRenderer.MeasureText(label, font);
171
+ box = new Rectangle(right - size.Width - S(14), (Height - S(24)) / 2, size.Width + S(14), S(24));
172
+ right = box.Left - S(6);
173
+ Color accent = Color.FromArgb(217, 119, 87);
174
+ using (System.Drawing.Drawing2D.GraphicsPath path = Rounded(box, S(12)))
175
+ {
176
+ if (on) using (SolidBrush fill = new SolidBrush(accent)) g.FillPath(fill, path);
177
+ else using (Pen pen = new Pen(Color.FromArgb(110, 110, 118))) g.DrawPath(pen, path);
178
+ }
179
+ TextRenderer.DrawText(g, label, font, box, on ? Color.White : Color.FromArgb(170, 170, 178), TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
180
+ }
181
+
182
+ protected override void OnPaint(PaintEventArgs e)
183
+ {
184
+ Graphics g = e.Graphics;
185
+ g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
186
+ using (Font font = new Font("Microsoft YaHei UI", 9f))
187
+ {
188
+ int right = Width - S(10);
189
+ Chip(g, font, "逐字", Program.Typing, ref right, out typingBox);
190
+ Chip(g, font, "跟随", Program.Following, ref right, out followBox);
191
+ using (SolidBrush dot = new SolidBrush(Color.FromArgb(217, 119, 87))) g.FillEllipse(dot, S(12), (Height - S(8)) / 2, S(8), S(8));
192
+ Rectangle text = new Rectangle(S(26), 0, right - S(30), Height);
193
+ TextRenderer.DrawText(g, line, font, text, Color.FromArgb(235, 235, 240), TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix);
194
+ }
195
+ }
196
+
197
+ protected override void OnMouseDown(MouseEventArgs e) { pressed = true; dragged = false; grip = e.Location; }
198
+
199
+ protected override void OnMouseMove(MouseEventArgs e)
200
+ {
201
+ if (!pressed) return;
202
+ if (!dragged && Math.Abs(e.X - grip.X) + Math.Abs(e.Y - grip.Y) < S(4)) return;
203
+ dragged = true;
204
+ Location = new Point(Location.X + e.X - grip.X, Location.Y + e.Y - grip.Y);
205
+ }
206
+
207
+ protected override void OnMouseUp(MouseEventArgs e)
208
+ {
209
+ pressed = false;
210
+ if (dragged) return;
211
+ if (followBox.Contains(e.Location)) { Program.Following = !Program.Following; FollowChosen = true; }
212
+ else if (typingBox.Contains(e.Location)) { Program.Typing = !Program.Typing; TypingChosen = true; }
213
+ shown = DateTime.UtcNow;
214
+ Invalidate();
215
+ }
216
+
217
+ public static void Start()
218
+ {
219
+ Thread thread = new Thread(delegate()
220
+ {
221
+ try { instance = new Card(); IntPtr handle = instance.Handle; }
222
+ catch (Exception) { instance = null; }
223
+ Ready.Set();
224
+ if (instance != null) Application.Run();
225
+ });
226
+ thread.SetApartmentState(ApartmentState.STA);
227
+ thread.IsBackground = true;
228
+ thread.Start();
229
+ Ready.WaitOne(5000);
230
+ }
231
+
232
+ /// Show the card (if it is not up yet) with this line; it goes away by itself a few seconds after the last report.
233
+ public static void Report(string text)
234
+ {
235
+ Card card = instance;
236
+ if (card == null) return;
237
+ try
238
+ {
239
+ card.BeginInvoke(new MethodInvoker(delegate
240
+ {
241
+ card.line = text;
242
+ card.shown = DateTime.UtcNow;
243
+ if (!card.Visible) ShowWindow(card.Handle, 4);
244
+ card.Invalidate();
245
+ }));
246
+ }
247
+ catch (Exception) { }
248
+ }
249
+ }
250
+
103
251
  static class Program
104
252
  {
253
+ [DllImport("user32.dll")] static extern bool SetProcessDPIAware();
254
+
105
255
  static readonly JavaScriptSerializer Json = new JavaScriptSerializer();
106
256
  static TextWriter Out;
107
257
 
@@ -113,6 +263,7 @@ static class Program
113
263
  Out = new StreamWriter(Console.OpenStandardOutput(), new UTF8Encoding(false));
114
264
  TextReader input = new StreamReader(Console.OpenStandardInput(), new UTF8Encoding(false));
115
265
  MessageFilter.Register();
266
+ try { SetProcessDPIAware(); } catch (Exception) { }
116
267
  Send(new Dictionary<string, object> { { "event", "ready" } });
117
268
  string line;
118
269
  while ((line = input.ReadLine()) != null)
@@ -320,6 +471,20 @@ static class Program
320
471
  if (cmd == "render") return Render(kind, app, doc, a);
321
472
  if (cmd == "save") return Save(kind, app, doc, a);
322
473
  if (cmd == "close") return Close(kind, doc, a);
474
+ if (cmd == "view")
475
+ {
476
+ // Where the window is looking (for checking the follow mode).
477
+ Dictionary<string, object> view = new Dictionary<string, object>();
478
+ if (kind == "word")
479
+ {
480
+ dynamic window = doc.Windows[1];
481
+ view["scrolled"] = (int)window.VerticalPercentScrolled;
482
+ view["windows"] = (int)doc.Windows.Count;
483
+ view["viewType"] = (int)window.View.Type;
484
+ view["selection"] = (int)window.Selection.Start;
485
+ }
486
+ return view;
487
+ }
323
488
  if (cmd == "undo")
324
489
  {
325
490
  if (kind != "word") throw new Fail("BAD_ARGS", "Only Word keeps the agent's edits on its undo stack.");
@@ -395,6 +560,13 @@ static class Program
395
560
  }
396
561
  Dictionary<string, object> info = Info(kind, doc, Active(kind, app));
397
562
  info["how"] = how;
563
+ if (a.Flag("card", false))
564
+ {
565
+ if (!cardStarted) { cardStarted = true; Card.Start(); }
566
+ Following = Card.FollowChosen ? Following : a.Flag("follow", false);
567
+ Typing = Card.TypingChosen ? Typing : a.Flag("typing", false);
568
+ Card.Report("AI 打开了 " + (string)doc.Name);
569
+ }
398
570
  return info;
399
571
  }
400
572
 
@@ -503,6 +675,226 @@ static class Program
503
675
  return text.Replace("\r\n", "\r").Replace("\n", "\r");
504
676
  }
505
677
 
678
+
679
+ // ───────────────────────── watching the work ─────────────────────────
680
+
681
+ /// Scroll the window to where the agent is working (off = silent mode: the user's view is left alone).
682
+ public static volatile bool Following;
683
+ /// Write text a few characters at a time, the way a person types, instead of all at once.
684
+ public static volatile bool Typing;
685
+
686
+ static bool cardStarted;
687
+
688
+ static void Follow(dynamic doc, dynamic range)
689
+ {
690
+ if (!Following) return;
691
+ try { doc.Windows[1].ScrollIntoView(range, true); } catch (Exception) { }
692
+ }
693
+
694
+ /// Pieces to write a text in: up to 40 of them, so that a long paragraph still takes about a second.
695
+ static List<string> Pieces(string text)
696
+ {
697
+ List<string> pieces = new List<string>();
698
+ int size = Math.Max(2, (int)Math.Ceiling(text.Length / 40.0));
699
+ for (int i = 0; i < text.Length; i += size) pieces.Add(text.Substring(i, Math.Min(size, text.Length - i)));
700
+ return pieces;
701
+ }
702
+
703
+ /// Put text at a position of a Word document; returns the position after it.
704
+ static int Write(dynamic doc, int at, string text)
705
+ {
706
+ if (text.Length == 0) return at;
707
+ if (!Typing || text.Length < 4)
708
+ {
709
+ doc.Range(at, at).InsertAfter(text);
710
+ return at + text.Length;
711
+ }
712
+ foreach (string piece in Pieces(text))
713
+ {
714
+ doc.Range(at, at).InsertAfter(piece);
715
+ at += piece.Length;
716
+ Thread.Sleep(22);
717
+ }
718
+ return at;
719
+ }
720
+
721
+ // ───────────────────────── equations ─────────────────────────
722
+
723
+ static readonly Dictionary<string, string> TexSymbols = new Dictionary<string, string>
724
+ {
725
+ { "alpha", "α" }, { "beta", "β" }, { "gamma", "γ" }, { "delta", "δ" }, { "epsilon", "ε" }, { "varepsilon", "ε" }, { "zeta", "ζ" }, { "eta", "η" },
726
+ { "theta", "θ" }, { "vartheta", "ϑ" }, { "iota", "ι" }, { "kappa", "κ" }, { "lambda", "λ" }, { "mu", "μ" }, { "nu", "ν" }, { "xi", "ξ" },
727
+ { "pi", "π" }, { "rho", "ρ" }, { "sigma", "σ" }, { "tau", "τ" }, { "upsilon", "υ" }, { "phi", "φ" }, { "varphi", "φ" }, { "chi", "χ" },
728
+ { "psi", "ψ" }, { "omega", "ω" }, { "Gamma", "Γ" }, { "Delta", "Δ" }, { "Theta", "Θ" }, { "Lambda", "Λ" }, { "Xi", "Ξ" }, { "Pi", "Π" },
729
+ { "Sigma", "Σ" }, { "Phi", "Φ" }, { "Psi", "Ψ" }, { "Omega", "Ω" },
730
+ { "cdot", "⋅" }, { "times", "×" }, { "div", "÷" }, { "pm", "±" }, { "mp", "∓" }, { "approx", "≈" }, { "neq", "≠" }, { "ne", "≠" },
731
+ { "leq", "≤" }, { "le", "≤" }, { "geq", "≥" }, { "ge", "≥" }, { "ll", "≪" }, { "gg", "≫" }, { "equiv", "≡" }, { "propto", "∝" }, { "sim", "∼" },
732
+ { "infty", "∞" }, { "partial", "∂" }, { "nabla", "∇" }, { "sum", "∑" }, { "prod", "∏" }, { "int", "∫" }, { "oint", "∮" },
733
+ { "to", "→" }, { "rightarrow", "→" }, { "leftarrow", "←" }, { "Rightarrow", "⇒" }, { "Leftrightarrow", "⇔" }, { "in", "∈" }, { "notin", "∉" },
734
+ { "subset", "⊂" }, { "cup", "∪" }, { "cap", "∩" }, { "forall", "∀" }, { "exists", "∃" }, { "angle", "∠" }, { "perp", "⊥" }, { "parallel", "∥" },
735
+ { "circ", "∘" }, { "degree", "°" }, { "ldots", "…" }, { "cdots", "⋯" }, { "dots", "…" }, { "prime", "′" }, { "hbar", "ℏ" }, { "ell", "ℓ" },
736
+ { "quad", " " }, { "qquad", " " }, { "left", "" }, { "right", "" }, { "displaystyle", "" }, { "limits", "" }, { "lim", "lim" },
737
+ { "sin", "sin" }, { "cos", "cos" }, { "tan", "tan" }, { "cot", "cot" }, { "ln", "ln" }, { "log", "log" }, { "exp", "exp" }, { "max", "max" }, { "min", "min" },
738
+ { "arcsin", "arcsin" }, { "arccos", "arccos" }, { "arctan", "arctan" }, { "sinh", "sinh" }, { "cosh", "cosh" }, { "tanh", "tanh" },
739
+ };
740
+
741
+ /// One TeX argument starting at i: a {group}, a \command, or a single character. Returns it converted.
742
+ static string TexArg(string s, ref int i)
743
+ {
744
+ while (i < s.Length && s[i] == ' ') i++;
745
+ if (i >= s.Length) return "";
746
+ if (s[i] == '{')
747
+ {
748
+ int depth = 0, start = i + 1;
749
+ for (; i < s.Length; i++)
750
+ {
751
+ if (s[i] == '{') depth++;
752
+ else if (s[i] == '}' && --depth == 0) break;
753
+ }
754
+ string inner = s.Substring(start, Math.Max(0, Math.Min(i, s.Length) - start));
755
+ i++;
756
+ return Tex(inner);
757
+ }
758
+ if (s[i] == '\\')
759
+ {
760
+ int start = i;
761
+ i++;
762
+ while (i < s.Length && char.IsLetter(s[i])) i++;
763
+ if (i == start + 1 && i < s.Length) i++;
764
+ return Tex(s.Substring(start, i - start));
765
+ }
766
+ return s[i++].ToString();
767
+ }
768
+
769
+ /// A space ends the fraction or root just written; without it Word pulls what follows into the denominator.
770
+ static void Gap(StringBuilder o, string s, int i)
771
+ {
772
+ while (i < s.Length && s[i] == ' ') i++;
773
+ if (i < s.Length && (char.IsLetterOrDigit(s[i]) || s[i] == '\\' || s[i] == '(' || s[i] == '{')) o.Append(' ');
774
+ }
775
+
776
+ /// The common part of LaTeX math to Word's linear format (UnicodeMath), which Word then builds into a native equation.
777
+ static string Tex(string s)
778
+ {
779
+ StringBuilder o = new StringBuilder();
780
+ int i = 0;
781
+ while (i < s.Length)
782
+ {
783
+ char c = s[i];
784
+ if (c == '\\')
785
+ {
786
+ int start = ++i;
787
+ while (i < s.Length && char.IsLetter(s[i])) i++;
788
+ if (i == start)
789
+ {
790
+ // \, \; \! \ are spacing; \{ \} \% and the like are the character itself.
791
+ char next = i < s.Length ? s[i++] : ' ';
792
+ if (next == ',' || next == ';' || next == ':' || next == ' ') o.Append(' ');
793
+ else if (next == '\\') o.Append(' ');
794
+ else if (next != '!') o.Append(next);
795
+ continue;
796
+ }
797
+ string name = s.Substring(start, i - start);
798
+ if (name == "frac" || name == "dfrac" || name == "tfrac") {
799
+ string top = TexArg(s, ref i), bottom = TexArg(s, ref i);
800
+ // A factor written right before the fraction (2 rac{a}{b}) must not be read into its numerator.
801
+ if (o.Length > 0 && char.IsLetterOrDigit(o[o.Length - 1])) o.Append(' ');
802
+ o.Append("(" + top + ")/(" + bottom + ")");
803
+ Gap(o, s, i);
804
+ }
805
+ else if (name == "sqrt")
806
+ {
807
+ string degree = null;
808
+ if (i < s.Length && s[i] == '[') { int end = s.IndexOf(']', i); if (end > i) { degree = Tex(s.Substring(i + 1, end - i - 1)); i = end + 1; } }
809
+ string body = TexArg(s, ref i);
810
+ o.Append(degree == null ? "√(" + body + ")" : "√(" + degree + "&" + body + ")");
811
+ Gap(o, s, i);
812
+ }
813
+ else if (name == "bar" || name == "overline") o.Append("¯(" + TexArg(s, ref i) + ")");
814
+ else if (name == "hat") o.Append(TexArg(s, ref i) + "\u0302");
815
+ else if (name == "vec") o.Append(TexArg(s, ref i) + "\u20D7");
816
+ else if (name == "dot") o.Append(TexArg(s, ref i) + "\u0307");
817
+ else if (name == "ddot") o.Append(TexArg(s, ref i) + "\u0308");
818
+ else if (name == "tilde") o.Append(TexArg(s, ref i) + "\u0303");
819
+ else if (name == "text" || name == "mathrm" || name == "textrm" || name == "operatorname")
820
+ {
821
+ while (i < s.Length && s[i] == ' ') i++;
822
+ if (i < s.Length && s[i] == '{') { int end = s.IndexOf('}', i); if (end < 0) end = s.Length; o.Append("\"" + s.Substring(i + 1, end - i - 1) + "\""); i = Math.Min(s.Length, end + 1); }
823
+ }
824
+ else if (name == "mathbf" || name == "boldsymbol" || name == "mathit" || name == "mathbb" || name == "mathcal") o.Append(TexArg(s, ref i));
825
+ else
826
+ {
827
+ string symbol;
828
+ if (TexSymbols.TryGetValue(name, out symbol))
829
+ {
830
+ o.Append(symbol);
831
+ // A function name is applied to what follows: Word needs a space after it.
832
+ if (symbol.Length > 1 && symbol == name && i < s.Length && s[i] != ' ' && s[i] != '^' && s[i] != '_') o.Append(' ');
833
+ }
834
+ else o.Append('\\').Append(name);
835
+ }
836
+ continue;
837
+ }
838
+ if (c == '^' || c == '_')
839
+ {
840
+ i++;
841
+ bool braced = i < s.Length && s[i] == '{';
842
+ string arg = TexArg(s, ref i);
843
+ o.Append(c).Append(braced && arg.Length > 1 ? "(" + arg + ")" : arg);
844
+ // Otherwise Word reads the next letter into the script: x^2y.
845
+ if (i < s.Length && (char.IsLetterOrDigit(s[i]) || s[i] == '\\')) o.Append(' ');
846
+ continue;
847
+ }
848
+ if (c == '{') { string group = TexArg(s, ref i); o.Append("〖" + group + "〗"); continue; }
849
+ if (c == '}') { i++; continue; }
850
+ if (c == '~') { o.Append(' '); i++; continue; }
851
+ o.Append(c);
852
+ i++;
853
+ }
854
+ return o.ToString();
855
+ }
856
+
857
+ static readonly System.Text.RegularExpressions.Regex Dollars = new System.Text.RegularExpressions.Regex(@"\$\$([^$\r]+?)\$\$|\$([^$\r]+?)\$(?!\$)");
858
+
859
+ static readonly System.Text.RegularExpressions.Regex Tag = new System.Text.RegularExpressions.Regex(@"\\tag\s*\{([^}]*)\}");
860
+
861
+ /// Turn every $...$ (inline) and $$...$$ (on a line of its own) inside a range into a native Word equation.
862
+ static int Mathify(dynamic doc, dynamic range)
863
+ {
864
+ string text = (string)range.Text;
865
+ if (text == null || text.IndexOf('$') < 0) return 0;
866
+ int start = (int)range.Start, made = 0;
867
+ System.Text.RegularExpressions.MatchCollection found = Dollars.Matches(text);
868
+ for (int k = found.Count - 1; k >= 0; k--)
869
+ {
870
+ System.Text.RegularExpressions.Match m = found[k];
871
+ bool display = m.Groups[1].Success;
872
+ string source = (display ? m.Groups[1].Value : m.Groups[2].Value).Trim();
873
+ // ag{1} numbers a display equation: Word sets "#(1)" flush right on the equation's line.
874
+ string tag = null;
875
+ System.Text.RegularExpressions.Match tagged = Tag.Match(source);
876
+ if (tagged.Success) { tag = tagged.Groups[1].Value.Trim(); source = source.Remove(tagged.Index, tagged.Length).Trim(); }
877
+ dynamic spot = null;
878
+ if (m.Length <= 250)
879
+ {
880
+ // Found by its text: positions counted in the text are off wherever the range holds a field (a caption number).
881
+ dynamic search = range.Duplicate;
882
+ if ((bool)search.Find.Execute(FindText: m.Value.Replace("^", "^^"), MatchCase: true, MatchWildcards: false, Forward: true, Wrap: 0)) spot = search;
883
+ }
884
+ if (spot == null) spot = doc.Range(start + m.Index, start + m.Index + m.Length);
885
+ spot.Text = Tex(source) + (display && !string.IsNullOrEmpty(tag) ? "#(" + tag + ")" : "");
886
+ try
887
+ {
888
+ dynamic math = doc.OMaths.Add(spot);
889
+ math.OMaths[1].BuildUp();
890
+ if (display) { try { math.OMaths[1].Type = 0; math.OMaths[1].Justification = 1; } catch (COMException) { } }
891
+ made++;
892
+ }
893
+ catch (COMException) { }
894
+ }
895
+ return made;
896
+ }
897
+
506
898
  // ───────────────────────── Word ─────────────────────────
507
899
 
508
900
  static readonly Dictionary<string, int> WordStyles = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
@@ -530,6 +922,7 @@ static class Program
530
922
  int total = (int)doc.Paragraphs.Count;
531
923
  if (index < 1 || index > total) throw new Fail("ANCHOR_MISSING", "There is no paragraph " + index + " (the document has " + total + "). Call office_read again.");
532
924
  dynamic p = doc.Paragraphs[index];
925
+ Follow(doc, p.Range);
533
926
  if (expect != null)
534
927
  {
535
928
  string text = Squash(Clean((string)p.Range.Text)), want = Squash(expect);
@@ -572,7 +965,7 @@ static class Program
572
965
  int from = Math.Max(1, a.Int("from", 1)), count = Math.Max(1, Math.Min(400, a.Int("count", 120)));
573
966
  bool full = a.Flag("full", false);
574
967
  List<object> items = new List<object>();
575
- int shown = from - 1;
968
+ int shown = from - 1, openTable = -1;
576
969
  string titleStyle = null;
577
970
  try { titleStyle = (string)doc.Styles[-63].NameLocal; } catch (Exception) { }
578
971
  if (from <= total)
@@ -596,19 +989,38 @@ static class Program
596
989
  try { inTable = Truthy(p.Range.Information[12]); } catch (Exception) { }
597
990
  if (inTable)
598
991
  {
599
- // A table is one entry, not one per cell: its cells are read with "table".
600
992
  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;
993
+ int tableStart = (int)table.Range.Start, end = (int)table.Range.End;
994
+ if (tableStart != openTable)
995
+ {
996
+ int inside = (int)table.Range.Paragraphs.Count, cells = (int)table.Range.Cells.Count, rows = (int)table.Rows.Count;
997
+ bool nested = false;
998
+ try { nested = (int)table.NestingLevel > 1; } catch (Exception) { }
999
+ Dictionary<string, object> head = new Dictionary<string, object>();
1000
+ head["i"] = i;
1001
+ head["to"] = i + inside - 1;
1002
+ if (!nested) head["table"] = (int)doc.Range(0, end).Tables.Count;
1003
+ string size = "?";
1004
+ try { size = rows + "x" + (int)table.Columns.Count; } catch (COMException) { size = cells + " cells"; }
1005
+ head["size"] = size;
1006
+ // A grid of short cells is one entry (read its cells with "table"). A table whose cells hold the
1007
+ // document's text, as in a form or a report template, is listed paragraph by paragraph.
1008
+ bool open = inside > cells + rows + 2;
1009
+ if (open) head["open"] = true;
1010
+ items.Add(head);
1011
+ if (!open)
1012
+ {
1013
+ i += inside - 1;
1014
+ p = i < total ? doc.Range(end, end).Paragraphs[1] : null;
1015
+ shown = i;
1016
+ continue;
1017
+ }
1018
+ openTable = tableStart;
1019
+ }
1020
+ bool rowEnd = false;
1021
+ try { rowEnd = Truthy(p.Range.Information[31]); } catch (Exception) { }
1022
+ if (rowEnd) { shown = i; p = p.Next(); continue; }
1023
+ item["inTable"] = true;
612
1024
  }
613
1025
  items.Add(item);
614
1026
  shown = i;
@@ -663,7 +1075,11 @@ static class Program
663
1075
  {
664
1076
  string where = op.Str("where", op.Has("para") ? "after" : "end");
665
1077
  // 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];
1078
+ if ((int)doc.Paragraphs.Count == 1 && (string)doc.Paragraphs[1].Range.Text == "\r")
1079
+ {
1080
+ op.Raw("para"); op.Raw("expect");
1081
+ return doc.Paragraphs[1];
1082
+ }
667
1083
  if (where == "end")
668
1084
  {
669
1085
  dynamic last = doc.Paragraphs.Last;
@@ -683,7 +1099,62 @@ static class Program
683
1099
  {
684
1100
  int end = (int)paragraph.Range.End;
685
1101
  paragraph.Range.InsertParagraphAfter();
686
- return doc.Range(end, end).Paragraphs[1];
1102
+ dynamic made = doc.Range(end, end).Paragraphs[1];
1103
+ // Word gives the new paragraph the look of whatever FOLLOWS (a caption, a heading); it should
1104
+ // continue the paragraph it was added after.
1105
+ try
1106
+ {
1107
+ made.Style = paragraph.Style;
1108
+ made.Range.ParagraphFormat = paragraph.Range.ParagraphFormat.Duplicate;
1109
+ made.Range.Font = paragraph.Range.Font.Duplicate;
1110
+ }
1111
+ catch (COMException) { }
1112
+ return made;
1113
+ }
1114
+
1115
+ /// Breathing room around a table or figure: at least this much space before / after a paragraph.
1116
+ /// Paragraph spacing, not blank lines, so nothing has to be cleaned up later.
1117
+ const float Air = 8f;
1118
+
1119
+ static void Space(dynamic paragraph, bool before)
1120
+ {
1121
+ if (paragraph == null) return;
1122
+ try
1123
+ {
1124
+ dynamic format = paragraph.Range.ParagraphFormat;
1125
+ if (before) { if ((float)format.SpaceBefore < Air) format.SpaceBefore = Air; }
1126
+ else if ((float)format.SpaceAfter < Air) format.SpaceAfter = Air;
1127
+ }
1128
+ catch (Exception) { }
1129
+ }
1130
+
1131
+ /// A numbered caption, the way reports set them: "表 1 ..." centred above a table, "图 1 ..." centred below
1132
+ /// a figure. The number is a Word caption field, so it renumbers itself and can be cross-referenced.
1133
+ static void Caption(dynamic doc, dynamic target, bool table, string title, string fontName, string fontFarEast)
1134
+ {
1135
+ string label = table ? "表" : "图";
1136
+ dynamic labels = doc.Application.CaptionLabels;
1137
+ bool known = false;
1138
+ foreach (dynamic item in labels) if ((string)item.Name == label) { known = true; break; }
1139
+ if (!known) labels.Add(label);
1140
+ int start = (int)target.Start, end = (int)target.End;
1141
+ target.InsertCaption(Label: label, Title: " " + title.Trim(), Position: table ? 0 : 1);
1142
+ // Above a table the caption is the paragraph that now sits where the table began; below a figure it is the next one.
1143
+ dynamic caption = table ? doc.Range(start, start).Paragraphs[1] : doc.Range(end, end).Paragraphs[1].Next();
1144
+ if (caption == null) return;
1145
+ try
1146
+ {
1147
+ dynamic range = caption.Range;
1148
+ range.ParagraphFormat.Alignment = 1;
1149
+ range.ParagraphFormat.CharacterUnitFirstLineIndent = 0; range.ParagraphFormat.FirstLineIndent = 0;
1150
+ range.Font.Bold = 0; range.Font.Italic = 0; range.Font.Color = -16777216; range.Font.Size = 10.5f;
1151
+ if (!string.IsNullOrEmpty(fontName)) range.Font.Name = fontName;
1152
+ if (!string.IsNullOrEmpty(fontFarEast)) range.Font.NameFarEast = fontFarEast;
1153
+ range.ParagraphFormat.SpaceBefore = table ? Air : 3f;
1154
+ range.ParagraphFormat.SpaceAfter = table ? 3f : Air;
1155
+ Mathify(doc, caption.Range);
1156
+ }
1157
+ catch (COMException) { }
687
1158
  }
688
1159
 
689
1160
  /// The number of a paragraph, counted from the start of the document.
@@ -692,6 +1163,23 @@ static class Program
692
1163
  return (int)doc.Range(0, paragraph.Range.End).Paragraphs.Count;
693
1164
  }
694
1165
 
1166
+ /// A new paragraph with no style named: body text that matches its surroundings. Written next to body
1167
+ /// text it keeps that text's font, size and spacing (a template's own look) but not the emphasis of the
1168
+ /// line it was split from (a bold label, a red hint); written after a heading it becomes Normal.
1169
+ static void Body(dynamic paragraph)
1170
+ {
1171
+ int level = 10;
1172
+ try { level = (int)paragraph.OutlineLevel; } catch (Exception) { }
1173
+ if (level != 10) { Plain(paragraph, "Normal"); return; }
1174
+ try
1175
+ {
1176
+ dynamic font = paragraph.Range.Font;
1177
+ font.Bold = 0; font.Italic = 0; font.Underline = 0; font.Color = -16777216;
1178
+ paragraph.Range.ParagraphFormat.Alignment = 3;
1179
+ }
1180
+ catch (COMException) { }
1181
+ }
1182
+
695
1183
  static void Plain(dynamic paragraph, string style)
696
1184
  {
697
1185
  SetStyle(paragraph.Range, style);
@@ -721,7 +1209,7 @@ static class Program
721
1209
  IList items = op.List("items");
722
1210
  if (items == null) { items = new ArrayList(); items.Add(new Dictionary<string, object> { { "text", op.Need("text") } }); }
723
1211
  dynamic current = null;
724
- int made = 0;
1212
+ int made = 0, equations = 0;
725
1213
  foreach (object raw in items)
726
1214
  {
727
1215
  Bag item = raw is string ? new Bag(new Dictionary<string, object> { { "text", raw } }) : new Bag(raw);
@@ -730,16 +1218,20 @@ static class Program
730
1218
  dynamic p;
731
1219
  if (current == null) p = NewParagraph(doc, op);
732
1220
  else p = After(doc, current);
733
- Plain(p, item.Str("style", op.Str("style", "Normal")));
734
- if (text.Length > 0) p.Range.InsertBefore(text);
1221
+ string style = item.Str("style", op.Str("style", null));
1222
+ if (style != null) Plain(p, style);
1223
+ else Body(p);
735
1224
  WordFormat(p.Range, StyleLess(op));
736
1225
  WordFormat(p.Range, StyleLess(item));
1226
+ Follow(doc, p.Range);
1227
+ Write(doc, (int)p.Range.Start, text);
1228
+ equations += Mathify(doc, p.Range);
737
1229
  current = p;
738
1230
  made++;
739
1231
  }
740
1232
  }
741
1233
  int lastIndex = Index(doc, current);
742
- return "inserted " + made + " paragraph(s), now paragraphs " + (lastIndex - made + 1) + "–" + lastIndex + " of " + (int)doc.Paragraphs.Count;
1234
+ return "inserted " + made + " paragraph(s), now paragraphs " + (lastIndex - made + 1) + "–" + lastIndex + " of " + (int)doc.Paragraphs.Count + (equations > 0 ? ", " + equations + " equation(s)" : "");
743
1235
  }
744
1236
  if ((type == "set_text" || (type == "delete_range" && op.Has("para"))) && op.Raw("expect") == null)
745
1237
  {
@@ -750,8 +1242,10 @@ static class Program
750
1242
  dynamic p = Para(doc, op.Int("para", 0), op.Str("expect", null));
751
1243
  dynamic range = p.Range;
752
1244
  range.MoveEnd(1, -1);
753
- range.Text = Lines(op.Raw("text") ?? "");
754
- return "paragraph " + op.Int("para", 0) + " rewritten";
1245
+ range.Text = "";
1246
+ Write(doc, (int)p.Range.Start, Lines(op.Raw("text") ?? ""));
1247
+ int built = Mathify(doc, p.Range);
1248
+ return "paragraph " + op.Int("para", 0) + " rewritten" + (built > 0 ? ", " + built + " equation(s)" : "");
755
1249
  }
756
1250
  if (type == "format_text" || type == "set_style")
757
1251
  {
@@ -762,8 +1256,20 @@ static class Program
762
1256
  if (type == "delete_range")
763
1257
  {
764
1258
  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";
1259
+ dynamic gone = WordRange(doc, op);
1260
+ string note = "";
1261
+ try { gone.Delete(); }
1262
+ catch (COMException)
1263
+ {
1264
+ // The mark that ends a table cell cannot be deleted: take everything before it.
1265
+ int limit = (int)gone.End - 1;
1266
+ try { int from = (int)gone.Start; limit = Math.Min(limit, (int)doc.Range(from, from).Cells[1].Range.End - 1); } catch (Exception) { }
1267
+ gone.End = limit;
1268
+ if ((int)gone.End <= (int)gone.Start) throw new Fail("BAD_ARGS", "This is the last paragraph of a table cell; a cell always keeps one. Leave it empty.");
1269
+ gone.Delete();
1270
+ note = " (the last paragraph of the cell stays, a cell always keeps one)";
1271
+ }
1272
+ return "deleted, " + (int)doc.Paragraphs.Count + " paragraphs now" + note;
767
1273
  }
768
1274
  if (type == "insert_table")
769
1275
  {
@@ -772,7 +1278,18 @@ static class Program
772
1278
  if (data != null) foreach (object row in data) { IList cells = row as IList; if (cells != null) cols = Math.Max(cols, cells.Count); }
773
1279
  if (rows < 1 || cols < 1) throw new Fail("BAD_ARGS", "Give \"data\" (rows of cells) or \"rows\" and \"cols\".");
774
1280
  dynamic p = NewParagraph(doc, op);
1281
+ string fontName = null, fontFarEast = null;
1282
+ float fontSize = 0;
1283
+ try
1284
+ {
1285
+ if ((int)p.OutlineLevel == 10)
1286
+ {
1287
+ fontName = (string)p.Range.Font.Name; fontFarEast = (string)p.Range.Font.NameFarEast; fontSize = (float)p.Range.Font.Size;
1288
+ }
1289
+ }
1290
+ catch (Exception) { }
775
1291
  Plain(p, "Normal");
1292
+ Follow(doc, p.Range);
776
1293
  dynamic table = doc.Tables.Add(p.Range, rows, cols);
777
1294
  // The cells take the formatting of the paragraph that follows (a heading, say): make them plain.
778
1295
  SetStyle(table.Range, "Normal");
@@ -782,36 +1299,76 @@ static class Program
782
1299
  table.Range.ParagraphFormat.CharacterUnitFirstLineIndent = 0; table.Range.ParagraphFormat.FirstLineIndent = 0;
783
1300
  }
784
1301
  catch (COMException) { }
1302
+ try
1303
+ {
1304
+ // Same typeface and size as the body text the table sits in; cells centred like a data table.
1305
+ if (!string.IsNullOrEmpty(fontName)) table.Range.Font.Name = fontName;
1306
+ if (!string.IsNullOrEmpty(fontFarEast)) table.Range.Font.NameFarEast = fontFarEast;
1307
+ if (fontSize > 4 && fontSize < 100) table.Range.Font.Size = fontSize;
1308
+ table.Range.ParagraphFormat.Alignment = 1;
1309
+ table.Range.Cells.VerticalAlignment = 1;
1310
+ }
1311
+ catch (COMException) { }
785
1312
  table.Borders.Enable = 1;
1313
+ try { table.AutoFitBehavior(2); } catch (COMException) { }
786
1314
  if (data != null)
787
1315
  {
788
1316
  for (int r = 0; r < data.Count && r < rows; r++)
789
1317
  {
790
1318
  IList cells = data[r] as IList;
791
1319
  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]);
1320
+ for (int c = 0; c < cells.Count; c++)
1321
+ {
1322
+ if (cells[c] == null) continue;
1323
+ dynamic cell = table.Cell(r + 1, c + 1).Range;
1324
+ string value = Convert.ToString(cells[c], System.Globalization.CultureInfo.InvariantCulture);
1325
+ cell.Text = value;
1326
+ if (value.IndexOf('$') >= 0) Mathify(doc, table.Cell(r + 1, c + 1).Range);
1327
+ }
1328
+ if (Typing) Thread.Sleep(25);
793
1329
  }
794
1330
  }
795
1331
  if (op.Flag("header", true)) table.Rows[1].Range.Font.Bold = 1;
1332
+ if (op.Has("caption")) Caption(doc, table.Range, true, op.Need("caption"), fontName, fontFarEast);
1333
+ else
1334
+ {
1335
+ int tableStart = (int)table.Range.Start;
1336
+ if (tableStart > 0) Space(doc.Range(tableStart - 1, tableStart - 1).Paragraphs[1], false);
1337
+ }
1338
+ int afterTable = (int)table.Range.End;
1339
+ if (afterTable < (int)doc.Content.End) Space(doc.Range(afterTable, afterTable).Paragraphs[1], true);
796
1340
  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;
1341
+ bool nested = false;
1342
+ try { nested = (int)table.NestingLevel > 1; } catch (Exception) { }
1343
+ return (nested ? "table inserted inside a cell of table " + (int)doc.Range(0, table.Range.End).Tables.Count : "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
1344
  }
799
1345
  if (type == "set_cell")
800
1346
  {
801
1347
  int n = op.Int("table", 1);
802
1348
  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";
1349
+ dynamic cell = doc.Tables[n].Cell(op.Int("row", 1), op.Int("col", 1));
1350
+ Follow(doc, cell.Range);
1351
+ cell.Range.Text = "";
1352
+ Write(doc, (int)cell.Range.Start, Lines(op.Raw("text") ?? ""));
1353
+ int built = Mathify(doc, doc.Tables[n].Cell(op.Int("row", 1), op.Int("col", 1)).Range);
1354
+ return "cell set" + (built > 0 ? ", " + built + " equation(s)" : "");
805
1355
  }
806
1356
  if (type == "insert_image")
807
1357
  {
808
1358
  string path = op.Need("path");
809
1359
  if (!File.Exists(path)) throw new Fail("BAD_ARGS", "Image \"" + path + "\" does not exist.");
810
1360
  dynamic p = NewParagraph(doc, op);
1361
+ string bodyFont = null, bodyFarEast = null;
1362
+ try { if ((int)p.OutlineLevel == 10) { bodyFont = (string)p.Range.Font.Name; bodyFarEast = (string)p.Range.Font.NameFarEast; } } catch (Exception) { }
811
1363
  Plain(p, "Normal");
1364
+ Follow(doc, p.Range);
812
1365
  dynamic picture = p.Range.InlineShapes.AddPicture(FileName: path, LinkToFile: false, SaveWithDocument: true);
813
1366
  if (op.Has("width")) { picture.LockAspectRatio = -1; picture.Width = (float)op.Num("width", 300); }
1367
+ try { p.Range.ParagraphFormat.CharacterUnitFirstLineIndent = 0; p.Range.ParagraphFormat.FirstLineIndent = 0; p.Range.ParagraphFormat.Alignment = 1; } catch (COMException) { }
814
1368
  WordFormat(p.Range, StyleLess(op));
1369
+ p.Range.ParagraphFormat.SpaceBefore = Air;
1370
+ if (op.Has("caption")) Caption(doc, picture.Range, false, op.Need("caption"), bodyFont, bodyFarEast);
1371
+ else p.Range.ParagraphFormat.SpaceAfter = Air;
815
1372
  return "image inserted";
816
1373
  }
817
1374
  throw new Fail("BAD_ARGS", "Unknown Word operation \"" + type + "\".");
@@ -953,6 +1510,23 @@ static class Program
953
1510
  if (op.Has("rowHeight")) range.RowHeight = op.Num("rowHeight", 15);
954
1511
  }
955
1512
 
1513
+ /// Bring the cells being worked on into view, without touching the selection.
1514
+ static void Show(dynamic book, dynamic sheet, dynamic range)
1515
+ {
1516
+ if (!Following) return;
1517
+ try
1518
+ {
1519
+ sheet.Activate();
1520
+ dynamic window = book.Windows[1];
1521
+ dynamic visible = window.VisibleRange;
1522
+ int row = (int)range.Row, column = (int)range.Column;
1523
+ int top = (int)visible.Row, left = (int)visible.Column;
1524
+ if (row < top || row >= top + (int)visible.Rows.Count - 1) window.ScrollRow = Math.Max(1, row - 3);
1525
+ if (column < left || column >= left + (int)visible.Columns.Count - 1) window.ScrollColumn = Math.Max(1, column - 1);
1526
+ }
1527
+ catch (Exception) { }
1528
+ }
1529
+
956
1530
  static string ExcelOp(dynamic app, dynamic book, string type, Bag op)
957
1531
  {
958
1532
  if (type == "add_sheet")
@@ -977,7 +1551,18 @@ static class Program
977
1551
  for (int c = 0; c < cells.Count; c++) grid[r, c] = Scalar(cells[c]);
978
1552
  }
979
1553
  dynamic target = Cells(ws, op.Str("range", "A1")).Cells[1, 1].Resize[rows.Count, width];
980
- target.Formula = grid;
1554
+ Show(book, ws, target);
1555
+ if (Typing && rows.Count > 1 && rows.Count <= 60)
1556
+ {
1557
+ for (int r = 0; r < rows.Count; r++)
1558
+ {
1559
+ object[,] line = new object[1, width];
1560
+ for (int c = 0; c < width; c++) line[0, c] = grid[r, c];
1561
+ target.Rows[r + 1].Formula = line;
1562
+ Thread.Sleep(35);
1563
+ }
1564
+ }
1565
+ else target.Formula = grid;
981
1566
  ExcelFormat(target, op);
982
1567
  return "wrote " + (string)target.Address[false, false];
983
1568
  }
@@ -1103,9 +1688,22 @@ static class Program
1103
1688
  return result;
1104
1689
  }
1105
1690
 
1691
+ static void PptWrite(dynamic textRange, string text)
1692
+ {
1693
+ if (!Typing || text.Length < 4) { textRange.Text = text; return; }
1694
+ textRange.Text = "";
1695
+ foreach (string piece in Pieces(text)) { textRange.InsertAfter(piece); Thread.Sleep(22); }
1696
+ }
1697
+
1698
+ static void GoTo(dynamic deck, int slide)
1699
+ {
1700
+ if (!Following) return;
1701
+ try { deck.Windows[1].View.GotoSlide(slide); } catch (Exception) { }
1702
+ }
1703
+
1106
1704
  static void PptText(dynamic shape, Bag op)
1107
1705
  {
1108
- if (op.Has("text")) shape.TextFrame.TextRange.Text = Lines(op.Raw("text"));
1706
+ if (op.Has("text")) PptWrite(shape.TextFrame.TextRange, Lines(op.Raw("text")));
1109
1707
  if (!(op.Has("font") || op.Has("size") || op.Has("bold") || op.Has("italic") || op.Has("color") || op.Has("align"))) return;
1110
1708
  dynamic range = shape.TextFrame.TextRange;
1111
1709
  if (op.Has("font")) { string font = op.Need("font"); range.Font.Name = font; try { range.Font.NameFarEast = font; } catch (COMException) { } }
@@ -1144,12 +1742,14 @@ static class Program
1144
1742
  string name = op.Str("layout", "title_content");
1145
1743
  if (!Layouts.TryGetValue(name, out layout)) throw new Fail("BAD_ARGS", "Unknown layout \"" + name + "\": use " + string.Join(", ", new List<string>(Layouts.Keys).ToArray()) + ".");
1146
1744
  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"));
1745
+ GoTo(deck, index);
1746
+ if (op.Has("title")) PptWrite(slide.Shapes.Title.TextFrame.TextRange, Lines(op.Raw("title")));
1747
+ if (op.Has("body")) PptWrite(slide.Shapes.Placeholders[2].TextFrame.TextRange, Lines(op.Raw("body")));
1149
1748
  if (op.Has("notes")) slide.NotesPage.Shapes.Placeholders[2].TextFrame.TextRange.Text = Lines(op.Raw("notes"));
1150
1749
  return "slide " + index + " added";
1151
1750
  }
1152
1751
  dynamic target = Slide(deck, op);
1752
+ GoTo(deck, op.Int("slide", 0));
1153
1753
  if (type == "delete_slide") { target.Delete(); return "slide deleted"; }
1154
1754
  if (type == "move_slide") { target.MoveTo(op.Int("to", 1)); return "slide moved to " + op.Int("to", 1); }
1155
1755
  if (type == "set_notes") { target.NotesPage.Shapes.Placeholders[2].TextFrame.TextRange.Text = Lines(op.Raw("text") ?? ""); return "notes set"; }
@@ -1189,6 +1789,12 @@ static class Program
1189
1789
  if (ops == null || ops.Count == 0) throw new Fail("BAD_ARGS", "\"ops\" must list at least one operation.");
1190
1790
  List<object> done = new List<object>();
1191
1791
  Dictionary<string, object> result = new Dictionary<string, object>();
1792
+ // The settings give the two modes, until the user flips one on the card.
1793
+ if (!Card.FollowChosen) Following = a.Flag("follow", false);
1794
+ if (!Card.TypingChosen) Typing = a.Flag("typing", false);
1795
+ bool card = a.Flag("card", false);
1796
+ if (card && !cardStarted) { cardStarted = true; Card.Start(); }
1797
+ string docName = (string)doc.Name;
1192
1798
  dynamic undo = null;
1193
1799
  // One undo step for the whole batch: the user takes it back with a single Ctrl+Z.
1194
1800
  if (kind == "word") { try { undo = app.UndoRecord; undo.StartCustomRecord("AI edit"); } catch (COMException) { undo = null; } }
@@ -1201,6 +1807,7 @@ static class Program
1201
1807
  {
1202
1808
  Bag op = new Bag(ops[i]);
1203
1809
  type = op.Need("op");
1810
+ if (card) Card.Report("AI 正在编辑 " + docName + (ops.Count > 1 ? " · " + (i + 1) + "/" + ops.Count : ""));
1204
1811
  string line = kind == "word" ? WordOp(doc, type, op) : kind == "excel" ? ExcelOp(app, doc, type, op) : PptOp(doc, type, op);
1205
1812
  string ignored = op.Unread();
1206
1813
  done.Add(ignored.Length == 0 ? line : line + " — IGNORED field(s) " + ignored + ": " + type + " does not take them, check the operation list");
@@ -1222,6 +1829,7 @@ static class Program
1222
1829
  {
1223
1830
  if (undo != null) { try { undo.EndCustomRecord(); } catch (COMException) { } }
1224
1831
  }
1832
+ if (card) Card.Report(result.ContainsKey("failed") ? "AI 的修改停在第 " + (done.Count + 1) + " 项" : "AI 已改好 " + docName);
1225
1833
  result["done"] = done;
1226
1834
  result["total"] = ops.Count;
1227
1835
  return result;
@@ -1252,13 +1860,21 @@ static class Program
1252
1860
  using (Metafile meta = new Metafile(stream))
1253
1861
  {
1254
1862
  int height = (int)Math.Round(width * (double)meta.Height / meta.Width);
1863
+ // Drawn straight at the final size, hairlines (table borders) fall between pixels and vanish:
1864
+ // draw the page large first, then scale the picture down.
1865
+ int factor = Math.Max(2, (int)Math.Ceiling(3000.0 / width));
1866
+ using (Bitmap large = new Bitmap(width * factor, height * factor))
1255
1867
  using (Bitmap bitmap = new Bitmap(width, height))
1256
1868
  {
1257
- using (Graphics g = Graphics.FromImage(bitmap))
1869
+ using (Graphics g = Graphics.FromImage(large))
1258
1870
  {
1259
1871
  g.Clear(Color.White);
1872
+ g.DrawImage(meta, 0, 0, large.Width, large.Height);
1873
+ }
1874
+ using (Graphics g = Graphics.FromImage(bitmap))
1875
+ {
1260
1876
  g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
1261
- g.DrawImage(meta, 0, 0, width, height);
1877
+ g.DrawImage(large, 0, 0, width, height);
1262
1878
  }
1263
1879
  bitmap.Save(path, ImageFormat.Png);
1264
1880
  }