@copylee/dsh-office 0.2.3 → 0.3.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/README.md CHANGED
@@ -14,9 +14,11 @@
14
14
  | 实时可见,可以一起改 | 每一步修改即时出现在 Word / Excel / PowerPoint 窗口里。你可以同时在里面打字、调格式,AI 每次动手前读到的都是文档当下的样子 |
15
15
  | 不需要窗口在前台 | 修改不靠鼠标键盘,Office 窗口被挡住或在后台也照常进行,你可以同时用电脑做别的事 |
16
16
  | 跟随与静默 | 默认“跟随”:AI 改到哪里,窗口自动滚动到哪里(Word 滚到那一段,Excel 滚到那片单元格,PowerPoint 切到那一页),不动你的光标和选区。关闭即静默执行,不动你的视图 |
17
- | 逐字写入 | 文字分小段写出,像打字一样出现,表格逐行填入,而不是整块贴上去。每段最多多花约 1 秒,可关闭 |
18
- | 迷你卡片 | AI 打开或编辑文档时屏幕右下角出现一张小卡片:正在改哪个文档、改到第几项,带“跟随”“逐字”两个开关,点一下立即生效(正在进行的这一批也跟着变)。点“跟随”会把文档窗口带到前台并开始跟随;你切到别的窗口后它自动熄灭,再点一次回来。可拖动,不抢焦点,AI 停手几秒后自动消失 |
19
- | Word 原生公式 | AI 在文字里用 `$...$`(行内)和 `$$...$$`(单独一行)写 LaTeX,插件转成 Word 原生公式(分式、根号、上下标、希腊字母、求和、积分、上划线等常用写法);单独一行的公式末尾加 `\tag{1}` 即在右侧对齐编号 |
17
+ | 逐字写入 | 文字分小段写出,像打字一样出现,表格逐行填入,而不是整块贴上去。每段约多花 0.3 秒,可关闭 |
18
+ | 迷你卡片 | AI 每次动手修改文档时,屏幕右下角出现一张小卡片:正在改哪个文档、改到第几项,带“查看”“逐字”两个开关,点一下立即生效(正在进行的这一批也跟着变)。点“查看”会把文档窗口带到前台并跟随修改位置,按钮变成“查看中”;你切到别的窗口后它自动熄灭,再点一次回来。可拖动,不抢焦点;这一批改完就消失,下一批开始时再出现 |
19
+ | Word 原生公式 | AI 在文字里用 `$...$`(行内)和 `$$...$$`(单独一行)写 LaTeX,插件转成 Word 原生公式:分式、根号、上下标、希腊字母、求和与积分、范数、矩阵(`pmatrix` 等)、分段函数(`cases`)等常用写法;单独一行的公式末尾加 `\tag{1}` 即在右侧对齐编号。正文、表格单元格、题注、查找替换的内容里都可以写 |
20
+ | 引用与参考文献 | 正文里写 `\cite{1}`、`\cite{2,5}`、`\cite{3-6}`,变成上标 [1]、[2,5]、[3-6];`insert_references` 生成参考文献列表。引用是 Word 的交叉引用域,按住 Ctrl 点击跳到对应文献 |
21
+ | 样式与版式 | 直接修改样式本身(标题用什么字体字号、是否自动编号,正文的字体、行距、首行缩进),所有用到它的段落一起变;设置纸张和页边距、页码、页眉、目录(保存时自动刷新)、某段另起一页。这些都不需要再去点 Word 的对话框 |
20
22
  | 填模板 | 报告模板常把正文放在大单元格里。这类表格会按段落展开给模型看,正文、公式、表格都写进单元格里面,而不是堆到文档末尾;新写的正文沿用旁边正文的字体、字号和行距 |
21
23
  | 规范的图表题注 | 插入表格或图片时给出题注,插件按规范排:自动编号(“表 1”“图 1”,是 Word 题注域,增删后自动重排)、居中、表题在上、图题在下;图表前后留出段间距,图片居中 |
22
24
  | 不被自带技能带偏 | DSH 桌面版自带的 Office 技能会让模型写脚本生成文件,这样你看不到过程,文件开着时还会写入失败。插件在模型第一次加载这些技能时把它引回来,确有需要(批量转换、应用打不开的文件)再次请求即放行 |
@@ -66,10 +68,11 @@ AI 只有在调用保存时才写入文件,不会替你关闭文档。
66
68
  | `office_edit` | 按顺序执行一批修改,不保存 |
67
69
  | `office_render` | 把一页 / 一张幻灯片 / 一个单元格区域渲染成图片;Word 和 PowerPoint 一次最多看 4 页,或用总览把全部页面拼成一张图 |
68
70
  | `office_save` | 保存;给出路径时另存为,扩展名为 `.pdf` 时导出 PDF;改完没看过成品时附带各页图片 |
71
+ | `office_close` | 关闭 AI 自己打开或新建的文档;你原本开着的文档会被拒绝,有未保存修改时必须明确说保存还是丢弃 |
69
72
 
70
73
  `office_edit` 支持的操作:
71
74
 
72
- - Word:`insert_paragraphs`、`set_text`、`replace_text`、`format_text`、`delete_range`、`insert_table`、`set_cell`、`insert_image`
75
+ - Word:`insert_paragraphs`、`set_text`、`replace_text`、`format_text`、`delete_range`、`insert_table`、`set_cell`、`insert_image`、`insert_references`、`style_format`、`page_setup`、`page_numbers`、`header`、`page_break`、`insert_toc`、`update_fields`
73
76
  - Excel:`write_range`(值或公式)、`format_range`、`autofit`、`insert_rows`、`delete_rows`、`add_sheet`、`rename_sheet`、`delete_sheet`、`add_chart`
74
77
  - PowerPoint:`add_slide`、`set_text`、`add_textbox`、`add_image`、`set_shape`、`delete_shape`、`delete_slide`、`move_slide`、`set_notes`
75
78
 
@@ -81,7 +84,7 @@ AI 只有在调用保存时才写入文件,不会替你关闭文档。
81
84
  |---|---|---|
82
85
  | 跟随 AI 的修改位置 | 开 | 窗口自动滚动到 AI 正在修改的位置;关闭为静默执行 |
83
86
  | 逐字写入 | 开 | 文字像打字一样逐步出现;关闭后写得最快 |
84
- | 迷你卡片 | 开 | AI 编辑时在右下角显示带“跟随”“逐字”开关的小卡片。在卡片上切换过的开关(包括你离开文档窗口后自动熄灭的“跟随”),到 DSH 重启前以卡片为准 |
87
+ | 迷你卡片 | 开 | AI 修改文档期间在右下角显示带“查看”“逐字”开关的小卡片。在卡片上切换过的开关(包括你离开文档窗口后自动熄灭的“查看”),到 DSH 重启前以卡片为准 |
85
88
  | 交付前整体视觉检查 | 开 | 保存时自动把整份文档的总览图交给 AI 检查;会多用一些 token |
86
89
  | 优先在真实 Office 里操作 | 开 | DSH 桌面版自带 office-docx / office-xlsx / office-pptx 技能,用脚本生成文件。开启后,AI 第一次想加载这些技能时会被引回本插件(对应的 Office 应用已安装时才拦);它再次请求时放行 |
87
90
  | 打开文档时显示到前台 | 开 | 关闭后文档仍会打开,只是不抢当前窗口 |
@@ -94,7 +97,8 @@ AI 只有在调用保存时才写入文件,不会替你关闭文档。
94
97
  - Excel 和 PowerPoint 里 AI 的修改无法用 Ctrl+Z 撤回(Excel 的外部修改不进撤销栈,并且会清空已有的撤销记录)。只有 Word 支持整批撤销。
95
98
  - 你正在单元格里输入、或 Office 开着模态对话框时,修改会被 Office 拒绝;插件等待约 5 秒后报告“应用忙”。
96
99
  - Excel 区域的图片是通过剪贴板取得的,渲染时剪贴板里的文字会被保留,图片、文件等其他内容会丢失。
97
- - 公式只支持 Word,PowerPoint 和 Excel 里还不能写原生公式;LaTeX 只转换常用写法,矩阵、多行对齐等环境不支持。
100
+ - 公式只支持 Word,PowerPoint 和 Excel 里还不能写原生公式;LaTeX 只转换常用写法,没转成功的公式保留为文本并在结果里提示。
101
+ - 引用按编号书写,插入或删除文献后不会自动重新编号。
98
102
  - 兼容模式(Word 97–2003 格式)的文档里不能建立原生公式。
