@godcreator/word-report 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/site.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { r as AssemblyResult, v as ReportDocument } from "./types-Bcl48xQN.mjs";
2
- import { a as ReportTocItem, i as ReportSummary, n as ReportPageComments, o as reportAssetHrefOf, r as ReportPageData, s as reportHrefOf, t as ReportListData } from "./page-data-CJ6nCBnW.mjs";
1
+ import { r as AssemblyResult, y as ReportDocument } from "./types-Ea2OT6Ac.mjs";
2
+ import { a as ReportTocItem, i as ReportSummary, n as ReportPageComments, o as reportAssetHrefOf, r as ReportPageData, s as reportHrefOf, t as ReportListData } from "./page-data-C1veAE55.mjs";
3
3
  import { CommentAvatar, CommentBodyComponent } from "@godcreator/comments/site";
4
4
  //#region src/site/report-pages.d.ts
5
5
  declare function ReportListPage(): import("react").JSX.Element;
package/dist/site.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { t as assembleDocument } from "./assemble-BpllQNVY.mjs";
1
+ import { t as assembleDocument } from "./assemble-BEt9_xsB.mjs";
2
2
  import { n as reportHrefOf, t as reportAssetHrefOf } from "./page-data-DLPoVgXy.mjs";
3
3
  import { Link, useLoaderData } from "react-router";
4
4
  import { DocsBody, DocsPage, DocsTitle } from "@fumadocs/base-ui/layouts/docs/page";
