@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.
package/dist/node.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { n as AssemblyOptions, r as AssemblyResult } from "./types-Bcl48xQN.mjs";
2
- import { a as ReportCheckDiagnostic, i as reportExport, n as ReportExportResult, o as ReportCheckResult, r as reportEntry, s as checkReports, t as ReportExportOptions } from "./export-DC764Umz.mjs";
1
+ import { n as AssemblyOptions, r as AssemblyResult } from "./types-Ea2OT6Ac.mjs";
2
+ import { a as ReportCheckDiagnostic, i as reportExport, n as ReportExportResult, o as ReportCheckResult, r as reportEntry, s as checkReports, t as ReportExportOptions } from "./export-DMKj4gl2.mjs";
3
3
  //#region src/node/read-report.d.ts
4
4
  interface SnapshotEntry {
5
5
  path: string;
@@ -18,18 +18,24 @@ declare function readReport(entryAbsolute: string, options?: AssemblyOptions & {
18
18
  }): Promise<ReadReportResult>;
19
19
  //#endregion
20
20
  //#region src/node/report-files.d.ts
21
- /** 站点里的报告:站点根下 reports/<报告 id>/content.json,章节与图片在同一个报告目录里 */
21
+ /** 站点里的报告:站点根下 reports/<报告 id>/,入口是 content.json(JSON 报告)或 index.mdx(MDX 报告),两种只能有一种 */
22
22
  declare const reportsDirName = "reports";
23
+ declare const JSON_ENTRY_NAME = "content.json";
23
24
  /** 报告 id 就是报告目录名,出现在网址里:小写字母、数字与 -,以字母或数字开头 */
24
25
  declare const REPORT_ID_PATTERN: RegExp;
25
- /** 报告的入口,相对站点目录 */
26
+ /** JSON 报告的入口,相对站点目录;报告是哪种格式要看磁盘,用 reportEntryIn */
26
27
  declare function reportEntryOf(reportId: string): string;
27
- /** 站点里的报告 id:reports/ 下有 content.json 的目录,按名字排;目录名不合 REPORT_ID_PATTERN 的直接报错,不悄悄跳过 */
28
+ declare const isMdxEntry: (entry: string) => boolean;
29
+ /** 一份报告的入口,相对站点目录:reports/<id>/content.json 或 reports/<id>/index.mdx */
30
+ declare function reportEntryIn(siteDir: string, reportId: string): Promise<string>;
31
+ /** 站点里的报告 id:reports/ 下有入口文件的目录,按名字排;目录名不合 REPORT_ID_PATTERN 的直接报错,不悄悄跳过 */
28
32
  declare function reportIdsOf(siteDir: string): Promise<string[]>;
29
33
  /**
30
34
  * 读一份报告组装要的全部 JSON 文件(入口与它 include 到的章节),键是相对站点目录的路径,交给 assembleDocument。
31
35
  * 不读图片、不解码:网页显示用;检查与导出用 readReport,它还核对图片与快照
32
36
  */
33
37
  declare function readReportFiles(siteDir: string, entry: string): Promise<Record<string, string>>;
38
+ /** 读一份 MDX 报告的全部原文:入口、chapters 列的各章、存在时的 references.mdx,键是相对站点目录的路径,交给 assembleMdxReport */
39
+ declare function readMdxReportFiles(siteDir: string, entry: string): Promise<Record<string, string>>;
34
40
  //#endregion
35
- export { REPORT_ID_PATTERN, type ReadReportResult, type ReportCheckDiagnostic, type ReportCheckResult, type ReportExportOptions, type ReportExportResult, type SnapshotEntry, checkReports, readReport, readReportFiles, reportEntry, reportEntryOf, reportExport, reportIdsOf, reportsDirName };
41
+ export { JSON_ENTRY_NAME, REPORT_ID_PATTERN, type ReadReportResult, type ReportCheckDiagnostic, type ReportCheckResult, type ReportExportOptions, type ReportExportResult, type SnapshotEntry, checkReports, isMdxEntry, readMdxReportFiles, readReport, readReportFiles, reportEntry, reportEntryIn, reportEntryOf, reportExport, reportIdsOf, reportsDirName };
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-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
- export { REPORT_ID_PATTERN, checkReports, readReport, readReportFiles, reportEntry, reportEntryOf, reportExport, reportIdsOf, reportsDirName };
1
+ import { i as readReport, n as reportExport, r as checkReports, t as reportEntry } from "./export-B01-yaAq.mjs";
2
+ import { a as readReportFiles, c as reportIdsOf, i as readMdxReportFiles, l as reportsDirName, n as REPORT_ID_PATTERN, o as reportEntryIn, r as isMdxEntry, s as reportEntryOf, t as JSON_ENTRY_NAME } from "./report-files-Dfr71bRv.mjs";
3
+ export { JSON_ENTRY_NAME, REPORT_ID_PATTERN, checkReports, isMdxEntry, readMdxReportFiles, readReport, readReportFiles, reportEntry, reportEntryIn, reportEntryOf, reportExport, reportIdsOf, reportsDirName };
@@ -1,4 +1,4 @@
1
- import { r as AssemblyResult } from "./types-Bcl48xQN.mjs";
1
+ import { r as AssemblyResult } from "./types-Ea2OT6Ac.mjs";
2
2
  import { CommentsAccess, PageThreadSummary } from "@godcreator/comments/site";
3
3
  //#region src/site/page-data.d.ts
4
4
  /** 报告列表里的一行:报告读不对时 problem 是原话,标题退回报告 id */
@@ -0,0 +1,103 @@
1
+ import { i as validateDocument, n as safeRelativePath, o as parseReportJson, s as ReportContentError } from "./assemble-BEt9_xsB.mjs";
2
+ import { a as mdxReportInputsOf, t as MDX_ENTRY_NAME } from "./mdx-C1RVv6E-.mjs";
3
+ import { readFile, readdir, realpath, stat } from "node:fs/promises";
4
+ import path from "node:path";
5
+ //#region src/node/report-files.ts
6
+ /** 站点里的报告:站点根下 reports/<报告 id>/,入口是 content.json(JSON 报告)或 index.mdx(MDX 报告),两种只能有一种 */
7
+ const reportsDirName = "reports";
8
+ const JSON_ENTRY_NAME = "content.json";
9
+ /** 报告 id 就是报告目录名,出现在网址里:小写字母、数字与 -,以字母或数字开头 */
10
+ const REPORT_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
11
+ /** JSON 报告的入口,相对站点目录;报告是哪种格式要看磁盘,用 reportEntryIn */
12
+ function reportEntryOf(reportId) {
13
+ if (!REPORT_ID_PATTERN.test(reportId)) throw new ReportContentError(`报告 id 只能是小写字母、数字与 -,以字母或数字开头:${JSON.stringify(reportId)}`);
14
+ return `${reportsDirName}/${reportId}/${JSON_ENTRY_NAME}`;
15
+ }
16
+ const isMdxEntry = (entry) => entry === "index.mdx" || entry.endsWith(`/index.mdx`);
17
+ async function isFile(file) {
18
+ return stat(file).then((info) => info.isFile(), (error) => {
19
+ if (error.code === "ENOENT") return false;
20
+ throw error;
21
+ });
22
+ }
23
+ /** 报告目录里有哪种入口:两种都有直接报错,都没有回 undefined */
24
+ async function entryNameIn(reportDir, reportId) {
25
+ const [hasJson, hasMdx] = await Promise.all([isFile(path.join(reportDir, JSON_ENTRY_NAME)), isFile(path.join(reportDir, MDX_ENTRY_NAME))]);
26
+ if (hasJson && hasMdx) throw new ReportContentError(`报告目录里 ${JSON_ENTRY_NAME} 和 ${MDX_ENTRY_NAME} 只能留一个`, `${reportsDirName}/${reportId}`);
27
+ return hasJson ? JSON_ENTRY_NAME : hasMdx ? MDX_ENTRY_NAME : void 0;
28
+ }
29
+ /** 一份报告的入口,相对站点目录:reports/<id>/content.json 或 reports/<id>/index.mdx */
30
+ async function reportEntryIn(siteDir, reportId) {
31
+ const jsonEntry = reportEntryOf(reportId);
32
+ const name = await entryNameIn(path.join(siteDir, path.dirname(jsonEntry)), reportId);
33
+ if (name === void 0) throw new ReportContentError(`站点里没有报告 ${reportId}:reports/${reportId}/ 下既没有 ${JSON_ENTRY_NAME} 也没有 ${MDX_ENTRY_NAME}`, siteDir);
34
+ return `${reportsDirName}/${reportId}/${name}`;
35
+ }
36
+ /** 站点里的报告 id:reports/ 下有入口文件的目录,按名字排;目录名不合 REPORT_ID_PATTERN 的直接报错,不悄悄跳过 */
37
+ async function reportIdsOf(siteDir) {
38
+ const reportsDir = path.join(siteDir, reportsDirName);
39
+ let entries;
40
+ try {
41
+ entries = await readdir(reportsDir, { withFileTypes: true });
42
+ } catch (error) {
43
+ if (error.code === "ENOENT") return [];
44
+ throw error;
45
+ }
46
+ const ids = [];
47
+ for (const entry of entries) {
48
+ if (!entry.isDirectory()) continue;
49
+ if (await entryNameIn(path.join(reportsDir, entry.name), entry.name) === void 0) continue;
50
+ reportEntryOf(entry.name);
51
+ ids.push(entry.name);
52
+ }
53
+ return ids.sort();
54
+ }
55
+ /** 读站点目录里的一个文件(相对路径),挡住经符号链接跑出站点目录的 */
56
+ async function readInsideSite(root, relative) {
57
+ let real;
58
+ try {
59
+ real = await realpath(path.join(root, relative));
60
+ } catch {
61
+ throw new ReportContentError("Missing or unreadable file", relative);
62
+ }
63
+ const inside = path.relative(root, real);
64
+ if (inside === ".." || inside.startsWith(".." + path.sep) || path.isAbsolute(inside)) throw new ReportContentError("File escapes report root", relative);
65
+ return readFile(real, "utf8");
66
+ }
67
+ /**
68
+ * 读一份报告组装要的全部 JSON 文件(入口与它 include 到的章节),键是相对站点目录的路径,交给 assembleDocument。
69
+ * 不读图片、不解码:网页显示用;检查与导出用 readReport,它还核对图片与快照
70
+ */
71
+ async function readReportFiles(siteDir, entry) {
72
+ const root = await realpath(siteDir);
73
+ const files = {};
74
+ const load = async (relative) => {
75
+ if (Object.hasOwn(files, relative)) return;
76
+ const text = await readInsideSite(root, relative);
77
+ files[relative] = text;
78
+ const document = parseReportJson(text, relative);
79
+ validateDocument(document, relative);
80
+ for (const [index, block] of document.blocks.entries()) {
81
+ if (block.type !== "include") continue;
82
+ try {
83
+ safeRelativePath(block.src);
84
+ } catch (error) {
85
+ throw new ReportContentError(error.message, relative, `/blocks/${index}/src`);
86
+ }
87
+ await load(path.posix.join(path.posix.dirname(relative), block.src));
88
+ }
89
+ };
90
+ await load(entry);
91
+ return files;
92
+ }
93
+ /** 读一份 MDX 报告的全部原文:入口、chapters 列的各章、存在时的 references.mdx,键是相对站点目录的路径,交给 assembleMdxReport */
94
+ async function readMdxReportFiles(siteDir, entry) {
95
+ const root = await realpath(siteDir);
96
+ const files = { [entry]: await readInsideSite(root, entry) };
97
+ const { chapters, references } = mdxReportInputsOf(files[entry], entry);
98
+ for (const chapter of chapters) files[chapter] = await readInsideSite(root, chapter);
99
+ if (await isFile(path.join(root, references))) files[references] = await readInsideSite(root, references);
100
+ return files;
101
+ }
102
+ //#endregion
103
+ export { readReportFiles as a, reportIdsOf as c, readMdxReportFiles as i, reportsDirName as l, REPORT_ID_PATTERN as n, reportEntryIn as o, isMdxEntry as r, reportEntryOf as s, JSON_ENTRY_NAME as t };
@@ -1,4 +1,4 @@
1
- import { a as ReportTocItem, i as ReportSummary, n as ReportPageComments, r as ReportPageData, t as ReportListData } from "./page-data-CJ6nCBnW.mjs";
1
+ import { a as ReportTocItem, i as ReportSummary, n as ReportPageComments, r as ReportPageData, t as ReportListData } from "./page-data-C1veAE55.mjs";
2
2
  //#region src/site/loaders.server.d.ts
3
3
  /**
4
4
  * 报告功能的站点信息:站点目录(报告在它的 reports/ 下)、站名、报告页的地址前缀,
@@ -10,15 +10,19 @@ type WordReportSite = {
10
10
  hrefBase: string;
11
11
  pageComments: (page: string) => ReportPageComments;
12
12
  };
13
- /** loader 用到的路由参数:报告页是 :report,图片再加 * */
13
+ /** loader 用到的路由参数:报告页是 :report,图片再加 *;图片的 loader 收到 request 时按 If-None-Match 回 304 */
14
14
  type ReportRouteArgs = {
15
15
  params: Partial<Record<string, string>>;
16
+ request?: Request;
16
17
  };
17
18
  /** 报告列表:站点 reports/ 下的每份报告各一行;读不对的报告也列出来,带上原话 */
18
19
  declare function reportListLoader(_args: ReportRouteArgs, site: WordReportSite): Promise<ReportListData>;
19
20
  /** 报告页:整份报告组装成一页(各章按 include 的顺序接在一起);报告读不对直接报错,原话在错误页上 */
20
21
  declare function reportPageLoader({ params }: ReportRouteArgs, site: WordReportSite): Promise<ReportPageData>;
21
- /** 报告里的一张图:地址是组装后的路径 assets/<文档 id>/<文件名>,只给这份报告用到的图 */
22
- declare function reportAssetLoader({ params }: ReportRouteArgs, site: WordReportSite): Promise<Response>;
22
+ /**
23
+ * 报告里的一张图:地址是组装后的路径 assets/<文档 id>/<文件名>,只给这份报告用到的图。
24
+ * 浏览器每次都来问(no-cache),ETag 由文件大小与修改时间算出,没变就回 304、不再传图
25
+ */
26
+ declare function reportAssetLoader({ params, request }: ReportRouteArgs, site: WordReportSite): Promise<Response>;
23
27
  //#endregion
24
28
  export { type ReportListData, type ReportPageComments, type ReportPageData, ReportRouteArgs, type ReportSummary, type ReportTocItem, WordReportSite, reportAssetLoader, reportListLoader, reportPageLoader };
@@ -1,18 +1,85 @@
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";
1
+ import { t as assembleDocument } from "./assemble-BEt9_xsB.mjs";
2
+ import { a as mdxReportInputsOf, r as assembleMdxReport, t as MDX_ENTRY_NAME } from "./mdx-C1RVv6E-.mjs";
3
+ import { a as readReportFiles, c as reportIdsOf, i as readMdxReportFiles, n as REPORT_ID_PATTERN, o as reportEntryIn, r as isMdxEntry, t as JSON_ENTRY_NAME } from "./report-files-Dfr71bRv.mjs";
3
4
  import { n as reportHrefOf } from "./page-data-DLPoVgXy.mjs";
5
+ import { readFile, realpath, stat } from "node:fs/promises";
4
6
  import path from "node:path";
5
7
  import { createReadStream } from "node:fs";
6
8
  import { Readable } from "node:stream";
7
9
  //#region src/site/loaders.server.ts
8
10
  /**
9
- * `@godcreator/word-report/site/server`:报告列表、报告页与报告里图片的 loader,报告每次从站点 reports/ 现读、现组装。
11
+ * `@godcreator/word-report/site/server`:报告列表、报告页与报告里图片的 loader。报告从站点 reports/ 现读,
12
+ * 原文没变时沿用上一次的组装结果。
10
13
  */
11
14
  /** 组装结果里有 Object.create(null) 的对象:换成普通对象再交给 React Router 序列化 */
12
15
  const plainOf = (result) => JSON.parse(JSON.stringify(result));
16
+ /**
17
+ * 每份报告最近一次组装,键是站点目录加报告 id。一页报告的几十张图同时请求,组装一次要几百毫秒到几秒、
18
+ * 全在一个线程上排队:不共用的话,最后一张图要等几十次组装
19
+ */
20
+ const latestAssemblies = /* @__PURE__ */ new Map();
21
+ async function assembleFromDisk(siteDir, reportId) {
22
+ const entry = await reportEntryIn(siteDir, reportId);
23
+ const reportDir = path.posix.dirname(entry);
24
+ if (isMdxEntry(entry)) {
25
+ const files = await readMdxReportFiles(siteDir, entry);
26
+ const { chapters, references } = mdxReportInputsOf(files[entry], entry);
27
+ return {
28
+ files,
29
+ absentFiles: [`${reportDir}/${JSON_ENTRY_NAME}`, ...Object.hasOwn(files, references) ? [] : [references]],
30
+ result: assembleMdxReport({
31
+ reportId,
32
+ entry,
33
+ files
34
+ }, { mode: "complete" }),
35
+ chapterCount: chapters.length
36
+ };
37
+ }
38
+ const files = await readReportFiles(siteDir, entry);
39
+ const entryDocument = JSON.parse(files[entry]);
40
+ const result = assembleDocument(entry, files, { mode: "complete" });
41
+ return {
42
+ files,
43
+ absentFiles: [`${reportDir}/${MDX_ENTRY_NAME}`],
44
+ result,
45
+ chapterCount: entryDocument.blocks.filter((block) => block.type === "include").length
46
+ };
47
+ }
48
+ /** 上次组装读过的每个文件原文都没变、当时不存在的文件仍不存在;读过的文件没了也算变了,交给重新组装去报错 */
49
+ async function unchangedOnDisk(siteDir, assembly) {
50
+ const root = await realpath(siteDir);
51
+ const textOrMissing = async (relative) => {
52
+ try {
53
+ return await readFile(path.join(root, relative), "utf8");
54
+ } catch (error) {
55
+ if (error.code === "ENOENT") return null;
56
+ throw error;
57
+ }
58
+ };
59
+ const [sameTexts, stillAbsent] = await Promise.all([Promise.all(Object.entries(assembly.files).map(async ([relative, text]) => await textOrMissing(relative) === text)), Promise.all(assembly.absentFiles.map(async (relative) => await textOrMissing(relative) === null))]);
60
+ return [...sameTexts, ...stillAbsent].every(Boolean);
61
+ }
62
+ /** 报告的整份组装:原文和上次一样就用上次的,同时到的请求共用同一次组装;结果只读,要改先复制 */
63
+ async function latestAssembly(siteDir, reportId) {
64
+ const key = `${path.resolve(siteDir)}\0${reportId}`;
65
+ const latest = latestAssemblies.get(key);
66
+ if (latest !== void 0) {
67
+ const assembly = await latest;
68
+ if (await unchangedOnDisk(siteDir, assembly)) return assembly;
69
+ }
70
+ const newer = latestAssemblies.get(key);
71
+ if (newer !== void 0 && newer !== latest) return newer;
72
+ const fresh = assembleFromDisk(siteDir, reportId);
73
+ latestAssemblies.set(key, fresh);
74
+ try {
75
+ return await fresh;
76
+ } catch (error) {
77
+ if (latestAssemblies.get(key) === fresh) latestAssemblies.delete(key);
78
+ throw error;
79
+ }
80
+ }
13
81
  async function assembledReport(siteDir, reportId) {
14
- const entry = reportEntryOf(reportId);
15
- return assembleDocument(entry, await readReportFiles(siteDir, entry), { mode: "complete" });
82
+ return (await latestAssembly(siteDir, reportId)).result;
16
83
  }
17
84
  /** 地址里的报告 id:形状不对或站点里没有这份报告都是 404 */
18
85
  async function reportIdOf(params, siteDir) {
@@ -20,31 +87,26 @@ async function reportIdOf(params, siteDir) {
20
87
  if (!REPORT_ID_PATTERN.test(reportId) || !(await reportIdsOf(siteDir)).includes(reportId)) throw new Response(`站点里没有报告 ${reportId}`, { status: 404 });
21
88
  return reportId;
22
89
  }
23
- function summaryOf(reportId, files, result, problem) {
24
- const metadata = result?.document.metadata ?? {};
25
- const entryDocument = files === null ? null : JSON.parse(files[reportEntryOf(reportId)]);
90
+ function summaryOf(reportId, read, problem) {
91
+ const metadata = read?.result.document.metadata ?? {};
26
92
  return {
27
93
  id: reportId,
28
94
  title: metadata.title ?? reportId,
29
95
  revision: metadata.revision ?? null,
30
96
  date: metadata.date ?? null,
31
97
  authors: metadata.authors ?? [],
32
- chapterCount: entryDocument?.blocks.filter((block) => block.type === "include").length ?? 0,
33
- warningCount: result?.warnings.length ?? 0,
98
+ chapterCount: read?.chapterCount ?? 0,
99
+ warningCount: read?.result.warnings.length ?? 0,
34
100
  problem
35
101
  };
36
102
  }
37
103
  /** 报告列表:站点 reports/ 下的每份报告各一行;读不对的报告也列出来,带上原话 */
38
104
  async function reportListLoader(_args, site) {
39
105
  const reports = [];
40
- for (const reportId of await reportIdsOf(site.siteDir)) {
41
- let files = null;
42
- try {
43
- files = await readReportFiles(site.siteDir, reportEntryOf(reportId));
44
- reports.push(summaryOf(reportId, files, assembleDocument(reportEntryOf(reportId), files, { mode: "complete" }), null));
45
- } catch (error) {
46
- reports.push(summaryOf(reportId, null, null, error instanceof Error ? error.message : String(error)));
47
- }
106
+ for (const reportId of await reportIdsOf(site.siteDir)) try {
107
+ reports.push(summaryOf(reportId, await latestAssembly(site.siteDir, reportId), null));
108
+ } catch (error) {
109
+ reports.push(summaryOf(reportId, null, error instanceof Error ? error.message : String(error)));
48
110
  }
49
111
  return {
50
112
  siteName: site.siteName,
@@ -88,15 +150,29 @@ const imageContentTypes = {
88
150
  ".jpg": "image/jpeg",
89
151
  ".jpeg": "image/jpeg"
90
152
  };
91
- /** 报告里的一张图:地址是组装后的路径 assets/<文档 id>/<文件名>,只给这份报告用到的图 */
92
- async function reportAssetLoader({ params }, site) {
153
+ /**
154
+ * 报告里的一张图:地址是组装后的路径 assets/<文档 id>/<文件名>,只给这份报告用到的图。
155
+ * 浏览器每次都来问(no-cache),ETag 由文件大小与修改时间算出,没变就回 304、不再传图
156
+ */
157
+ async function reportAssetLoader({ params, request }, site) {
93
158
  const reportId = await reportIdOf(params, site.siteDir);
94
159
  const compiledPath = `assets/${params["*"] ?? ""}`;
95
160
  const source = (await assembledReport(site.siteDir, reportId)).assets[compiledPath];
96
161
  if (source === void 0) return new Response(`报告 ${reportId} 里没有图 ${compiledPath}`, { status: 404 });
97
162
  const contentType = imageContentTypes[path.extname(source).toLowerCase()];
98
163
  if (contentType === void 0) throw new Error(`报告 ${reportId} 的图 ${source} 不是 png 或 jpg`);
99
- return new Response(Readable.toWeb(createReadStream(path.join(site.siteDir, source))), { headers: { "content-type": contentType } });
164
+ const file = path.join(site.siteDir, source);
165
+ const { size, mtimeMs } = await stat(file);
166
+ const headers = {
167
+ "content-type": contentType,
168
+ "cache-control": "no-cache",
169
+ etag: `"${size.toString(16)}-${Math.trunc(mtimeMs).toString(16)}"`
170
+ };
171
+ if (request?.headers.get("if-none-match") === headers.etag) return new Response(null, {
172
+ status: 304,
173
+ headers
174
+ });
175
+ return new Response(Readable.toWeb(createReadStream(file)), { headers });
100
176
  }
101
177
  //#endregion
102
178
  export { reportAssetLoader, reportListLoader, reportPageLoader };
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-D25NLmLx.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