99
103
  - 目前不支持 WPS。
100
104
  - 没有打开 Office 时处理文件、批量转换格式不在本插件范围内。
@@ -119,6 +119,8 @@ class Card : Form
119
119
  static readonly ManualResetEvent Ready = new ManualResetEvent(false);
120
120
  /// Set once the user has flipped a switch here: from then on the card, not the settings page, decides.
121
121
  public static volatile bool FollowChosen, TypingChosen;
122
+ /// Set for as long as an edit is running: the card stays up through it, however long one operation takes, and goes when it ends.
123
+ public static volatile bool Hold;
122
124
 
123
125
  string line = "";
124
126
  DateTime shown = DateTime.MinValue;
@@ -184,7 +186,7 @@ class Card : Form
184
186
  }
185
187
  bool nowLit = Program.Following && watching;
186
188
  if (nowLit != lit) { lit = nowLit; Invalidate(); }
187
- if (Visible && (DateTime.UtcNow - shown).TotalSeconds > 6) Hide();
189
+ if (Visible && !Hold && (DateTime.UtcNow - shown).TotalSeconds > 1) Hide();
188
190
  };
189
191
  timer.Start();
190
192
  }
@@ -226,7 +228,7 @@ class Card : Form
226
228
  int right = Width - S(10);
227
229
  Chip(g, font, "逐字", Program.Typing, ref right, out typingBox);
228
230
  bool following = Program.Following && Watching();
229
- Chip(g, font, following ? "跟随中" : "跟随", following, ref right, out followBox);
231
+ Chip(g, font, following ? "查看中" : "查看", following, ref right, out followBox);
230
232
  using (SolidBrush dot = new SolidBrush(Color.FromArgb(217, 119, 87))) g.FillEllipse(dot, S(12), (Height - S(8)) / 2, S(8), S(8));
231
233
  Rectangle text = new Rectangle(S(26), 0, right - S(30), Height);
232
234
  TextRenderer.DrawText(g, line, font, text, Color.FromArgb(235, 235, 240), TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix);
@@ -507,6 +509,13 @@ static class Program
507
509
  static object Run(string cmd, Bag a)
508
510
  {
509
511
  if (cmd == "ping") return "pong";
512
+ if (cmd == "card")
513
+ {
514
+ // The agent's turn ended: say so and let the card go.
515
+ Card.Hold = a.Flag("hold", false);
516
+ if (cardStarted) Card.Report(a.Str("text", "AI 已完成"));
517
+ return "ok";
518
+ }
510
519
  if (cmd == "status") return Status();
511
520
  string kind = Kind(a);
512
521
  if (cmd == "open") return Open(kind, a);
@@ -621,7 +630,7 @@ static class Program
621
630
  if (!cardStarted) { cardStarted = true; Card.Start(); }
622
631
  Following = Card.FollowChosen ? Following : a.Flag("follow", false);
623
632
  Typing = Card.TypingChosen ? Typing : a.Flag("typing", false);
624
- Card.Report("AI 打开了 " + (string)doc.Name);
633
+ // No card here: it shows while an edit is running.
625
634
  }
626
635
  return info;
627
636
  }
@@ -650,6 +659,7 @@ static class Program
650
659
 
651
660
  static object Save(string kind, dynamic app, dynamic doc, Bag a)
652
661
  {
662
+ if (kind == "word") Refresh(doc);
653
663
  string path = a.Str("path", null);
654
664
  Dictionary<string, object> result = new Dictionary<string, object>();
655
665
  if (path == null)
@@ -755,11 +765,11 @@ static class Program
755
765
  try { doc.Windows[1].ScrollIntoView(range, true); } catch (Exception) { }
756
766
  }
757
767
 
758
- /// Pieces to write a text in: up to 40 of them, so that a long paragraph still takes about a second.
768
+ /// Pieces to write a text in: up to 14 of them, so that a long paragraph takes about a third of a second.
759
769
  static List<string> Pieces(string text)
760
770
  {
761
771
  List<string> pieces = new List<string>();
762
- int size = Math.Max(2, (int)Math.Ceiling(text.Length / 40.0));
772
+ int size = Math.Max(3, (int)Math.Ceiling(text.Length / 14.0));
763
773
  for (int i = 0; i < text.Length; i += size) pieces.Add(text.Substring(i, Math.Min(size, text.Length - i)));
764
774
  return pieces;
765
775
  }
@@ -777,7 +787,7 @@ static class Program
777
787
  {
778
788
  doc.Range(at, at).InsertAfter(piece);
779
789
  at += piece.Length;
780
- Thread.Sleep(22);
790
+ Thread.Sleep(14);
781
791
  }
782
792
  return at;
783
793
  }
@@ -797,6 +807,9 @@ static class Program
797
807
  { "to", "→" }, { "rightarrow", "→" }, { "leftarrow", "←" }, { "Rightarrow", "⇒" }, { "Leftrightarrow", "⇔" }, { "in", "∈" }, { "notin", "∉" },
798
808
  { "subset", "⊂" }, { "cup", "∪" }, { "cap", "∩" }, { "forall", "∀" }, { "exists", "∃" }, { "angle", "∠" }, { "perp", "⊥" }, { "parallel", "∥" },
799
809
  { "circ", "∘" }, { "degree", "°" }, { "ldots", "…" }, { "cdots", "⋯" }, { "dots", "…" }, { "prime", "′" }, { "hbar", "ℏ" }, { "ell", "ℓ" },
810
+ { "Vert", "‖" }, { "lVert", "‖" }, { "rVert", "‖" }, { "vert", "|" }, { "lvert", "|" }, { "rvert", "|" }, { "mid", "|" },
811
+ { "top", "⊤" }, { "langle", "⟨" }, { "rangle", "⟩" }, { "otimes", "⊗" }, { "oplus", "⊕" }, { "neg", "¬" }, { "ast", "∗" },
812
+ { "det", "det" }, { "rank", "rank" }, { "dim", "dim" }, { "sup", "sup" }, { "inf", "inf" }, { "arg", "arg" }, { "cond", "cond" },
800
813
  { "quad", " " }, { "qquad", " " }, { "left", "" }, { "right", "" }, { "displaystyle", "" }, { "limits", "" }, { "lim", "lim" },
801
814
  { "sin", "sin" }, { "cos", "cos" }, { "tan", "tan" }, { "cot", "cot" }, { "ln", "ln" }, { "log", "log" }, { "exp", "exp" }, { "max", "max" }, { "min", "min" },
802
815
  { "arcsin", "arcsin" }, { "arccos", "arccos" }, { "arctan", "arctan" }, { "sinh", "sinh" }, { "cosh", "cosh" }, { "tanh", "tanh" },
