@godcreator/word-report 0.2.0 → 0.3.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.
@@ -371,13 +371,90 @@ const schema = {
371
371
  "$ref": "#/$defs/inlines",
372
372
  "minItems": 1
373
373
  },
374
- "alt": { "$ref": "#/$defs/line" }
374
+ "alt": { "$ref": "#/$defs/line" },
375
+ "rows": {
376
+ "type": "integer",
377
+ "minimum": 1,
378
+ "maximum": 6
379
+ },
380
+ "columns": {
381
+ "type": "integer",
382
+ "minimum": 1,
383
+ "maximum": 6
384
+ },
385
+ "panels": {
386
+ "type": "array",
387
+ "items": { "$ref": "#/$defs/figurePanel" },
388
+ "minItems": 2,
389
+ "maxItems": 36
390
+ }
375
391
  },
376
392
  "required": [
377
393
  "type",
378
394
  "id",
395
+ "caption"
396
+ ],
397
+ "additionalProperties": false,
398
+ "allOf": [
399
+ {
400
+ "if": { "required": ["panels"] },
401
+ "then": { "required": ["rows", "columns"] }
402
+ },
403
+ {
404
+ "if": { "required": ["panels"] },
405
+ "then": { "not": { "required": ["src"] } }
406
+ },
407
+ {
408
+ "if": { "required": ["panels"] },
409
+ "then": { "not": { "required": ["alt"] } }
410
+ },
411
+ {
412
+ "if": { "not": { "required": ["panels"] } },
413
+ "then": { "required": ["src", "alt"] }
414
+ },
415
+ {
416
+ "if": { "not": { "required": ["panels"] } },
417
+ "then": { "not": { "required": ["rows"] } }
418
+ },
419
+ {
420
+ "if": { "not": { "required": ["panels"] } },
421
+ "then": { "not": { "required": ["columns"] } }
422
+ }
423
+ ],
424
+ "description": "A single image (src, alt) or several images under one figure number (rows, columns, panels). Each panel names its own grid cell and label; cells nobody names stay empty. Cells inside the grid, one panel per cell and unique labels are semantic checks."
425
+ },
426
+ "figurePanel": {
427
+ "type": "object",
428
+ "properties": {
429
+ "id": { "$ref": "#/$defs/id" },
430
+ "row": {
431
+ "type": "integer",
432
+ "minimum": 1,
433
+ "maximum": 6
434
+ },
435
+ "column": {
436
+ "type": "integer",
437
+ "minimum": 1,
438
+ "maximum": 6
439
+ },
440
+ "label": {
441
+ "type": "string",
442
+ "pattern": "^[^\\s()()]{1,8}$",
443
+ "description": "Shown under the image as (label) and in references as figure number + (label); written without parentheses."
444
+ },
445
+ "src": { "$ref": "#/$defs/assetPath" },
446
+ "alt": { "$ref": "#/$defs/line" },
447
+ "caption": {
448
+ "$ref": "#/$defs/inlines",
449
+ "minItems": 1
450
+ }
451
+ },
452
+ "required": [
453
+ "id",
454
+ "row",
455
+ "column",
456
+ "label",
379
457
  "src",
380
- "caption",
381
458
  "alt"
382
459
  ],
383
460
  "additionalProperties": false
@@ -675,8 +752,10 @@ function shape(value, rule, pointer, file) {
675
752
  if (rule.format === "date" && (Number.isNaN(Date.parse(value)) || new Date(value).toISOString().slice(0, 10) !== value)) fail("Invalid calendar date");
676
753
  }
677
754
  if (typeof value === "number" && rule.minimum !== void 0 && value < rule.minimum) fail(`Minimum is ${rule.minimum}`);
755
+ if (typeof value === "number" && rule.maximum !== void 0 && value > rule.maximum) fail(`Maximum is ${rule.maximum}`);
678
756
  if (Array.isArray(value)) {
679
- if (rule.minItems && value.length < rule.minItems) fail("Array must not be empty");
757
+ if (rule.minItems && value.length < rule.minItems) fail(rule.minItems === 1 ? "Array must not be empty" : `At least ${rule.minItems} items`);
758
+ if (rule.maxItems && value.length > rule.maxItems) fail(`At most ${rule.maxItems} items`);
680
759
  if (rule.uniqueItems && new Set(value.map(stableValue)).size !== value.length) fail("Duplicate array values");
681
760
  if (rule.items) value.forEach((v, i) => shape(v, rule.items, `${pointer}/${i}`, file));
682
761
  if (rule.contains && !value.some((v) => {
@@ -767,6 +846,23 @@ function assembleDocument(entry, files, options = {}) {
767
846
  const pending = /* @__PURE__ */ new Map();
768
847
  const resourceNames = /* @__PURE__ */ new Map();
769
848
  let rootBibliography;
849
+ /** 图片路径改成按文档 id 分开的 assets/<文档 id>/…,同名的章节图片不互相覆盖 */
850
+ function registerAsset(image, file, srcPointer) {
851
+ try {
852
+ safeRelativePath(image.src, true);
853
+ } catch (e) {
854
+ throw new ReportContentError(e.message, file, srcPointer);
855
+ }
856
+ const original = join(file, image.src);
857
+ const output = `assets/${documents.get(file).id}/${image.src.slice(7)}`;
858
+ for (const resource of [original, output]) {
859
+ const key = resource.normalize("NFC").toLowerCase(), previous = resourceNames.get(key);
860
+ if (previous && previous !== resource) throw new ReportContentError("Asset names collide on Windows", file, srcPointer);
861
+ resourceNames.set(key, resource);
862
+ }
863
+ assets[output] = original;
864
+ image.src = output;
865
+ }
770
866
  function register(value, file, prefix) {
771
867
  for (const { node, pointer } of walk(value, prefix)) {
772
868
  if (node.id !== void 0) {
@@ -778,20 +874,8 @@ function assembleDocument(entry, files, options = {}) {
778
874
  };
779
875
  }
780
876
  if (node.type === "figure") {
781
- try {
782
- safeRelativePath(node.src, true);
783
- } catch (e) {
784
- throw new ReportContentError(e.message, file, pointer + "/src");
785
- }
786
- const original = join(file, node.src);
787
- const output = `assets/${documents.get(file).id}/${node.src.slice(7)}`;
788
- for (const resource of [original, output]) {
789
- const key = resource.normalize("NFC").toLowerCase(), previous = resourceNames.get(key);
790
- if (previous && previous !== resource) throw new ReportContentError("Asset names collide on Windows", file, pointer + "/src");
791
- resourceNames.set(key, resource);
792
- }
793
- assets[output] = original;
794
- node.src = output;
877
+ const images = node.panels ? node.panels.map((panel, k) => [panel, `${pointer}/panels/${k}/src`]) : [[node, `${pointer}/src`]];
878
+ for (const [image, srcPointer] of images) registerAsset(image, file, srcPointer);
795
879
  }
796
880
  }
797
881
  }
@@ -892,6 +976,7 @@ function assembleDocument(entry, files, options = {}) {
892
976
  const source = sources[node.id];
893
977
  validateTable(node, source.file, source.pointer);
894
978
  }
979
+ if (node.type === "figure" && node.panels) validatePanelGrid(node, fail);
895
980
  if (node.type === "math" || node.type === "equation") {
896
981
  const latex = node.latex.trim();
897
982
  if (!latex || /[^\x00-\x7f]/.test(latex) || /^(\$|\\\(|\\\[)/.test(latex) || /\\(?:tag|label|documentclass)\b|\\begin\s*\{document\}/.test(latex)) fail(node, "MathType requires nonempty ASCII TeX without delimiters or manual numbering");
@@ -964,6 +1049,15 @@ function assembleDocument(entry, files, options = {}) {
964
1049
  label,
965
1050
  title
966
1051
  };
1052
+ if (node.type === "figure" && node.panels) for (const panel of node.panels) {
1053
+ const panelNumber = `${number}(${panel.label})`;
1054
+ index[panel.id] = {
1055
+ type: "figure-panel",
1056
+ number: panelNumber,
1057
+ label: `图 ${panelNumber}`,
1058
+ title: plain(panel.caption)
1059
+ };
1060
+ }
967
1061
  if (node.type === "table") {
968
1062
  if (tables >= 6) fail(node, "At most six table levels");
969
1063
  for (const row of node.rows) for (const cell of row.cells) blockNumbers(cell.blocks, depth, chapter, parentNumber, parentMode, tables + 1);
@@ -1033,6 +1127,11 @@ function assembleDocument(entry, files, options = {}) {
1033
1127
  }
1034
1128
  fail(node, `Unknown reference target: ${node.target}`);
1035
1129
  }
1130
+ if (index[node.target]?.type === "figure-panel") {
1131
+ if (node.display === "title" && (!target.caption?.length || target.caption.some((x) => !["text", "break"].includes(x.type)))) fail(node, "Title reference requires plain caption");
1132
+ delete node.externalLabel;
1133
+ continue;
1134
+ }
1036
1135
  if (![
1037
1136
  "section",
1038
1137
  "figure",
@@ -1065,6 +1164,18 @@ function assembleDocument(entry, files, options = {}) {
1065
1164
  warnings
1066
1165
  };
1067
1166
  }
1167
+ /** 多图的每张都要在 rows × columns 的网格里、一格只放一张、编号不重复 */
1168
+ function validatePanelGrid(figure, fail) {
1169
+ const occupied = /* @__PURE__ */ new Map(), labels = /* @__PURE__ */ new Map();
1170
+ for (const panel of figure.panels) {
1171
+ if (panel.row > figure.rows || panel.column > figure.columns) fail(panel, `Panel cell row ${panel.row}, column ${panel.column} is outside the ${figure.rows} x ${figure.columns} grid of ${figure.id}`);
1172
+ const cell = `${panel.row},${panel.column}`;
1173
+ if (occupied.has(cell)) fail(panel, `Panel shares row ${panel.row}, column ${panel.column} with ${occupied.get(cell)}`);
1174
+ if (labels.has(panel.label)) fail(panel, `Panel label (${panel.label}) is also used by ${labels.get(panel.label)}`);
1175
+ occupied.set(cell, panel.id);
1176
+ labels.set(panel.label, panel.id);
1177
+ }
1178
+ }
1068
1179
  function alphabet(n) {
1069
1180
  let s = "";
1070
1181
  while (n > 0) {
@@ -1075,4 +1186,4 @@ function alphabet(n) {
1075
1186
  return s;
1076
1187
  }
1077
1188
  //#endregion
1078
- export { ReportContentError as a, parseReportJson as i, safeRelativePath as n, validateDocument as r, assembleDocument as t };
1189
+ export { parseReportJson as a, walk as i, safeRelativePath as n, ReportContentError as o, validateDocument as r, assembleDocument as t };
package/dist/cli.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { n as ReportExportResult, o as ReportCheckResult, t as ReportExportOptions } from "./export-Bi5yS3GW.mjs";
1
+ import { n as ReportExportResult, o as ReportCheckResult, t as ReportExportOptions } from "./export-DC764Umz.mjs";
2
2
  //#region src/node/cli.d.ts
3
3
  type CliCommand = {
4
4
  command: 'export' | 'status';
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-BdwmhUIp.mjs";
3
- import { r as reportEntryOf } from "./report-files-DwLdh0QQ.mjs";
2
+ import { n as reportExport, r as checkReports } from "./export-TbclsIsE.mjs";
3
+ import { r as reportEntryOf } from "./report-files-DM9Ea6yK.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.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { C as TableCell, S as Table, _ as ReportIndex, a as CellBlock, b as Section, c as ContentWarning, d as Include, f as IndexEntry, g as ReportDocument, h as ReportContentError, i as Bibliography, l as Equation, m as ReportBlock, n as AssemblyOptions, o as Code, p as Paragraph, r as AssemblyResult, s as CompiledReport, t as Abstract, u as Figure, v as ReportInline, x as SourceLocation, y as ReportList } from "./types-BYcLlSjZ.mjs";
1
+ import { C as SingleImageFigure, E as TableCell, S as Section, T as Table, _ as ReportContentError, a as CellBlock, b as ReportInline, c as ContentWarning, d as FigurePanel, f as Include, g as ReportBlock, h as Paragraph, i as Bibliography, l as Equation, m as MultiImageFigure, n as AssemblyOptions, o as Code, p as IndexEntry, r as AssemblyResult, s as CompiledReport, t as Abstract, u as Figure, v as ReportDocument, w as SourceLocation, x as ReportList, y as ReportIndex } from "./types-Bcl48xQN.mjs";
2
2
  //#region src/core/assemble.d.ts
3
3
  declare function assembleDocument(entry: string, files: Record<string, string | ReportDocument>, options?: AssemblyOptions): AssemblyResult;
4
4
  //#endregion
@@ -10,4 +10,4 @@ declare function parseReportJson(text: string, file?: string): unknown;
10
10
  declare function validateDocument(value: unknown, file?: string): asserts value is ReportDocument;
11
11
  declare function safeRelativePath(raw: string, asset?: boolean): string;
12
12
  //#endregion
13
- export { Abstract, AssemblyOptions, AssemblyResult, Bibliography, CellBlock, Code, CompiledReport, ContentWarning, Equation, Figure, Include, IndexEntry, Paragraph, ReportBlock, ReportContentError, ReportDocument, ReportIndex, ReportInline, ReportList, Section, SourceLocation, Table, TableCell, assembleDocument, parseReportJson, safeRelativePath, validateDocument };
13
+ export { Abstract, AssemblyOptions, AssemblyResult, Bibliography, CellBlock, Code, CompiledReport, ContentWarning, Equation, Figure, FigurePanel, Include, IndexEntry, MultiImageFigure, Paragraph, ReportBlock, ReportContentError, ReportDocument, ReportIndex, ReportInline, ReportList, Section, SingleImageFigure, SourceLocation, Table, TableCell, assembleDocument, parseReportJson, safeRelativePath, validateDocument };
package/dist/core.mjs CHANGED
@@ -1 +1 @@
1
- export { a as ReportContentError, t as assembleDocument, i as parseReportJson, n as safeRelativePath, r as validateDocument } from "./assemble-Czu-J7tB.mjs";
1
+ export { o as ReportContentError, t as assembleDocument, a as parseReportJson, n as safeRelativePath, r as validateDocument } from "./assemble-3cx8L2vo.mjs";
@@ -1,4 +1,4 @@
1
- import { c as ContentWarning } from "./types-BYcLlSjZ.mjs";
1
+ import { c as ContentWarning } from "./types-Bcl48xQN.mjs";
2
2
  //#region src/node/check.d.ts
3
3
  interface ReportCheckDiagnostic {
4
4
  file: string;
@@ -1,5 +1,5 @@
1
- import { a as ReportContentError, i as parseReportJson, n as safeRelativePath, r as validateDocument, t as assembleDocument } from "./assemble-Czu-J7tB.mjs";
2
- import { a as reportsDirName } from "./report-files-DwLdh0QQ.mjs";
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";
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";
@@ -283,12 +283,17 @@ async function layoutOf(root, entry) {
283
283
  value: JSON.parse(bytes.toString("utf8"))
284
284
  };
285
285
  }
286
+ /** Word 引擎还不会排一个图号下的多张图:报告里有多图就在启动 Word 之前拒绝 */
287
+ function rejectMultiImageFigures(document) {
288
+ for (const { node } of walk(document)) if (node.type === "figure" && node.panels) throw new Error(`图 ${node.id} 是多图(panels),导出 Word 还不支持多图;网页上照常显示`);
289
+ }
286
290
  async function capture(site, engine, compiler, entry, mode) {
287
291
  const root = site;
288
292
  const report = await readReport(path.join(root, entry), {
289
293
  root,
290
294
  mode
291
295
  });
296
+ rejectMultiImageFigures(report.document);
292
297
  const layout = await layoutOf(root, entry);
293
298
  const engineHash = await engineFingerprint(engine, compiler);
294
299
  return {
package/dist/node.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { n as AssemblyOptions, r as AssemblyResult } from "./types-BYcLlSjZ.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-Bi5yS3GW.mjs";
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";
3
3
  //#region src/node/read-report.d.ts
4
4
  interface SnapshotEntry {
5
5
  path: string;
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-BdwmhUIp.mjs";
2
- import { a as reportsDirName, i as reportIdsOf, n as readReportFiles, r as reportEntryOf, t as REPORT_ID_PATTERN } from "./report-files-DwLdh0QQ.mjs";
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";
3
3
  export { REPORT_ID_PATTERN, checkReports, readReport, readReportFiles, reportEntry, reportEntryOf, reportExport, reportIdsOf, reportsDirName };
@@ -1,4 +1,4 @@
1
- import { r as AssemblyResult } from "./types-BYcLlSjZ.mjs";
1
+ import { r as AssemblyResult } from "./types-Bcl48xQN.mjs";
2
2
  import { CommentsAccess, PageThreadSummary } from "@godcreator/comments/site";
3
3
  //#region src/site/page-data.d.ts
4
4
  /** 报告列表里的一行:报告读不对时 problem 是原话,标题退回报告 id */
@@ -1,4 +1,4 @@
1
- import { a as ReportContentError, i as parseReportJson, n as safeRelativePath, r as validateDocument } from "./assemble-Czu-J7tB.mjs";
1
+ import { a as parseReportJson, n as safeRelativePath, o as ReportContentError, r as validateDocument } from "./assemble-3cx8L2vo.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
@@ -1,4 +1,4 @@
1
- import { a as ReportTocItem, i as ReportSummary, n as ReportPageComments, r as ReportPageData, t as ReportListData } from "./page-data-DIsywo6U.mjs";
1
+ import { a as ReportTocItem, i as ReportSummary, n as ReportPageComments, r as ReportPageData, t as ReportListData } from "./page-data-CJ6nCBnW.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,60 @@
1
- import { t as assembleDocument } from "./assemble-Czu-J7tB.mjs";
2
- import { i as reportIdsOf, n as readReportFiles, r as reportEntryOf, t as REPORT_ID_PATTERN } from "./report-files-DwLdh0QQ.mjs";
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";
3
3
  import { n as reportHrefOf } from "./page-data-DLPoVgXy.mjs";
4
+ import { readFile, realpath, stat } from "node:fs/promises";
4
5
  import path from "node:path";
5
6
  import { createReadStream } from "node:fs";
6
7
  import { Readable } from "node:stream";
7
8
  //#region src/site/loaders.server.ts
8
9
  /**
9
- * `@godcreator/word-report/site/server`:报告列表、报告页与报告里图片的 loader,报告每次从站点 reports/ 现读、现组装。
10
+ * `@godcreator/word-report/site/server`:报告列表、报告页与报告里图片的 loader。报告从站点 reports/ 现读,
11
+ * 报告页与图片在 JSON 原文没变时沿用上一次的组装结果。
10
12
  */
11
13
  /** 组装结果里有 Object.create(null) 的对象:换成普通对象再交给 React Router 序列化 */
12
14
  const plainOf = (result) => JSON.parse(JSON.stringify(result));
13
- async function assembledReport(siteDir, reportId) {
15
+ /**
16
+ * 每份报告最近一次组装,键是站点目录加报告 id。一页报告的几十张图同时请求,组装一次要几百毫秒到几秒、
17
+ * 全在一个线程上排队:不共用的话,最后一张图要等几十次组装
18
+ */
19
+ const latestAssemblies = /* @__PURE__ */ new Map();
20
+ async function assembleFromDisk(siteDir, reportId) {
14
21
  const entry = reportEntryOf(reportId);
15
- return assembleDocument(entry, await readReportFiles(siteDir, entry), { mode: "complete" });
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
+ 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
+ }
16
58
  }
17
59
  /** 地址里的报告 id:形状不对或站点里没有这份报告都是 404 */
18
60
  async function reportIdOf(params, siteDir) {
@@ -88,15 +130,29 @@ const imageContentTypes = {
88
130
  ".jpg": "image/jpeg",
89
131
  ".jpeg": "image/jpeg"
90
132
  };
91
- /** 报告里的一张图:地址是组装后的路径 assets/<文档 id>/<文件名>,只给这份报告用到的图 */
92
- async function reportAssetLoader({ params }, site) {
133
+ /**
134
+ * 报告里的一张图:地址是组装后的路径 assets/<文档 id>/<文件名>,只给这份报告用到的图。
135
+ * 浏览器每次都来问(no-cache),ETag 由文件大小与修改时间算出,没变就回 304、不再传图
136
+ */
137
+ async function reportAssetLoader({ params, request }, site) {
93
138
  const reportId = await reportIdOf(params, site.siteDir);
94
139
  const compiledPath = `assets/${params["*"] ?? ""}`;
95
140
  const source = (await assembledReport(site.siteDir, reportId)).assets[compiledPath];
96
141
  if (source === void 0) return new Response(`报告 ${reportId} 里没有图 ${compiledPath}`, { status: 404 });
97
142
  const contentType = imageContentTypes[path.extname(source).toLowerCase()];
98
143
  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 } });
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 });
100
156
  }
101
157
  //#endregion
102
158
  export { reportAssetLoader, reportListLoader, reportPageLoader };
package/dist/site.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { g as ReportDocument, r as AssemblyResult } from "./types-BYcLlSjZ.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-DIsywo6U.mjs";
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";
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-Czu-J7tB.mjs";
1
+ import { t as assembleDocument } from "./assemble-3cx8L2vo.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";
@@ -178,19 +178,56 @@ function ReportView({ report, assetUrl }) {
178
178
  })]
179
179
  }, node.id);
180
180
  case "figure": {
181
- const source = report.assets[node.src] ?? node.src;
181
+ const image = (src, id, alt) => {
182
+ const source = report.assets[src] ?? src;
183
+ return /* @__PURE__ */ jsx("img", {
184
+ id,
185
+ src: assetUrl?.(source, src) ?? source,
186
+ alt,
187
+ loading: "lazy"
188
+ });
189
+ };
190
+ const caption = /* @__PURE__ */ jsxs("figcaption", {
191
+ id: `${node.id}-caption`,
192
+ children: [label?.number && /* @__PURE__ */ jsxs("span", { children: [label.label, " "] }), inlines(node.caption)]
193
+ });
194
+ if (!node.panels) return /* @__PURE__ */ jsxs("figure", {
195
+ "data-report-source": origin?.file,
196
+ "data-report-pointer": origin?.pointer,
197
+ children: [image(node.src, node.id, node.alt), caption]
198
+ }, node.id);
182
199
  return /* @__PURE__ */ jsxs("figure", {
200
+ className: "report-multi-figure",
183
201
  "data-report-source": origin?.file,
184
202
  "data-report-pointer": origin?.pointer,
185
- children: [/* @__PURE__ */ jsx("img", {
203
+ children: [/* @__PURE__ */ jsx("div", {
186
204
  id: node.id,
187
- src: assetUrl?.(source, node.src) ?? source,
188
- alt: node.alt,
189
- loading: "lazy"
190
- }), /* @__PURE__ */ jsxs("figcaption", {
191
- id: `${node.id}-caption`,
192
- children: [label?.number && /* @__PURE__ */ jsxs("span", { children: [label.label, " "] }), inlines(node.caption)]
193
- })]
205
+ className: "report-panels",
206
+ style: {
207
+ gridTemplateColumns: `repeat(${node.columns}, minmax(0, 1fr))`,
208
+ gridTemplateRows: `repeat(${node.rows}, auto)`
209
+ },
210
+ children: node.panels.map((panel) => /* @__PURE__ */ jsxs("div", {
211
+ className: "report-panel",
212
+ style: {
213
+ gridRow: panel.row,
214
+ gridColumn: panel.column
215
+ },
216
+ children: [/* @__PURE__ */ jsx("div", {
217
+ className: "report-panel-image",
218
+ children: image(panel.src, panel.id, panel.alt)
219
+ }), /* @__PURE__ */ jsxs("div", {
220
+ id: `${panel.id}-caption`,
221
+ className: "report-panel-caption",
222
+ children: [
223
+ "(",
224
+ panel.label,
225
+ ")",
226
+ panel.caption && /* @__PURE__ */ jsxs(Fragment$1, { children: [" ", inlines(panel.caption)] })
227
+ ]
228
+ })]
229
+ }, panel.id))
230
+ }), caption]
194
231
  }, node.id);
195
232
  }
196
233
  case "code": return /* @__PURE__ */ jsx("pre", {
@@ -403,7 +440,7 @@ function reportMeta(input) {
403
440
  case "abstract": return `## ${node.title ?? "摘要"}\n\n${node.paragraphs.map((p) => block(p)).join("\n\n")}\n\n关键词:${node.keywords.join(";")}`;
404
441
  case "paragraph": return inline(node.content);
405
442
  case "equation": return `$$\n${node.latex}\n$$${report?.index[node.id]?.number ? `\n(${report.index[node.id].number})` : ""}`;
406
- case "figure": return `![${node.alt}](${node.src})\n\n${report?.index[node.id]?.label ?? ""} ${inline(node.caption)}`;
443
+ case "figure": return `${node.panels ? node.panels.map((panel) => `![${panel.alt}](${panel.src})\n${report?.index[panel.id]?.label ?? panel.id}${panel.caption ? " " + inline(panel.caption) : ""}`).join("\n\n") : `![${node.alt}](${node.src})`}\n\n${report?.index[node.id]?.label ?? ""} ${inline(node.caption)}`;
407
444
  case "table": return `${node.caption ? `${report?.index[node.id]?.label ?? ""} ${inline(node.caption)}\n\n` : ""}${node.rows.map((row) => row.cells.map((cell) => cell.blocks.map((child) => block(child, depth)).join("\n")).join(" | ")).join("\n")}`;
408
445
  case "list": return node.items.map((item, i) => `${node.ordered ? `${(node.start ?? 1) + i}.` : "-"} ${item.blocks.map((child) => block(child, depth)).join("\n")}`).join("\n");
409
446
  case "code": return `\`\`\`${node.language}\n${node.text}\n\`\`\``;
@@ -51,13 +51,38 @@ interface ReportList {
51
51
  blocks: (Paragraph | ReportList)[];
52
52
  }[];
53
53
  }
54
- interface Figure {
54
+ /** 多图里的一张:放在第 row 行第 column 列(从 1 起),图下显示「(label)」,caption 是写在编号后面的小标题;label 由人写,不自动编 */
55
+ interface FigurePanel {
56
+ id: string;
57
+ row: number;
58
+ column: number;
59
+ label: string;
60
+ src: string;
61
+ alt: string;
62
+ caption?: ReportInline[];
63
+ }
64
+ interface SingleImageFigure {
55
65
  type: 'figure';
56
66
  id: string;
57
67
  src: string;
58
68
  caption: ReportInline[];
59
69
  alt: string;
70
+ rows?: never;
71
+ columns?: never;
72
+ panels?: never;
73
+ }
74
+ /** 一个图号下的多张图:rows 行 columns 列的网格,每张图写明自己的格子,没写到的格子留空;caption 是整个图的图题 */
75
+ interface MultiImageFigure {
76
+ type: 'figure';
77
+ id: string;
78
+ caption: ReportInline[];
79
+ rows: number;
80
+ columns: number;
81
+ panels: FigurePanel[];
82
+ src?: never;
83
+ alt?: never;
60
84
  }
85
+ type Figure = SingleImageFigure | MultiImageFigure;
61
86
  interface Equation {
62
87
  type: 'equation';
63
88
  id: string;
@@ -169,4 +194,4 @@ declare class ReportContentError extends Error {
169
194
  constructor(message: string, file?: string, pointer?: string);
170
195
  }
171
196
  //#endregion
172
- export { TableCell as C, Table as S, ReportIndex as _, CellBlock as a, Section as b, ContentWarning as c, Include as d, IndexEntry as f, ReportDocument as g, ReportContentError as h, Bibliography as i, Equation as l, ReportBlock as m, AssemblyOptions as n, Code as o, Paragraph as p, AssemblyResult as r, CompiledReport as s, Abstract as t, Figure as u, ReportInline as v, SourceLocation as x, ReportList as y };
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@godcreator/word-report",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "author": {
5
5
  "name": "godcreator"
6
6
  },
package/styles/report.css CHANGED
@@ -6,6 +6,12 @@
6
6
  .report-document figure { margin: 1.5rem 0; text-align: center; }
7
7
  .report-document img { margin: 0 auto; max-width: 100%; height: auto; }
8
8
  .report-document figcaption, .report-document caption { color: var(--color-fd-muted-foreground); font-size: .9em; }
9
+ /* 多图:行列由报告写定(网格与每张的格子写在元素的 style 上),各列等宽、没写到的格子留空,任何宽度都不重排;同一行里图片上下居中、(a)(b) 对齐在同一条线上 */
10
+ .report-panels { display: grid; gap: .75rem 1rem; }
11
+ .report-panel { min-width: 0; display: flex; flex-direction: column; }
12
+ .report-panel-image { flex: 1; display: flex; align-items: center; justify-content: center; }
13
+ .report-panel-caption { margin-top: .25rem; font-size: .9em; color: var(--color-fd-muted-foreground); }
14
+ .report-multi-figure > figcaption { margin-top: .5rem; }
9
15
  .report-document .report-equation { display: flex; align-items: center; gap: 1rem; margin: 1.25rem 0; }
10
16
  .report-math-block { display: block; flex: 1; min-width: 0; overflow-x: auto; overflow-y: hidden; padding: .5em 0; }
11
17
  .report-equation-number { flex-shrink: 0; }