@copylee/dsh-office 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.");
@@ -503,6 +668,226 @@ static class Program
503
668
  return text.Replace("\r\n", "\r").Replace("\n", "\r");
504
669
  }
505
670
 
671
+
672
+ // ───────────────────────── watching the work ─────────────────────────
673
+
674
+ /// Scroll the window to where the agent is working (off = silent mode: the user's view is left alone).
675
+ public static volatile bool Following;
676
+ /// Write text a few characters at a time, the way a person types, instead of all at once.
677
+ public static volatile bool Typing;
678
+
679
+ static bool cardStarted;
680
+
681
+ static void Follow(dynamic doc, dynamic range)
682
+ {
683
+ if (!Following) return;
684
+ try { doc.Windows[1].ScrollIntoView(range, true); } catch (Exception) { }
685
+ }
686
+
687
+ /// Pieces to write a text in: up to 40 of them, so that a long paragraph still takes about a second.
688
+ static List<string> Pieces(string text)
689
+ {
690
+ List<string> pieces = new List<string>();
691
+ int size = Math.Max(2, (int)Math.Ceiling(text.Length / 40.0));
692
+ for (int i = 0; i < text.Length; i += size) pieces.Add(text.Substring(i, Math.Min(size, text.Length - i)));
693
+ return pieces;
694
+ }
695
+
696
+ /// Put text at a position of a Word document; returns the position after it.
697
+ static int Write(dynamic doc, int at, string text)
698
+ {
699
+ if (text.Length == 0) return at;
700
+ if (!Typing || text.Length < 4)
701
+ {
702
+ doc.Range(at, at).InsertAfter(text);
703
+ return at + text.Length;
704
+ }
705
+ foreach (string piece in Pieces(text))
706
+ {
707
+ doc.Range(at, at).InsertAfter(piece);
708
+ at += piece.Length;
709
+ Thread.Sleep(22);
710
+ }
711
+ return at;
712
+ }
713
+
714
+ // ───────────────────────── equations ─────────────────────────
715
+
716
+ static readonly Dictionary<string, string> TexSymbols = new Dictionary<string, string>
717
+ {
718
+ { "alpha", "α" }, { "beta", "β" }, { "gamma", "γ" }, { "delta", "δ" }, { "epsilon", "ε" }, { "varepsilon", "ε" }, { "zeta", "ζ" }, { "eta", "η" },
719
+ { "theta", "θ" }, { "vartheta", "ϑ" }, { "iota", "ι" }, { "kappa", "κ" }, { "lambda", "λ" }, { "mu", "μ" }, { "nu", "ν" }, { "xi", "ξ" },
720
+ { "pi", "π" }, { "rho", "ρ" }, { "sigma", "σ" }, { "tau", "τ" }, { "upsilon", "υ" }, { "phi", "φ" }, { "varphi", "φ" }, { "chi", "χ" },
721
+ { "psi", "ψ" }, { "omega", "ω" }, { "Gamma", "Γ" }, { "Delta", "Δ" }, { "Theta", "Θ" }, { "Lambda", "Λ" }, { "Xi", "Ξ" }, { "Pi", "Π" },
722
+ { "Sigma", "Σ" }, { "Phi", "Φ" }, { "Psi", "Ψ" }, { "Omega", "Ω" },
723
+ { "cdot", "⋅" }, { "times", "×" }, { "div", "÷" }, { "pm", "±" }, { "mp", "∓" }, { "approx", "≈" }, { "neq", "≠" }, { "ne", "≠" },
724
+ { "leq", "≤" }, { "le", "≤" }, { "geq", "≥" }, { "ge", "≥" }, { "ll", "≪" }, { "gg", "≫" }, { "equiv", "≡" }, { "propto", "∝" }, { "sim", "∼" },
725
+ { "infty", "∞" }, { "partial", "∂" }, { "nabla", "∇" }, { "sum", "∑" }, { "prod", "∏" }, { "int", "∫" }, { "oint", "∮" },
726
+ { "to", "→" }, { "rightarrow", "→" }, { "leftarrow", "←" }, { "Rightarrow", "⇒" }, { "Leftrightarrow", "⇔" }, { "in", "∈" }, { "notin", "∉" },
727
+ { "subset", "⊂" }, { "cup", "∪" }, { "cap", "∩" }, { "forall", "∀" }, { "exists", "∃" }, { "angle", "∠" }, { "perp", "⊥" }, { "parallel", "∥" },
728
+ { "circ", "∘" }, { "degree", "°" }, { "ldots", "…" }, { "cdots", "⋯" }, { "dots", "…" }, { "prime", "′" }, { "hbar", "ℏ" }, { "ell", "ℓ" },
729
+ { "quad", " " }, { "qquad", " " }, { "left", "" }, { "right", "" }, { "displaystyle", "" }, { "limits", "" }, { "lim", "lim" },
730
+ { "sin", "sin" }, { "cos", "cos" }, { "tan", "tan" }, { "cot", "cot" }, { "ln", "ln" }, { "log", "log" }, { "exp", "exp" }, { "max", "max" }, { "min", "min" },
731
+ { "arcsin", "arcsin" }, { "arccos", "arccos" }, { "arctan", "arctan" }, { "sinh", "sinh" }, { "cosh", "cosh" }, { "tanh", "tanh" },
732
+ };
733
+
734
+ /// One TeX argument starting at i: a {group}, a \command, or a single character. Returns it converted.
735
+ static string TexArg(string s, ref int i)
736
+ {
737
+ while (i < s.Length && s[i] == ' ') i++;
738
+ if (i >= s.Length) return "";
739
+ if (s[i] == '{')
740
+ {
741
+ int depth = 0, start = i + 1;
742
+ for (; i < s.Length; i++)
743
+ {
744
+ if (s[i] == '{') depth++;
745
+ else if (s[i] == '}' && --depth == 0) break;
746
+ }
747
+ string inner = s.Substring(start, Math.Max(0, Math.Min(i, s.Length) - start));
748
+ i++;
749
+ return Tex(inner);
750
+ }
751
+ if (s[i] == '\\')
752
+ {
753
+ int start = i;
754
+ i++;
755
+ while (i < s.Length && char.IsLetter(s[i])) i++;
756
+ if (i == start + 1 && i < s.Length) i++;
757
+ return Tex(s.Substring(start, i - start));
758
+ }
759
+ return s[i++].ToString();
760
+ }
761
+
762
+ /// A space ends the fraction or root just written; without it Word pulls what follows into the denominator.
763
+ static void Gap(StringBuilder o, string s, int i)
764
+ {
765
+ while (i < s.Length && s[i] == ' ') i++;
766
+ if (i < s.Length && (char.IsLetterOrDigit(s[i]) || s[i] == '\\' || s[i] == '(' || s[i] == '{')) o.Append(' ');
767
+ }
768
+
769
+ /// The common part of LaTeX math to Word's linear format (UnicodeMath), which Word then builds into a native equation.
770
+ static string Tex(string s)
771
+ {
772
+ StringBuilder o = new StringBuilder();
773
+ int i = 0;
774
+ while (i < s.Length)
775
+ {
776
+ char c = s[i];
777
+ if (c == '\\')
778
+ {
779
+ int start = ++i;
780
+ while (i < s.Length && char.IsLetter(s[i])) i++;
781
+ if (i == start)
782
+ {
783
+ // \, \; \! \ are spacing; \{ \} \% and the like are the character itself.
784
+ char next = i < s.Length ? s[i++] : ' ';
785
+ if (next == ',' || next == ';' || next == ':' || next == ' ') o.Append(' ');
786
+ else if (next == '\\') o.Append(' ');
787
+ else if (next != '!') o.Append(next);
788
+ continue;
789
+ }
790
+ string name = s.Substring(start, i - start);
791
+ if (name == "frac" || name == "dfrac" || name == "tfrac") {
792
+ string top = TexArg(s, ref i), bottom = TexArg(s, ref i);
793
+ // A factor written right before the fraction (2 rac{a}{b}) must not be read into its numerator.
794
+ if (o.Length > 0 && char.IsLetterOrDigit(o[o.Length - 1])) o.Append(' ');
795
+ o.Append("(" + top + ")/(" + bottom + ")");
796
+ Gap(o, s, i);
797
+ }
798
+ else if (name == "sqrt")
799
+ {
800
+ string degree = null;
801
+ 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; } }
802
+ string body = TexArg(s, ref i);
803
+ o.Append(degree == null ? "√(" + body + ")" : "√(" + degree + "&" + body + ")");
804
+ Gap(o, s, i);
805
+ }
806
+ else if (name == "bar" || name == "overline") o.Append("¯(" + TexArg(s, ref i) + ")");
807
+ else if (name == "hat") o.Append(TexArg(s, ref i) + "\u0302");
808
+ else if (name == "vec") o.Append(TexArg(s, ref i) + "\u20D7");
809
+ else if (name == "dot") o.Append(TexArg(s, ref i) + "\u0307");
810
+ else if (name == "ddot") o.Append(TexArg(s, ref i) + "\u0308");
811
+ else if (name == "tilde") o.Append(TexArg(s, ref i) + "\u0303");
812
+ else if (name == "text" || name == "mathrm" || name == "textrm" || name == "operatorname")
813
+ {
814
+ while (i < s.Length && s[i] == ' ') i++;
815
+ 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); }
816
+ }
817
+ else if (name == "mathbf" || name == "boldsymbol" || name == "mathit" || name == "mathbb" || name == "mathcal") o.Append(TexArg(s, ref i));
818
+ else
819
+ {
820
+ string symbol;
821
+ if (TexSymbols.TryGetValue(name, out symbol))
822
+ {
823
+ o.Append(symbol);
824
+ // A function name is applied to what follows: Word needs a space after it.
825
+ if (symbol.Length > 1 && symbol == name && i < s.Length && s[i] != ' ' && s[i] != '^' && s[i] != '_') o.Append(' ');
826
+ }
827
+ else o.Append('\\').Append(name);
828
+ }
829
+ continue;
830
+ }
831
+ if (c == '^' || c == '_')
832
+ {
833
+ i++;
834
+ bool braced = i < s.Length && s[i] == '{';
835
+ string arg = TexArg(s, ref i);
836
+ o.Append(c).Append(braced && arg.Length > 1 ? "(" + arg + ")" : arg);
837
+ // Otherwise Word reads the next letter into the script: x^2y.
838
+ if (i < s.Length && (char.IsLetterOrDigit(s[i]) || s[i] == '\\')) o.Append(' ');
839
+ continue;
840
+ }
841
+ if (c == '{') { string group = TexArg(s, ref i); o.Append("〖" + group + "〗"); continue; }
842
+ if (c == '}') { i++; continue; }
843
+ if (c == '~') { o.Append(' '); i++; continue; }
844
+ o.Append(c);
845
+ i++;
846
+ }
847
+ return o.ToString();
848
+ }
849
+
850
+ static readonly System.Text.RegularExpressions.Regex Dollars = new System.Text.RegularExpressions.Regex(@"\$\$([^$\r]+?)\$\$|\$([^$\r]+?)\$(?!\$)");
851
+
852
+ static readonly System.Text.RegularExpressions.Regex Tag = new System.Text.RegularExpressions.Regex(@"\\tag\s*\{([^}]*)\}");
853
+
854
+ /// Turn every $...$ (inline) and $$...$$ (on a line of its own) inside a range into a native Word equation.
855
+ static int Mathify(dynamic doc, dynamic range)
856
+ {
857
+ string text = (string)range.Text;
858
+ if (text == null || text.IndexOf('$') < 0) return 0;
859
+ int start = (int)range.Start, made = 0;
860
+ System.Text.RegularExpressions.MatchCollection found = Dollars.Matches(text);
861
+ for (int k = found.Count - 1; k >= 0; k--)
862
+ {
863
+ System.Text.RegularExpressions.Match m = found[k];
864
+ bool display = m.Groups[1].Success;
865
+ string source = (display ? m.Groups[1].Value : m.Groups[2].Value).Trim();
866
+ // ag{1} numbers a display equation: Word sets "#(1)" flush right on the equation's line.
867
+ string tag = null;
868
+ System.Text.RegularExpressions.Match tagged = Tag.Match(source);
869
+ if (tagged.Success) { tag = tagged.Groups[1].Value.Trim(); source = source.Remove(tagged.Index, tagged.Length).Trim(); }
870
+ dynamic spot = null;
871
+ if (m.Length <= 250)
872
+ {
873
+ // Found by its text: positions counted in the text are off wherever the range holds a field (a caption number).
874
+ dynamic search = range.Duplicate;
875
+ if ((bool)search.Find.Execute(FindText: m.Value.Replace("^", "^^"), MatchCase: true, MatchWildcards: false, Forward: true, Wrap: 0)) spot = search;
876
+ }
877
+ if (spot == null) spot = doc.Range(start + m.Index, start + m.Index + m.Length);
878
+ spot.Text = Tex(source) + (display && !string.IsNullOrEmpty(tag) ? "#(" + tag + ")" : "");
879
+ try
880
+ {
881
+ dynamic math = doc.OMaths.Add(spot);
882
+ math.OMaths[1].BuildUp();
883
+ if (display) { try { math.OMaths[1].Type = 0; math.OMaths[1].Justification = 1; } catch (COMException) { } }
884
+ made++;
885
+ }
886
+ catch (COMException) { }
887
+ }
888
+ return made;
889
+ }
890
+
506
891
  // ───────────────────────── Word ─────────────────────────