@@ -842,9 +855,19 @@ static class Program
842
855
  {
843
856
  StringBuilder o = new StringBuilder();
844
857
  int i = 0;
858
+ // A sum, product or integral was written and its limits may still follow; what comes after them is its
859
+ // operand, which Word's linear format introduces with "▒" (left out, Word draws an empty box there).
860
+ bool nary = false;
845
861
  while (i < s.Length)
846
862
  {
847
863
  char c = s[i];
864
+ if (nary && c != '^' && c != '_' && c != ' ')
865
+ {
866
+ if (c == '\\' && string.CompareOrdinal(s, i, "\\limits", 0, 7) == 0) { i += 7; continue; }
867
+ if (c == '\\' && string.CompareOrdinal(s, i, "\\nolimits", 0, 9) == 0) { i += 9; continue; }
868
+ o.Append('▒');
869
+ nary = false;
870
+ }
848
871
  if (c == '\\')
849
872
  {
850
873
  int start = ++i;
@@ -853,12 +876,51 @@ static class Program
853
876
  {
854
877
  // \, \; \! \ are spacing; \{ \} \% and the like are the character itself.
855
878
  char next = i < s.Length ? s[i++] : ' ';
856
- if (next == ',' || next == ';' || next == ':' || next == ' ') o.Append(' ');
879
+ if (next == '|') o.Append('‖');
880
+ else if (next == ',' || next == ';' || next == ':' || next == ' ') o.Append(' ');
857
881
  else if (next == '\\') o.Append(' ');
858
882
  else if (next != '!') o.Append(next);
859
883
  continue;
860
884
  }
861
885
  string name = s.Substring(start, i - start);
886
+ if (name == "begin")
887
+ {
888
+ // \begin{pmatrix} a & b \\ c & d \end{pmatrix}: Word's linear form is (■(a&b@c&d)).
889
+ int close = s.IndexOf('}', i);
890
+ string env = close > i ? s.Substring(i + 1, close - i - 1).Trim() : "";
891
+ string ending = "\\end{" + env + "}";
892
+ int stop = close > i ? s.IndexOf(ending, close, StringComparison.Ordinal) : -1;
893
+ if (stop > 0)
894
+ {
895
+ string body = s.Substring(close + 1, stop - close - 1);
896
+ i = stop + ending.Length;
897
+ if (env == "array" && body.TrimStart().StartsWith("{", StringComparison.Ordinal)) body = body.Substring(body.IndexOf('}') + 1);
898
+ List<string> rows = new List<string>();
899
+ foreach (string row in body.Replace("\\\\", "\u0001").Split('\u0001'))
900
+ {
901
+ if (row.Trim().Length == 0) continue;
902
+ List<string> cells = new List<string>();
903
+ foreach (string cell in row.Split('&')) cells.Add(Tex(cell.Trim()));
904
+ rows.Add(string.Join("&", cells.ToArray()));
905
+ }
906
+ string grid = string.Join("@", rows.ToArray());
907
+ if (env == "pmatrix") o.Append("(■(" + grid + "))");
908
+ else if (env == "bmatrix") o.Append("[■(" + grid + ")]");
909
+ else if (env == "vmatrix") o.Append("|■(" + grid + ")|");
910
+ else if (env == "Vmatrix") o.Append("‖■(" + grid + ")‖");
911
+ else if (env == "cases") o.Append("{█(" + grid + ")┤");
912
+ else if (env == "aligned" || env == "align" || env == "split" || env == "gathered") o.Append("█(" + grid + ")");
913
+ else o.Append("■(" + grid + ")");
914
+ continue;
915
+ }
916
+ }
917
+ if (name == "mathbb")
918
+ {
919
+ string letter = TexArg(s, ref i);
920
+ string doubled = letter == "R" ? "ℝ" : letter == "N" ? "ℕ" : letter == "Z" ? "ℤ" : letter == "Q" ? "ℚ" : letter == "C" ? "ℂ" : letter;
921
+ o.Append(doubled);
922
+ continue;
923
+ }
862
924
  if (name == "frac" || name == "dfrac" || name == "tfrac") {
863
925
  string top = TexArg(s, ref i), bottom = TexArg(s, ref i);
864
926
  // A factor written right before the fraction (2 rac{a}{b}) must not be read into its numerator.
@@ -892,6 +954,7 @@ static class Program
892
954
  if (TexSymbols.TryGetValue(name, out symbol))
893
955
  {
894
956
  o.Append(symbol);
957
+ if (symbol == "∑" || symbol == "∏" || symbol == "∫" || symbol == "∮") nary = true;
895
958
  // A function name is applied to what follows: Word needs a space after it.
896
959
  if (symbol.Length > 1 && symbol == name && i < s.Length && s[i] != ' ' && s[i] != '^' && s[i] != '_') o.Append(' ');
897
960
  }
@@ -905,8 +968,8 @@ static class Program
905
968
  bool braced = i < s.Length && s[i] == '{';
906
969
  string arg = TexArg(s, ref i);
907
970
  o.Append(c).Append(braced && arg.Length > 1 ? "(" + arg + ")" : arg);
908
- // Otherwise Word reads the next letter into the script: x^2y.
909
- if (i < s.Length && (char.IsLetterOrDigit(s[i]) || s[i] == '\\')) o.Append(' ');
971
+ // A space ends the script; otherwise Word reads what follows into it: x^2y, λ_max(A).
972
+ if (!nary && i < s.Length && "^_ )]},.;:,。、".IndexOf(s[i]) < 0) o.Append(' ');
910
973
  continue;
911
974
  }
912
975
  if (c == '{') { string group = TexArg(s, ref i); o.Append("〖" + group + "〗"); continue; }
@@ -934,6 +997,30 @@ static class Program
934
997
  System.Text.RegularExpressions.Match m = found[k];
935
998
  bool display = m.Groups[1].Success;
936
999
  string source = (display ? m.Groups[1].Value : m.Groups[2].Value).Trim();
1000
+ try
1001
+ {
1002
+ if (BuildOne(doc, range, start, m, display, source)) made++;
1003
+ else MathFailed++;
1004
+ }
1005
+ catch (Exception) { MathFailed++; }
1006
+ }
1007
+ return made;
1008
+ }
1009
+
1010
+ /// Formulas that could not be turned into equations during the current operation (they stay as text).
1011
+ static int MathFailed;
1012
+
1013
+ static string MathNote()
1014
+ {
1015
+ if (MathFailed == 0) return "";
1016
+ string note = " — NOTE " + MathFailed + " formula(s) could not be built and were left as text: check them with office_render and rewrite them more simply";
1017
+ MathFailed = 0;
1018
+ return note;
1019
+ }
1020
+
1021
+ static bool BuildOne(dynamic doc, dynamic range, int start, System.Text.RegularExpressions.Match m, bool display, string source)
1022
+ {
1023
+ {
937
1024
  // ag{1} numbers a display equation: Word sets "#(1)" flush right on the equation's line.
938
1025
  string tag = null;
939
1026
  System.Text.RegularExpressions.Match tagged = Tag.Match(source);
@@ -952,11 +1039,10 @@ static class Program
952
1039
  dynamic math = doc.OMaths.Add(spot);
953
1040
  math.OMaths[1].BuildUp();
954
1041
  if (display) { try { math.OMaths[1].Type = 0; math.OMaths[1].Justification = 1; } catch (COMException) { } }
955
- made++;
1042
+ return true;
956
1043
  }
957
- catch (COMException) { }
1044
+ catch (COMException) { return false; }
958
1045
  }
959
- return made;
960
1046
  }
961
1047
 
962
1048
  // ───────────────────────── Word ─────────────────────────
@@ -1132,6 +1218,9 @@ static class Program
1132
1218
  if (op.Has("spaceBefore")) range.ParagraphFormat.SpaceBefore = (float)op.Num("spaceBefore", 0);
1133
1219
  if (op.Has("spaceAfter")) range.ParagraphFormat.SpaceAfter = (float)op.Num("spaceAfter", 0);
1134
1220
  if (op.Has("lineSpacing")) { range.ParagraphFormat.LineSpacingRule = 5; range.ParagraphFormat.LineSpacing = (float)(op.Num("lineSpacing", 1) * 12); }
1221
+ if (op.Has("indentChars")) { range.ParagraphFormat.FirstLineIndent = 0; range.ParagraphFormat.CharacterUnitFirstLineIndent = (float)op.Num("indentChars", 2); }
1222
+ if (op.Has("superscript")) range.Font.Superscript = op.Flag("superscript", false) ? 1 : 0;
1223
+ if (op.Has("subscript")) range.Font.Subscript = op.Flag("subscript", false) ? 1 : 0;
1135
1224
  }
1136
1225
 
1137
1226
  /// A new empty paragraph where an insert operation points: after / before paragraph "para", or at the start / end.
@@ -1311,22 +1400,34 @@ static class Program
1311
1400
  bool all = op.Flag("all", true), matchCase = op.Flag("matchCase", true);
1312
1401
  dynamic range = doc.Content;
1313
1402
  int count = 0;
1314
- while (count < 5000 && (bool)range.Find.Execute(FindText: find, MatchCase: matchCase, Forward: true, Wrap: 0))
1403
+ // "^" starts a special code in Word's find box; here the text is always meant literally.
1404
+ string literal = find.Replace("^", "^^");
1405
+ bool rich = replace.IndexOf('$') >= 0 || replace.IndexOf("\\cite", StringComparison.Ordinal) >= 0;
1406
+ string extra = "";
1407
+ while (count < 5000 && (bool)range.Find.Execute(FindText: literal, MatchCase: matchCase, MatchWildcards: false, Forward: true, Wrap: 0))
1315
1408
  {
1316
1409
  range.Text = replace;
1317
- range.Collapse(0);
1410
+ if (rich)
1411
+ {
1412
+ // Building formulas changes the length: keep the place by its distance from the end.
1413
+ int tail = (int)doc.Content.End - (int)range.End;
1414
+ extra = Enrich(doc, range);
1415
+ int resume = (int)doc.Content.End - tail;
1416
+ range = doc.Range(resume, resume);
1417
+ }
1418
+ else range.Collapse(0);
1318
1419
  count++;
1319
1420
  if (!all) break;
1320
1421
  }
