@godcreator/word-report 0.4.0 → 0.5.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.
@@ -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,11 +1,11 @@
1
1
  {
2
2
  "name": "@godcreator/word-report",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "author": {
5
5
  "name": "godcreator"
6
6
  },
7
7
  "type": "module",
8
- "description": "报告:JSON 报告文档的格式、校验与分章组装(.);读报告目录、检查、导出 Word 的命令行 report-docx(./node);报告页与报告列表(./site),两页与图片的 loader(./site/server);engine/ 是驱动本机 Word 的 Python 导出引擎与 .dotx 模板;template/ 是建站器复制进站点 app/features/word-report/ 的接线文件(两条页面路由、图片路由、侧栏入口、样式)。",
8
+ "description": "报告:JSON 报告文档的格式、校验与分章组装,MDX 写法编译成它(.);读报告目录、检查、导出 Word、JSON 报告转 MDX 的命令行 report-docx(./node);报告页与报告列表(./site),两页与图片的 loader(./site/server);engine/ 是驱动本机 Word 的 Python 导出引擎与 .dotx 模板;template/ 是建站器复制进站点 app/features/word-report/ 的接线文件(两条页面路由、图片路由、侧栏入口、样式)。",
9
9
  "license": "MIT",
10
10
  "exports": {
11
11
  ".": {
@@ -43,7 +43,14 @@
43
43
  ],
44
44
  "dependencies": {
45
45
  "@godcreator/comments": "^0.1.3",
46
- "sharp": "^0.35.5"
46
+ "remark-frontmatter": "^5.0.0",
47
+ "remark-gfm": "^4.0.1",
48
+ "remark-math": "^6.0.0",
49
+ "remark-mdx": "^3.1.0",
50
+ "remark-parse": "^11.0.0",
51
+ "sharp": "^0.35.5",
52
+ "unified": "^11.0.5",
53
+ "yaml": "^2.9.1"
47
54
  },
48
55
  "peerDependencies": {
49
56
  "@fumadocs/base-ui": "^16.17.1",
@@ -1,72 +0,0 @@
1
- import { a as parseReportJson, n as safeRelativePath, o as ReportContentError, r as validateDocument } from "./assemble-D25NLmLx.mjs";
2
- import { readFile, readdir, realpath, stat } from "node:fs/promises";
3
- import path from "node:path";
4
- //#region src/node/report-files.ts
5
- /** 站点里的报告:站点根下 reports/<报告 id>/content.json,章节与图片在同一个报告目录里 */
6
- const reportsDirName = "reports";
7
- /** 报告 id 就是报告目录名,出现在网址里:小写字母、数字与 -,以字母或数字开头 */
8
- const REPORT_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
9
- /** 报告的入口,相对站点目录 */
10
- function reportEntryOf(reportId) {
11
- if (!REPORT_ID_PATTERN.test(reportId)) throw new ReportContentError(`报告 id 只能是小写字母、数字与 -,以字母或数字开头:${JSON.stringify(reportId)}`);
12
- return `${reportsDirName}/${reportId}/content.json`;
13
- }
14
- /** 站点里的报告 id:reports/ 下有 content.json 的目录,按名字排;目录名不合 REPORT_ID_PATTERN 的直接报错,不悄悄跳过 */
15
- async function reportIdsOf(siteDir) {
16
- const reportsDir = path.join(siteDir, reportsDirName);
17
- let entries;
18
- try {
19
- entries = await readdir(reportsDir, { withFileTypes: true });
20
- } catch (error) {
21
- if (error.code === "ENOENT") return [];
22
- throw error;
23
- }
24
- const ids = [];
25
- for (const entry of entries) {
26
- if (!entry.isDirectory()) continue;
27
- if (!await stat(path.join(reportsDir, entry.name, "content.json")).then((info) => info.isFile(), (error) => {
28
- if (error.code === "ENOENT") return false;
29
- throw error;
30
- })) continue;
31
- reportEntryOf(entry.name);
32
- ids.push(entry.name);
33
- }
34
- return ids.sort();
35
- }
36
- /**
37
- * 读一份报告组装要的全部 JSON 文件(入口与它 include 到的章节),键是相对站点目录的路径,交给 assembleDocument。
38
- * 不读图片、不解码:网页显示用;检查与导出用 readReport,它还核对图片与快照
39
- */
40
- async function readReportFiles(siteDir, entry) {
41
- const root = await realpath(siteDir);
42
- const files = {};
43
- const load = async (relative) => {
44
- if (Object.hasOwn(files, relative)) return;
45
- const absolute = path.join(root, relative);
46
- let real;
47
- try {
48
- real = await realpath(absolute);
49
- } catch {
50
- throw new ReportContentError("Missing or unreadable file", relative);
51
- }
52
- const inside = path.relative(root, real);
53
- if (inside === ".." || inside.startsWith(".." + path.sep) || path.isAbsolute(inside)) throw new ReportContentError("File escapes report root", relative);
54
- const text = await readFile(real, "utf8");
55
- files[relative] = text;
56
- const document = parseReportJson(text, relative);
57
- validateDocument(document, relative);
58
- for (const [index, block] of document.blocks.entries()) {
59
- if (block.type !== "include") continue;
60
- try {
61
- safeRelativePath(block.src);
62
- } catch (error) {
63
- throw new ReportContentError(error.message, relative, `/blocks/${index}/src`);
64
- }
65
- await load(path.posix.join(path.posix.dirname(relative), block.src));
66
- }
67
- };
68
- await load(entry);
69
- return files;
70
- }
71
- //#endregion
72
- export { reportsDirName as a, reportIdsOf as i, readReportFiles as n, reportEntryOf as r, REPORT_ID_PATTERN as t };