507
892
 
508
893
  static readonly Dictionary<string, int> WordStyles = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
@@ -530,6 +915,7 @@ static class Program
530
915
  int total = (int)doc.Paragraphs.Count;
531
916
  if (index < 1 || index > total) throw new Fail("ANCHOR_MISSING", "There is no paragraph " + index + " (the document has " + total + "). Call office_read again.");
532
917
  dynamic p = doc.Paragraphs[index];
918
+ Follow(doc, p.Range);
533
919
  if (expect != null)
534
920
  {
535
921
  string text = Squash(Clean((string)p.Range.Text)), want = Squash(expect);
@@ -572,7 +958,7 @@ static class Program
572
958
  int from = Math.Max(1, a.Int("from", 1)), count = Math.Max(1, Math.Min(400, a.Int("count", 120)));
573
959
  bool full = a.Flag("full", false);
574
960
  List<object> items = new List<object>();
575
- int shown = from - 1;
961
+ int shown = from - 1, openTable = -1;
576
962
  string titleStyle = null;
577
963
  try { titleStyle = (string)doc.Styles[-63].NameLocal; } catch (Exception) { }
578
964
  if (from <= total)
@@ -596,19 +982,38 @@ static class Program
596
982
  try { inTable = Truthy(p.Range.Information[12]); } catch (Exception) { }
597
983
  if (inTable)
598
984
  {
599
- // A table is one entry, not one per cell: its cells are read with "table".
600
985
  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;
986
+ int tableStart = (int)table.Range.Start, end = (int)table.Range.End;
987
+ if (tableStart != openTable)
988
+ {
989
+ int inside = (int)table.Range.Paragraphs.Count, cells = (int)table.Range.Cells.Count, rows = (int)table.Rows.Count;
990
+ bool nested = false;
991
+ try { nested = (int)table.NestingLevel > 1; } catch (Exception) { }
992
+ Dictionary<string, object> head = new Dictionary<string, object>();
993
+ head["i"] = i;
994
+ head["to"] = i + inside - 1;
995
+ if (!nested) head["table"] = (int)doc.Range(0, end).Tables.Count;
996
+ string size = "?";
997
+ try { size = rows + "x" + (int)table.Columns.Count; } catch (COMException) { size = cells + " cells"; }
998
+ head["size"] = size;
999
+ // A grid of short cells is one entry (read its cells with "table"). A table whose cells hold the
1000
+ // document's text, as in a form or a report template, is listed paragraph by paragraph.
1001
+ bool open = inside > cells + rows + 2;
1002
+ if (open) head["open"] = true;
1003
+ items.Add(head);
1004
+ if (!open)
1005
+ {
1006
+ i += inside - 1;
1007
+ p = i < total ? doc.Range(end, end).Paragraphs[1] : null;
1008
+ shown = i;
1009
+ continue;
1010
+ }
1011
+ openTable = tableStart;
1012
+ }
1013
+ bool rowEnd = false;
1014
+ try { rowEnd = Truthy(p.Range.Information[31]); } catch (Exception) { }
1015
+ if (rowEnd) { shown = i; p = p.Next(); continue; }
1016
+ item["inTable"] = true;
612
1017
  }
613
1018
  items.Add(item);
614
1019
  shown = i;
@@ -683,7 +1088,62 @@ static class Program
683
1088
  {
684
1089
  int end = (int)paragraph.Range.End;
685
1090
  paragraph.Range.InsertParagraphAfter();
686
- return doc.Range(end, end).Paragraphs[1];
1091
+ dynamic made = doc.Range(end, end).Paragraphs[1];
1092
+ // Word gives the new paragraph the look of whatever FOLLOWS (a caption, a heading); it should
1093
+ // continue the paragraph it was added after.
1094
+ try
1095
+ {
1096
+ made.Style = paragraph.Style;
1097
+ made.Range.ParagraphFormat = paragraph.Range.ParagraphFormat.Duplicate;
1098
+ made.Range.Font = paragraph.Range.Font.Duplicate;
1099
+ }
1100
+ catch (COMException) { }
1101
+ return made;
1102
+ }
1103
+
1104
+ /// Breathing room around a table or figure: at least this much space before / after a paragraph.
1105
+ /// Paragraph spacing, not blank lines, so nothing has to be cleaned up later.
1106
+ const float Air = 8f;
1107
+
1108
+ static void Space(dynamic paragraph, bool before)
1109
+ {
1110
+ if (paragraph == null) return;
1111
+ try
1112
+ {
1113
+ dynamic format = paragraph.Range.ParagraphFormat;
1114
+ if (before) { if ((float)format.SpaceBefore < Air) format.SpaceBefore = Air; }
1115
+ else if ((float)format.SpaceAfter < Air) format.SpaceAfter = Air;
1116
+ }
1117
+ catch (Exception) { }
1118
+ }
1119
+
1120
+ /// A numbered caption, the way reports set them: "表 1 ..." centred above a table, "图 1 ..." centred below
1121
+ /// a figure. The number is a Word caption field, so it renumbers itself and can be cross-referenced.
1122
+ static void Caption(dynamic doc, dynamic target, bool table, string title, string fontName, string fontFarEast)
1123
+ {
1124
+ string label = table ? "表" : "图";
1125
+ dynamic labels = doc.Application.CaptionLabels;
1126
+ bool known = false;
1127
+ foreach (dynamic item in labels) if ((string)item.Name == label) { known = true; break; }
1128
+ if (!known) labels.Add(label);
1129
+ int start = (int)target.Start, end = (int)target.End;
1130
+ target.InsertCaption(Label: label, Title: " " + title.Trim(), Position: table ? 0 : 1);
1131
+ // Above a table the caption is the paragraph that now sits where the table began; below a figure it is the next one.
1132
+ dynamic caption = table ? doc.Range(start, start).Paragraphs[1] : doc.Range(end, end).Paragraphs[1].Next();
1133
+ if (caption == null) return;
1134
+ try
1135
+ {
1136
+ dynamic range = caption.Range;
1137
+ range.ParagraphFormat.Alignment = 1;
1138
+ range.ParagraphFormat.CharacterUnitFirstLineIndent = 0; range.ParagraphFormat.FirstLineIndent = 0;
1139
+ range.Font.Bold = 0; range.Font.Italic = 0; range.Font.Color = -16777216; range.Font.Size = 10.5f;
1140
+ if (!string.IsNullOrEmpty(fontName)) range.Font.Name = fontName;
1141
+ if (!string.IsNullOrEmpty(fontFarEast)) range.Font.NameFarEast = fontFarEast;
1142
+ range.ParagraphFormat.SpaceBefore = table ? Air : 3f;
1143
+ range.ParagraphFormat.SpaceAfter = table ? 3f : Air;
1144
+ Mathify(doc, caption.Range);
1145
+ }
1146
+ catch (COMException) { }
687
1147
  }
688
1148
 
689
1149
  /// The number of a paragraph, counted from the start of the document.
@@ -692,6 +1152,23 @@ static class Program
692
1152
  return (int)doc.Range(0, paragraph.Range.End).Paragraphs.Count;
693
1153
  }