1321
1422
  if (count == 0) throw new Fail("NOT_FOUND", "The text \"" + Clip(find, 60) + "\" does not occur in the document.");
1322
- return "replaced " + count;
1423
+ return "replaced " + count + extra + Lint(replace);
1323
1424
  }
1324
1425
  if (type == "insert_paragraphs")
1325
1426
  {
1326
1427
  IList items = op.List("items");
1327
1428
  if (items == null) { items = new ArrayList(); items.Add(new Dictionary<string, object> { { "text", op.Need("text") } }); }
1328
1429
  dynamic current = null;
1329
- int made = 0, equations = 0;
1430
+ int made = 0, equations = 0, cites = 0;
1330
1431
  string lint = "";
1331
1432
  foreach (object raw in items)
1332
1433
  {
@@ -1345,14 +1446,20 @@ static class Program
1345
1446
  Write(doc, (int)p.Range.Start, text);
1346
1447
  if (lint.Length == 0) lint = Lint(text);
1347
1448
  Unnumber(p, text);
1449
+ try
1450
+ {
1451
+ if (Unnumbered.Count > 0 && Unnumbered.Contains((string)doc.FullName + "|" + (string)p.Style.NameLocal) && (int)p.Range.ListFormat.ListType != 0) p.Range.ListFormat.RemoveNumbers();
1452
+ }
1453
+ catch (Exception) { }
1348
1454
  Whole(p.Range);
1455
+ cites += Cite(doc, p.Range);
1349
1456
  equations += Mathify(doc, p.Range);
1350
1457
  current = p;
1351
1458
  made++;
1352
1459
  }
1353
1460
  }
1354
1461
  int lastIndex = Index(doc, current);
1355
- return "inserted " + made + " paragraph(s), now paragraphs " + (lastIndex - made + 1) + "–" + lastIndex + " of " + (int)doc.Paragraphs.Count + (equations > 0 ? ", " + equations + " equation(s)" : "") + lint;
1462
+ return "inserted " + made + " paragraph(s), now paragraphs " + (lastIndex - made + 1) + "–" + lastIndex + " of " + (int)doc.Paragraphs.Count + (equations > 0 ? ", " + equations + " equation(s)" : "") + (cites > 0 ? ", " + cites + " citation(s)" : "") + MathNote() + lint;
1356
1463
  }
1357
1464
  if ((type == "set_text" || (type == "delete_range" && op.Has("para"))) && op.Raw("expect") == null)
1358
1465
  {
@@ -1366,8 +1473,8 @@ static class Program
1366
1473
  range.Text = "";
1367
1474
  Write(doc, (int)p.Range.Start, Lines(op.Raw("text") ?? ""));
1368
1475
  Whole(p.Range);
1369
- int built = Mathify(doc, p.Range);
1370
- return "paragraph " + op.Int("para", 0) + " rewritten" + (built > 0 ? ", " + built + " equation(s)" : "");
1476
+ string built = Enrich(doc, p.Range);
1477
+ return "paragraph " + op.Int("para", 0) + " rewritten" + built + Lint(Lines(op.Raw("text") ?? ""));
1371
1478
  }
1372
1479
  if (type == "format_text" || type == "set_style")
1373
1480
  {
@@ -1412,6 +1519,7 @@ static class Program
1412
1519
  catch (Exception) { }
1413
1520
  Plain(p, "Normal");
1414
1521
  Follow(doc, p.Range);
1522
+ string cellLint = "";
1415
1523
  dynamic table = doc.Tables.Add(p.Range, rows, cols);
1416
1524
  // The cells take the formatting of the paragraph that follows (a heading, say): make them plain.
1417
1525
  SetStyle(table.Range, "Normal");
@@ -1446,7 +1554,8 @@ static class Program
1446
1554
  dynamic cell = table.Cell(r + 1, c + 1).Range;
1447
1555
  string value = Convert.ToString(cells[c], System.Globalization.CultureInfo.InvariantCulture);
1448
1556
  cell.Text = value;
1449
- if (value.IndexOf('$') >= 0) Mathify(doc, table.Cell(r + 1, c + 1).Range);
1557
+ if (value.IndexOf('$') >= 0 || value.IndexOf("\\cite", StringComparison.Ordinal) >= 0) Enrich(doc, table.Cell(r + 1, c + 1).Range);
1558
+ else if (cellLint.Length == 0) cellLint = Lint(value);
1450
1559
  }
1451
1560
  if (Typing) Thread.Sleep(25);
1452
1561
  }
@@ -1469,7 +1578,7 @@ static class Program
1469
1578
  int tableEnd = (int)doc.Range(0, table.Range.End).Paragraphs.Count;
1470
1579
  bool nested = false;
1471
1580
  try { nested = (int)table.NestingLevel > 1; } catch (Exception) { }
1472
- 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;
1581
+ 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 + cellLint;
1473
1582
  }
1474
1583
  if (type == "set_cell")
1475
1584
  {
@@ -1479,8 +1588,8 @@ static class Program
1479
1588
  Follow(doc, cell.Range);
1480
1589
  cell.Range.Text = "";
1481
1590
  Write(doc, (int)cell.Range.Start, Lines(op.Raw("text") ?? ""));
1482
- int built = Mathify(doc, doc.Tables[n].Cell(op.Int("row", 1), op.Int("col", 1)).Range);
1483
- return "cell set" + (built > 0 ? ", " + built + " equation(s)" : "");
1591
+ string built = Enrich(doc, doc.Tables[n].Cell(op.Int("row", 1), op.Int("col", 1)).Range);
1592
+ return "cell set" + built + Lint(Lines(op.Raw("text") ?? ""));
1484
1593
  }
1485
1594
  if (type == "insert_image")
1486
1595
  {
@@ -1500,17 +1609,254 @@ static class Program
1500
1609
  else p.Range.ParagraphFormat.SpaceAfter = Air;
1501
1610
  return "image inserted";
1502
1611
  }
1612
+ if (type == "insert_references") return References(doc, op);
1613
+ if (type == "style_format") return StyleFormat(doc, op);
1614
+ if (type == "page_setup") return PageSetup(doc, op);
1615
+ if (type == "page_numbers") return PageNumbers(doc, op);
1616
+ if (type == "insert_toc") return Toc(doc, op);
1617
+ if (type == "page_break")
1618
+ {
1619
+ dynamic p = Para(doc, op.Int("para", 0), op.Str("expect", null));
1620
+ p.Range.ParagraphFormat.PageBreakBefore = op.Flag("on", true) ? -1 : 0;
1621
+ return "paragraph " + op.Int("para", 0) + " now starts a new page";
1622
+ }
1623
+ if (type == "header")
1624
+ {
1625
+ dynamic header = doc.Sections[1].Headers[1].Range;
1626
+ header.Text = Lines(op.Raw("text") ?? "");
1627
+ header.ParagraphFormat.Alignment = 1;
1628
+ if (op.Has("size")) header.Font.Size = (float)op.Num("size", 9);
1629
+ return "page header set";
1630
+ }
1631
+ if (type == "update_fields") { Refresh(doc); return "table of contents and cross-references refreshed"; }
1503
1632
  throw new Fail("BAD_ARGS", "Unknown Word operation \"" + type + "\".");
1504
1633
  }
1505
1634
 
1506
1635
  static Bag StyleLess(Bag source)
1507
1636
  {
1508
1637
  Dictionary<string, object> copy = new Dictionary<string, object>();
1509
- foreach (string key in new string[] { "font", "size", "bold", "italic", "underline", "color", "align", "firstLineIndent", "spaceBefore", "spaceAfter", "lineSpacing" })
1638
+ foreach (string key in new string[] { "font", "size", "bold", "italic", "underline", "color", "align", "firstLineIndent", "spaceBefore", "spaceAfter", "lineSpacing", "indentChars", "superscript", "subscript" })
1510
1639
  if (source.Has(key)) copy[key] = source.Raw(key);
1511
1640
  return new Bag(copy);
1512
1641
  }
1513
1642
 