@@ -7,6 +7,7 @@ type ReportInline = {
7
7
  type: 'math';
8
8
  id: string;
9
9
  latex: string;
10
+ plainText?: PlainMathRun[];
10
11
  } | {
11
12
  type: 'ref';
12
13
  id: string;
@@ -29,6 +30,11 @@ type ReportInline = {
29
30
  } | {
30
31
  type: 'break';
31
32
  };
33
+ /** 行内公式在 Word 里写成文字时的一段:网页仍按 latex 显示,Word 照这些文字与标记写,不插 MathType */
34
+ interface PlainMathRun {
35
+ text: string;
36
+ marks?: ('em' | 'sub' | 'sup')[];
37
+ }
32
38
  interface Paragraph {
33
39
  type: 'paragraph';
34
40
  id: string;
@@ -194,4 +200,4 @@ declare class ReportContentError extends Error {
194
200
  constructor(message: string, file?: string, pointer?: string);
195
201
  }
196
202
  //#endregion
197
- export { SingleImageFigure as C, TableCell as E, Section as S, Table as T, ReportContentError as _, CellBlock as a, ReportInline as b, ContentWarning as c, FigurePanel as d, Include as f, ReportBlock as g, Paragraph as h, Bibliography as i, Equation as l, MultiImageFigure as m, AssemblyOptions as n, Code as o, IndexEntry as p, AssemblyResult as r, CompiledReport as s, Abstract as t, Figure as u, ReportDocument as v, SourceLocation as w, ReportList as x, ReportIndex as y };
203
+ export { Section as C, TableCell as D, Table as E, ReportList as S, SourceLocation as T, ReportBlock as _, CellBlock as a, ReportIndex as b, ContentWarning as c, FigurePanel as d, Include as f, PlainMathRun as g, Paragraph as h, Bibliography as i, Equation as l, MultiImageFigure as m, AssemblyOptions as n, Code as o, IndexEntry as p, AssemblyResult as r, CompiledReport as s, Abstract as t, Figure as u, ReportContentError as v, SingleImageFigure as w, ReportInline as x, ReportDocument as y };
package/engine/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Word report engine
2
2
 
3
- 正式 Word COM / MathType 引擎迁入本仓。来源、提交与每份原始文件的 SHA-256 见 `migration-provenance.json`。仅迁入源仓已提交基线,未迁入源仓未提交代码、probe、旧运行输出或虚拟环境。Python 包版本仍为 0.0.1,依赖版本保持来源固定值。
3
+ 正式 Word COM / MathType 引擎迁入本仓。来源、提交与每份原始文件的 SHA-256 见仓根 `probes/2610131513_word-report-migration-baseline/migration-provenance.json`。仅迁入源仓已提交基线,未迁入源仓未提交代码、probe、旧运行输出或虚拟环境。Python 包版本仍为 0.0.1,依赖版本保持来源固定值。
4
4
 
5
5
  ## 环境
6
6
 
@@ -34,8 +34,8 @@
34
34
 
35
35
  ## 迁入回归
36
36
 
37
- 2026-10-13 的证据摘要见 `migration-verification.json`:23 个 Python 单测与依赖检查通过;原 demo 经迁入代码重新生成 12 页 DOCX/PDF,6 个 MathType 保存重开、5 张表、2 张源图片、正文、域引用和临时编号增删检查通过,模板哈希未变。页面显示仍待用户检查。
37
+ 2026-10-13 的证据在仓根 `probes/2610131513_word-report-migration-baseline/`,摘要是其中的 `migration-verification.json`:23 个 Python 单测与依赖检查通过;原 demo 经迁入代码重新生成 12 页 DOCX/PDF,6 个 MathType 保存重开、5 张表、2 张源图片、正文、域引用和临时编号增删检查通过,模板哈希未变。页面显示仍待用户检查。
38
38
 
39
- 首次运行在 `out/migration-baseline-20261013-01` 留下失败记录:已提交基线的链接锚点保留 `@LINK0@`。迁入包显式替换锚点文字后,在全新目录 `out/migration-baseline-20261013-02` 重新生成并通过自动验收;没有使用源仓未提交的修补代码。
39
+ 首次运行(同一目录的 `runs/migration-baseline-20261013-01`)留下失败记录:已提交基线的链接占位文字 `@LINK0@` 没被替换。迁入包显式替换占位文字后,在全新目录(`runs/migration-baseline-20261013-02`)重新生成并通过自动验收;没有使用源仓未提交的修补代码。
40
40
 
41
41
  独立 CLI 回归时,MathType 将读回文本的外层 `$...$` 换成了 `\[...\]`,旧校验错误地判定公式变化。当前只规范化一对外层数学分隔符,内部 TeX 仍逐字比较;不会改写公式、改 MathType 全局设置或忽略内容差异。对应回归同时检查错误公式与不匹配分隔符仍被拒绝。
@@ -143,6 +143,48 @@
143
143
  "latex": {
144
144
  "type": "string",
145
145
  "minLength": 1
146
+ },
147
+ "plainText": {
148
+ "type": "array",
149
+ "minItems": 1,
150
+ "description": "Word writes these runs as text instead of a MathType object; the web view still renders latex.",
151
+ "items": {
152
+ "type": "object",
153
+ "properties": {
154
+ "text": {
155
+ "$ref": "#/$defs/line"
156
+ },
157
+ "marks": {
158
+ "type": "array",
159
+ "items": {
160
+ "enum": [
161
+ "em",
162
+ "sub",
163
+ "sup"
164
+ ]
165
+ },
166
+ "uniqueItems": true,
167
+ "not": {
168
+ "allOf": [
169
+ {
170
+ "contains": {
171
+ "const": "sub"
172
+ }
173
+ },
174
+ {
175
+ "contains": {
176
+ "const": "sup"
177
+ }
178
+ }
179
+ ]
180
+ }
181
+ }
182
+ },
183
+ "required": [
184
+ "text"
185
+ ],
186
+ "additionalProperties": false
187
+ }
146
188
  }
147
189
  },
148
190
  "required": [
@@ -16,6 +16,7 @@ from types import SimpleNamespace
16
16
  from .content import read_json, validate_content, validate_layout
17
17
  from .compose import ContentComposer
18
18
  from .mathtype import DEFAULT_DLL, fill_and_reopen, validate_pending
19
+ from .plain_math import clear_east_asian_hints
19
20
  from .verify import document_state, package_state, pdf_state, sha, content_state, abstract_state
20
21
  from .word import opened_document, refresh, word_application
21
22
 
@@ -114,9 +115,18 @@ def build(directory, output, dll=DEFAULT_DLL, *, data_only=False, layout_path=No
114
115
  evidence = dict(status="running", startedAt=datetime.now(timezone.utc).isoformat(),
115
116
  report=str(directory), visualReview="pending", events=[])
116
117
 
118
+ # Rewriting the whole record per event is quadratic: a report with 858 equations logs ~3500 events.
119
+ last_written = [0.0]
120
+
121
+ def persist():
122
+ write_json(output / "verification.json", evidence)
123
+ last_written[0] = time.perf_counter()
124
+
117
125
  def record(event, **values):
126
+ values.setdefault('elapsedSeconds', round(time.perf_counter() - started, 3))
118
127
  evidence["events"].append(dict(event=event, **values))
119
- write_json(output / "verification.json", evidence)
128
+ if time.perf_counter() - last_written[0] >= 2:
129
+ persist()
120
130
  print(json.dumps(dict(event=event, **values), ensure_ascii=True), flush=True)
121
131
 
122
132
  manifest = dict(report=str(directory), inputs=inputs,
@@ -142,10 +152,12 @@ def build(directory, output, dll=DEFAULT_DLL, *, data_only=False, layout_path=No
142
152
  manifest["environment"]["word"] = dict(version=word.Version, build=word.Build)
143
153
  doc = word.Documents.Add(Template=str(template), NewTemplate=False)
144
154
  doc.UpdateStylesOnOpen = False
155
+ record("compose-start")
145
156
  pending = module.build(doc, content, output)
146
157
  validate_pending(pending)
147
158
  doc.SaveAs2(str(draft), 16, AddToRecentFiles=False)
148
159
  record("draft-saved", equations=len(pending))
160
+ record("inline-math-hints-cleared", runs=clear_east_asian_hints(draft))
149
161
 
150
162
  if pending:
151
163
  fill_and_reopen(draft, destination, pending, record, dll.resolve())
@@ -156,16 +168,19 @@ def build(directory, output, dll=DEFAULT_DLL, *, data_only=False, layout_path=No
156
168
  with opened_document(word, destination) as doc:
157
169
  refresh(doc)
158
170
  doc.Save()
171
+ # Checks and the PDF read the field results as saved; refreshing again here would hide a stale save.
159
172
  with opened_document(word, destination, True) as doc:
160
- refresh(doc)
161
173
  generic = document_state(doc, len(pending))
162
174
  record("document-check", **generic)
163
175
  if content.get('format') == 'docreport':
164
176
  abstracts = abstract_state(doc, content)
165
177
  if abstracts:
166
178
  record('abstract-check', abstracts=abstracts)
179
+ record("report-check-start")
167
180
  record("report-check", result=module.validate(doc, content))
181
+ record("pdf-export-start")
168
182
  doc.ExportAsFixedFormat(str(destination.with_suffix(".pdf")), 17)
183
+ record("pdf-export-finished")
169
184
  saved_hash = sha(destination)
170
185
  if callable(getattr(module, "validate_editing", None)):
171
186
  with opened_document(word, destination, True) as doc:
@@ -195,6 +210,7 @@ def build(directory, output, dll=DEFAULT_DLL, *, data_only=False, layout_path=No
195
210
  record("exception", detail=traceback.format_exc())
196
211
  raise
197
212
  finally:
213
+ persist()
198
214
  write_json(output / "manifest.json", manifest)
199
215
 
200
216
 
@@ -7,7 +7,8 @@ Word objects and deferred MathType/field placeholders.
7
7
  from pathlib import Path
8
8
  import re
9
9
 
10
- from docreport.word import utf16_length, find_text
10
+ from docreport.plain_math import INLINE_MATH_STYLE
11
+ from docreport.word import utf16_length, find_text, MarkerFinder
11
12
 
12
13
 
13
14
  def mm(value):
@@ -179,23 +180,21 @@ class Composer:
179
180
  if self._finished:
180
181
  raise RuntimeError('Composer.finish() may only be called once.')
181
182
  doc = self.doc
183
+ fields = MarkerFinder(doc)
182
184
  for token, code, bookmark in self._pending_fields:
183
- rng = find_text(doc, token)
185
+ rng = fields.find(token)
184
186
  field = doc.Fields.Add(rng, -1, code, False)
185
187
  field.Code.Font.Bold, field.Code.Font.Italic = 0, 0
186
188
  if bookmark:
187
189
  doc.Bookmarks.Add(bookmark, field.Result)
190
+ numbers = MarkerFinder(doc)
188
191
  for begin, end, bookmark in self._pending_numbers:
189
- rng = doc.Content.Duplicate
190
- if not rng.Find.Execute(FindText=end, Wrap=0):
191
- raise RuntimeError(f'Number boundary missing: {end}')
192
- end_position = rng.Start
193
- rng.Delete()
194
- rng = doc.Content.Duplicate
195
- if not rng.Find.Execute(FindText=begin, Wrap=0):
196
- raise RuntimeError(f'Number boundary missing: {begin}')
197
- start_position = rng.Start
198
- rng.Delete()
192
+ opening = numbers.find(begin)
193
+ closing = numbers.find(end)
194
+ end_position = closing.Start
195
+ closing.Delete()
196
+ start_position = opening.Start
197
+ opening.Delete()
199
198
  doc.Bookmarks.Add(bookmark, doc.Range(
200
199
  start_position, end_position - utf16_length(begin)))
201
200
  for table in doc.Tables:
@@ -227,6 +226,7 @@ class ContentComposer(Composer):
227
226
  self.heading_lists = {}
228
227
  self.appendix_numbering = None
229
228
  self.appendix_count = 0
229
+ self._inline_math_style = None
230
230
 
231
231
  def point(self):
232
232
  rng = self.doc.Content.Duplicate if self.container is None else self.container.Range.Duplicate
@@ -286,7 +286,13 @@ class ContentComposer(Composer):
286
286
  if kind == 'text':
287
287
  value = node['text']
288
288
  elif kind == 'math':
289
- value = self.math_token(node, True)
289
+ if 'plainText' not in node:
290
+ value = self.math_token(node, True)
291
+ else:
292
+ for run in node['plainText']:
293
+ spans.append((utf16_length(text), utf16_length(run['text']), ['math'] + run.get('marks', [])))
294
+ text += run['text']
295
+ continue
290
296
  elif kind == 'ref':
291
297
  value = self.ref_token(node)
292
298
  elif kind == 'break':
@@ -309,14 +315,39 @@ class ContentComposer(Composer):
309
315
  def rich_paragraph(self, nodes, style=None, prefix='', suffix=''):
310
316
  text, spans = self.inline(nodes)
311
317
  rng = self.paragraph(prefix + text + suffix, style)
318
+ self.apply_marks(rng, utf16_length(prefix), spans)
319
+ return rng
320
+
321
+ def apply_marks(self, rng, offset, spans):
312
322
  for start, length, marks in spans:
313
- run = self.part(rng, utf16_length(prefix) + start, length)
323
+ if not marks:
324
+ continue
325
+ run = self.part(rng, offset + start, length)
314
326
  for mark in marks:
315
327
  if mark == 'code':
316
328
  run.Font.Name = 'Consolas'
329
+ elif mark == 'math':
330
+ run.Style = self.inline_math_style()
317
331
  else:
318
332
  setattr(run.Font, {'strong': 'Bold', 'em': 'Italic', 'sup': 'Superscript', 'sub': 'Subscript'}[mark], True)
319
- return rng
333
+
334
+ def inline_math_style(self):
335
+ """Character style of inline formulas written as text; created in the document, the DOTX stays unchanged."""
336
+ if self._inline_math_style is None:
337
+ style = self.doc.Styles.Add(INLINE_MATH_STYLE, 2)
338
+ style.Font.Name = 'Times New Roman'
339
+ self._inline_math_style = style
340
+ return self._inline_math_style
341
+
342
+ def single_paragraph_cell(self, cell, node):
343
+ """Write a one-paragraph cell in one assignment; the general path inserts then trims a paragraph."""
344
+ text, spans = self.inline(node['content'])
345
+ rng = cell.Range
346
+ rng.Text = text
347
+ rng = self.part(cell.Range, 0, utf16_length(text))
348
+ rng.Style = self.doc.Styles.Item(self.layout.get(node['id'], {}).get('style') or self.style)
349
+ self.apply_marks(rng, 0, spans)
350
+ self.mark(node['id'], rng, '_b')
320
351
 
321
352
  def chapter_sequence(self, kind, bookmark):
322
353
  """「章号-序号」 for an equation, figure or table in the current top-level chapter."""
@@ -489,11 +520,14 @@ class ContentComposer(Composer):
489
520
  saved = self.container, self.cell, self.style, self.width
490
521
  for r, c, rs, cs, cell in placements:
491
522
  self.container = cells[r, c].Cells.Item(1)
492
- # Merge can concatenate the original empty cell paragraphs.
493
- self.container.Range.Text = ''
494
523
  self.cell = True
495
524
  self.style = 'Table Header' if r < node['headerRows'] else 'Table Text'
496
525
  self.width = sum(widths[c:c + cs]) - 8
526
+ if len(cell['blocks']) == 1 and cell['blocks'][0]['type'] == 'paragraph':
527
+ self.single_paragraph_cell(self.container, cell['blocks'][0])
528
+ continue
529
+ # Merge can concatenate the original empty cell paragraphs.
530
+ self.container.Range.Text = ''
497
531
  self.blocks(cell['blocks'], depth)
498
532
  self.trim_container()
499
533
  self.container, self.cell, self.style, self.width = saved
@@ -10,7 +10,7 @@ import re
10
10
  import struct
11
11
  from hashlib import sha256
12
12
 
13
- from .word import find_text
13
+ from .word import MarkerFinder
14
14
 
15
15
  DEFAULT_DLL = Path(r"C:\Program Files (x86)\MathType\MathPage\64\MathPage.wll")
16
16
 
@@ -138,12 +138,13 @@ def fill_and_reopen(draft, destination, pending, record, dll=DEFAULT_DLL):
138
138
  word.Visible, word.DisplayAlerts, word.AutomationSecurity = False, 0, 3
139
139
  missing = comtypes.automation.VARIANT.missing
140
140
  doc = word.Documents.Open(str(draft), False, False, False)
141
+ markers = MarkerFinder(doc)
141
142
  for item in pending:
142
143
  equation = item["equation"]
143
144
  tex = mathtype_tex(equation["latex"])
144
145
  if tex != equation["latex"]:
145
146
  record("tex-adaptation", id=equation["id"], sourceTex=equation["latex"], mathTypeTex=tex)
146
- target = find_text(doc, item["token"])
147
+ target = markers.find(item["token"])
147
148
  target.Text = ""
148
149
  shape = doc.InlineShapes.AddOLEObject("Equation.DSMT4", missing, False, False,
149
150
  missing, missing, missing, target)
@@ -0,0 +1,53 @@
1
+ """Inline formulas written as Word text: their character style and the saved-package fix-up.
2
+
3
+ The report marks each such formula with <WordPlainMath> and supplies the runs; the engine
4
+ does not decide which TeX can become text.
5
+ """
6
+
7
+
8
+ INLINE_MATH_STYLE = 'DR Inline Math'
9
+
10
+
11
+ def clear_east_asian_hints(path):
12
+ """Drop w:hint="eastAsia" from runs in the inline-math character style.
13
+
14
+ Word adds the hint when it inserts φ, ≈ or ° into a Chinese paragraph, which renders
15
+ them in the East Asian font; COM exposes no setter for it, so the saved package is edited.
16
+ """
17
+ import os
18
+ import re
19
+ import tempfile
20
+ import zipfile
21
+
22
+ with zipfile.ZipFile(path) as source:
23
+ styles = source.read('word/styles.xml').decode('utf8')
24
+ match = re.search(r'<w:style\b[^>]*w:styleId="([^"]+)"[^>]*>(?:(?!</w:style>).)*?<w:name w:val="'
25
+ + re.escape(INLINE_MATH_STYLE) + '"', styles, re.S)
26
+ if not match:
27
+ return 0
28
+ marker = f'<w:rStyle w:val="{match[1]}"/>'
29
+ cleared = 0
30
+
31
+ def clear(run):
32
+ nonlocal cleared
33
+ text = run[0]
34
+ if marker not in text or 'w:hint="eastAsia"' not in text:
35
+ return text
36
+ cleared += 1
37
+ text = text.replace(' w:hint="eastAsia"', '')
38
+ return text.replace('<w:rFonts/>', '')
39
+
40
+ fd, temporary = tempfile.mkstemp(suffix='.docx', dir=os.path.dirname(path))
41
+ os.close(fd)
42
+ try:
43
+ with zipfile.ZipFile(temporary, 'w') as target:
44
+ for item in source.infolist():
45
+ data = source.read(item)
46
+ if item.filename.startswith('word/') and item.filename.endswith('.xml') and marker.encode() in data:
47
+ data = re.sub(r'<w:r(?:\s[^>]*)?>.*?</w:r>', clear, data.decode('utf8'), flags=re.S).encode('utf8')
48
+ target.writestr(item, data, compress_type=item.compress_type)
49
+ except BaseException:
50
+ os.unlink(temporary)
51
+ raise
52
+ os.replace(temporary, path)
53
+ return cleared
@@ -59,6 +59,39 @@ def abstract_state(doc, content):
59
59
  return results
60
60
 
61
61
 
62
+ WORD_NS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
63
+ # Word writes these into a field's result when a REF/SEQ target or switch is broken; readers then see them as body text.
64
+ FIELD_ERROR_PREFIXES = ('错误!不能识别的开关参数', '错误!未找到引用源', '错误!找不到引用源', '错误!未定义书签',
65
+ 'error!unknownswitchargument', 'error!referencesourcenotfound', 'error!bookmarknotdefined')
66
+
67
+
68
+ def field_results(root):
69
+ """Yield (field code, displayed result) for every simple and complex field in one XML part."""
70
+ open_fields = [] # [code, result, past separator] per nested complex field
71
+ for node in root.iter():
72
+ if node.tag == WORD_NS + 'fldSimple':
73
+ yield node.get(WORD_NS + 'instr', ''), ''.join(t.text or '' for t in node.iter(WORD_NS + 't'))
74
+ elif node.tag == WORD_NS + 'fldChar':
75
+ kind = node.get(WORD_NS + 'fldCharType')
76
+ if kind == 'begin':
77
+ open_fields.append(['', '', False])
78
+ elif kind == 'separate' and open_fields:
79
+ open_fields[-1][2] = True
80
+ elif kind == 'end' and open_fields:
81
+ code, result, _ = open_fields.pop()
82
+ yield code, result
83
+ elif open_fields and node.tag == WORD_NS + 'instrText' and not open_fields[-1][2]:
84
+ open_fields[-1][0] += node.text or ''
85
+ elif open_fields and node.tag == WORD_NS + 't' and open_fields[-1][2]:
86
+ open_fields[-1][1] += node.text or ''
87
+
88
+
89
+ def reject_field_error_results(root):
90
+ for code, result in field_results(root):
91
+ if re.sub(r'\s+', '', result).lower().replace('!', '!').startswith(FIELD_ERROR_PREFIXES):
92
+ raise RuntimeError(f'Word field result error: {code.strip()}: {result}')
93
+
94
+
62
95
  def package_state(path, expected_equations):
63
96
  with ZipFile(path) as archive:
64
97
  objects, omml = [], []
@@ -66,6 +99,7 @@ def package_state(path, expected_equations):
66
99
  if not part.startswith('word/') or not part.endswith('.xml'):
67
100
  continue
68
101
  root = ET.fromstring(archive.read(part))
102
+ reject_field_error_results(root)
69
103
  found = [o for o in root.findall(".//{urn:schemas-microsoft-com:office:office}OLEObject")
70
104
  if o.get("ProgID") == "Equation.DSMT4"]
71
105
  objects.extend(found)
@@ -103,6 +137,11 @@ def content_state(path, content, directory):
103
137
  if compact(node['text']) not in available:
104
138
  raise RuntimeError(f"Source text missing after save: {node['text'][:100]}")
105
139
  checked += 1
140
+ elif node.get('type') == 'math' and 'plainText' in node:
141
+ written = ''.join(run['text'] for run in node['plainText'])
142
+ if compact(written) not in available:
143
+ raise RuntimeError(f"Inline formula text missing after save: {written[:100]}")
144
+ checked += 1
106
145
  elif node.get('type') == 'abstract':
107
146
  keywords = '关键词:' + ';'.join(node['keywords'])
108
147
  if compact(node.get('title', '摘要')) not in available or compact(keywords) not in available:
@@ -72,6 +72,29 @@ def find_text(doc, text):
72
72
  raise ValueError(f"Generation marker missing: {text}")
73
73
 
74
74
 
75
+ class MarkerFinder:
76
+ """Find generation markers written in body order without rescanning from the top each time.
77
+
78
+ The body story is searched from the previous body hit; a marker not found there
79
+ (notes, headers, or out of order) falls back to the full search of every story.
80
+ """
81
+
82
+ MAIN_STORY = 1
83
+
84
+ def __init__(self, doc):
85
+ self.doc = doc
86
+ self.position = 0
87
+
88
+ def find(self, text):
89
+ target = self.doc.Range(self.position, self.doc.Content.End)
90
+ target.Find.ClearFormatting()
91
+ if not target.Find.Execute(text, True, False, False, False, False, True, 0):
92
+ target = find_text(self.doc, text)
93
+ if target.StoryType == self.MAIN_STORY:
94
+ self.position = target.Start
95
+ return target
96
+
97
+
75
98
  def refresh(doc):
76
99
  """Resolve sequence fields before cross-references, then refresh TOC pages."""
77
100
  for _ in range(2):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@godcreator/word-report",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "author": {
5
5
  "name": "godcreator"
6
6
  },