694
1154
 
1155
+ /// A new paragraph with no style named: body text that matches its surroundings. Written next to body
1156
+ /// text it keeps that text's font, size and spacing (a template's own look) but not the emphasis of the
1157
+ /// line it was split from (a bold label, a red hint); written after a heading it becomes Normal.
1158
+ static void Body(dynamic paragraph)
1159
+ {
1160
+ int level = 10;
1161
+ try { level = (int)paragraph.OutlineLevel; } catch (Exception) { }
1162
+ if (level != 10) { Plain(paragraph, "Normal"); return; }
1163
+ try
1164
+ {
1165
+ dynamic font = paragraph.Range.Font;
1166
+ font.Bold = 0; font.Italic = 0; font.Underline = 0; font.Color = -16777216;
1167
+ paragraph.Range.ParagraphFormat.Alignment = 3;
1168
+ }
1169
+ catch (COMException) { }
1170
+ }
1171
+
695
1172
  static void Plain(dynamic paragraph, string style)
696
1173
  {
697
1174
  SetStyle(paragraph.Range, style);
@@ -721,7 +1198,7 @@ static class Program
721
1198
  IList items = op.List("items");
722
1199
  if (items == null) { items = new ArrayList(); items.Add(new Dictionary<string, object> { { "text", op.Need("text") } }); }
723
1200
  dynamic current = null;
724
- int made = 0;
1201
+ int made = 0, equations = 0;
725
1202
  foreach (object raw in items)
726
1203
  {
727
1204
  Bag item = raw is string ? new Bag(new Dictionary<string, object> { { "text", raw } }) : new Bag(raw);
@@ -730,16 +1207,20 @@ static class Program
730
1207
  dynamic p;
731
1208
  if (current == null) p = NewParagraph(doc, op);
732
1209
  else p = After(doc, current);
733
- Plain(p, item.Str("style", op.Str("style", "Normal")));
734
- if (text.Length > 0) p.Range.InsertBefore(text);
1210
+ string style = item.Str("style", op.Str("style", null));
1211
+ if (style != null) Plain(p, style);
1212
+ else Body(p);
735
1213
  WordFormat(p.Range, StyleLess(op));
736
1214
  WordFormat(p.Range, StyleLess(item));
1215
+ Follow(doc, p.Range);
1216
+ Write(doc, (int)p.Range.Start, text);
1217
+ equations += Mathify(doc, p.Range);
737
1218
  current = p;
738
1219
  made++;
739
1220
  }
740
1221
  }
