@godcreator/word-report 0.3.1 → 0.4.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.
@@ -994,12 +994,21 @@ function assembleDocument(entry, files, options = {}) {
994
994
  }
995
995
  const counts = {
996
996
  decimal: 0,
997
- appendix: 0,
998
- figure: 0,
999
- table: 0
997
+ appendix: 0
1000
998
  };
1001
999
  let appendixSeen = false;
1002
- const eqCounts = /* @__PURE__ */ new Map();
1000
+ const chapterCounts = {
1001
+ figure: /* @__PURE__ */ new Map(),
1002
+ table: /* @__PURE__ */ new Map(),
1003
+ equation: /* @__PURE__ */ new Map()
1004
+ };
1005
+ /** 图、编号表格、编号公式各自按所在的顶层章从 1 编,写成「章号-n」;导出 Word 的题注同样取章号,不在编号章里就没有章号可取 */
1006
+ function chapterNumber(node, kind, chapter) {
1007
+ if (!chapter) fail(node, `Numbered ${kind} outside a numbered chapter`);
1008
+ const n = (chapterCounts[kind].get(chapter) ?? 0) + 1;
1009
+ chapterCounts[kind].set(chapter, n);
1010
+ return `${chapter}-${n}`;
1011
+ }
1003
1012
  function blockNumbers(blocks, depth = 0, chapter, parentNumber, parentMode, tables = 0) {
1004
1013
  let decimal = 0;
1005
1014
  for (const node of blocks) {
@@ -1029,18 +1038,15 @@ function assembleDocument(entry, files, options = {}) {
1029
1038
  continue;
1030
1039
  }
1031
1040
  if (node.type === "figure") {
1032
- number = String(++counts.figure);
1041
+ number = chapterNumber(node, "figure", chapter);
1033
1042
  label = `图 ${number}`;
1034
1043
  }
1035
1044
  if (node.type === "table" && node.numbered) {
1036
- number = String(++counts.table);
1045
+ number = chapterNumber(node, "table", chapter);
1037
1046
  label = `表 ${number}`;
1038
1047
  }
1039
1048
  if (node.type === "equation" && node.numbered) {
1040
- if (!chapter) fail(node, "Numbered equation outside a numbered chapter");
1041
- const n = (eqCounts.get(chapter) ?? 0) + 1;
1042
- eqCounts.set(chapter, n);
1043
- number = `${chapter}-${n}`;
1049
+ number = chapterNumber(node, "equation", chapter);
1044
1050
  label = `式(${number})`;
1045
1051
  }
1046
1052
  if (node.id) index[node.id] = {
package/dist/cli.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { n as reportExport, r as checkReports } from "./export-TbclsIsE.mjs";
3
- import { r as reportEntryOf } from "./report-files-DM9Ea6yK.mjs";
2
+ import { n as reportExport, r as checkReports } from "./export-DJQiBPPU.mjs";
3
+ import { r as reportEntryOf } from "./report-files-CgWzLxH-.mjs";
4
4
  import path from "node:path";
5
5
  import { realpathSync } from "node:fs";
6
6
  import { fileURLToPath } from "node:url";
package/dist/core.mjs CHANGED
@@ -1 +1 @@
1
- export { o as ReportContentError, t as assembleDocument, a as parseReportJson, n as safeRelativePath, r as validateDocument } from "./assemble-3cx8L2vo.mjs";
1
+ export { o as ReportContentError, t as assembleDocument, a as parseReportJson, n as safeRelativePath, r as validateDocument } from "./assemble-D25NLmLx.mjs";
@@ -1,5 +1,5 @@
1
- import { a as parseReportJson, i as walk, n as safeRelativePath, o as ReportContentError, r as validateDocument, t as assembleDocument } from "./assemble-3cx8L2vo.mjs";
2
- import { a as reportsDirName } from "./report-files-DM9Ea6yK.mjs";
1
+ import { a as parseReportJson, i as walk, n as safeRelativePath, o as ReportContentError, r as validateDocument, t as assembleDocument } from "./assemble-D25NLmLx.mjs";
2
+ import { a as reportsDirName } from "./report-files-CgWzLxH-.mjs";
3
3
  import { access, mkdir, open, readFile, readdir, realpath, rename, stat, unlink, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { createHash, randomUUID } from "node:crypto";
package/dist/node.mjs CHANGED
@@ -1,3 +1,3 @@
1
- import { i as readReport, n as reportExport, r as checkReports, t as reportEntry } from "./export-TbclsIsE.mjs";
2
- import { a as reportsDirName, i as reportIdsOf, n as readReportFiles, r as reportEntryOf, t as REPORT_ID_PATTERN } from "./report-files-DM9Ea6yK.mjs";
1
+ import { i as readReport, n as reportExport, r as checkReports, t as reportEntry } from "./export-DJQiBPPU.mjs";
2
+ import { a as reportsDirName, i as reportIdsOf, n as readReportFiles, r as reportEntryOf, t as REPORT_ID_PATTERN } from "./report-files-CgWzLxH-.mjs";
3
3
  export { REPORT_ID_PATTERN, checkReports, readReport, readReportFiles, reportEntry, reportEntryOf, reportExport, reportIdsOf, reportsDirName };
@@ -1,4 +1,4 @@
1
- import { a as parseReportJson, n as safeRelativePath, o as ReportContentError, r as validateDocument } from "./assemble-3cx8L2vo.mjs";
1
+ import { a as parseReportJson, n as safeRelativePath, o as ReportContentError, r as validateDocument } from "./assemble-D25NLmLx.mjs";
2
2
  import { readFile, readdir, realpath, stat } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  //#region src/node/report-files.ts
@@ -10,19 +10,15 @@ type WordReportSite = {
10
10
  hrefBase: string;
11
11
  pageComments: (page: string) => ReportPageComments;
12
12
  };
13
- /** loader 用到的路由参数:报告页是 :report,图片再加 *;图片的 loader 收到 request 时按 If-None-Match 回 304 */
13
+ /** loader 用到的路由参数:报告页是 :report,图片再加 * */
14
14
  type ReportRouteArgs = {
15
15
  params: Partial<Record<string, string>>;
16
- request?: Request;
17
16
  };
18
17
  /** 报告列表:站点 reports/ 下的每份报告各一行;读不对的报告也列出来,带上原话 */
19
18
  declare function reportListLoader(_args: ReportRouteArgs, site: WordReportSite): Promise<ReportListData>;
20
19
  /** 报告页:整份报告组装成一页(各章按 include 的顺序接在一起);报告读不对直接报错,原话在错误页上 */
21
20
  declare function reportPageLoader({ params }: ReportRouteArgs, site: WordReportSite): Promise<ReportPageData>;
22
- /**
23
- * 报告里的一张图:地址是组装后的路径 assets/<文档 id>/<文件名>,只给这份报告用到的图。
24
- * 浏览器每次都来问(no-cache),ETag 由文件大小与修改时间算出,没变就回 304、不再传图
25
- */
26
- declare function reportAssetLoader({ params, request }: ReportRouteArgs, site: WordReportSite): Promise<Response>;
21
+ /** 报告里的一张图:地址是组装后的路径 assets/<文档 id>/<文件名>,只给这份报告用到的图 */
22
+ declare function reportAssetLoader({ params }: ReportRouteArgs, site: WordReportSite): Promise<Response>;
27
23
  //#endregion
28
24
  export { type ReportListData, type ReportPageComments, type ReportPageData, ReportRouteArgs, type ReportSummary, type ReportTocItem, WordReportSite, reportAssetLoader, reportListLoader, reportPageLoader };
@@ -1,60 +1,18 @@
1
- import { t as assembleDocument } from "./assemble-3cx8L2vo.mjs";
2
- import { i as reportIdsOf, n as readReportFiles, r as reportEntryOf, t as REPORT_ID_PATTERN } from "./report-files-DM9Ea6yK.mjs";
1
+ import { t as assembleDocument } from "./assemble-D25NLmLx.mjs";
2
+ import { i as reportIdsOf, n as readReportFiles, r as reportEntryOf, t as REPORT_ID_PATTERN } from "./report-files-CgWzLxH-.mjs";
3
3
  import { n as reportHrefOf } from "./page-data-DLPoVgXy.mjs";
4
- import { readFile, realpath, stat } from "node:fs/promises";
5
4
  import path from "node:path";
6
5
  import { createReadStream } from "node:fs";
7
6
  import { Readable } from "node:stream";
8
7
  //#region src/site/loaders.server.ts
9
8
  /**
10
- * `@godcreator/word-report/site/server`:报告列表、报告页与报告里图片的 loader。报告从站点 reports/ 现读,
11
- * 报告页与图片在 JSON 原文没变时沿用上一次的组装结果。
9
+ * `@godcreator/word-report/site/server`:报告列表、报告页与报告里图片的 loader,报告每次从站点 reports/ 现读、现组装。
12
10
  */
13
11
  /** 组装结果里有 Object.create(null) 的对象:换成普通对象再交给 React Router 序列化 */
14
12
  const plainOf = (result) => JSON.parse(JSON.stringify(result));
15
- /**
16
- * 每份报告最近一次组装,键是站点目录加报告 id。一页报告的几十张图同时请求,组装一次要几百毫秒到几秒、
17
- * 全在一个线程上排队:不共用的话,最后一张图要等几十次组装
18
- */
19
- const latestAssemblies = /* @__PURE__ */ new Map();
20
- async function assembleFromDisk(siteDir, reportId) {
21
- const entry = reportEntryOf(reportId);
22
- const files = await readReportFiles(siteDir, entry);
23
- return {
24
- files,
25
- result: assembleDocument(entry, files, { mode: "complete" })
26
- };
27
- }
28
- /** 上次组装读过的每个 JSON 文件原文都没变;文件没了也算变了,交给重新组装去报错 */
29
- async function sameFilesOnDisk(siteDir, files) {
30
- const root = await realpath(siteDir);
31
- return (await Promise.all(Object.entries(files).map(async ([relative, text]) => {
32
- try {
33
- return await readFile(path.join(root, relative), "utf8") === text;
34
- } catch (error) {
35
- if (error.code === "ENOENT") return false;
36
- throw error;
37
- }
38
- }))).every(Boolean);
39
- }
40
- /** 报告的整份组装结果:JSON 原文和上次一样就用上次的,同时到的请求共用同一次组装;结果只读,要改先复制 */
41
13
  async function assembledReport(siteDir, reportId) {
42
- const key = `${path.resolve(siteDir)}\0${reportId}`;
43
- const latest = latestAssemblies.get(key);
44
- if (latest !== void 0) {
45
- const assembly = await latest;
46
- if (await sameFilesOnDisk(siteDir, assembly.files)) return assembly.result;
47
- }
48
- const newer = latestAssemblies.get(key);
49
- if (newer !== void 0 && newer !== latest) return (await newer).result;
50
- const fresh = assembleFromDisk(siteDir, reportId);
51
- latestAssemblies.set(key, fresh);
52
- try {
53
- return (await fresh).result;
54
- } catch (error) {
55
- if (latestAssemblies.get(key) === fresh) latestAssemblies.delete(key);
56
- throw error;
57
- }
14
+ const entry = reportEntryOf(reportId);
15
+ return assembleDocument(entry, await readReportFiles(siteDir, entry), { mode: "complete" });
58
16
  }
59
17
  /** 地址里的报告 id:形状不对或站点里没有这份报告都是 404 */
60
18
  async function reportIdOf(params, siteDir) {
@@ -130,29 +88,15 @@ const imageContentTypes = {
130
88
  ".jpg": "image/jpeg",
131
89
  ".jpeg": "image/jpeg"
132
90
  };
133
- /**
134
- * 报告里的一张图:地址是组装后的路径 assets/<文档 id>/<文件名>,只给这份报告用到的图。
135
- * 浏览器每次都来问(no-cache),ETag 由文件大小与修改时间算出,没变就回 304、不再传图
136
- */
137
- async function reportAssetLoader({ params, request }, site) {
91
+ /** 报告里的一张图:地址是组装后的路径 assets/<文档 id>/<文件名>,只给这份报告用到的图 */
92
+ async function reportAssetLoader({ params }, site) {
138
93
  const reportId = await reportIdOf(params, site.siteDir);
139
94
  const compiledPath = `assets/${params["*"] ?? ""}`;
140
95
  const source = (await assembledReport(site.siteDir, reportId)).assets[compiledPath];
141
96
  if (source === void 0) return new Response(`报告 ${reportId} 里没有图 ${compiledPath}`, { status: 404 });
142
97
  const contentType = imageContentTypes[path.extname(source).toLowerCase()];
143
98
  if (contentType === void 0) throw new Error(`报告 ${reportId} 的图 ${source} 不是 png 或 jpg`);
144
- const file = path.join(site.siteDir, source);
145
- const { size, mtimeMs } = await stat(file);
146
- const headers = {
147
- "content-type": contentType,
148
- "cache-control": "no-cache",
149
- etag: `"${size.toString(16)}-${Math.trunc(mtimeMs).toString(16)}"`
150
- };
151
- if (request?.headers.get("if-none-match") === headers.etag) return new Response(null, {
152
- status: 304,
153
- headers
154
- });
155
- return new Response(Readable.toWeb(createReadStream(file)), { headers });
99
+ return new Response(Readable.toWeb(createReadStream(path.join(site.siteDir, source))), { headers: { "content-type": contentType } });
156
100
  }
157
101
  //#endregion
158
102
  export { reportAssetLoader, reportListLoader, reportPageLoader };
package/dist/site.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { t as assembleDocument } from "./assemble-3cx8L2vo.mjs";
1
+ import { t as assembleDocument } from "./assemble-D25NLmLx.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";
@@ -61,10 +61,19 @@ class Composer:
61
61
  if kind not in ('Table', 'Figure'):
62
62
  raise ValueError(f'Unknown caption kind: {kind}')
63
63
  name = {'Table': '表', 'Figure': '图'}[kind]
64
- number = self.field_token('SEQ ' + kind + r' \* ARABIC', bookmark)
64
+ number = self.chapter_number(r'STYLEREF 1 \s', 'SEQ ' + kind + r' \* ARABIC \s 1', bookmark)
65
65
  return self.paragraph(f'{name} {number} {text}',
66
66
  'Table Caption' if kind == 'Table' else -35)
67
67
 
68
+ def chapter_number(self, chapter_code, sequence_code, bookmark):
69
+ """Return「章号-序号」 placeholders; ``bookmark`` spans both fields so REF shows the whole number."""
70
+ chapter = self.field_token(chapter_code)
71
+ number = self.field_token(sequence_code)
72
+ index = len(self._pending_numbers)
73
+ begin, end = f'@NUMSTART{index}@', f'@NUMEND{index}@'
74
+ self._pending_numbers.append((begin, end, bookmark))
75
+ return f'{begin}{chapter}-{number}{end}'
76
+
68
77
  def list(self, items, ordered=False):
69
78
  doc = self.doc
70
79
  list_start = doc.Content.End - 1
@@ -100,12 +109,8 @@ class Composer:
100
109
  boldSymbol=bold_symbol))
101
110
  if inline:
102
111
  return self.paragraph(before + token + after)
103
- chapter = self.field_token(r'STYLEREF 1 \s')
104
- number = self.field_token(r'SEQ Equation \* ARABIC \s 1')
105
- index = len(self._pending_numbers)
106
- begin, end = f'@NUMSTART{index}@', f'@NUMEND{index}@'
107
- self._pending_numbers.append((begin, end, bookmark))
108
- return self.paragraph(f'\t{token}\t({begin}{chapter}-{number}{end})', 'Equation')
112
+ number = self.chapter_number(r'STYLEREF 1 \s', r'SEQ Equation \* ARABIC \s 1', bookmark)
113
+ return self.paragraph(f'\t{token}\t({number})', 'Equation')
109
114
 
110
115
  def footnote(self, token, text):
111
116
  rng = self.doc.Content.Duplicate
@@ -313,12 +318,26 @@ class ContentComposer(Composer):
313
318
  setattr(run.Font, {'strong': 'Bold', 'em': 'Italic', 'sup': 'Superscript', 'sub': 'Subscript'}[mark], True)
314
319
  return rng
315
320
 
321
+ def chapter_sequence(self, kind, bookmark):
322
+ """「章号-序号」 for an equation, figure or table in the current top-level chapter."""
323
+ if self.chapter_is_appendix:
324
+ # The SEQ "\s 1" restart only fires on built-in Heading 1
325
+ # paragraphs; the template's Appendix Heading is Normal-based,
326
+ # so appendix sequences never restart there. Giving each
327
+ # appendix its own SEQ identifier keeps a natural 1, 2, 3 …
328
+ # count that renumbers on insert or delete like main chapters,
329
+ # without touching heading styles or their linked lists.
330
+ letter = chr(ord('A') + self.appendix_count - 1)
331
+ return self.chapter_number(r'STYLEREF "Appendix Heading" \s',
332
+ 'SEQ ' + kind + 'Appendix' + letter + r' \* ARABIC', bookmark)
333
+ return self.chapter_number(r'STYLEREF 1 \s', 'SEQ ' + kind + r' \* ARABIC \s 1', bookmark)
334
+
316
335
  def numbered_caption(self, node, kind):
317
336
  from .content import bookmark
318
337
  numbered = node.get('numbered', True)
319
338
  prefix = ''
320
339
  if numbered:
321
- prefix = {'Figure': '图 ', 'Table': '表 '}[kind] + self.field_token('SEQ ' + kind + r' \* ARABIC', bookmark(node['id'], '_n')) + ' '
340
+ prefix = {'Figure': '图 ', 'Table': '表 '}[kind] + self.chapter_sequence(kind, bookmark(node['id'], '_n')) + ' '
322
341
  rng = self.rich_paragraph(node['caption'], 'Table Caption' if kind == 'Table' else -35, prefix=prefix)
323
342
  title = self.part(rng, utf16_length(prefix), rng.End - rng.Start - utf16_length(prefix) - 1)
324
343
  self.mark(node['id'], title, '_t')
@@ -416,24 +435,9 @@ class ContentComposer(Composer):
416
435
  token = self.math_token(node, False)
417
436
  suffix = ''
418
437
  if node['numbered']:
419
- chapter = self.field_token(r'STYLEREF 1 \s' if self.chapter_style == 'Heading 1' else r'STYLEREF "Appendix Heading" \s')
420
- if self.chapter_is_appendix:
421
- # The SEQ "\s 1" restart only fires on built-in Heading 1
422
- # paragraphs; the template's Appendix Heading is Normal-based,
423
- # so appendix sequences never restart there. Giving each
424
- # appendix its own SEQ identifier keeps a natural 1, 2, 3 …
425
- # count that renumbers on insert or delete like main chapters,
426
- # without touching heading styles or their linked lists.
427
- letter = chr(ord('A') + self.appendix_count - 1)
428
- number = self.field_token(r'SEQ EquationAppendix' + letter + r' \* ARABIC')
429
- else:
430
- number = self.field_token(r'SEQ Equation \* ARABIC \s 1')
431
- self.chapter_first_equation = False
432
- i = len(self._pending_numbers)
433
- begin, end = f'@NUMSTART{i}@', f'@NUMEND{i}@'
434
438
  from .content import bookmark
435
- self._pending_numbers.append((begin, end, bookmark(node['id'], '_n')))
436
- suffix = f'\t({begin}{chapter}-{number}{end})'
439
+ self.chapter_first_equation = False
440
+ suffix = '\t(' + self.chapter_sequence('Equation', bookmark(node['id'], '_n')) + ')'
437
441
  rng = self.paragraph('\t' + token + suffix, 'Equation')
438
442
  rng.ParagraphFormat.TabStops.ClearAll()
439
443
  rng.ParagraphFormat.TabStops.Add(self.width / 2, 1, 0)
@@ -166,7 +166,10 @@ def validate_content(content, directory):
166
166
  blocks(item["children"], depth + 1, scope, mode, tables)
167
167
  elif kind == "equation" and item["numbered"]:
168
168
  require(chapter is not None, f"{item['id']}: numbered equation outside a chapter")
169
+ elif kind == "figure":
170
+ require(chapter is not None, f"{item['id']}: numbered figure outside a chapter")
169
171
  elif kind == "table":
172
+ require(chapter is not None or not item["numbered"], f"{item['id']}: numbered table outside a chapter")
170
173
  require(tables < 6, f"{item['id']}: Word renderer supports at most six table levels")
171
174
  for row in item["rows"]:
172
175
  for cell in row["cells"]:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@godcreator/word-report",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "author": {
5
5
  "name": "godcreator"
6
6
  },