1643
+
1644
+ // ───────────────────────── citations ─────────────────────────
1645
+
1646
+ static readonly System.Text.RegularExpressions.Regex Cites = new System.Text.RegularExpressions.Regex(@"\\cite\s*\{([0-9,\-–\s]+)\}");
1647
+
1648
+ /// One citation number at a position, as a superscript that jumps to the reference: a cross-reference field
1649
+ /// when the reference list is already there, otherwise a link to the bookmark the list will bring.
1650
+ static void CiteNumber(dynamic doc, int at, string number)
1651
+ {
1652
+ string mark = "cite_" + number;
1653
+ dynamic spot = doc.Range(at, at);
1654
+ if ((bool)doc.Bookmarks.Exists(mark))
1655
+ {
1656
+ // MERGEFORMAT keeps the superscript when the field is refreshed.
1657
+ dynamic field = doc.Fields.Add(spot, -1, "REF " + mark + " \\h \\* MERGEFORMAT", false);
1658
+ field.Result.Font.Superscript = 1;
1659
+ }
1660
+ else
1661
+ {
1662
+ dynamic link = doc.Hyperlinks.Add(Anchor: spot, Address: "", SubAddress: mark, TextToDisplay: number);
1663
+ link.Range.Font.Superscript = 1;
1664
+ link.Range.Font.Underline = 0;
1665
+ link.Range.Font.Color = -16777216;
1666
+ }
1667
+ }
1668
+
1669
+ /// \cite{1}, \cite{2,5}, \cite{3-6} in a range become superscript [1], [2,5], [3-6] whose numbers jump to the references.
1670
+ static int Cite(dynamic doc, dynamic range)
1671
+ {
1672
+ string text = (string)range.Text;
1673
+ if (text == null || text.IndexOf("\\cite", StringComparison.Ordinal) < 0) return 0;
1674
+ int made = 0;
1675
+ System.Text.RegularExpressions.MatchCollection found = Cites.Matches(text);
1676
+ for (int k = found.Count - 1; k >= 0; k--)
1677
+ {
1678
+ System.Text.RegularExpressions.Match m = found[k];
1679
+ dynamic search = range.Duplicate;
1680
+ if (!(bool)search.Find.Execute(FindText: m.Value, MatchCase: true, MatchWildcards: false, Forward: true, Wrap: 0)) continue;
1681
+ int at = (int)search.Start;
1682
+ search.Text = "";
1683
+ // Written back to front at one position, so each piece lands before the one after it.
1684
+ List<string> pieces = new List<string>();
1685
+ pieces.Add("[");
1686
+ foreach (string part in m.Groups[1].Value.Replace("–", "-").Replace(" ", "").Split(','))
1687
+ {
1688
+ if (part.Length == 0) continue;
1689
+ if (pieces.Count > 1) pieces.Add(",");
1690
+ string[] ends = part.Split('-');
1691
+ pieces.Add("#" + ends[0]);
1692
+ if (ends.Length > 1 && ends[1].Length > 0) { pieces.Add("-"); pieces.Add("#" + ends[1]); }
1693
+ }
1694
+ pieces.Add("]");
1695
+ for (int p = pieces.Count - 1; p >= 0; p--)
1696
+ {
1697
+ string piece = pieces[p];
1698
+ if (piece[0] == '#') { CiteNumber(doc, at, piece.Substring(1)); continue; }
1699
+ doc.Range(at, at).InsertAfter(piece);
1700
+ dynamic plain = doc.Range(at, at + piece.Length);
1701
+ plain.Font.Superscript = 1;
1702
+ }
1703
+ made++;
1704
+ }
1705
+ return made;
1706
+ }
1707
+
1708
+ /// Formulas and citations written in the text of a range.
1709
+ static string Enrich(dynamic doc, dynamic range)
1710
+ {
1711
+ int cites = Cite(doc, range), equations = Mathify(doc, range);
1712
+ return (equations > 0 ? ", " + equations + " equation(s)" : "") + (cites > 0 ? ", " + cites + " citation(s)" : "") + MathNote();
1713
+ }
1714
+
1715
+ /// The reference list: "[n] ..." paragraphs whose numbers carry the bookmarks the citations point at.
1716
+ static string References(dynamic doc, Bag op)
1717
+ {
1718
+ IList items = op.List("items");
1719
+ if (items == null || items.Count == 0) throw new Fail("BAD_ARGS", "\"items\" must list the references, one string each.");
1720
+ System.Text.RegularExpressions.Regex lead = new System.Text.RegularExpressions.Regex(@"^\s*\[(\d+)\]\s*");
1721
+ dynamic current = null;
1722
+ int number = 0;
1723
+ foreach (object raw in items)
1724
+ {
1725
+ string body = Convert.ToString(raw).Trim();
1726
+ System.Text.RegularExpressions.Match m = lead.Match(body);
1727
+ if (m.Success) { number = int.Parse(m.Groups[1].Value); body = body.Substring(m.Length); }
1728
+ else number++;
1729
+ dynamic p = current == null ? NewParagraph(doc, op) : After(doc, current);
1730
+ Plain(p, "Normal");
1731
+ Follow(doc, p.Range);
1732
+ string label = "[" + number + "] ";
1733
+ Write(doc, (int)p.Range.Start, label + body);
1734
+ Whole(p.Range);
1735
+ try
1736
+ {
1737
+ dynamic format = p.Range.ParagraphFormat;
1738
+ format.CharacterUnitFirstLineIndent = 0; format.FirstLineIndent = 0; format.Alignment = 3;
1739
+ format.CharacterUnitLeftIndent = 0; format.LeftIndent = 21f; format.FirstLineIndent = -21f;
1740
+ }
1741
+ catch (COMException) { }
1742
+ int start = (int)p.Range.Start;
1743
+ string mark = "cite_" + number;
1744
+ if ((bool)doc.Bookmarks.Exists(mark)) doc.Bookmarks[mark].Delete();
1745
+ doc.Bookmarks.Add(mark, doc.Range(start + 1, start + 1 + number.ToString().Length));
1746
+ current = p;
1747
+ }
1748
+ // Citations written before the list existed are links; now that their targets exist, make them cross-references.
1749
+ int linked = 0;
1750
+ for (int i = (int)doc.Hyperlinks.Count; i >= 1; i--)
1751
+ {
1752
+ dynamic link = doc.Hyperlinks[i];
1753
+ string target = "";
1754
+ try { target = (string)link.SubAddress; } catch (Exception) { }
1755
+ if (target == null || !target.StartsWith("cite_", StringComparison.Ordinal) || !(bool)doc.Bookmarks.Exists(target)) continue;
1756
+ dynamic spot = link.Range;
1757
+ int at = (int)spot.Start;
1758
+ link.Delete();
1759
+ dynamic text = doc.Range(at, at + target.Length - 5);
1760
+ text.Text = "";
1761
+ dynamic field = doc.Fields.Add(doc.Range(at, at), -1, "REF " + target + " \\h \\* MERGEFORMAT", false);
1762
+ field.Result.Font.Superscript = 1;
1763
+ linked++;
1764
+ }
1765
+ return items.Count + " reference(s) listed" + (linked > 0 ? ", " + linked + " citation(s) in the text now cross-reference them" : "");
1766
+ }
1767
+
1768
+ // ───────────────────────── styles and page layout ─────────────────────────
1769
+
1770
+ /// Styles the agent switched automatic numbering off for (document path | style name).
1771
+ static readonly HashSet<string> Unnumbered = new HashSet<string>();
1772
+
1773
+ static dynamic StyleOf(dynamic doc, string name)
1774
+ {
1775
+ int builtin;
1776
+ try { return WordStyles.TryGetValue(name, out builtin) ? doc.Styles[builtin] : doc.Styles[name]; }
1777
+ catch (COMException) { throw new Fail("STYLE_MISSING", "This document has no style \"" + name + "\". Built-in names that always work: " + string.Join(", ", new List<string>(WordStyles.Keys).ToArray()) + "."); }
1778
+ }
1779
+
1780
+ /// Change what a style looks like, for every paragraph that uses it.
1781
+ static string StyleFormat(dynamic doc, Bag op)
1782
+ {
1783
+ string name = op.Need("style");
1784
+ dynamic style = StyleOf(doc, name);
1785
+ dynamic font = style.Font, format = style.ParagraphFormat;
1786
+ if (op.Has("font")) { string f = op.Need("font"); font.Name = f; try { font.NameFarEast = f; } catch (COMException) { } }
1787
+ if (op.Has("latinFont")) { string f = op.Need("latinFont"); try { font.NameAscii = f; font.NameOther = f; } catch (COMException) { } }
1788
+ if (op.Has("size")) font.Size = (float)op.Num("size", 12);
1789
+ if (op.Has("bold")) font.Bold = op.Flag("bold", false) ? 1 : 0;
1790
+ if (op.Has("italic")) font.Italic = op.Flag("italic", false) ? 1 : 0;
1791
+ if (op.Has("color")) font.Color = Bgr(op.Need("color"));
1792
+ if (op.Has("align")) { string align = op.Need("align"); format.Alignment = align == "center" ? 1 : align == "right" ? 2 : align == "justify" ? 3 : 0; }
1793
+ if (op.Has("indentChars")) { format.FirstLineIndent = 0; format.CharacterUnitFirstLineIndent = (float)op.Num("indentChars", 2); }
1794
+ if (op.Has("firstLineIndent")) { format.CharacterUnitFirstLineIndent = 0; format.FirstLineIndent = (float)op.Num("firstLineIndent", 0); }
1795
+ if (op.Has("spaceBefore")) format.SpaceBefore = (float)op.Num("spaceBefore", 0);
1796
+ if (op.Has("spaceAfter")) format.SpaceAfter = (float)op.Num("spaceAfter", 0);
1797
+ if (op.Has("lineSpacing")) { format.LineSpacingRule = 5; format.LineSpacing = (float)(op.Num("lineSpacing", 1) * 12); }
1798
+ if (op.Has("pageBreakBefore")) format.PageBreakBefore = op.Flag("pageBreakBefore", false) ? -1 : 0;
1799
+ if (op.Has("numbering") && !op.On("numbering"))
1800
+ {
1801
+ Unnumbered.Add((string)doc.FullName + "|" + (string)style.NameLocal);
1802
+ // Stop the style from numbering its paragraphs by itself (headings that carry their number in the text).
1803
+ try { style.LinkToListTemplate(null); } catch (Exception) { }
1804
+ foreach (dynamic p in doc.Paragraphs)
1805
+ {
1806
+ try { if ((string)p.Style.NameLocal == (string)style.NameLocal && (int)p.Range.ListFormat.ListType != 0) p.Range.ListFormat.RemoveNumbers(); }
1807
+ catch (Exception) { }
1808
+ }
1809
+ }
1810
+ try { format.WordWrap = -1; } catch (Exception) { }
1811
+ return "style \"" + (string)style.NameLocal + "\" changed";
1812
+ }
1813
+
1814
+ static string PageSetup(dynamic doc, Bag op)
1815
+ {
1816
+ dynamic setup = doc.PageSetup;
1817
+ const float cm = 28.3465f;
1818
+ if (op.Has("paper")) { string paper = op.Need("paper").ToUpperInvariant(); setup.PaperSize = paper == "A3" ? 6 : paper == "LETTER" ? 2 : paper == "B5" ? 13 : 7; }
1819
+ if (op.Has("orientation")) setup.Orientation = op.Need("orientation") == "landscape" ? 1 : 0;
1820
+ if (op.Has("top")) setup.TopMargin = (float)op.Num("top", 2.54) * cm;
1821
+ if (op.Has("bottom")) setup.BottomMargin = (float)op.Num("bottom", 2.54) * cm;
1822
+ if (op.Has("left")) setup.LeftMargin = (float)op.Num("left", 3.17) * cm;
1823
+ if (op.Has("right")) setup.RightMargin = (float)op.Num("right", 3.17) * cm;
1824
+ return "page setup changed";
1825
+ }
1826
+
1827
+ static string PageNumbers(dynamic doc, Bag op)
1828
+ {
1829
+ string align = op.Str("align", "center");
1830
+ dynamic numbers = doc.Sections[1].Footers[1].PageNumbers;
1831
+ numbers.Add(align == "left" ? 0 : align == "right" ? 2 : 1, op.Flag("firstPage", true));
1832
+ if (op.Has("start")) { numbers.RestartNumberingAtSection = true; numbers.StartingNumber = op.Int("start", 1); }
1833
+ return "page numbers added to the footer";
1834
+ }
1835
+
1836
+ static string Toc(dynamic doc, Bag op)
1837
+ {
1838
+ dynamic p = NewParagraph(doc, op);
1839
+ Plain(p, "Normal");
1840
+ if (op.Has("title"))
1841
+ {
1842
+ p.Range.InsertBefore(op.Need("title"));
1843
+ p.Range.ParagraphFormat.Alignment = 1; p.Range.ParagraphFormat.CharacterUnitFirstLineIndent = 0; p.Range.ParagraphFormat.FirstLineIndent = 0;
1844
+ p.Range.Font.Bold = 1; p.Range.Font.Size = 16f;
1845
+ p = After(doc, p);
1846
+ Plain(p, "Normal");
1847
+ }
1848
+ Follow(doc, p.Range);
1849
+ doc.TablesOfContents.Add(Range: p.Range, UseHeadingStyles: true, UpperHeadingLevel: 1, LowerHeadingLevel: Math.Max(1, Math.Min(9, op.Int("levels", 3))));
1850
+ return "table of contents inserted (it is refreshed on every save)";
1851
+ }
1852
+
1853
+ /// Bring fields up to date: the table of contents, cross-references, caption numbers.
1854
+ static void Refresh(dynamic doc)
1855
+ {
1856
+ try { foreach (dynamic toc in doc.TablesOfContents) toc.Update(); } catch (Exception) { }
1857
+ try { doc.Fields.Update(); } catch (Exception) { }
1858
+ }
1859
+
1514
1860
  // ───────────────────────── Excel ─────────────────────────