741
1222
  int lastIndex = Index(doc, current);
742
- return "inserted " + made + " paragraph(s), now paragraphs " + (lastIndex - made + 1) + "–" + lastIndex + " of " + (int)doc.Paragraphs.Count;
1223
+ return "inserted " + made + " paragraph(s), now paragraphs " + (lastIndex - made + 1) + "–" + lastIndex + " of " + (int)doc.Paragraphs.Count + (equations > 0 ? ", " + equations + " equation(s)" : "");
743
1224
  }
744
1225
  if ((type == "set_text" || (type == "delete_range" && op.Has("para"))) && op.Raw("expect") == null)
745
1226
  {
@@ -750,8 +1231,10 @@ static class Program
750
1231
  dynamic p = Para(doc, op.Int("para", 0), op.Str("expect", null));
751
1232
  dynamic range = p.Range;
752
1233
  range.MoveEnd(1, -1);
753
- range.Text = Lines(op.Raw("text") ?? "");
754
- return "paragraph " + op.Int("para", 0) + " rewritten";
1234
+ range.Text = "";
1235
+ Write(doc, (int)p.Range.Start, Lines(op.Raw("text") ?? ""));
1236
+ int built = Mathify(doc, p.Range);
1237
+ return "paragraph " + op.Int("para", 0) + " rewritten" + (built > 0 ? ", " + built + " equation(s)" : "");
755
1238
  }
