@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/{assemble-BpllQNVY.mjs → assemble-BEt9_xsB.mjs} +23 -0
- package/dist/cli.d.mts +1 -7
- package/dist/cli.mjs +4 -30
- package/dist/core.d.mts +2 -22
- package/dist/core.mjs +3 -3
- package/dist/{convert-o7bPWP9A.mjs → export-CGvF3Wz-.mjs} +12 -160
- package/dist/{export-Da4yhrZa.d.mts → export-DMKj4gl2.d.mts} +2 -18
- package/dist/{mdx-4Rs1N1nv.mjs → mdx-B1V34o8x.mjs} +84 -337
- package/dist/node.d.mts +3 -3
- package/dist/node.mjs +3 -3
- package/dist/{page-data-CJ6nCBnW.d.mts → page-data-C1veAE55.d.mts} +1 -1
- package/dist/{report-files-BsIvifZe.mjs → report-files-DyPBOCgh.mjs} +2 -2
- package/dist/site-server.d.mts +1 -1
- package/dist/site-server.mjs +3 -3
- package/dist/site.d.mts +2 -2
- package/dist/site.mjs +1 -1
- package/dist/{types-Bcl48xQN.d.mts → types-Ea2OT6Ac.d.mts} +7 -1
- package/engine/README.md +3 -3
- package/engine/schemas/report-content.schema.json +42 -0
- package/engine/src/docreport/__main__.py +18 -2
- package/engine/src/docreport/compose.py +51 -17
- package/engine/src/docreport/mathtype.py +3 -2
- package/engine/src/docreport/plain_math.py +53 -0
- package/engine/src/docreport/verify.py +39 -0
- package/engine/src/docreport/word.py +23 -0
- package/engine/templates/project-report.dotx +0 -0
- package/package.json +1 -1
package/dist/site.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { r as AssemblyResult,
|
|
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-
|
|
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-
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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):
|
|
Binary file
|