@copylee/dsh-office 0.2.2 → 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,13 +14,18 @@
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 技能会让模型写脚本生成文件,这样你看不到过程,文件开着时还会写入失败。插件在模型第一次加载这些技能时把它引回来,确有需要(批量转换、应用打不开的文件)再次请求即放行 |
23
- | 交付前整体视觉检查 | 保存时如果 AI 改完之后还没看过成品,保存结果会自动附上各页(最多 8 页)的渲染图让它过一遍,有问题先修再保存 |
25
+ | 交付前整体视觉检查 | 保存时如果 AI 改完之后还没看过成品,保存结果会自动附上整份文档的总览图让它过一遍,有问题先修再保存。总览图把各页(或各张幻灯片)带页码拼在一张图里,一张最多 6 页,最多 18 页;模型也可以随时主动调出总览,再放大某一页细看 |
26
+ | 不拆词、不重复编号 | 插件写入的段落、表格、题注一律按整词换行(有些模板的样式带着“允许西文在单词中间换行”,会把 37000 拆成 37 和 000);标题文字自带编号而样式又自动编号时,去掉自动的那个 |
27
+ | 提醒纯文本公式 | 正文里出现 d_k、10^18 这类没写成公式的下标和幂时,结果里会提醒模型改用公式 |
28
+ | 表格跨页 | 表头行在下一页重复,单行不被拆到两页,表题不会单独留在页尾 |
24
29
  | 批量编辑 | `office_edit` 一次调用按顺序执行一批修改(写一整篇文档、填一张表加公式加图表、做几页幻灯片),中途某一项失败就停下并报告已完成哪些 |
25
30
  | 改错位置的防护 | Word 按“段落编号 + 这段开头的文字”定位。你刚改过导致编号对不上时,这一步会被拒绝并要求重新读取;改写和删除段落必须带开头文字 |
26
31
  | Word 一次撤销 | AI 的一批修改在 Word 里是一个撤销步骤,按一次 Ctrl+Z 整批撤回 |
@@ -61,12 +66,13 @@ AI 只有在调用保存时才写入文件,不会替你关闭文档。
61
66
  | `office_status` | 列出三个应用里现在打开了什么、哪些有未保存的修改 |
62
67
  | `office_read` | 把打开的文档读成文本(段落 / 单元格 / 形状) |
63
68
  | `office_edit` | 按顺序执行一批修改,不保存 |
64
- | `office_render` | 把一页 / 一张幻灯片 / 一个单元格区域渲染成图片;Word 和 PowerPoint 一次最多看 4 页 |
69
+ | `office_render` | 把一页 / 一张幻灯片 / 一个单元格区域渲染成图片;Word 和 PowerPoint 一次最多看 4 页,或用总览把全部页面拼成一张图 |
65
70
  | `office_save` | 保存;给出路径时另存为,扩展名为 `.pdf` 时导出 PDF;改完没看过成品时附带各页图片 |
71
+ | `office_close` | 关闭 AI 自己打开或新建的文档;你原本开着的文档会被拒绝,有未保存修改时必须明确说保存还是丢弃 |
66
72
 
67
73
  `office_edit` 支持的操作:
68
74
 