756
1239
  if (type == "format_text" || type == "set_style")
757
1240
  {
@@ -762,8 +1245,20 @@ static class Program
762
1245
  if (type == "delete_range")
763
1246
  {
764
1247
  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";
1248
+ dynamic gone = WordRange(doc, op);
1249
+ string note = "";
1250
+ try { gone.Delete(); }
1251
+ catch (COMException)
1252
+ {
1253
+ // The mark that ends a table cell cannot be deleted: take everything before it.
1254
+ int limit = (int)gone.End - 1;
1255
+ try { int from = (int)gone.Start; limit = Math.Min(limit, (int)doc.Range(from, from).Cells[1].Range.End - 1); } catch (Exception) { }
1256
+ gone.End = limit;
1257
+ 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.");
1258
+ gone.Delete();
1259
+ note = " (the last paragraph of the cell stays, a cell always keeps one)";
1260
+ }
1261
+ return "deleted, " + (int)doc.Paragraphs.Count + " paragraphs now" + note;
767
1262
  }
768
1263
  if (type == "insert_table")
769
1264
  {
@@ -772,7 +1267,18 @@ static class Program
772
1267
  if (data != null) foreach (object row in data) { IList cells = row as IList; if (cells != null) cols = Math.Max(cols, cells.Count); }
773
1268
  if (rows < 1 || cols < 1) throw new Fail("BAD_ARGS", "Give \"data\" (rows of cells) or \"rows\" and \"cols\".");
774
1269
  dynamic p = NewParagraph(doc, op);
1270
+ string fontName = null, fontFarEast = null;
1271
+ float fontSize = 0;
1272
+ try
1273
+ {
1274
+ if ((int)p.OutlineLevel == 10)
1275
+ {
1276
+ fontName = (string)p.Range.Font.Name; fontFarEast = (string)p.Range.Font.NameFarEast; fontSize = (float)p.Range.Font.Size;
1277
+ }
1278
+ }
1279
+ catch (Exception) { }
775
1280
  Plain(p, "Normal");
1281
+ Follow(doc, p.Range);
776
1282
  dynamic table = doc.Tables.Add(p.Range, rows, cols);
777
1283
  // The cells take the formatting of the paragraph that follows (a heading, say): make them plain.
778
1284
  SetStyle(table.Range, "Normal");
@@ -782,36 +1288,76 @@ static class Program
782
1288
  table.Range.ParagraphFormat.CharacterUnitFirstLineIndent = 0; table.Range.ParagraphFormat.FirstLineIndent = 0;
783
1289
  }
784
1290
  catch (COMException) { }
1291
+ try
1292
+ {
1293
+ // Same typeface and size as the body text the table sits in; cells centred like a data table.
1294
+ if (!string.IsNullOrEmpty(fontName)) table.Range.Font.Name = fontName;
1295
+ if (!string.IsNullOrEmpty(fontFarEast)) table.Range.Font.NameFarEast = fontFarEast;
1296
+ if (fontSize > 4 && fontSize < 100) table.Range.Font.Size = fontSize;
1297
+ table.Range.ParagraphFormat.Alignment = 1;
1298
+ table.Range.Cells.VerticalAlignment = 1;
1299
+ }
1300
+ catch (COMException) { }
785
1301
  table.Borders.Enable = 1;
1302
+ try { table.AutoFitBehavior(2); } catch (COMException) { }
786
1303
  if (data != null)
787
1304
  {
788
1305
  for (int r = 0; r < data.Count && r < rows; r++)
789
1306
  {
790
1307
  IList cells = data[r] as IList;
791
1308
  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]);
1309
+ for (int c = 0; c < cells.Count; c++)
1310
+ {
1311
+ if (cells[c] == null) continue;
1312
+ dynamic cell = table.Cell(r + 1, c + 1).Range;
1313
+ string value = Convert.ToString(cells[c], System.Globalization.CultureInfo.InvariantCulture);
1314
+ cell.Text = value;
1315
+ if (value.IndexOf('$') >= 0) Mathify(doc, table.Cell(r + 1, c + 1).Range);
1316
+ }
1317
+ if (Typing) Thread.Sleep(25);
793
1318
  }