1515
1861
 
1516
1862
  static dynamic Sheet(dynamic book, Bag op)
@@ -1821,7 +2167,7 @@ static class Program
1821
2167
  {
1822
2168
  if (!Typing || text.Length < 4) { textRange.Text = text; return; }
1823
2169
  textRange.Text = "";
1824
- foreach (string piece in Pieces(text)) { textRange.InsertAfter(piece); Thread.Sleep(22); }
2170
+ foreach (string piece in Pieces(text)) { textRange.InsertAfter(piece); Thread.Sleep(14); }
1825
2171
  }
1826
2172
 
1827
2173
  static void GoTo(dynamic deck, int slide)
@@ -1937,6 +2283,7 @@ static class Program
1937
2283
  {
1938
2284
  Bag op = new Bag(ops[i]);
1939
2285
  type = op.Need("op");
2286
+ if (card) Card.Hold = true;
1940
2287
  if (card) Card.Report("AI 正在编辑 " + docName + (ops.Count > 1 ? " · " + (i + 1) + "/" + ops.Count : ""));
1941
2288
  string line = kind == "word" ? WordOp(doc, type, op) : kind == "excel" ? ExcelOp(app, doc, type, op) : PptOp(doc, type, op);
1942
2289
  string ignored = op.Unread();
@@ -1959,7 +2306,7 @@ static class Program
1959
2306
  {
1960
2307
  if (undo != null) { try { undo.EndCustomRecord(); } catch (COMException) { } }
1961
2308
  }
1962
- if (card) Card.Report(result.ContainsKey("failed") ? "AI 的修改停在第 " + (done.Count + 1) + " 项" : "AI 已改好 " + docName);
2309
+ if (card) Card.Hold = false;
1963
2310
  result["done"] = done;
1964
2311
  result["total"] = ops.Count;
1965
2312
  return result;
package/lib/client.js CHANGED
@@ -491,7 +491,7 @@ window.__ModuleLoader__.load({
491
491
  } }, !app.installed ? "未安装" : !app.running ? "已安装,未运行(需要时自动启动)" : app.documents.length === 0 ? "运行中,没有打开的文档" : `运行中:${app.documents.map((doc) => doc.name + (doc.saved ? "" : "(未保存)")).join("、")}`))), h("p", { style: S.hint }, "需要本机装有微软 Office。Word 里 AI 的一批修改可以用一次 Ctrl+Z 撤回;Excel 和 PowerPoint 的修改无法用 Ctrl+Z 撤回。你正在单元格里输入或开着对话框时,AI 会等你。")), h("section", { style: S.card }, h("h3", { style: S.cardTitle }, "行为"), h("div", { style: S.toggleRow }, h("div", { style: S.toggleText }, "跟随 AI 的修改位置", h("span", { style: {
492
492
  ...S.hint,
493
493
  fontWeight: 400
494
- } }, "AI 改到哪里,窗口就自动滚动到哪里(Word 滚到那一段,Excel 滚到那片单元格,PowerPoint 切到那一页),不用自己翻找。关闭即静默执行:AI 照常修改,但不动你的视图。想看时点迷你卡片上的“跟随”:文档窗口被带到前台并开始跟随;你切到别的窗口后它自动熄灭,再点一次回来。")), h(Switch, {
494
+ } }, "AI 改到哪里,窗口就自动滚动到哪里(Word 滚到那一段,Excel 滚到那片单元格,PowerPoint 切到那一页),不用自己翻找。关闭即静默执行:AI 照常修改,但不动你的视图。想看时点迷你卡片上的“查看”:文档窗口被带到前台并开始跟随,按钮变成“查看中”;你切到别的窗口后它自动熄灭,再点一次回来。")), h(Switch, {
495
495
  checked: settings.follow,
496
496
  disabled,
497
497
  label: "跟随 AI 的修改位置",
@@ -501,7 +501,7 @@ window.__ModuleLoader__.load({
501
501
  })), h("div", { style: S.toggleRow }, h("div", { style: S.toggleText }, "逐字写入", h("span", { style: {
502
502
  ...S.hint,
503
503
  fontWeight: 400
504
- } }, "文字像打字一样逐步出现,而不是整块贴上去;表格逐行填入。每段最多多花约 1 秒,关闭后写得最快。")), h(Switch, {
504
+ } }, "文字像打字一样逐步出现,而不是整块贴上去;表格逐行填入。每段约多花 0.3 秒,关闭后写得最快。")), h(Switch, {
505
505
  checked: settings.typing,
506
506
  disabled,
507
507
  label: "逐字写入",
@@ -511,7 +511,7 @@ window.__ModuleLoader__.load({
511
511
  })), h("div", { style: S.toggleRow }, h("div", { style: S.toggleText }, "迷你卡片", h("span", { style: {
512
512
  ...S.hint,
513
513
  fontWeight: 400
514
- } }, "AI 编辑时在屏幕右下角显示一张小卡片:正在改哪个文档、改到第几项,以及“跟随”“逐字”两个开关,点一下立即生效(连正在进行的这一批也会跟着变)。卡片可拖动,不抢焦点,AI 停手几秒后自动消失。在卡片上切换过的开关,本次运行期间以卡片为准。")), h(Switch, {
514
+ } }, "AI 每次动手修改文档时,屏幕右下角显示一张小卡片:正在改哪个文档、改到第几项,以及“查看”“逐字”两个开关,点一下立即生效(连正在进行的这一批也会跟着变)。卡片可拖动,不抢焦点;这一批改完就消失,下一批开始时再出现。在卡片上切换过的开关,本次运行期间以卡片为准。")), h(Switch, {
515
515
  checked: settings.card,
516
516
  disabled,
517
517
  label: "迷你卡片",
package/lib/index.js CHANGED
@@ -252,7 +252,8 @@ function promptText() {
252
252
  "- The user may edit while you work. Read again before editing a part you have not just read, and pass \"expect\" on Word paragraphs.",
253
253
  "- Use these tools, not python-docx / openpyxl / python-pptx or other file libraries, whenever the document is to be worked on in the user's Office app or is already open there; and check the result with office_render, not by converting the file with another renderer, which lays it out differently from Word.",
254
254
  "- Finish with a polish pass, it is part of the job: office_render with overview: true for the whole document at a glance, then the pages that need a closer look; look at the pictures the way a careful reader would, fix what is off with office_edit, and render again until it is right. Look for: content outside the frame or section it belongs to; fonts, sizes and line spacing that differ from the surrounding text or from what the template asks for; formulas left as plain text; tables that are too wide, uneven or uncentred; captions not next to their table or figure; leftover template hints and sample text; runs of blank paragraphs and half-empty pages. Then office_save. Nothing is saved until you call office_save; do not close the user's documents.",
255
- "- In Word, formulas are LaTeX between dollar signs ($...$ inline, $$...$$ on its own line); they become native equations.",
255
+ "- In Word, formulas are LaTeX between dollar signs ($...$ inline, $$...$$ on its own line); they become native equations. Citations are \\cite{n} plus insert_references.",
256
+ "- Set the look through styles before writing a long document (style_format for Normal and the headings, page_setup); all of that is done with office_edit, not with mouse and keyboard in Word's dialogs.",
256
257
  "- When filling in a template, keep its structure: put each part where its heading is, replace or delete the sample text and hints, and match the formatting the template asks for.",
257
258
  "- If a call reports the app is busy, the user is typing or has a dialog open: wait a moment and retry.",
258
259
  "- If you also have mouse / keyboard computer tools, use them on an Office window only for what office_edit cannot do, such as a setting that exists only in a dialog."
@@ -5958,6 +5959,15 @@ function formatPpt(read) {
5958
5959
  }
5959
5960
  return lines.join("\n");
5960
5961
  }
5962
+ /** Failures found before anything was touched. */
5963
+ const PRECHECKED = /* @__PURE__ */ new Set([
5964
+ "BAD_ARGS",
5965
+ "ANCHOR_MISSING",
5966
+ "ANCHOR_MISMATCH",
5967
+ "NOT_FOUND",
5968
+ "STYLE_MISSING",
5969
+ "BUSY"
5970
+ ]);
5961
5971
  function formatEdit(result) {
5962
5972
  const done = result.done.map((line, index) => `${index + 1}. ${line}`);
5963
5973
  if (!result.failed) return [
@@ -5968,7 +5978,8 @@ function formatEdit(result) {
5968
5978
  const { index, op, error } = result.failed;
5969
5979
  return [
5970
5980
  `Stopped at operation ${index + 1} (${op}): ${error}`,
5971
- done.length ? `Applied before it:\n${done.join("\n")}` : "Nothing was applied.",
5981
+ done.length ? `Applied before it:\n${done.join("\n")}` : "Nothing before it was applied.",
5982
+ PRECHECKED.has(result.failed.code) ? "" : "The failed operation itself may have changed part of the document: read it before you retry.",
5972
5983
  result.total - index - 1 > 0 ? `The ${result.total - index - 1} operation(s) after it did not run.` : ""
5973
5984
  ].filter(Boolean).join("\n");
5974
5985
  }
@@ -6107,15 +6118,18 @@ const EDIT_GUIDE = `Edit a document that is open in Word, Excel or PowerPoint, l
6107
6118
 
6108
6119
  Word ops. para = paragraph number from office_read; pass expect = its first words so a number made stale by other edits is caught. where = after (default with para) | before | start | end (default without para: the very end of the document).
6109
6120
  In a form or template whose sections are table cells, write INSIDE the cell: give para = a paragraph of that cell. Without para the text or table lands after everything, outside the form.
6110
- Formulas: write LaTeX between dollar signs in any text: $...$ inside a sentence, $$...$$ as a paragraph of its own. Number a display formula when the document numbers its formulas by ending it with \\tag{1}: $$T=2\\pi\\sqrt{l/g} \\tag{1}$$ puts (1) flush right. They become native Word equations (\\frac, \\sqrt, ^, _, Greek letters, \\bar, \\sum, \\int ...). Never write a formula as plain text such as T = 2π√(l/g).
6121
+ Formulas: write LaTeX between dollar signs in any text (paragraphs, table cells, captions, replace_text): $...$ inside a sentence, $$...$$ as a paragraph of its own. Every symbol, subscript, power, norm or matrix in running text goes between dollar signs too ($\\kappa(A)$, $\\|x\\|_2$, $10^{-8}$, \\begin{pmatrix}..\\end{pmatrix}, \\begin{cases}..\\end{cases}); do not type them with Unicode characters such as ‖x‖₂ or κ₂. Number a display formula when the document numbers its formulas by ending it with \\tag{1}: $$T=2\\pi\\sqrt{l/g} \\tag{1}$$ puts (1) flush right. They become native Word equations (\\frac, \\sqrt, ^, _, Greek letters, \\bar, \\sum, \\int ...). Never write a formula as plain text such as T = 2π√(l/g).
6111
6122
  - insert_paragraphs {items:[{text, style?}], para?, expect?, where?} — style: Normal, Title, Heading 1..6, List Bullet, List Number, Quote, or a style name the document has. Without style a paragraph is body text in the font, size and spacing of the text it is placed next to. A newline in text starts another paragraph. Items also take the format_text fields.
6112
6123
  - set_text {para, expect, text} — rewrite one paragraph, keeping its style. expect is required ("" for an empty paragraph).
6113
- - replace_text {find, replace, all?}
6114
- - format_text {para?, to?, find?, style?, font?, size?, bold?, italic?, underline?, color?, align?, firstLineIndent?, spaceBefore?, spaceAfter?, lineSpacing?} — para..to, or the first match of find.
6124
+ - replace_text {find, replace, all?} — find is literal text within one paragraph; replace may hold formulas and citations.
6125
+ - format_text {para?, to?, find?, style?, font?, size?, bold?, italic?, underline?, superscript?, subscript?, color?, align?, indentChars?, firstLineIndent?, spaceBefore?, spaceAfter?, lineSpacing?} — para..to, or the first match of find.
6115
6126
  - delete_range {para, expect, to?} — expect is required; para..to in one operation removes a run of paragraphs (leftover hints, surplus blank lines). Paragraph numbers shift after every insert or delete; each result tells you the new numbers.
6116
6127
  - insert_table {data:[[cell,..],..], caption?, para?, where?, header?} — with para inside a cell, the table goes inside that cell. set_cell {table, row, col, text}
6117
6128
  - Captions: give caption (the title only, no "表 1") on insert_table / insert_image. It is set the standard way: numbered automatically ("表 1", "图 1"), centred, above a table and below a figure. Do not write captions as ordinary paragraphs.
6118
6129
  - insert_image {path, caption?, para?, where?, width?} — the picture is centred on a line of its own.
6130
+ - Citations: write \\cite{1}, \\cite{2,5} or \\cite{3-6} in the text where the source is used; they become superscript [1] that jump to the reference. List the sources with insert_references {items:["Author. Title[M]. ...", ..], para?, where?} (numbered [1], [2].. in order; put it under a "参考文献" heading). Do not type [1] by hand.
6131
+ - style_format {style, font?, latinFont?, size?, bold?, italic?, color?, align?, indentChars?, spaceBefore?, spaceAfter?, lineSpacing?, pageBreakBefore?, numbering?: false} — change what a style looks like everywhere it is used (e.g. Heading 1 in 黑体 三号 without automatic numbers; Normal in 宋体 小四, 1.5 lines, 2-character first-line indent). Set the styles first, then write; this replaces fiddling with Word's style dialogs.
6132
+ - page_setup {paper?: A4|A3|B5|Letter, orientation?, top?, bottom?, left?, right?} (margins in cm); page_numbers {align?, start?}; header {text}; page_break {para, expect} (that paragraph starts a new page); insert_toc {title?, levels?, para?, where?}; update_fields {}
6119
6133
 
6120
6134
  Excel ops. sheet = sheet name (default: the active sheet).
6121
6135
  - write_range {range: top-left cell, values:[[..],..]} — a string starting with "=" is a formula; also takes the format_range fields.
@@ -6137,6 +6151,7 @@ function createTools(host) {
6137
6151
  const { helper } = host;
6138
6152
  const tools = [];
6139
6153
  let current;
6154
+ const own = /* @__PURE__ */ new Set();
6140
6155
  const unseen = /* @__PURE__ */ new Set();
6141
6156
  const keyOf = (on) => `${on.app}:${fileName(on.doc).toLowerCase()}`;
6142
6157
  /** Pictures of the document as it is now: Word pages or PowerPoint slides first..last, or an Excel range. */
@@ -6265,6 +6280,7 @@ function createTools(host) {
6265
6280
  app: kind,
6266
6281
  doc: doc.path ?? doc.name
6267
6282
  };
6283
+ if (doc.how !== "attached") own.add(keyOf(current));
6268
6284
  return { text: formatOpened(doc) };
6269
6285
  },
6270
6286
  presentCall: (args) => card(`打开 ${fileName(args.path) || "新文档"}`)
@@ -6459,10 +6475,16 @@ function createTools(host) {
6459
6475
  }, 11e4);
6460
6476
  const moved = input.path !== void 0 && extname(input.path).toLowerCase() !== ".pdf";
6461
6477
  const wasUnseen = unseen.delete(keyOf(on));
6462
- if (moved) current = {
6463
- app: on.app,
6464
- doc: result.path
6465
- };
6478
+ if (moved) {
6479
+ if (own.delete(keyOf(on))) own.add(keyOf({
6480
+ app: on.app,
6481
+ doc: result.path
6482
+ }));
6483
+ current = {
6484
+ app: on.app,
6485
+ doc: result.path
6486
+ };
6487
+ }
6466
6488
  const text = `Saved ${result.path} (${result.bytes} bytes).${moved ? " The open document is now this file." : ""}`;
6467
6489
  if (!wasUnseen || !host.settings().finalCheck || !await host.vision(exec)) return { text };
6468
6490
  try {
@@ -6483,6 +6505,38 @@ function createTools(host) {
6483
6505
  },
6484
6506
  presentCall: (args) => card(`保存 ${fileName(args.path ?? args.doc) || "当前文档"}`)
6485
6507
  }));
6508
+ tools.push(defineTool({
6509
+ name: "office_close",
6510
+ description: "Close a document that YOU opened or created with office_open (a scratch file, a source you are done with). Documents the user already had open are theirs and are refused. Unsaved changes are refused unless you pass save.",
6511
+ parameters: {
6512
+ doc: DOC,
6513
+ app: APP,
6514
+ save: {
6515
+ type: "boolean",
6516
+ description: "true: save and close. false: close and drop unsaved changes."
6517
+ }
6518
+ },
6519
+ output,
6520
+ timeoutMs: 6e4,
6521
+ async execute(args) {
6522
+ const input = args;
6523
+ const on = target(input);
6524
+ if (!own.has(keyOf(on))) throw new Error("This document was open before you came to it, or was not opened by you: leave closing it to the user.");
6525
+ if (input.save === void 0) {
6526
+ const info = (await helper.call("status", {}, 3e4)).flatMap((app) => app.documents).find((doc) => doc.app === on.app && fileName(doc.path ?? doc.name).toLowerCase() === fileName(on.doc).toLowerCase());
6527
+ if (info && !info.saved) throw new Error("It has unsaved changes: pass save: true to keep them or save: false to drop them.");
6528
+ }
6529
+ await helper.call("close", {
6530
+ ...on,
6531
+ save: input.save === true
6532
+ }, 5e4);
6533
+ own.delete(keyOf(on));
6534
+ unseen.delete(keyOf(on));
6535
+ if (current && keyOf(current) === keyOf(on)) current = void 0;
6536
+ return { text: `Closed ${fileName(on.doc)}.` };
6537
+ },
6538
+ presentCall: (args) => card(`关闭 ${fileName(args.doc) || "文档"}`)
6539
+ }));
6486
6540
  return tools;
6487
6541
  }
6488
6542
  //#endregion
@@ -6496,8 +6550,8 @@ const Config = z.object({
6496
6550
  "en-US": { $description: "Scroll to where the AI is editing; off = silent, your view is left alone" }
6497
6551
  }),
6498
6552
  typing: z.boolean().default(true).volatile().i18n({
6499
- "zh-CN": { $description: "文字像打字一样逐步写出,而不是整块出现(每段最多约 1 秒)" },
6500
- "en-US": { $description: "Write text progressively like typing instead of all at once (about a second per paragraph at most)" }
6553
+ "zh-CN": { $description: "文字像打字一样逐步写出,而不是整块出现(每段约多花 0.3 秒)" },
6554
+ "en-US": { $description: "Write text progressively like typing instead of all at once (about 0.3 s more per paragraph)" }
6501
6555
  }),
6502
6556
  card: z.boolean().default(true).volatile().i18n({
6503
6557
  "zh-CN": { $description: "AI 编辑时在屏幕右下角显示迷你卡片,可随时开关“跟随”和“逐字”" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@copylee/dsh-office",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "DeepSeek Harness Office 插件(Windows):AI 直接在你打开的 Word / Excel / PowerPoint 里改文档,改动实时出现在窗口中,你可以同时编辑;读结构、批量编辑、渲染成图核对、保存。",
5
5
  "type": "module",
6
6
  "author": "copylee",