69
- - 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`
70
76
  - Excel:`write_range`(值或公式)、`format_range`、`autofit`、`insert_rows`、`delete_rows`、`add_sheet`、`rename_sheet`、`delete_sheet`、`add_chart`
71
77
  - PowerPoint:`add_slide`、`set_text`、`add_textbox`、`add_image`、`set_shape`、`delete_shape`、`delete_slide`、`move_slide`、`set_notes`
72
78
 
@@ -78,8 +84,8 @@ AI 只有在调用保存时才写入文件,不会替你关闭文档。
78
84
  |---|---|---|
79
85
  | 跟随 AI 的修改位置 | 开 | 窗口自动滚动到 AI 正在修改的位置;关闭为静默执行 |
80
86
  | 逐字写入 | 开 | 文字像打字一样逐步出现;关闭后写得最快 |
81
- | 迷你卡片 | 开 | AI 编辑时在右下角显示带“跟随”“逐字”开关的小卡片。在卡片上切换过的开关(包括你离开文档窗口后自动熄灭的“跟随”),到 DSH 重启前以卡片为准 |
82
- | 交付前整体视觉检查 | 开 | 保存时自动把各页渲染图交给 AI 检查;会多用一些 token |
87
+ | 迷你卡片 | 开 | AI 修改文档期间在右下角显示带“查看”“逐字”开关的小卡片。在卡片上切换过的开关(包括你离开文档窗口后自动熄灭的“查看”),到 DSH 重启前以卡片为准 |
88
+ | 交付前整体视觉检查 | 开 | 保存时自动把整份文档的总览图交给 AI 检查;会多用一些 token |
83
89
  | 优先在真实 Office 里操作 | 开 | DSH 桌面版自带 office-docx / office-xlsx / office-pptx 技能,用脚本生成文件。开启后,AI 第一次想加载这些技能时会被引回本插件(对应的 Office 应用已安装时才拦);它再次请求时放行 |
84
90
  | 打开文档时显示到前台 | 开 | 关闭后文档仍会打开,只是不抢当前窗口 |
85
91
  | 核对排版的图片宽度 | 1100 像素 | `office_render` 给模型的图片宽度;越大越清晰,也越费 token |
@@ -91,7 +97,8 @@ AI 只有在调用保存时才写入文件,不会替你关闭文档。
91
97
  - Excel 和 PowerPoint 里 AI 的修改无法用 Ctrl+Z 撤回(Excel 的外部修改不进撤销栈,并且会清空已有的撤销记录)。只有 Word 支持整批撤销。
92
98
  - 你正在单元格里输入、或 Office 开着模态对话框时,修改会被 Office 拒绝;插件等待约 5 秒后报告“应用忙”。
93
99
  - Excel 区域的图片是通过剪贴板取得的,渲染时剪贴板里的文字会被保留,图片、文件等其他内容会丢失。
94
- - 公式只支持 Word,PowerPoint 和 Excel 里还不能写原生公式;LaTeX 只转换常用写法,矩阵、多行对齐等环境不支持。
100
+ - 公式只支持 Word,PowerPoint 和 Excel 里还不能写原生公式;LaTeX 只转换常用写法,没转成功的公式保留为文本并在结果里提示。
101
+ - 引用按编号书写,插入或删除文献后不会自动重新编号。
95
102
  - 兼容模式(Word 97–2003 格式)的文档里不能建立原生公式。
96
103
  - 目前不支持 WPS。
97
104
  - 没有打开 Office 时处理文件、批量转换格式不在本插件范围内。
@@ -68,6 +68,16 @@ class Bag
68
68
  object v = Raw(key);
69
69
  return v is bool ? (bool)v : fallback;
70
70
  }
71
+ /// A switch given loosely: true, "thin", "all", 1 are on; false, "none", "off", 0 and absent are off.
72
+ public bool On(string key)
73
+ {
74
+ object v = Raw(key);
75
+ if (v == null) return false;
76
+ if (v is bool) return (bool)v;
77
+ string text = Convert.ToString(v, System.Globalization.CultureInfo.InvariantCulture).Trim().ToLowerInvariant();
78
+ return text.Length > 0 && text != "false" && text != "none" && text != "off" && text != "no" && text != "0";
79
+ }
80
+
71
81
  public IList List(string key)
72
82
  {
73
83
  object v = Raw(key);
@@ -109,6 +119,8 @@ class Card : Form
109
119
  static readonly ManualResetEvent Ready = new ManualResetEvent(false);
110
120
  /// Set once the user has flipped a switch here: from then on the card, not the settings page, decides.
111
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;
112
124
 
113
125
  string line = "";
114
126
  DateTime shown = DateTime.MinValue;
@@ -174,7 +186,7 @@ class Card : Form
174
186
  }
175
187
  bool nowLit = Program.Following && watching;
176
188
  if (nowLit != lit) { lit = nowLit; Invalidate(); }
177
- if (Visible && (DateTime.UtcNow - shown).TotalSeconds > 6) Hide();
189
+ if (Visible && !Hold && (DateTime.UtcNow - shown).TotalSeconds > 1) Hide();
178
190
  };
179
191
  timer.Start();
180
192
  }
@@ -215,7 +227,8 @@ class Card : Form
215
227
  {
216
228
  int right = Width - S(10);
217
229
  Chip(g, font, "逐字", Program.Typing, ref right, out typingBox);
218
- Chip(g, font, "跟随", Program.Following && Watching(), ref right, out followBox);
230
+ bool following = Program.Following && Watching();
231
+ Chip(g, font, following ? "查看中" : "查看", following, ref right, out followBox);
219
232
  using (SolidBrush dot = new SolidBrush(Color.FromArgb(217, 119, 87))) g.FillEllipse(dot, S(12), (Height - S(8)) / 2, S(8), S(8));
220
233
  Rectangle text = new Rectangle(S(26), 0, right - S(30), Height);
221
234
  TextRenderer.DrawText(g, line, font, text, Color.FromArgb(235, 235, 240), TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix);
@@ -496,6 +509,13 @@ static class Program
496
509
  static object Run(string cmd, Bag a)
497
510
  {
498
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
+ }
499
519
  if (cmd == "status") return Status();
500
520
  string kind = Kind(a);
501
521
  if (cmd == "open") return Open(kind, a);
@@ -610,7 +630,7 @@ static class Program
610
630
  if (!cardStarted) { cardStarted = true; Card.Start(); }
611
631
  Following = Card.FollowChosen ? Following : a.Flag("follow", false);
612
632
  Typing = Card.TypingChosen ? Typing : a.Flag("typing", false);
613
- Card.Report("AI 打开了 " + (string)doc.Name);
633
+ // No card here: it shows while an edit is running.
614
634
  }
615
635
  return info;
616
636
  }
@@ -639,6 +659,7 @@ static class Program
639
659
 
640
660
  static object Save(string kind, dynamic app, dynamic doc, Bag a)
641
661
  {
662
+ if (kind == "word") Refresh(doc);
642
663
  string path = a.Str("path", null);
643
664
  Dictionary<string, object> result = new Dictionary<string, object>();
644
665
  if (path == null)
@@ -744,11 +765,11 @@ static class Program
744
765
  try { doc.Windows[1].ScrollIntoView(range, true); } catch (Exception) { }
745
766
  }
746
767
 
747
- /// 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.
748
769
  static List<string> Pieces(string text)
749
770
  {
750
771
  List<string> pieces = new List<string>();
751
- 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));
752
773
  for (int i = 0; i < text.Length; i += size) pieces.Add(text.Substring(i, Math.Min(size, text.Length - i)));
753
774
  return pieces;
754
775
  }
@@ -766,7 +787,7 @@ static class Program
766
787
  {
767
788
  doc.Range(at, at).InsertAfter(piece);
768
789
  at += piece.Length;
769
- Thread.Sleep(22);
790
+ Thread.Sleep(14);
770
791
  }
771
792
  return at;
772
793
  }
@@ -786,6 +807,9 @@ static class Program
786
807
  { "to", "→" }, { "rightarrow", "→" }, { "leftarrow", "←" }, { "Rightarrow", "⇒" }, { "Leftrightarrow", "⇔" }, { "in", "∈" }, { "notin", "∉" },
787
808
  { "subset", "⊂" }, { "cup", "∪" }, { "cap", "∩" }, { "forall", "∀" }, { "exists", "∃" }, { "angle", "∠" }, { "perp", "⊥" }, { "parallel", "∥" },
788
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" },
789
813
  { "quad", " " }, { "qquad", " " }, { "left", "" }, { "right", "" }, { "displaystyle", "" }, { "limits", "" }, { "lim", "lim" },
790
814
  { "sin", "sin" }, { "cos", "cos" }, { "tan", "tan" }, { "cot", "cot" }, { "ln", "ln" }, { "log", "log" }, { "exp", "exp" }, { "max", "max" }, { "min", "min" },
791
815
  { "arcsin", "arcsin" }, { "arccos", "arccos" }, { "arctan", "arctan" }, { "sinh", "sinh" }, { "cosh", "cosh" }, { "tanh", "tanh" },
@@ -831,9 +855,19 @@ static class Program
831
855
  {
832
856
  StringBuilder o = new StringBuilder();
833
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;
834
861
  while (i < s.Length)
835
862
  {
836
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
+ }
837
871
  if (c == '\\')
838
872
  {
839
873
  int start = ++i;
@@ -842,12 +876,51 @@ static class Program
842
876
  {
843
877
  // \, \; \! \ are spacing; \{ \} \% and the like are the character itself.
844
878
  char next = i < s.Length ? s[i++] : ' ';
845
- if (next == ',' || next == ';' || next == ':' || next == ' ') o.Append(' ');
879
+ if (next == '|') o.Append('‖');
880
+ else if (next == ',' || next == ';' || next == ':' || next == ' ') o.Append(' ');
846
881
  else if (next == '\\') o.Append(' ');
847
882
  else if (next != '!') o.Append(next);
848
883
  continue;
849
884
  }
850
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
+ }
851
924
  if (name == "frac" || name == "dfrac" || name == "tfrac") {
852
925
  string top = TexArg(s, ref i), bottom = TexArg(s, ref i);
853
926
  // A factor written right before the fraction (2 rac{a}{b}) must not be read into its numerator.
@@ -881,6 +954,7 @@ static class Program
881
954
  if (TexSymbols.TryGetValue(name, out symbol))
882
955
  {
883
956
  o.Append(symbol);
957
+ if (symbol == "∑" || symbol == "∏" || symbol == "∫" || symbol == "∮") nary = true;
884
958
  // A function name is applied to what follows: Word needs a space after it.
885
959
  if (symbol.Length > 1 && symbol == name && i < s.Length && s[i] != ' ' && s[i] != '^' && s[i] != '_') o.Append(' ');
886
960
  }
@@ -894,8 +968,8 @@ static class Program
894
968
  bool braced = i < s.Length && s[i] == '{';
895
969
  string arg = TexArg(s, ref i);
896
970
  o.Append(c).Append(braced && arg.Length > 1 ? "(" + arg + ")" : arg);
897
- // Otherwise Word reads the next letter into the script: x^2y.
898
- 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(' ');
899
973
  continue;
900
974
  }
901
975
  if (c == '{') { string group = TexArg(s, ref i); o.Append("〖" + group + "〗"); continue; }
@@ -923,6 +997,30 @@ static class Program
923
997
  System.Text.RegularExpressions.Match m = found[k];
924
998
  bool display = m.Groups[1].Success;
925
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
+ {
926
1024
  // ag{1} numbers a display equation: Word sets "#(1)" flush right on the equation's line.
927
1025
  string tag = null;
928
1026
  System.Text.RegularExpressions.Match tagged = Tag.Match(source);
@@ -941,11 +1039,10 @@ static class Program
941
1039
  dynamic math = doc.OMaths.Add(spot);
942
1040
  math.OMaths[1].BuildUp();
943
1041
  if (display) { try { math.OMaths[1].Type = 0; math.OMaths[1].Justification = 1; } catch (COMException) { } }
944
- made++;
1042
+ return true;
945
1043
  }
946
- catch (COMException) { }
1044
+ catch (COMException) { return false; }
947
1045
  }
948
- return made;
949
1046
  }
950
1047
 
951
1048
  // ───────────────────────── Word ─────────────────────────
@@ -1121,6 +1218,9 @@ static class Program
1121
1218
  if (op.Has("spaceBefore")) range.ParagraphFormat.SpaceBefore = (float)op.Num("spaceBefore", 0);
1122
1219
  if (op.Has("spaceAfter")) range.ParagraphFormat.SpaceAfter = (float)op.Num("spaceAfter", 0);
1123
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;
1124
1224
  }
1125
1225
 
1126
1226
  /// A new empty paragraph where an insert operation points: after / before paragraph "para", or at the start / end.
@@ -1147,6 +1247,37 @@ static class Program
1147
1247
  return doc.Range(start, start).Paragraphs[1];
1148
1248
  }
1149
1249
 
1250
+ static readonly System.Text.RegularExpressions.Regex Numbered = new System.Text.RegularExpressions.Regex(@"^\s*(第?[一二三四五六七八九十百]+[、..章节]|[((][一二三四五六七八九十\d]+[))]|\d+(\.\d+)*[\s、..])");
1251
+
1252
+ static readonly System.Text.RegularExpressions.Regex PlainMath = new System.Text.RegularExpressions.Regex(@"[A-Za-z0-9\)\}]_(\{[^}]+\}|[A-Za-z0-9])|[A-Za-z0-9\)]\^(\{[^}]+\}|\(?-?\d)|\\(frac|sqrt|sum|int|alpha|beta|theta|pi)\b");
1253
+
1254
+ /// Subscripts and powers written as plain text (d_k, 10^18) outside $...$: say so, with the first one found.
1255
+ static string Lint(string text)
1256
+ {
1257
+ if (text == null) return "";
1258
+ string outside = Dollars.Replace(text, " ");
1259
+ System.Text.RegularExpressions.Match m = PlainMath.Match(outside);
1260
+ if (!m.Success) return "";
1261
+ int start = Math.Max(0, m.Index - 6);
1262
+ return " — NOTE plain-text formula \"" + outside.Substring(start, Math.Min(outside.Length - start, m.Length + 12)).Trim() + "\": write formulas, subscripts and powers between dollar signs ($d_k$, $10^{18}$) so they are typeset";
1263
+ }
1264
+
1265
+ /// Keep Latin words and numbers whole at the end of a line. Some templates carry "allow Latin text to wrap in
1266
+ /// the middle of a word" in a style, and every paragraph written after it inherits that: 37 / 000, mode / l.
1267
+ /// (Word's object model names this backwards: WordWrap = True is the whole-word setting.)
1268
+ static void Whole(dynamic range)
1269
+ {
1270
+ try { range.ParagraphFormat.WordWrap = -1; } catch (Exception) { }
1271
+ }
1272
+
1273
+ /// The text already starts with its number ("一、", "2.1 ") and the style would add another: drop the automatic one.
1274
+ static void Unnumber(dynamic paragraph, string text)
1275
+ {
1276
+ if (!Numbered.IsMatch(text)) return;
1277
+ try { if ((int)paragraph.Range.ListFormat.ListType != 0) paragraph.Range.ListFormat.RemoveNumbers(); }
1278
+ catch (Exception) { }
1279
+ }
1280
+
1150
1281
  /// A new empty paragraph right after the given one.
1151
1282
  static dynamic After(dynamic doc, dynamic paragraph)
1152
1283
  {
@@ -1158,8 +1289,27 @@ static class Program
1158
1289
  try
1159
1290
  {
1160
1291
  made.Style = paragraph.Style;
1161
- made.Range.ParagraphFormat = paragraph.Range.ParagraphFormat.Duplicate;
1162
- made.Range.Font = paragraph.Range.Font.Duplicate;
1292
+ dynamic from = paragraph.Range.ParagraphFormat, to = made.Range.ParagraphFormat;
1293
+ to.Alignment = from.Alignment;
1294
+ to.LeftIndent = from.LeftIndent; to.RightIndent = from.RightIndent;
1295
+ to.CharacterUnitFirstLineIndent = from.CharacterUnitFirstLineIndent; to.FirstLineIndent = from.FirstLineIndent;
1296
+ to.SpaceBefore = from.SpaceBefore; to.SpaceAfter = from.SpaceAfter;
1297
+ to.LineSpacingRule = from.LineSpacingRule;
1298
+ if ((int)from.LineSpacingRule >= 3) to.LineSpacing = from.LineSpacing;
1299
+ dynamic font = paragraph.Range.Font, target = made.Range.Font;
1300
+ // 9999999 / empty mean "mixed" in the source paragraph: leave those to the style.
1301
+ string name = (string)font.Name, farEast = (string)font.NameFarEast;
1302
+ if (!string.IsNullOrEmpty(name)) target.Name = name;
1303
+ if (!string.IsNullOrEmpty(farEast)) target.NameFarEast = farEast;
1304
+ float size = (float)font.Size;
1305
+ if (size > 0 && size < 1000) target.Size = size;
1306
+ foreach (string flag in new string[] { "Bold", "Italic" })
1307
+ {
1308
+ int value = flag == "Bold" ? (int)font.Bold : (int)font.Italic;
1309
+ if (value == 0 || value == -1) { if (flag == "Bold") target.Bold = value; else target.Italic = value; }
1310
+ }
1311
+ int color = (int)font.Color;
1312
+ if (color != 9999999) target.Color = color;
1163
1313
  }
1164
1314
  catch (COMException) { }
1165
1315
  return made;
@@ -1199,10 +1349,13 @@ static class Program
1199
1349
  {
1200
1350
  dynamic range = caption.Range;
1201
1351
  range.ParagraphFormat.Alignment = 1;
1352
+ Whole(range);
1202
1353
  range.ParagraphFormat.CharacterUnitFirstLineIndent = 0; range.ParagraphFormat.FirstLineIndent = 0;
1203
1354
  range.Font.Bold = 0; range.Font.Italic = 0; range.Font.Color = -16777216; range.Font.Size = 10.5f;
1204
1355
  if (!string.IsNullOrEmpty(fontName)) range.Font.Name = fontName;
1205
1356
  if (!string.IsNullOrEmpty(fontFarEast)) range.Font.NameFarEast = fontFarEast;
1357
+ // A table's caption must not be left alone at the foot of a page.
1358
+ if (table) range.ParagraphFormat.KeepWithNext = -1;
1206
1359
  range.ParagraphFormat.SpaceBefore = table ? Air : 3f;
1207
1360
  range.ParagraphFormat.SpaceAfter = table ? 3f : Air;
1208
1361
  Mathify(doc, caption.Range);
@@ -1247,22 +1400,35 @@ static class Program
1247
1400
  bool all = op.Flag("all", true), matchCase = op.Flag("matchCase", true);
1248
1401
  dynamic range = doc.Content;
1249
1402
  int count = 0;
1250
- 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))
1251
1408
  {
1252
1409
  range.Text = replace;
1253
- 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);
1254
1419
  count++;
1255
1420
  if (!all) break;
1256
1421
  }
1257
1422
  if (count == 0) throw new Fail("NOT_FOUND", "The text \"" + Clip(find, 60) + "\" does not occur in the document.");
1258
- return "replaced " + count;
1423
+ return "replaced " + count + extra + Lint(replace);
1259
1424
  }
1260
1425
  if (type == "insert_paragraphs")
1261
1426
  {
1262
1427
  IList items = op.List("items");
1263
1428
  if (items == null) { items = new ArrayList(); items.Add(new Dictionary<string, object> { { "text", op.Need("text") } }); }
1264
1429
  dynamic current = null;
1265
- int made = 0, equations = 0;
1430
+ int made = 0, equations = 0, cites = 0;
1431
+ string lint = "";
1266
1432
  foreach (object raw in items)
1267
1433
  {
1268
1434
  Bag item = raw is string ? new Bag(new Dictionary<string, object> { { "text", raw } }) : new Bag(raw);
@@ -1278,13 +1444,22 @@ static class Program
1278
1444
  WordFormat(p.Range, StyleLess(item));
1279
1445
  Follow(doc, p.Range);
1280
1446
  Write(doc, (int)p.Range.Start, text);
1447
+ if (lint.Length == 0) lint = Lint(text);
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) { }
1454
+ Whole(p.Range);
1455
+ cites += Cite(doc, p.Range);
1281
1456
  equations += Mathify(doc, p.Range);
1282
1457
  current = p;
1283
1458
  made++;
1284
1459
  }
1285
1460
  }
1286
1461
  int lastIndex = Index(doc, current);
1287
- return "inserted " + made + " paragraph(s), now paragraphs " + (lastIndex - made + 1) + "–" + lastIndex + " of " + (int)doc.Paragraphs.Count + (equations > 0 ? ", " + equations + " equation(s)" : "");
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;
1288
1463
  }
1289
1464
  if ((type == "set_text" || (type == "delete_range" && op.Has("para"))) && op.Raw("expect") == null)
1290
1465
  {
@@ -1297,8 +1472,9 @@ static class Program
1297
1472
  range.MoveEnd(1, -1);
1298
1473
  range.Text = "";
1299
1474
  Write(doc, (int)p.Range.Start, Lines(op.Raw("text") ?? ""));
1300
- int built = Mathify(doc, p.Range);
1301
- return "paragraph " + op.Int("para", 0) + " rewritten" + (built > 0 ? ", " + built + " equation(s)" : "");
1475
+ Whole(p.Range);
1476
+ string built = Enrich(doc, p.Range);
1477
+ return "paragraph " + op.Int("para", 0) + " rewritten" + built + Lint(Lines(op.Raw("text") ?? ""));
1302
1478
  }
1303
1479
  if (type == "format_text" || type == "set_style")
1304
1480
  {
@@ -1343,6 +1519,7 @@ static class Program
1343
1519
  catch (Exception) { }
1344
1520
  Plain(p, "Normal");
1345
1521
  Follow(doc, p.Range);
1522
+ string cellLint = "";
1346
1523
  dynamic table = doc.Tables.Add(p.Range, rows, cols);
1347
1524
  // The cells take the formatting of the paragraph that follows (a heading, say): make them plain.
1348
1525
  SetStyle(table.Range, "Normal");
@@ -1359,6 +1536,7 @@ static class Program
1359
1536
  if (!string.IsNullOrEmpty(fontFarEast)) table.Range.Font.NameFarEast = fontFarEast;
1360
1537
  if (fontSize > 4 && fontSize < 100) table.Range.Font.Size = fontSize;
1361
1538
  table.Range.ParagraphFormat.Alignment = 1;
1539
+ Whole(table.Range);
1362
1540
  table.Range.Cells.VerticalAlignment = 1;
1363
1541
  }
1364
1542
  catch (COMException) { }
@@ -1376,12 +1554,19 @@ static class Program
1376
1554
  dynamic cell = table.Cell(r + 1, c + 1).Range;
1377
1555
  string value = Convert.ToString(cells[c], System.Globalization.CultureInfo.InvariantCulture);
1378
1556
  cell.Text = value;
1379
- 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);
1380
1559
  }
1381
1560
  if (Typing) Thread.Sleep(25);
1382
1561
  }
1383
1562
  }
1384
- if (op.Flag("header", true)) table.Rows[1].Range.Font.Bold = 1;
1563
+ if (op.Flag("header", true))
1564
+ {
1565
+ table.Rows[1].Range.Font.Bold = 1;
1566
+ // On a page break the header row is repeated at the top of the next page.
1567
+ try { table.Rows[1].HeadingFormat = -1; } catch (COMException) { }
1568
+ }
1569
+ try { table.Rows.AllowBreakAcrossPages = 0; } catch (COMException) { }
1385
1570
  if (op.Has("caption")) Caption(doc, table.Range, true, op.Need("caption"), fontName, fontFarEast);
1386
1571
  else
1387
1572
  {
@@ -1393,7 +1578,7 @@ static class Program
1393
1578
  int tableEnd = (int)doc.Range(0, table.Range.End).Paragraphs.Count;
1394
1579
  bool nested = false;
1395
1580
  try { nested = (int)table.NestingLevel > 1; } catch (Exception) { }
1396
- 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;
1397
1582
  }
1398
1583
  if (type == "set_cell")
1399
1584
  {
@@ -1403,8 +1588,8 @@ static class Program
1403
1588
  Follow(doc, cell.Range);
1404
1589
  cell.Range.Text = "";
1405
1590
  Write(doc, (int)cell.Range.Start, Lines(op.Raw("text") ?? ""));
1406
- int built = Mathify(doc, doc.Tables[n].Cell(op.Int("row", 1), op.Int("col", 1)).Range);
1407
- 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") ?? ""));
1408
1593
  }
1409
1594
  if (type == "insert_image")
1410
1595
  {
@@ -1424,17 +1609,254 @@ static class Program
1424
1609
  else p.Range.ParagraphFormat.SpaceAfter = Air;
1425
1610
  return "image inserted";
1426
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"; }
1427
1632
  throw new Fail("BAD_ARGS", "Unknown Word operation \"" + type + "\".");
1428
1633
  }
1429
1634
 
1430
1635
  static Bag StyleLess(Bag source)
1431
1636
  {
1432
1637
  Dictionary<string, object> copy = new Dictionary<string, object>();
1433
- 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" })
1434
1639
  if (source.Has(key)) copy[key] = source.Raw(key);
1435
1640
  return new Bag(copy);
1436
1641
  }
1437
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
+
1438
1860
  // ───────────────────────── Excel ─────────────────────────
1439
1861
 
1440
1862
  static dynamic Sheet(dynamic book, Bag op)
@@ -1557,8 +1979,8 @@ static class Program
1557
1979
  range.HorizontalAlignment = align == "center" ? -4108 : align == "right" ? -4152 : -4131;
1558
1980
  }
1559
1981
  if (op.Has("wrap")) range.WrapText = op.Flag("wrap", false);
1560
- if (op.Flag("border", false)) range.Borders.LineStyle = 1;
1561
- if (op.Flag("merge", false)) range.Merge();
1982
+ if (op.On("border")) range.Borders.LineStyle = 1;
1983
+ if (op.On("merge")) range.Merge();
1562
1984
  if (op.Has("columnWidth")) range.ColumnWidth = op.Num("columnWidth", 10);
1563
1985
  if (op.Has("rowHeight")) range.RowHeight = op.Num("rowHeight", 15);
1564
1986
  }
@@ -1745,7 +2167,7 @@ static class Program
1745
2167
  {
1746
2168
  if (!Typing || text.Length < 4) { textRange.Text = text; return; }
1747
2169
  textRange.Text = "";
1748
- 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); }
1749
2171
  }
1750
2172
 
1751
2173
  static void GoTo(dynamic deck, int slide)
@@ -1861,6 +2283,7 @@ static class Program
1861
2283
  {
1862
2284
  Bag op = new Bag(ops[i]);
1863
2285
  type = op.Need("op");
2286
+ if (card) Card.Hold = true;
1864
2287
  if (card) Card.Report("AI 正在编辑 " + docName + (ops.Count > 1 ? " · " + (i + 1) + "/" + ops.Count : ""));
1865
2288
  string line = kind == "word" ? WordOp(doc, type, op) : kind == "excel" ? ExcelOp(app, doc, type, op) : PptOp(doc, type, op);
1866
2289
  string ignored = op.Unread();
@@ -1883,7 +2306,7 @@ static class Program
1883
2306
  {
1884
2307
  if (undo != null) { try { undo.EndCustomRecord(); } catch (COMException) { } }
1885
2308
  }
1886
- if (card) Card.Report(result.ContainsKey("failed") ? "AI 的修改停在第 " + (done.Count + 1) + " 项" : "AI 已改好 " + docName);
2309
+ if (card) Card.Hold = false;
1887
2310
  result["done"] = done;
1888
2311
  result["total"] = ops.Count;
1889
2312
  return result;
@@ -1891,8 +2314,97 @@ static class Program
1891
2314
 
1892
2315
  // ───────────────────────── rendering ─────────────────────────
1893
2316
 
2317
+ /// One Word page as a picture of the given width.
2318
+ static Bitmap WordPage(dynamic pages, int page, int width)
2319
+ {
2320
+ byte[] bits = (byte[])pages[page].EnhMetaFileBits;
2321
+ using (MemoryStream stream = new MemoryStream(bits))
2322
+ using (Metafile meta = new Metafile(stream))
2323
+ {
2324
+ int height = (int)Math.Round(width * (double)meta.Height / meta.Width);
2325
+ // Drawn straight at the final size, hairlines (table borders) fall between pixels and vanish:
2326
+ // draw the page large first, then scale the picture down.
2327
+ int factor = Math.Max(2, (int)Math.Ceiling(3000.0 / width));
2328
+ Bitmap bitmap = new Bitmap(width, height);
2329
+ using (Bitmap large = new Bitmap(width * factor, height * factor))
2330
+ {
2331
+ using (Graphics g = Graphics.FromImage(large))
2332
+ {
2333
+ g.Clear(Color.White);
2334
+ g.DrawImage(meta, 0, 0, large.Width, large.Height);
2335
+ }
2336
+ using (Graphics g = Graphics.FromImage(bitmap))
2337
+ {
2338
+ g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
2339
+ g.DrawImage(large, 0, 0, width, height);
2340
+ }
2341
+ }
2342
+ return bitmap;
2343
+ }
2344
+ }
2345
+
2346
+ /// Several pages or slides side by side in one picture, each with its number: the whole document at a glance.
2347
+ static object Sheet(string kind, dynamic doc, Bag a)
2348
+ {
2349
+ string path = a.Need("out");
2350
+ int cell = Math.Max(300, Math.Min(1000, a.Int("width", 640)));
2351
+ dynamic pages = kind == "word" ? doc.Windows[1].Panes[1].Pages : null;
2352
+ int total = kind == "word" ? (int)pages.Count : (int)doc.Slides.Count;
2353
+ if (total == 0) throw new Fail("ANCHOR_MISSING", "There is nothing to picture yet.");
2354
+ int first = Math.Max(1, a.Int("from", 1)), last = Math.Min(total, a.Int("to", total));
2355
+ if (first > total) throw new Fail("ANCHOR_MISSING", "There is no " + (kind == "word" ? "page " : "slide ") + first + " (there are " + total + ").");
2356
+ last = Math.Min(last, first + 11);
2357
+ int count = last - first + 1, columns = Math.Min(count, kind == "word" ? 3 : 2), rows = (count + columns - 1) / columns;
2358
+ List<Bitmap> pictures = new List<Bitmap>();
2359
+ string temp = path + ".slide.png";
2360
+ try
2361
+ {
2362
+ for (int n = first; n <= last; n++)
2363
+ {
2364
+ if (kind == "word") pictures.Add(WordPage(pages, n, cell));
2365
+ else
2366
+ {
2367
+ double ratio = (double)doc.PageSetup.SlideHeight / (double)doc.PageSetup.SlideWidth;
2368
+ doc.Slides[n].Export(temp, "PNG", cell, (int)Math.Round(cell * ratio));
2369
+ using (Image loaded = Image.FromFile(temp)) pictures.Add(new Bitmap(loaded));
2370
+ }
2371
+ }
2372
+ int gap = 14, label = 30, height = 0;
2373
+ foreach (Bitmap picture in pictures) height = Math.Max(height, picture.Height);
2374
+ using (Bitmap sheet = new Bitmap(columns * cell + (columns + 1) * gap, rows * (height + label + gap) + gap))
2375
+ {
2376
+ using (Graphics g = Graphics.FromImage(sheet))
2377
+ using (Font font = new Font("Microsoft YaHei UI", 14f, FontStyle.Bold, GraphicsUnit.Pixel))
2378
+ using (Pen border = new Pen(Color.FromArgb(150, 150, 150)))
2379
+ {
2380
+ g.Clear(Color.FromArgb(232, 232, 236));
2381
+ for (int k = 0; k < pictures.Count; k++)
2382
+ {
2383
+ int x = gap + (k % columns) * (cell + gap), y = gap + (k / columns) * (height + label + gap);
2384
+ g.DrawString((kind == "word" ? "page " : "slide ") + (first + k), font, Brushes.Black, x, y + 4);
2385
+ g.DrawImage(pictures[k], x, y + label);
2386
+ g.DrawRectangle(border, x, y + label, pictures[k].Width - 1, pictures[k].Height - 1);
2387
+ }
2388
+ }
2389
+ sheet.Save(path, ImageFormat.Png);
2390
+ }
2391
+ }
2392
+ finally
2393
+ {
2394
+ foreach (Bitmap picture in pictures) picture.Dispose();
2395
+ try { File.Delete(temp); } catch (Exception) { }
2396
+ }
2397
+ Dictionary<string, object> result = new Dictionary<string, object>();
2398
+ result["what"] = (kind == "word" ? "pages " : "slides ") + first + "–" + last + " of " + total;
2399
+ result["first"] = first; result["last"] = last; result["total"] = total;
2400
+ result["path"] = path;
2401
+ using (Image saved = Image.FromFile(path)) { result["width"] = saved.Width; result["height"] = saved.Height; }
2402
+ return result;
2403
+ }
2404
+
1894
2405
  static object Render(string kind, dynamic app, dynamic doc, Bag a)
1895
2406
  {
2407
+ if (a.Flag("sheet", false) && kind != "excel") return Sheet(kind, doc, a);
1896
2408
  string path = a.Need("out");
1897
2409
  int width = Math.Max(320, Math.Min(2400, a.Int("width", 1100)));
1898
2410
  Dictionary<string, object> result = new Dictionary<string, object>();
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: "迷你卡片",
@@ -521,7 +521,7 @@ window.__ModuleLoader__.load({
521
521
  })), h("div", { style: S.toggleRow }, h("div", { style: S.toggleText }, "交付前整体视觉检查", h("span", { style: {
522
522
  ...S.hint,
523
523
  fontWeight: 400
524
- } }, "AI 保存时,如果它改完之后还没看过成品,就自动把文档各页(最多 8 页)渲染成图片交给它过一遍,发现问题先修再交付。会多用一些 token。")), h(Switch, {
524
+ } }, "AI 保存时,如果它改完之后还没看过成品,就自动把整份文档拼成带页码的总览图(一张图最多 6 页)交给它过一遍,发现问题先修再交付。会多用一些 token。")), h(Switch, {
525
525
  checked: settings.finalCheck,
526
526
  disabled,
527
527
  label: "交付前整体视觉检查",
package/lib/index.js CHANGED
@@ -251,8 +251,9 @@ function promptText() {
251
251
  "- Put all the changes you can into one office_edit call; it runs them in order.",
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
- "- Finish with a polish pass, it is part of the job: office_render every page or slide you changed, 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.",
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. 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. */
@@ -6178,6 +6193,38 @@ function createTools(host) {
6178
6193
  ...total === void 0 ? {} : { total }
6179
6194
  };
6180
6195
  };
6196
+ /** The whole document at a glance: its pages (6 to a picture) or slides tiled with their numbers, up to 18. */
6197
+ const overview = async (on) => {
6198
+ if (on.app === "excel") return picture(on, 0, 0, {}, Math.min(host.settings().renderWidth, 1e3));
6199
+ const folder = join(tmpdir(), "dsh-office");
6200
+ await mkdir(folder, { recursive: true });
6201
+ const lines = [];
6202
+ const images = [];
6203
+ let total = 1;
6204
+ for (let from = 1; from <= Math.min(total, 18); from += 6) {
6205
+ const out = join(folder, `sheet-${process.pid}-${Date.now()}-${from}.png`);
6206
+ try {
6207
+ const result = await helper.call("render", {
6208
+ ...on,
6209
+ sheet: true,
6210
+ from,
6211
+ to: from + 5,
6212
+ out,
6213
+ width: 640
6214
+ }, 11e4);
6215
+ total = result.total;
6216
+ images.push(toJson(await host.saveImage(await readFile(out), `${on.app}-overview-${from}.png`)));
6217
+ lines.push(`${APP_NAMES[on.app]} ${result.what}, tiled in one picture.`);
6218
+ } finally {
6219
+ await rm(out, { force: true });
6220
+ }
6221
+ }
6222
+ if (total > 18) lines.push(`Only the first 18 of ${total} are shown.`);
6223
+ return {
6224
+ lines,
6225
+ images
6226
+ };
6227
+ };
6181
6228
  /** The helper arguments naming the document; the app comes from doc / app only, never from a save-as path. */
6182
6229
  const target = (args) => {
6183
6230
  if (args.doc === void 0 && args.app === void 0) {
@@ -6233,6 +6280,7 @@ function createTools(host) {
6233
6280
  app: kind,
6234
6281
  doc: doc.path ?? doc.name
6235
6282
  };
6283
+ if (doc.how !== "attached") own.add(keyOf(current));
6236
6284
  return { text: formatOpened(doc) };
6237
6285
  },
6238
6286
  presentCall: (args) => card(`打开 ${fileName(args.path) || "新文档"}`)
@@ -6344,7 +6392,7 @@ function createTools(host) {
6344
6392
  }));
6345
6393
  tools.push(defineTool({
6346
6394
  name: "office_render",
6347
- description: "Get a picture of how the open document really looks: a Word page, a PowerPoint slide (with \"to\": up to 4 in a row), or an Excel range (default: the used range). Use it on every page you changed before you finish: it shows what office_read cannot, such as content that landed outside its frame, a table that is too wide, text that overflows. It pictures the document as Word itself lays it out, unsaved changes included, with the window in the background.",
6395
+ description: "Get a picture of how the open document really looks: a Word page, a PowerPoint slide (with \"to\": up to 4 in a row), an Excel range (default: the used range), or with overview: true the whole document tiled into one picture. Use it on every page you changed before you finish: it shows what office_read cannot, such as content that landed outside its frame, a table that is too wide, text that overflows. It pictures the document as Word itself lays it out, unsaved changes included, with the window in the background.",
6348
6396
  parameters: {
6349
6397
  doc: DOC,
6350
6398
  app: APP,
@@ -6360,6 +6408,10 @@ function createTools(host) {
6360
6408
  type: "integer",
6361
6409
  description: "Word / PowerPoint: last page or slide, to get up to 4 in one call."
6362
6410
  },
6411
+ overview: {
6412
+ type: "boolean",
6413
+ description: "Word / PowerPoint: every page or slide tiled into one picture (6 pages per picture, numbered): the whole document at a glance, for the final check. Then look closer at a page with page / slide."
6414
+ },
6363
6415
  sheet: {
6364
6416
  type: "string",
6365
6417
  description: "Excel: sheet name."
@@ -6374,9 +6426,19 @@ function createTools(host) {
6374
6426
  async execute(args, exec) {
6375
6427
  const input = args;
6376
6428
  const on = target(input), kind = on.app;
6377
- if (kind === "ppt" && input.slide === void 0) throw new Error("slide is required for PowerPoint.");
6429
+ if (kind === "ppt" && input.slide === void 0 && input.overview !== true) throw new Error("slide is required for PowerPoint (or pass overview: true).");
6378
6430
  if (!await host.vision(exec)) return { text: "The current model cannot view images; check the result with office_read instead." };
6379
- const { doc: _doc, app: _app, to, page: _page, slide: _slide, ...rest } = input;
6431
+ if (input.overview === true && kind !== "excel") {
6432
+ const all = await overview(on);
6433
+ unseen.delete(keyOf(on));
6434
+ const [image, ...more] = all.images;
6435
+ return {
6436
+ text: all.lines.join("\n"),
6437
+ ...image === void 0 ? {} : { image },
6438
+ ...more.length ? { more } : {}
6439
+ };
6440
+ }
6441
+ const { doc: _doc, app: _app, to, page: _page, slide: _slide, overview: _overview, ...rest } = input;
6380
6442
  const first = kind === "word" ? input.page ?? 1 : kind === "ppt" ? input.slide : 0;
6381
6443
  const last = kind === "excel" || to === void 0 ? first : Math.min(Math.max(to, first), first + 3);
6382
6444
  const { lines, images } = await picture(on, first, last, rest, host.settings().renderWidth);
@@ -6413,10 +6475,16 @@ function createTools(host) {
6413
6475
  }, 11e4);
6414
6476
  const moved = input.path !== void 0 && extname(input.path).toLowerCase() !== ".pdf";
6415
6477
  const wasUnseen = unseen.delete(keyOf(on));
6416
- if (moved) current = {
6417
- app: on.app,
6418
- doc: result.path
6419
- };
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
+ }
6420
6488
  const text = `Saved ${result.path} (${result.bytes} bytes).${moved ? " The open document is now this file." : ""}`;
6421
6489
  if (!wasUnseen || !host.settings().finalCheck || !await host.vision(exec)) return { text };
6422
6490
  try {
@@ -6424,10 +6492,10 @@ function createTools(host) {
6424
6492
  app: on.app,
6425
6493
  doc: result.path
6426
6494
  } : on;
6427
- const shown = await picture(now, 1, on.app === "excel" ? 1 : 8, {}, Math.min(host.settings().renderWidth, 800));
6495
+ const shown = await overview(now);
6428
6496
  const [image, ...more] = shown.images;
6429
6497
  return {
6430
- text: `${text}\nFinal check: you edited after you last looked at the document, so here is ${on.app === "excel" ? "the sheet" : `${shown.images.length}${shown.total !== void 0 && shown.total > shown.images.length ? ` of ${shown.total}` : ""} ${on.app === "word" ? "page(s)" : "slide(s)"}`} as it is now. Go through every picture before you tell the user it is done. If anything is off (content outside its frame, uneven fonts or spacing, formulas as plain text, bad tables, leftover hints, blank gaps), fix it with office_edit and save again; if all is well, say so.`,
6498
+ text: `${text}\nFinal check: you edited after you last looked at the document, so here is the whole of it as it is now. ${shown.lines.join(" ")}\nGo through every page before you tell the user it is done. If anything is off (content outside its frame, uneven fonts or spacing, formulas as plain text, bad tables, leftover hints, blank gaps, a nearly empty last page), look closer with office_render, fix it with office_edit and save again; if all is well, say so.`,
6431
6499
  ...image === void 0 ? {} : { image },
6432
6500
  ...more.length ? { more } : {}
6433
6501
  };
@@ -6437,6 +6505,38 @@ function createTools(host) {
6437
6505
  },
6438
6506
  presentCall: (args) => card(`保存 ${fileName(args.path ?? args.doc) || "当前文档"}`)
6439
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
+ }));
6440
6540
  return tools;
6441
6541
  }
6442
6542
  //#endregion
@@ -6450,8 +6550,8 @@ const Config = z.object({
6450
6550
  "en-US": { $description: "Scroll to where the AI is editing; off = silent, your view is left alone" }
6451
6551
  }),
6452
6552
  typing: z.boolean().default(true).volatile().i18n({
6453
- "zh-CN": { $description: "文字像打字一样逐步写出,而不是整块出现(每段最多约 1 秒)" },
6454
- "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)" }
6455
6555
  }),
6456
6556
  card: z.boolean().default(true).volatile().i18n({
6457
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.2",
3
+ "version": "0.3.0",
4
4
  "description": "DeepSeek Harness Office 插件(Windows):AI 直接在你打开的 Word / Excel / PowerPoint 里改文档,改动实时出现在窗口中,你可以同时编辑;读结构、批量编辑、渲染成图核对、保存。",
5
5
  "type": "module",
6
6
  "author": "copylee",