794
1319
  }
795
1320
  if (op.Flag("header", true)) table.Rows[1].Range.Font.Bold = 1;
1321
+ if (op.Has("caption")) Caption(doc, table.Range, true, op.Need("caption"), fontName, fontFarEast);
1322
+ else
1323
+ {
1324
+ int tableStart = (int)table.Range.Start;
1325
+ if (tableStart > 0) Space(doc.Range(tableStart - 1, tableStart - 1).Paragraphs[1], false);
1326
+ }
1327
+ int afterTable = (int)table.Range.End;
1328
+ if (afterTable < (int)doc.Content.End) Space(doc.Range(afterTable, afterTable).Paragraphs[1], true);
796
1329
  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;
1330
+ bool nested = false;
1331
+ try { nested = (int)table.NestingLevel > 1; } catch (Exception) { }
1332
+ 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
1333
  }
799
1334
  if (type == "set_cell")
800
1335
  {
801
1336
  int n = op.Int("table", 1);
802
1337
  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";
1338
+ dynamic cell = doc.Tables[n].Cell(op.Int("row", 1), op.Int("col", 1));
1339
+ Follow(doc, cell.Range);
1340
+ cell.Range.Text = "";
1341
+ Write(doc, (int)cell.Range.Start, Lines(op.Raw("text") ?? ""));
1342
+ int built = Mathify(doc, doc.Tables[n].Cell(op.Int("row", 1), op.Int("col", 1)).Range);
1343
+ return "cell set" + (built > 0 ? ", " + built + " equation(s)" : "");
805
1344
  }
806
1345
  if (type == "insert_image")
