diff --git a/web/package-lock.json b/web/package-lock.json index 51a950e5..b420a6df 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,11 +8,13 @@ "name": "shuimu-workbench", "version": "1.0.0", "dependencies": { + "docx": "^9.7.1", "katex": "^0.18.7", "lucide-react": "^1.16.0", "mermaid": "^11.17.2", "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "xlsx": "^0.18.5" }, "devDependencies": { "@types/katex": "^0.16.8", @@ -1652,6 +1654,15 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.11.21", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", @@ -1720,6 +1731,28 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/commander": { "version": "15.0.0", "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", @@ -1736,6 +1769,12 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cose-base": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", @@ -1745,6 +1784,18 @@ "layout-base": "^1.0.0" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2293,6 +2344,56 @@ "robust-predicates": "^3.0.2" } }, + "node_modules/docx": { + "version": "9.7.1", + "resolved": "https://registry.npmjs.org/docx/-/docx-9.7.1.tgz", + "integrity": "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==", + "license": "MIT", + "dependencies": { + "@types/node": "^25.2.3", + "hash.js": "^1.1.7", + "jszip": "^3.10.1", + "nanoid": "^5.1.3", + "xml": "^1.0.1", + "xml-js": "^1.6.8" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/docx/node_modules/@types/node": { + "version": "25.9.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz", + "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/docx/node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/docx/node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT" + }, "node_modules/dompurify": { "version": "3.4.15", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz", @@ -2400,6 +2501,15 @@ } } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2431,6 +2541,16 @@ "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", "license": "MIT" }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -2443,6 +2563,12 @@ "node": ">=0.10.0" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-meta-resolve": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", @@ -2453,6 +2579,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -2462,6 +2594,12 @@ "node": ">=12" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2495,6 +2633,18 @@ "node": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/katex": { "version": "0.18.7", "resolved": "https://registry.npmjs.org/katex/-/katex-0.18.7.tgz", @@ -2522,6 +2672,15 @@ "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", "license": "MIT" }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lodash-es": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", @@ -2614,6 +2773,12 @@ "katex": "cli.js" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2656,6 +2821,12 @@ "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", "license": "MIT" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/path-data-parser": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", @@ -2727,6 +2898,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -2758,6 +2935,21 @@ "node": ">=0.10.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/robust-predicates": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", @@ -2828,12 +3020,27 @@ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", "license": "BSD-3-Clause" }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -2850,6 +3057,12 @@ "semver": "bin/semver.js" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2860,12 +3073,33 @@ "node": ">=0.10.0" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/strictdom": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/strictdom/-/strictdom-1.0.1.tgz", "integrity": "sha512-cEmp9QeXXRmjj/rVp9oyiqcvyocWab/HaoN4+bwFeZ7QzykJD6L3yD4v12K1x0tHpqRqVpJevN3gW7kyM39Bqg==", "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/stylis": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", @@ -2978,6 +3212,12 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/uuid": { "version": "14.0.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", @@ -3066,6 +3306,63 @@ } } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", + "license": "MIT" + }, + "node_modules/xml-js": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "license": "MIT", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/web/package.json b/web/package.json index 26fe1fbe..44f1e41a 100644 --- a/web/package.json +++ b/web/package.json @@ -10,11 +10,13 @@ "test": "tsx --test \"src/**/*.test.ts\"" }, "dependencies": { + "docx": "^9.7.1", "katex": "^0.18.7", "lucide-react": "^1.16.0", "mermaid": "^11.17.2", "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "xlsx": "^0.18.5" }, "devDependencies": { "@types/katex": "^0.16.8", diff --git a/web/src/engine/docxRenderer.ts b/web/src/engine/docxRenderer.ts new file mode 100644 index 00000000..caa0bf12 --- /dev/null +++ b/web/src/engine/docxRenderer.ts @@ -0,0 +1,662 @@ +import { + Document, + Paragraph, + TextRun, + Table, + TableRow, + TableCell, + WidthType, + BorderStyle, + AlignmentType, + HeadingLevel, + Packer, + Header, + Footer, + PageNumber, + ShadingType, +} from 'docx'; +import { DocumentAST } from '../types/block'; +import { cleanHeadingContent } from './autoNumber'; + +/** + * parseInlineTextRuns: 将包含简易 Markdown (粗体/斜体/代码/下划线) 的字符串转换为 docx TextRun 数组 + */ +export function parseInlineTextRuns(text: string, defaultOptions?: { color?: string; size?: number }): TextRun[] { + if (!text) return []; + + // 正则匹配:**粗体** | *斜体* | `代码` | 纯文本 + const tokens: TextRun[] = []; + const regex = /(\*\*([^\*]+?)\*\*|\*([^\*]+?)\*|`([^`]+?)`|([^\*`]+))/g; + let match: RegExpExecArray | null; + + while ((match = regex.exec(text)) !== null) { + if (match[2] !== undefined) { + // **粗体** + tokens.push( + new TextRun({ + text: match[2], + bold: true, + color: defaultOptions?.color || '222222', + size: defaultOptions?.size || 22, // 11pt = 22 half-pts + }) + ); + } else if (match[3] !== undefined) { + // *斜体* + tokens.push( + new TextRun({ + text: match[3], + italics: true, + color: defaultOptions?.color || '333333', + size: defaultOptions?.size || 22, + }) + ); + } else if (match[4] !== undefined) { + // `代码` + tokens.push( + new TextRun({ + text: match[4], + font: 'Consolas', + shading: { + type: ShadingType.CLEAR, + fill: 'F1F5F9', + }, + color: '0369A1', + size: (defaultOptions?.size || 22) - 2, + }) + ); + } else if (match[5] !== undefined) { + // 纯文本 + tokens.push( + new TextRun({ + text: match[5], + color: defaultOptions?.color || '333333', + size: defaultOptions?.size || 22, + }) + ); + } + } + + return tokens.length > 0 + ? tokens + : [ + new TextRun({ + text, + color: defaultOptions?.color || '333333', + size: defaultOptions?.size || 22, + }), + ]; +} + +/** + * buildDocxModel: 将 DocumentAST 转换为完整的 docx Document 实例 + */ +export function buildDocxModel(ast: DocumentAST): Document { + const docElements: (Paragraph | Table)[] = []; + + // 1. 文档大标题 + docElements.push( + new Paragraph({ + alignment: AlignmentType.CENTER, + spacing: { before: 240, after: 120 }, + children: [ + new TextRun({ + text: ast.title, + bold: true, + size: 36, // 18pt + color: '0F172A', + }), + ], + }) + ); + + // 2. 受控元数据签署栏 (四列两行受控表格) + const metaBorder = { + style: BorderStyle.SINGLE, + size: 4, + color: 'CBD5E1', + }; + + const statusMap: Record = { + drafting: '编制中 (草稿)', + reviewing: '审核中 (流程锁定)', + approving: '批准中 (流程锁定)', + approved: '已批准生效 (受控基线)', + rejected: '已驳回修改', + }; + + const metaTable = new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + rows: [ + new TableRow({ + children: [ + new TableCell({ + width: { size: 25, type: WidthType.PERCENTAGE }, + shading: { fill: 'F8FAFC', type: ShadingType.CLEAR }, + borders: { top: metaBorder, bottom: metaBorder, left: metaBorder, right: metaBorder }, + children: [ + new Paragraph({ + alignment: AlignmentType.CENTER, + children: [new TextRun({ text: '受控版本', bold: true, size: 20 })], + }), + ], + }), + new TableCell({ + width: { size: 25, type: WidthType.PERCENTAGE }, + borders: { top: metaBorder, bottom: metaBorder, left: metaBorder, right: metaBorder }, + children: [ + new Paragraph({ + alignment: AlignmentType.CENTER, + children: [new TextRun({ text: ast.version, size: 20, color: '0284C7', bold: true })], + }), + ], + }), + new TableCell({ + width: { size: 25, type: WidthType.PERCENTAGE }, + shading: { fill: 'F8FAFC', type: ShadingType.CLEAR }, + borders: { top: metaBorder, bottom: metaBorder, left: metaBorder, right: metaBorder }, + children: [ + new Paragraph({ + alignment: AlignmentType.CENTER, + children: [new TextRun({ text: '受控状态', bold: true, size: 20 })], + }), + ], + }), + new TableCell({ + width: { size: 25, type: WidthType.PERCENTAGE }, + borders: { top: metaBorder, bottom: metaBorder, left: metaBorder, right: metaBorder }, + children: [ + new Paragraph({ + alignment: AlignmentType.CENTER, + children: [new TextRun({ text: statusMap[ast.lifecycleState] || ast.lifecycleState, size: 20 })], + }), + ], + }), + ], + }), + new TableRow({ + children: [ + new TableCell({ + shading: { fill: 'F8FAFC', type: ShadingType.CLEAR }, + borders: { top: metaBorder, bottom: metaBorder, left: metaBorder, right: metaBorder }, + children: [ + new Paragraph({ + alignment: AlignmentType.CENTER, + children: [new TextRun({ text: '法定签署责任人', bold: true, size: 20 })], + }), + ], + }), + new TableCell({ + borders: { top: metaBorder, bottom: metaBorder, left: metaBorder, right: metaBorder }, + children: [ + new Paragraph({ + alignment: AlignmentType.CENTER, + children: [ + new TextRun({ + text: `编制: ${ast.author} | 审核: ${ast.reviewer} | 批准: ${ast.approver}`, + size: 19, + }), + ], + }), + ], + }), + new TableCell({ + shading: { fill: 'F8FAFC', type: ShadingType.CLEAR }, + borders: { top: metaBorder, bottom: metaBorder, left: metaBorder, right: metaBorder }, + children: [ + new Paragraph({ + alignment: AlignmentType.CENTER, + children: [new TextRun({ text: '更新归档日期', bold: true, size: 20 })], + }), + ], + }), + new TableCell({ + borders: { top: metaBorder, bottom: metaBorder, left: metaBorder, right: metaBorder }, + children: [ + new Paragraph({ + alignment: AlignmentType.CENTER, + children: [new TextRun({ text: ast.updatedAt || '2026-09-08', size: 19 })], + }), + ], + }), + ], + }), + ], + }); + + docElements.push(metaTable); + + // 空行分割 + docElements.push(new Paragraph({ spacing: { before: 180, after: 120 } })); + + // 3. 递归遍历渲染 AST 子块 + const blocks = ast.rootBlock.children || []; + + for (const block of blocks) { + switch (block.type) { + case 'heading': { + const level = block.level || 1; + const cleanTitle = cleanHeadingContent(block.content); + const headingText = block.numbering ? `${block.numbering} ${cleanTitle}` : cleanTitle; + + let hLevel: (typeof HeadingLevel)[keyof typeof HeadingLevel] = HeadingLevel.HEADING_1; + let fontSize = 28; // 14pt + if (level === 2) { + hLevel = HeadingLevel.HEADING_2; + fontSize = 26; // 13pt + } else if (level === 3) { + hLevel = HeadingLevel.HEADING_3; + fontSize = 24; // 12pt + } else if (level >= 4) { + hLevel = HeadingLevel.HEADING_4; + fontSize = 22; // 11pt + } + + docElements.push( + new Paragraph({ + heading: hLevel, + spacing: { before: 240, after: 100 }, + children: [ + new TextRun({ + text: headingText, + bold: true, + size: fontSize, + color: level <= 2 ? '0F172A' : '334155', + }), + ], + }) + ); + break; + } + + case 'paragraph': { + if (!block.content) break; + const lines = block.content.split('\n'); + for (const line of lines) { + if (!line.trim()) continue; + docElements.push( + new Paragraph({ + spacing: { before: 60, after: 60, line: 280 }, + children: parseInlineTextRuns(line), + }) + ); + } + break; + } + + case 'callout': { + const typeTag = block.properties?.calloutType === 'regulatory' ? '合规依据与法规强标' : '重点提示与风险警示'; + docElements.push( + new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + rows: [ + new TableRow({ + children: [ + new TableCell({ + shading: { fill: 'F0F9FF', type: ShadingType.CLEAR }, + borders: { + left: { style: BorderStyle.SINGLE, size: 24, color: '0284C7' }, + top: { style: BorderStyle.NONE }, + bottom: { style: BorderStyle.NONE }, + right: { style: BorderStyle.NONE }, + }, + children: [ + new Paragraph({ + spacing: { before: 80, after: 40 }, + children: [ + new TextRun({ + text: `【${typeTag}】`, + bold: true, + size: 20, + color: '0369A1', + }), + ], + }), + new Paragraph({ + spacing: { before: 40, after: 80 }, + children: parseInlineTextRuns(block.content, { color: '0F172A', size: 20 }), + }), + ], + }), + ], + }), + ], + }) + ); + docElements.push(new Paragraph({ spacing: { before: 60, after: 60 } })); + break; + } + + case 'table': { + const data = block.properties?.tableData || []; + const caption = block.properties?.caption || '关键指标与技术评估表'; + const numLabel = block.numbering ? `${block.numbering} ${caption}` : caption; + + // 题注 (表 1-1 ...) + docElements.push( + new Paragraph({ + alignment: AlignmentType.CENTER, + spacing: { before: 180, after: 60 }, + children: [ + new TextRun({ + text: numLabel, + bold: true, + size: 21, + color: '334155', + }), + ], + }) + ); + + if (data.length > 0) { + const colCount = Math.max(...data.map((r) => r.length)); + const colWidthPercent = Math.floor(100 / colCount); + + // 医疗器械规范标准三线表边线 + const thickBorder = { style: BorderStyle.SINGLE, size: 12, color: '0F172A' }; // 顶底粗线 + const thinBorder = { style: BorderStyle.SINGLE, size: 6, color: '334155' }; // 表头分割细线 + const noBorder = { style: BorderStyle.NONE }; + + const tableRows: TableRow[] = []; + + data.forEach((row, rIdx) => { + const isHeader = rIdx === 0; + const isLast = rIdx === data.length - 1; + + const cells: TableCell[] = []; + for (let c = 0; c < colCount; c++) { + const cellText = row[c] || ''; + cells.push( + new TableCell({ + width: { size: colWidthPercent, type: WidthType.PERCENTAGE }, + shading: isHeader ? { fill: 'F8FAFC', type: ShadingType.CLEAR } : undefined, + borders: { + top: isHeader ? thickBorder : isLast ? thinBorder : noBorder, + bottom: isHeader ? thinBorder : isLast ? thickBorder : noBorder, + left: noBorder, + right: noBorder, + }, + children: [ + new Paragraph({ + alignment: isHeader ? AlignmentType.CENTER : AlignmentType.LEFT, + spacing: { before: 60, after: 60 }, + children: parseInlineTextRuns(cellText, { + size: 20, + color: isHeader ? '0F172A' : '334155', + }), + }), + ], + }) + ); + } + tableRows.push(new TableRow({ children: cells })); + }); + + docElements.push( + new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + rows: tableRows, + }) + ); + } + docElements.push(new Paragraph({ spacing: { before: 100, after: 100 } })); + break; + } + + case 'math': { + const caption = block.properties?.caption || '关键计算判据与物理算法模型'; + const numLabel = block.numbering ? `${block.numbering} ${caption}` : caption; + + docElements.push( + new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + rows: [ + new TableRow({ + children: [ + new TableCell({ + shading: { fill: 'F8FAFC', type: ShadingType.CLEAR }, + borders: { + top: { style: BorderStyle.SINGLE, size: 4, color: 'E2E8F0' }, + bottom: { style: BorderStyle.SINGLE, size: 4, color: 'E2E8F0' }, + left: { style: BorderStyle.SINGLE, size: 4, color: 'E2E8F0' }, + right: { style: BorderStyle.SINGLE, size: 4, color: 'E2E8F0' }, + }, + children: [ + new Paragraph({ + alignment: AlignmentType.CENTER, + spacing: { before: 120, after: 60 }, + children: [ + new TextRun({ + text: block.content.trim(), + font: 'Cambria Math', + size: 24, + bold: true, + color: '0F172A', + }), + ], + }), + new Paragraph({ + alignment: AlignmentType.RIGHT, + spacing: { before: 0, after: 60 }, + children: [ + new TextRun({ + text: numLabel, + size: 18, + color: '64748B', + }), + ], + }), + ], + }), + ], + }), + ], + }) + ); + docElements.push(new Paragraph({ spacing: { before: 80, after: 80 } })); + break; + } + + case 'mermaid': { + const caption = block.properties?.caption || '系统架构与时序拓扑图'; + const numLabel = block.numbering ? `${block.numbering} ${caption}` : caption; + + docElements.push( + new Paragraph({ + alignment: AlignmentType.CENTER, + spacing: { before: 120, after: 40 }, + children: [ + new TextRun({ + text: numLabel, + bold: true, + size: 21, + color: '334155', + }), + ], + }) + ); + + docElements.push( + new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + rows: [ + new TableRow({ + children: [ + new TableCell({ + shading: { fill: 'F8FAFC', type: ShadingType.CLEAR }, + borders: { + top: { style: BorderStyle.DASHED, size: 4, color: 'CBD5E1' }, + bottom: { style: BorderStyle.DASHED, size: 4, color: 'CBD5E1' }, + left: { style: BorderStyle.DASHED, size: 4, color: 'CBD5E1' }, + right: { style: BorderStyle.DASHED, size: 4, color: 'CBD5E1' }, + }, + children: [ + new Paragraph({ + spacing: { before: 80, after: 40 }, + children: [ + new TextRun({ + text: '[Mermaid 系统矢量拓扑图已定义,图形代码如下]:', + color: '0284C7', + bold: true, + size: 19, + }), + ], + }), + new Paragraph({ + spacing: { before: 40, after: 80 }, + children: [ + new TextRun({ + text: block.content.trim(), + font: 'Consolas', + size: 18, + color: '475569', + }), + ], + }), + ], + }), + ], + }), + ], + }) + ); + break; + } + + case 'list': { + const isOrdered = block.properties?.isOrdered ?? true; + const startNumber = block.properties?.startNumber || 1; + const items = + block.properties?.listItems || + block.content + .split('\n') + .map((line) => line.replace(/^\s*(\d+\.|[-*+])\s+/, '').trim()) + .filter(Boolean); + + items.forEach((item, idx) => { + const prefix = isOrdered ? `${startNumber + idx}. ` : '• '; + docElements.push( + new Paragraph({ + spacing: { before: 40, after: 40 }, + indent: { left: 360 }, // 悬挂缩进 + children: [ + new TextRun({ + text: prefix, + bold: true, + color: '0284C7', + size: 21, + }), + ...parseInlineTextRuns(item, { size: 21 }), + ], + }) + ); + }); + break; + } + + case 'divider': { + docElements.push( + new Paragraph({ + spacing: { before: 120, after: 120 }, + border: { + bottom: { style: BorderStyle.SINGLE, size: 6, color: 'E2E8F0' }, + }, + }) + ); + break; + } + + default: + break; + } + } + + // 4. 构建标准 Word Document 结构 (带页眉和页脚) + return new Document({ + sections: [ + { + headers: { + default: new Header({ + children: [ + new Paragraph({ + alignment: AlignmentType.RIGHT, + children: [ + new TextRun({ + text: '水木东方医疗器械受控体系交付物 | 研发机密', + size: 16, + color: '94A3B8', + }), + ], + }), + ], + }), + }, + footers: { + default: new Footer({ + children: [ + new Paragraph({ + alignment: AlignmentType.CENTER, + children: [ + new TextRun({ + text: '第 ', + size: 18, + color: '94A3B8', + }), + new TextRun({ + children: [PageNumber.CURRENT], + size: 18, + color: '94A3B8', + }), + new TextRun({ + text: ' 页,共 ', + size: 18, + color: '94A3B8', + }), + new TextRun({ + children: [PageNumber.TOTAL_PAGES], + size: 18, + color: '94A3B8', + }), + new TextRun({ + text: ' 页', + size: 18, + color: '94A3B8', + }), + ], + }), + ], + }), + }, + properties: { + page: { + margin: { + top: 1440, // 1 inch + bottom: 1440, + left: 1440, + right: 1440, + }, + }, + }, + children: docElements, + }, + ], + }); +} + +/** + * exportDocumentToDocx: 编译并触发浏览器下载 DOCX 二进制文件 + */ +export async function exportDocumentToDocx(ast: DocumentAST): Promise { + const doc = buildDocxModel(ast); + const blob = await Packer.toBlob(doc); + + if (typeof window !== 'undefined' && typeof document !== 'undefined') { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${ast.title}_${ast.version}_受控交付物.docx`; + a.click(); + URL.revokeObjectURL(url); + } + + return blob; +} diff --git a/web/src/engine/xlsxRenderer.ts b/web/src/engine/xlsxRenderer.ts new file mode 100644 index 00000000..e6e8e407 --- /dev/null +++ b/web/src/engine/xlsxRenderer.ts @@ -0,0 +1,156 @@ +import * as XLSX from 'xlsx'; +import { DocumentAST } from '../types/block'; + +/** + * sanitizeSheetName: 净化 Excel 工作表名称(限制在 31 字符以内,移除 \ / ? * [ ] : 等非法字符) + */ +function sanitizeSheetName(name: string, fallback: string): string { + const cleaned = name.replace(/[\\/?*\[\]:]/g, '_').trim(); + if (!cleaned) return fallback; + return cleaned.substring(0, 30); +} + +/** + * buildXlsxWorkbook: 将 DocumentAST 结构化提炼为正规的 Excel 工作簿模型 + */ +export function buildXlsxWorkbook(ast: DocumentAST): XLSX.WorkBook { + const wb = XLSX.utils.book_new(); + + // 1. 创建 Sheet 1: “文档概览与受控签署” + const statusMap: Record = { + drafting: '编制中 (草稿)', + reviewing: '审核中 (流程锁定)', + approving: '批准中 (流程锁定)', + approved: '已批准生效 (受控基线)', + rejected: '已驳回修改', + }; + + const overviewData: (string | number)[][] = [ + ['水木东方医疗器械受控体系交付物数据台账'], + [], + ['交付物标题', ast.title], + ['文档编号/ID', ast.docId], + ['业务任务分类', ast.taskKey], + ['受控版本', ast.version], + ['受控状态', statusMap[ast.lifecycleState] || ast.lifecycleState], + ['空间分类', ast.category === 'private' ? '个人私密生成' : ast.category === 'shared' ? '项目协作共享' : '流程受控交付物'], + ['编制责任人', ast.author], + ['审核责任人', ast.reviewer], + ['批准责任人', ast.approver], + ['最新归档时间', ast.updatedAt || '2026-09-08'], + ['文档摘要', ast.summary || ''], + [], + ['导出来源', '水木东方协同智能体工作台 (Browser XLSX Engine)'], + ]; + + const wsOverview = XLSX.utils.aoa_to_sheet(overviewData); + // 设置概览页列宽 + wsOverview['!cols'] = [{ wch: 18 }, { wch: 60 }]; + XLSX.utils.book_append_sheet(wb, wsOverview, '文档概览与签署'); + + // 2. 遍历 AST 提取所有表格块 (Table Blocks) + const blocks = ast.rootBlock.children || []; + const tableBlocks = blocks.filter((b) => b.type === 'table' && b.properties?.tableData?.length); + + if (tableBlocks.length > 0) { + const usedSheetNames = new Set(['文档概览与签署']); + + tableBlocks.forEach((tblBlock, idx) => { + const rawCaption = tblBlock.properties?.caption || `数据表_${idx + 1}`; + const prefix = tblBlock.numbering ? `${tblBlock.numbering} ` : ''; + let baseSheetName = sanitizeSheetName(`${prefix}${rawCaption}`, `数据表_${idx + 1}`); + + // 处理 Sheet 名称防重复 + let finalSheetName = baseSheetName; + let counter = 1; + while (usedSheetNames.has(finalSheetName)) { + finalSheetName = `${baseSheetName.substring(0, 26)}_${counter++}`; + } + usedSheetNames.add(finalSheetName); + + const tableData = tblBlock.properties?.tableData || []; + const sheetAoa: (string | number)[][] = []; + + // 表头之前加入表格标题与归属说明 + sheetAoa.push([`${prefix}${rawCaption}`]); + sheetAoa.push([]); + + // 压入表格真实数据行 + for (const row of tableData) { + // 清除单元格中的 Markdown 格式字符(如 **粗体**)使 Excel 数据整洁 + const cleanedRow = row.map((cell) => cell.replace(/\*\*(.*?)\*\*/g, '$1').replace(/\*(.*?)\*/g, '$1').trim()); + sheetAoa.push(cleanedRow); + } + + const ws = XLSX.utils.aoa_to_sheet(sheetAoa); + + // 计算自适应列宽 + if (tableData.length > 0) { + const colCount = Math.max(...tableData.map((r) => r.length)); + const colWidths: { wch: number }[] = []; + for (let c = 0; c < colCount; c++) { + let maxLen = 10; + for (const row of tableData) { + const cellStr = row[c] || ''; + const len = cellStr.length * 1.5; // 中文字符加权 + if (len > maxLen) maxLen = len; + } + colWidths.push({ wch: Math.min(Math.max(Math.round(maxLen), 12), 50) }); + } + ws['!cols'] = colWidths; + } + + XLSX.utils.book_append_sheet(wb, ws, finalSheetName); + }); + } else { + // 若文档无独立表格,提取列表及段落构建结构化清单 Sheet + const listData: string[][] = [['章节/类型', '序号', '条目内容']]; + let curChapter = '正文'; + + for (const b of blocks) { + if (b.type === 'heading') { + curChapter = `${b.numbering || ''} ${b.content}`.trim(); + } else if (b.type === 'list') { + const items = + b.properties?.listItems || + b.content + .split('\n') + .map((line) => line.replace(/^\s*(\d+\.|[-*+])\s+/, '').trim()) + .filter(Boolean); + items.forEach((item, i) => { + listData.push([curChapter, String(i + 1), item]); + }); + } + } + + if (listData.length > 1) { + const wsList = XLSX.utils.aoa_to_sheet(listData); + wsList['!cols'] = [{ wch: 25 }, { wch: 8 }, { wch: 60 }]; + XLSX.utils.book_append_sheet(wb, wsList, '结构化条目清单'); + } + } + + return wb; +} + +/** + * exportDocumentToXlsx: 编译并触发浏览器下载 XLSX 二进制文件 + */ +export async function exportDocumentToXlsx(ast: DocumentAST): Promise { + const wb = buildXlsxWorkbook(ast); + const wbout = XLSX.write(wb, { bookType: 'xlsx', type: 'array' }); + const blob = new Blob([wbout], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }); + + if (typeof window !== 'undefined' && typeof document !== 'undefined') { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${ast.title}_${ast.version}_数据清单.xlsx`; + a.click(); + URL.revokeObjectURL(url); + } + + return blob; +}