807
1346
  {
808
1347
  string path = op.Need("path");
809
1348
  if (!File.Exists(path)) throw new Fail("BAD_ARGS", "Image \"" + path + "\" does not exist.");
810
1349
  dynamic p = NewParagraph(doc, op);
1350
+ string bodyFont = null, bodyFarEast = null;
1351
+ try { if ((int)p.OutlineLevel == 10) { bodyFont = (string)p.Range.Font.Name; bodyFarEast = (string)p.Range.Font.NameFarEast; } } catch (Exception) { }
811
1352
  Plain(p, "Normal");
1353
+ Follow(doc, p.Range);
812
1354
  dynamic picture = p.Range.InlineShapes.AddPicture(FileName: path, LinkToFile: false, SaveWithDocument: true);
813
1355
  if (op.Has("width")) { picture.LockAspectRatio = -1; picture.Width = (float)op.Num("width", 300); }
1356
+ try { p.Range.ParagraphFormat.CharacterUnitFirstLineIndent = 0; p.Range.ParagraphFormat.FirstLineIndent = 0; p.Range.ParagraphFormat.Alignment = 1; } catch (COMException) { }
814
1357
  WordFormat(p.Range, StyleLess(op));
1358
+ p.Range.ParagraphFormat.SpaceBefore = Air;
1359
+ if (op.Has("caption")) Caption(doc, picture.Range, false, op.Need("caption"), bodyFont, bodyFarEast);
1360
+ else p.Range.ParagraphFormat.SpaceAfter = Air;
815
1361
  return "image inserted";
816
1362
  }
817
1363
  throw new Fail("BAD_ARGS", "Unknown Word operation \"" + type + "\".");
@@ -953,6 +1499,23 @@ static class Program
953
1499
  if (op.Has("rowHeight")) range.RowHeight = op.Num("rowHeight", 15);
954
1500
  }
955
1501
 
1502
+ /// Bring the cells being worked on into view, without touching the selection.
1503
+ static void Show(dynamic book, dynamic sheet, dynamic range)
1504
+ {
1505
+ if (!Following) return;
1506
+ try
1507
+ {
1508
+ sheet.Activate();
1509
+ dynamic window = book.Windows[1];
1510
+ dynamic visible = window.VisibleRange;
1511
+ int row = (int)range.Row, column = (int)range.Column;
1512
+ int top = (int)visible.Row, left = (int)visible.Column;
1513
+ if (row < top || row >= top + (int)visible.Rows.Count - 1) window.ScrollRow = Math.Max(1, row - 3);
1514
+ if (column < left || column >= left + (int)visible.Columns.Count - 1) window.ScrollColumn = Math.Max(1, column - 1);
1515
+ }
1516
+ catch (Exception) { }
1517
+ }
1518
+
956
1519
  static string ExcelOp(dynamic app, dynamic book, string type, Bag op)
957
1520
  {
958
1521
  if (type == "add_sheet")
@@ -977,7 +1540,18 @@ static class Program
977
1540
  for (int c = 0; c < cells.Count; c++) grid[r, c] = Scalar(cells[c]);
978
1541
  }
979
1542
  dynamic target = Cells(ws, op.Str("range", "A1")).Cells[1, 1].Resize[rows.Count, width];
980
- target.Formula = grid;
1543
+ Show(book, ws, target);
1544
+ if (Typing && rows.Count > 1 && rows.Count <= 60)
1545
+ {
1546
+ for (int r = 0; r < rows.Count; r++)
1547
+ {
1548
+ object[,] line = new object[1, width];
1549
+ for (int c = 0; c < width; c++) line[0, c] = grid[r, c];
1550
+ target.Rows[r + 1].Formula = line;
1551
+ Thread.Sleep(35);
1552
+ }
1553
+ }
1554
+ else target.Formula = grid;
981
1555
  ExcelFormat(target, op);
982
1556
  return "wrote " + (string)target.Address[false, false];
983
1557
  }
@@ -1103,9 +1677,22 @@ static class Program
1103
1677
  return result;
1104
1678
  }
1105
1679
 
1680
+ static void PptWrite(dynamic textRange, string text)
1681
+ {
1682
+ if (!Typing || text.Length < 4) { textRange.Text = text; return; }
1683
+ textRange.Text = "";
1684
+ foreach (string piece in Pieces(text)) { textRange.InsertAfter(piece); Thread.Sleep(22); }
1685
+ }
1686
+
1687
+ static void GoTo(dynamic deck, int slide)
1688
+ {
1689
+ if (!Following) return;
1690
+ try { deck.Windows[1].View.GotoSlide(slide); } catch (Exception) { }
1691
+ }
1692
+
1106
1693
  static void PptText(dynamic shape, Bag op)
1107
1694
  {
1108
- if (op.Has("text")) shape.TextFrame.TextRange.Text = Lines(op.Raw("text"));
1695
+ if (op.Has("text")) PptWrite(shape.TextFrame.TextRange, Lines(op.Raw("text")));
1109
1696
  if (!(op.Has("font") || op.Has("size") || op.Has("bold") || op.Has("italic") || op.Has("color") || op.Has("align"))) return;
1110
1697
  dynamic range = shape.TextFrame.TextRange;
1111
1698
  if (op.Has("font")) { string font = op.Need("font"); range.Font.Name = font; try { range.Font.NameFarEast = font; } catch (COMException) { } }
@@ -1144,12 +1731,14 @@ static class Program
1144
1731
  string name = op.Str("layout", "title_content");
1145
1732
  if (!Layouts.TryGetValue(name, out layout)) throw new Fail("BAD_ARGS", "Unknown layout \"" + name + "\": use " + string.Join(", ", new List<string>(Layouts.Keys).ToArray()) + ".");
1146
1733
  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"));
1734
+ GoTo(deck, index);
1735
+ if (op.Has("title")) PptWrite(slide.Shapes.Title.TextFrame.TextRange, Lines(op.Raw("title")));
1736
+ if (op.Has("body")) PptWrite(slide.Shapes.Placeholders[2].TextFrame.TextRange, Lines(op.Raw("body")));
1149
1737
  if (op.Has("notes")) slide.NotesPage.Shapes.Placeholders[2].TextFrame.TextRange.Text = Lines(op.Raw("notes"));
1150
1738
  return "slide " + index + " added";
1151
1739
  }
1152
1740
  dynamic target = Slide(deck, op);
1741
+ GoTo(deck, op.Int("slide", 0));
1153
1742
  if (type == "delete_slide") { target.Delete(); return "slide deleted"; }
1154
1743
  if (type == "move_slide") { target.MoveTo(op.Int("to", 1)); return "slide moved to " + op.Int("to", 1); }
1155
1744
  if (type == "set_notes") { target.NotesPage.Shapes.Placeholders[2].TextFrame.TextRange.Text = Lines(op.Raw("text") ?? ""); return "notes set"; }
@@ -1189,6 +1778,12 @@ static class Program
1189
1778
  if (ops == null || ops.Count == 0) throw new Fail("BAD_ARGS", "\"ops\" must list at least one operation.");
1190
1779
  List<object> done = new List<object>();
1191
1780
  Dictionary<string, object> result = new Dictionary<string, object>();
1781
+ // The settings give the two modes, until the user flips one on the card.
1782
+ if (!Card.FollowChosen) Following = a.Flag("follow", false);
1783
+ if (!Card.TypingChosen) Typing = a.Flag("typing", false);
1784
+ bool card = a.Flag("card", false);
1785
+ if (card && !cardStarted) { cardStarted = true; Card.Start(); }
1786
+ string docName = (string)doc.Name;
1192
1787
  dynamic undo = null;
1193
1788
  // One undo step for the whole batch: the user takes it back with a single Ctrl+Z.
1194
1789
  if (kind == "word") { try { undo = app.UndoRecord; undo.StartCustomRecord("AI edit"); } catch (COMException) { undo = null; } }
@@ -1201,6 +1796,7 @@ static class Program
1201
1796
  {
1202
1797
  Bag op = new Bag(ops[i]);
1203
1798
  type = op.Need("op");
1799
+ if (card) Card.Report("AI 正在编辑 " + docName + (ops.Count > 1 ? " · " + (i + 1) + "/" + ops.Count : ""));
1204
1800
  string line = kind == "word" ? WordOp(doc, type, op) : kind == "excel" ? ExcelOp(app, doc, type, op) : PptOp(doc, type, op);
1205
1801
  string ignored = op.Unread();
1206
1802
  done.Add(ignored.Length == 0 ? line : line + " — IGNORED field(s) " + ignored + ": " + type + " does not take them, check the operation list");
@@ -1222,6 +1818,7 @@ static class Program
1222
1818
  {
1223
1819
  if (undo != null) { try { undo.EndCustomRecord(); } catch (COMException) { } }
1224
1820
  }
1821
+ if (card) Card.Report(result.ContainsKey("failed") ? "AI 的修改停在第 " + (done.Count + 1) + " 项" : "AI 已改好 " + docName);
1225
1822
  result["done"] = done;
1226
1823
  result["total"] = ops.Count;
1227
1824
  return result;
@@ -1252,13 +1849,21 @@ static class Program
1252
1849
  using (Metafile meta = new Metafile(stream))
1253
1850
  {
1254
1851
  int height = (int)Math.Round(width * (double)meta.Height / meta.Width);
1852
+ // Drawn straight at the final size, hairlines (table borders) fall between pixels and vanish:
1853
+ // draw the page large first, then scale the picture down.
1854
+ int factor = Math.Max(2, (int)Math.Ceiling(3000.0 / width));
1855
+ using (Bitmap large = new Bitmap(width * factor, height * factor))
1255
1856
  using (Bitmap bitmap = new Bitmap(width, height))
1256
1857
  {
1257
- using (Graphics g = Graphics.FromImage(bitmap))
1858
+ using (Graphics g = Graphics.FromImage(large))
1258
1859
  {
1259
1860
  g.Clear(Color.White);
1861
+ g.DrawImage(meta, 0, 0, large.Width, large.Height);
1862
+ }
1863
+ using (Graphics g = Graphics.FromImage(bitmap))
1864
+ {
1260
1865
  g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
1261
- g.DrawImage(meta, 0, 0, width, height);
1866
+ g.DrawImage(large, 0, 0, width, height);
1262
1867
  }
1263
1868
  bitmap.Save(path, ImageFormat.Png);
1264
1869
  }