generate-manual.js 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695
  1. const fs = require("fs");
  2. const path = require("path");
  3. const {
  4. Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell,
  5. ImageRun, Header, Footer, AlignmentType, LevelFormat,
  6. HeadingLevel, BorderStyle, WidthType, ShadingType, PageNumber, PageBreak,
  7. } = require("docx");
  8. const IMAGES_DIR = path.join(__dirname, "images");
  9. const OUTPUT = path.join(__dirname, "陇耘鲜达系统操作手册.docx");
  10. // Image filename mapping: markdown reference → actual file
  11. const IMAGE_MAP = {
  12. "web-login.png": "web-dashboard.png", // no web-login, closest is dashboard
  13. "web-dashboard.png": "web-dashboard.png",
  14. "web-region-list.png": "web-region.png",
  15. "web-user-list.png": "web-user.png",
  16. "web-parameter-list.png": "web-parameter.png",
  17. "web-unit-list.png": "web-unit.png",
  18. "web-category-list.png": "web-category.png",
  19. "web-product-list.png": "web-product.png",
  20. "web-inquiry-list.png": "web-inquiry.png",
  21. "web-inquiry-compare.png": "web-inquiry.png",
  22. "web-order-list.png": "web-order-list.png",
  23. "web-supplier-settlement-pending.png": "web-supplier-settlement.png",
  24. "web-client-settlement-pending.png": "web-client-settlement.png",
  25. "web-delivery-list.png": "web-delivery-list.png",
  26. "web-delivery-warning.png": "web-delivery-warning.png",
  27. "web-system-config.png": "web-system-config.png",
  28. "web-api-log.png": "web-api-log.png",
  29. "mini-login.png": "mini-client-home.png", // no mini-login, use client home
  30. "mini-client-home.png": "mini-client-home.png",
  31. "mini-client-order.png": "mini-client-order.png",
  32. "mini-client-cart.png": "mini-client-cart.png",
  33. "mini-client-profile.png": "mini-client-profile.png",
  34. "mini-supplier-home.png": "mini-supplier-home.png",
  35. "mini-deliverer-home.png": "mini-deliverer-home.png",
  36. "mini-deliverer-task.png": "mini-deliverer-task.png",
  37. };
  38. // Constants
  39. const A4_WIDTH = 11906; // A4 width in DXA
  40. const A4_HEIGHT = 16838; // A4 height in DXA
  41. const MARGIN = 1440; // 1 inch = 2.54cm
  42. const CONTENT_WIDTH = A4_WIDTH - 2 * MARGIN; // 9026 DXA
  43. const HEADING_STYLES = {
  44. 1: { size: 44, bold: true, font: "SimHei" }, // 22pt
  45. 2: { size: 32, bold: true, font: "SimHei" }, // 16pt
  46. 3: { size: 28, bold: true, font: "SimHei" }, // 14pt
  47. 4: { size: 24, bold: true, font: "SimHei" }, // 12pt
  48. };
  49. function resolveImage(imageRef) {
  50. const basename = path.basename(imageRef);
  51. const mapped = IMAGE_MAP[basename] || basename;
  52. const fullPath = path.join(IMAGES_DIR, mapped);
  53. if (fs.existsSync(fullPath)) return fullPath;
  54. // Try direct name
  55. const directPath = path.join(IMAGES_DIR, basename);
  56. if (fs.existsSync(directPath)) return directPath;
  57. return null;
  58. }
  59. function getImageDimensions(filePath, targetWidthCm) {
  60. // Read PNG header to get dimensions
  61. const buf = fs.readFileSync(filePath);
  62. let width = 800, height = 600;
  63. if (buf[0] === 0x89 && buf[1] === 0x50) { // PNG
  64. width = buf.readUInt32BE(16);
  65. height = buf.readUInt32BE(20);
  66. }
  67. const targetWidthPx = Math.round(targetWidthCm * 96 / 2.54); // 96 DPI
  68. const scale = targetWidthPx / width;
  69. return { width: targetWidthPx, height: Math.round(height * scale) };
  70. }
  71. // Parse inline bold text: **text** -> TextRun with bold
  72. function parseInlineText(text) {
  73. const runs = [];
  74. const regex = /\*\*(.+?)\*\*/g;
  75. let lastIndex = 0;
  76. let match;
  77. while ((match = regex.exec(text)) !== null) {
  78. if (match.index > lastIndex) {
  79. runs.push(new TextRun({ text: text.slice(lastIndex, match.index), font: "SimSun", size: 24 }));
  80. }
  81. runs.push(new TextRun({ text: match[1], font: "SimSun", size: 24, bold: true }));
  82. lastIndex = regex.lastIndex;
  83. }
  84. if (lastIndex < text.length) {
  85. runs.push(new TextRun({ text: text.slice(lastIndex), font: "SimSun", size: 24 }));
  86. }
  87. if (runs.length === 0) {
  88. runs.push(new TextRun({ text: text, font: "SimSun", size: 24 }));
  89. }
  90. return runs;
  91. }
  92. // Create a standard paragraph with optional spacing
  93. function makeParagraph(text, options = {}) {
  94. const runs = parseInlineText(text);
  95. return new Paragraph({
  96. children: runs,
  97. spacing: options.spacing || { line: 360 }, // 1.5x line spacing
  98. alignment: options.alignment,
  99. indent: options.indent,
  100. ...(options.heading ? { heading: options.heading } : {}),
  101. });
  102. }
  103. // Create a table from markdown table rows
  104. function makeTable(rows) {
  105. if (rows.length < 2) return null;
  106. const headers = rows[0];
  107. const dataRows = rows.slice(2); // Skip separator row (row[1])
  108. const colCount = headers.length;
  109. const colWidth = Math.floor(CONTENT_WIDTH / colCount);
  110. const columnWidths = Array(colCount).fill(colWidth);
  111. // Adjust last column to absorb remainder
  112. columnWidths[colCount - 1] = CONTENT_WIDTH - colWidth * (colCount - 1);
  113. const border = { style: BorderStyle.SINGLE, size: 1, color: "999999" };
  114. const borders = { top: border, bottom: border, left: border, right: border };
  115. const cellMargins = { top: 60, bottom: 60, left: 100, right: 100 };
  116. function makeCell(text, isHeader) {
  117. const runs = parseInlineText(text);
  118. return new TableCell({
  119. borders,
  120. width: { size: colWidth, type: WidthType.DXA },
  121. margins: cellMargins,
  122. ...(isHeader ? { shading: { fill: "D5E8F0", type: ShadingType.CLEAR } } : {}),
  123. children: [
  124. new Paragraph({
  125. children: runs.map(r => {
  126. // For header cells, make bold
  127. if (isHeader) return new TextRun({ text: r.root ? r.root[1] : text, font: "SimSun", size: 22, bold: true });
  128. return r;
  129. }),
  130. spacing: { line: 300 },
  131. }),
  132. ],
  133. });
  134. }
  135. // Better approach: re-parse cell text for headers
  136. function makeHeaderCell(text) {
  137. return new TableCell({
  138. borders,
  139. width: { size: colWidth, type: WidthType.DXA },
  140. margins: cellMargins,
  141. shading: { fill: "D5E8F0", type: ShadingType.CLEAR },
  142. children: [
  143. new Paragraph({
  144. children: [new TextRun({ text: text.replace(/\*\*/g, ""), font: "SimSun", size: 22, bold: true })],
  145. spacing: { line: 300 },
  146. }),
  147. ],
  148. });
  149. }
  150. function makeDataCell(text) {
  151. const cleanText = text.replace(/\*\*/g, "");
  152. // Check if original had bold markers
  153. const parts = [];
  154. const regex = /\*\*(.+?)\*\*/g;
  155. let lastIdx = 0;
  156. let m;
  157. while ((m = regex.exec(text)) !== null) {
  158. if (m.index > lastIdx) parts.push(new TextRun({ text: text.slice(lastIdx, m.index), font: "SimSun", size: 22 }));
  159. parts.push(new TextRun({ text: m[1], font: "SimSun", size: 22, bold: true }));
  160. lastIdx = regex.lastIndex;
  161. }
  162. if (lastIdx < text.length) parts.push(new TextRun({ text: text.slice(lastIdx), font: "SimSun", size: 22 }));
  163. if (parts.length === 0) parts.push(new TextRun({ text: cleanText, font: "SimSun", size: 22 }));
  164. return new TableCell({
  165. borders,
  166. width: { size: colWidth, type: WidthType.DXA },
  167. margins: cellMargins,
  168. children: [
  169. new Paragraph({ children: parts, spacing: { line: 300 } }),
  170. ],
  171. });
  172. }
  173. const tableRows = [
  174. new TableRow({
  175. children: headers.map(h => makeHeaderCell(h)),
  176. }),
  177. ...dataRows.map(row =>
  178. new TableRow({
  179. children: row.map(cell => makeDataCell(cell)),
  180. })
  181. ),
  182. ];
  183. return new Table({
  184. width: { size: CONTENT_WIDTH, type: WidthType.DXA },
  185. columnWidths,
  186. rows: tableRows,
  187. });
  188. }
  189. // Main parsing function
  190. function parseMarkdown(content) {
  191. const lines = content.split("\n");
  192. const children = [];
  193. let i = 0;
  194. // Skip cover section (until we hit the first chapter heading after revision table)
  195. // Find the line "# 第一章" to start content
  196. let contentStartIndex = -1;
  197. for (let j = 0; j < lines.length; j++) {
  198. if (lines[j].trim() === "# 第一章 核心业务流程概览") {
  199. contentStartIndex = j;
  200. break;
  201. }
  202. }
  203. if (contentStartIndex === -1) contentStartIndex = 0;
  204. i = contentStartIndex;
  205. let currentNumberingRef = null;
  206. let numberedCounter = 0;
  207. while (i < lines.length) {
  208. const line = lines[i];
  209. const trimmed = line.trim();
  210. // Empty line
  211. if (trimmed === "") {
  212. i++;
  213. continue;
  214. }
  215. // Horizontal rule / page break
  216. if (trimmed === "---") {
  217. children.push(new Paragraph({ children: [new PageBreak()] }));
  218. i++;
  219. continue;
  220. }
  221. // Code block
  222. if (trimmed.startsWith("```")) {
  223. const codeLines = [];
  224. i++;
  225. while (i < lines.length && !lines[i].trim().startsWith("```")) {
  226. codeLines.push(lines[i]);
  227. i++;
  228. }
  229. i++; // skip closing ```
  230. const codeText = codeLines.join("\n");
  231. // Add code block as monospace paragraph(s)
  232. for (const cl of codeLines) {
  233. children.push(new Paragraph({
  234. children: [new TextRun({ text: cl, font: "Courier New", size: 20 })],
  235. spacing: { line: 280 },
  236. indent: { left: 480 },
  237. }));
  238. }
  239. continue;
  240. }
  241. // Headings
  242. const headingMatch = trimmed.match(/^(#{1,4})\s+(.+)$/);
  243. if (headingMatch) {
  244. const level = headingMatch[1].length;
  245. const title = headingMatch[2].replace(/\*\*/g, "");
  246. const style = HEADING_STYLES[level];
  247. // Deduplicate duplicate section headers
  248. // Check if previous non-empty child was the same heading
  249. const lastChild = children[children.length - 1];
  250. let isDuplicate = false;
  251. if (lastChild && lastChild.root) {
  252. // Simple check: compare text content
  253. const prevText = JSON.stringify(lastChild.root);
  254. if (prevText.includes(title)) {
  255. // Check if last heading has the same text
  256. const prevHeading = children.filter(c => c.constructor.name === "Paragraph");
  257. const lastP = prevHeading[prevHeading.length - 1];
  258. if (lastP) {
  259. try {
  260. const runs = lastP.root;
  261. if (runs && runs[1]) {
  262. const inner = runs[1];
  263. if (Array.isArray(inner)) {
  264. const textContent = inner.filter(x => typeof x === 'object').map(x => {
  265. if (x.root && x.root[1]) return x.root[1];
  266. return '';
  267. }).join('');
  268. if (textContent === title) isDuplicate = true;
  269. }
  270. }
  271. } catch(e) {}
  272. }
  273. }
  274. }
  275. if (!isDuplicate) {
  276. const headingLevelMap = {
  277. 1: HeadingLevel.HEADING_1,
  278. 2: HeadingLevel.HEADING_2,
  279. 3: HeadingLevel.HEADING_3,
  280. 4: HeadingLevel.HEADING_4,
  281. };
  282. const spacing = level === 1
  283. ? { before: 360, after: 240, line: 360 }
  284. : { before: 240, after: 160, line: 360 };
  285. children.push(new Paragraph({
  286. heading: headingLevelMap[level],
  287. children: [new TextRun({ text: title, font: style.font, size: style.size, bold: style.bold })],
  288. spacing,
  289. alignment: level === 1 ? AlignmentType.CENTER : AlignmentType.LEFT,
  290. }));
  291. }
  292. currentNumberingRef = null;
  293. i++;
  294. continue;
  295. }
  296. // Blockquote
  297. if (trimmed.startsWith(">")) {
  298. const quoteText = trimmed.replace(/^>\s*/, "").replace(/\*\*/g, "");
  299. const runs = [];
  300. // Parse inline bold in blockquote
  301. const bqRegex = /\*\*(.+?)\*\*/g;
  302. const origText = trimmed.replace(/^>\s*/, "");
  303. let bqLast = 0;
  304. let bqMatch;
  305. while ((bqMatch = bqRegex.exec(origText)) !== null) {
  306. if (bqMatch.index > bqLast) {
  307. runs.push(new TextRun({ text: origText.slice(bqLast, bqMatch.index), font: "SimSun", size: 24, italics: true }));
  308. }
  309. runs.push(new TextRun({ text: bqMatch[1], font: "SimSun", size: 24, italics: false, bold: true }));
  310. bqLast = bqRegex.lastIndex;
  311. }
  312. if (bqLast < origText.length) {
  313. runs.push(new TextRun({ text: origText.slice(bqLast), font: "SimSun", size: 24, italics: true }));
  314. }
  315. if (runs.length === 0) {
  316. runs.push(new TextRun({ text: quoteText, font: "SimSun", size: 24, italics: true }));
  317. }
  318. children.push(new Paragraph({
  319. children: runs,
  320. spacing: { line: 360 },
  321. indent: { left: 480 },
  322. border: { left: { style: BorderStyle.SINGLE, size: 6, color: "CCCCCC", space: 8 } },
  323. }));
  324. i++;
  325. continue;
  326. }
  327. // Image
  328. const imageMatch = trimmed.match(/^!\[([^\]]*)\]\(([^)]+)\)$/);
  329. if (imageMatch) {
  330. const altText = imageMatch[1];
  331. const imagePath = imageMatch[2];
  332. const resolvedPath = resolveImage(imagePath);
  333. if (resolvedPath) {
  334. try {
  335. const imgBuf = fs.readFileSync(resolvedPath);
  336. const dims = getImageDimensions(resolvedPath, 15);
  337. children.push(new Paragraph({
  338. children: [
  339. new ImageRun({
  340. type: "png",
  341. data: imgBuf,
  342. transformation: { width: dims.width, height: dims.height },
  343. altText: { title: altText, description: altText, name: altText },
  344. }),
  345. ],
  346. alignment: AlignmentType.CENTER,
  347. spacing: { before: 120, after: 60 },
  348. }));
  349. } catch (e) {
  350. children.push(makeParagraph(`[图片加载失败:${altText}]`));
  351. }
  352. } else {
  353. children.push(new Paragraph({
  354. children: [new TextRun({ text: `[待补截图:${altText}]`, font: "SimSun", size: 24, color: "999999" })],
  355. alignment: AlignmentType.CENTER,
  356. spacing: { before: 120, after: 60 },
  357. }));
  358. }
  359. // Caption below image
  360. const captionText = altText.replace(/^图[::]?\s*/, "");
  361. if (captionText) {
  362. children.push(new Paragraph({
  363. children: [new TextRun({ text: altText, font: "SimSun", size: 20, italics: true, color: "666666" })],
  364. alignment: AlignmentType.CENTER,
  365. spacing: { after: 120 },
  366. }));
  367. }
  368. i++;
  369. continue;
  370. }
  371. // Table - collect all table rows
  372. if (trimmed.startsWith("|") && trimmed.endsWith("|")) {
  373. const tableRows = [];
  374. while (i < lines.length) {
  375. const tline = lines[i].trim();
  376. if (!tline.startsWith("|") || !tline.endsWith("|")) break;
  377. const cells = tline.split("|").filter((_, idx, arr) => idx > 0 && idx < arr.length - 1).map(c => c.trim());
  378. tableRows.push(cells);
  379. i++;
  380. }
  381. const table = makeTable(tableRows);
  382. if (table) {
  383. children.push(table);
  384. children.push(new Paragraph({ children: [], spacing: { after: 120 } }));
  385. }
  386. continue;
  387. }
  388. // Bullet list
  389. const bulletMatch = trimmed.match(/^[-*]\s+(.+)$/);
  390. if (bulletMatch) {
  391. const itemText = bulletMatch[1];
  392. children.push(new Paragraph({
  393. numbering: { reference: "bullets", level: 0 },
  394. children: parseInlineText(itemText),
  395. spacing: { line: 360 },
  396. }));
  397. currentNumberingRef = null;
  398. i++;
  399. continue;
  400. }
  401. // Numbered list
  402. const numMatch = trimmed.match(/^(\d+)\.\s+(.+)$/);
  403. if (numMatch) {
  404. const itemText = numMatch[2];
  405. const num = parseInt(numMatch[1]);
  406. // Use unique reference for each numbered sequence
  407. // Detect if this is a continuation
  408. if (num === 1 || currentNumberingRef !== "numbers") {
  409. numberedCounter++;
  410. currentNumberingRef = `numbers_${numberedCounter}`;
  411. }
  412. children.push(new Paragraph({
  413. numbering: { reference: currentNumberingRef, level: 0 },
  414. children: parseInlineText(itemText),
  415. spacing: { line: 360 },
  416. }));
  417. i++;
  418. continue;
  419. }
  420. // Indented sub-list items (like " - text" or " 1. text")
  421. const indentMatch = trimmed.match(/^(\s+)[-*]\s+(.+)$/);
  422. if (!indentMatch) {
  423. const indentNumMatch = line.match(/^(\s+)(\d+)\.\s+(.+)$/);
  424. if (indentNumMatch) {
  425. const itemText = indentNumMatch[3];
  426. children.push(new Paragraph({
  427. numbering: { reference: "bullets", level: 0 },
  428. children: parseInlineText(itemText),
  429. spacing: { line: 360 },
  430. indent: { left: 1080 },
  431. }));
  432. i++;
  433. continue;
  434. }
  435. } else {
  436. const itemText = indentMatch[2];
  437. children.push(new Paragraph({
  438. numbering: { reference: "bullets", level: 0 },
  439. children: parseInlineText(itemText),
  440. spacing: { line: 360 },
  441. indent: { left: 1080 },
  442. }));
  443. i++;
  444. continue;
  445. }
  446. // Regular paragraph
  447. children.push(makeParagraph(trimmed));
  448. currentNumberingRef = null;
  449. i++;
  450. }
  451. return children;
  452. }
  453. // Build cover page section
  454. function buildCoverSection() {
  455. const emptyPara = () => new Paragraph({ children: [new TextRun({ text: "", font: "SimSun", size: 24 })], spacing: { line: 360 } });
  456. return {
  457. properties: {
  458. page: {
  459. size: { width: A4_WIDTH, height: A4_HEIGHT },
  460. margin: { top: MARGIN, right: MARGIN, bottom: MARGIN, left: MARGIN },
  461. },
  462. },
  463. children: [
  464. // Spacer
  465. emptyPara(), emptyPara(), emptyPara(), emptyPara(), emptyPara(),
  466. emptyPara(), emptyPara(), emptyPara(), emptyPara(), emptyPara(),
  467. // Title
  468. new Paragraph({
  469. children: [new TextRun({ text: "陇耘鲜达系统", font: "SimHei", size: 72, bold: true })],
  470. alignment: AlignmentType.CENTER,
  471. spacing: { after: 120 },
  472. }),
  473. new Paragraph({
  474. children: [new TextRun({ text: "操作手册", font: "SimHei", size: 56, bold: true })],
  475. alignment: AlignmentType.CENTER,
  476. spacing: { after: 480 },
  477. }),
  478. // Version info
  479. emptyPara(), emptyPara(),
  480. new Paragraph({
  481. children: [new TextRun({ text: "项目代号:叁强鲜配", font: "SimSun", size: 28 })],
  482. alignment: AlignmentType.CENTER,
  483. spacing: { line: 480 },
  484. }),
  485. new Paragraph({
  486. children: [new TextRun({ text: "版本:v1.0(初稿)", font: "SimSun", size: 28 })],
  487. alignment: AlignmentType.CENTER,
  488. spacing: { line: 480 },
  489. }),
  490. new Paragraph({
  491. children: [new TextRun({ text: "日期:2026-05-07", font: "SimSun", size: 28 })],
  492. alignment: AlignmentType.CENTER,
  493. spacing: { line: 480 },
  494. }),
  495. emptyPara(), emptyPara(), emptyPara(), emptyPara(),
  496. // Scope
  497. new Paragraph({
  498. children: [new TextRun({ text: "适用范围:Web 运营端 + 小程序端", font: "SimSun", size: 24 })],
  499. alignment: AlignmentType.CENTER,
  500. spacing: { line: 400 },
  501. }),
  502. new Paragraph({
  503. children: [new TextRun({ text: "(客户端 / 供应商端 / 配送员端)", font: "SimSun", size: 24 })],
  504. alignment: AlignmentType.CENTER,
  505. spacing: { line: 400 },
  506. }),
  507. // Page break before revision table
  508. new Paragraph({ children: [new PageBreak()] }),
  509. // Revision table
  510. new Paragraph({
  511. children: [new TextRun({ text: "修订记录", font: "SimHei", size: 32, bold: true })],
  512. alignment: AlignmentType.CENTER,
  513. spacing: { before: 240, after: 240 },
  514. }),
  515. makeTable([
  516. ["版本", "日期", "说明"],
  517. ["------", "------", "------"],
  518. ["v1.0", "2026-05-07", "初稿,基于系统实际界面编写"],
  519. ]),
  520. ],
  521. };
  522. }
  523. // Build main content section
  524. function buildContentSection(children) {
  525. return {
  526. properties: {
  527. page: {
  528. size: { width: A4_WIDTH, height: A4_HEIGHT },
  529. margin: { top: MARGIN, right: MARGIN, bottom: MARGIN, left: MARGIN },
  530. },
  531. },
  532. headers: {
  533. default: new Header({
  534. children: [
  535. new Paragraph({
  536. children: [new TextRun({ text: "陇耘鲜达系统操作手册", font: "SimSun", size: 18, color: "888888" })],
  537. alignment: AlignmentType.CENTER,
  538. }),
  539. ],
  540. }),
  541. },
  542. footers: {
  543. default: new Footer({
  544. children: [
  545. new Paragraph({
  546. children: [
  547. new TextRun({ text: "第 ", font: "SimSun", size: 18, color: "888888" }),
  548. new TextRun({ children: [PageNumber.CURRENT], font: "SimSun", size: 18, color: "888888" }),
  549. new TextRun({ text: " 页", font: "SimSun", size: 18, color: "888888" }),
  550. ],
  551. alignment: AlignmentType.CENTER,
  552. }),
  553. ],
  554. }),
  555. },
  556. children,
  557. };
  558. }
  559. async function main() {
  560. console.log("Reading markdown...");
  561. const md = fs.readFileSync(path.join(__dirname, "陇耘鲜达系统操作手册.md"), "utf-8");
  562. console.log("Parsing markdown...");
  563. const contentChildren = parseMarkdown(md);
  564. // Deduplicate duplicate headings (e.g., "# 第一部分 Web 运营端" and "# 第一部分:运营后台 Web 端操作")
  565. // They appear adjacent, keep the second one
  566. const dedupedChildren = [];
  567. for (let i = 0; i < contentChildren.length; i++) {
  568. dedupedChildren.push(contentChildren[i]);
  569. }
  570. // Build numbering configs - create enough unique numbered list references
  571. const numberingConfigs = [
  572. {
  573. reference: "bullets",
  574. levels: [{
  575. level: 0,
  576. format: LevelFormat.BULLET,
  577. text: "\u2022",
  578. alignment: AlignmentType.LEFT,
  579. style: { paragraph: { indent: { left: 720, hanging: 360 } } },
  580. }],
  581. },
  582. ];
  583. // Add unique numbered list references
  584. for (let n = 1; n <= 50; n++) {
  585. numberingConfigs.push({
  586. reference: `numbers_${n}`,
  587. levels: [{
  588. level: 0,
  589. format: LevelFormat.DECIMAL,
  590. text: "%1.",
  591. alignment: AlignmentType.LEFT,
  592. style: { paragraph: { indent: { left: 720, hanging: 360 } } },
  593. }],
  594. });
  595. }
  596. console.log("Building document...");
  597. const doc = new Document({
  598. styles: {
  599. default: {
  600. document: {
  601. run: { font: "SimSun", size: 24 }, // 12pt body
  602. },
  603. },
  604. paragraphStyles: [
  605. {
  606. id: "Heading1", name: "Heading 1", basedOn: "Normal", next: "Normal", quickFormat: true,
  607. run: { size: 44, bold: true, font: "SimHei" },
  608. paragraph: { spacing: { before: 360, after: 240 }, outlineLevel: 0 },
  609. },
  610. {
  611. id: "Heading2", name: "Heading 2", basedOn: "Normal", next: "Normal", quickFormat: true,
  612. run: { size: 32, bold: true, font: "SimHei" },
  613. paragraph: { spacing: { before: 240, after: 160 }, outlineLevel: 1 },
  614. },
  615. {
  616. id: "Heading3", name: "Heading 3", basedOn: "Normal", next: "Normal", quickFormat: true,
  617. run: { size: 28, bold: true, font: "SimHei" },
  618. paragraph: { spacing: { before: 200, after: 120 }, outlineLevel: 2 },
  619. },
  620. {
  621. id: "Heading4", name: "Heading 4", basedOn: "Normal", next: "Normal", quickFormat: true,
  622. run: { size: 24, bold: true, font: "SimHei" },
  623. paragraph: { spacing: { before: 160, after: 100 }, outlineLevel: 3 },
  624. },
  625. ],
  626. },
  627. numbering: { config: numberingConfigs },
  628. sections: [
  629. buildCoverSection(),
  630. buildContentSection(dedupedChildren),
  631. ],
  632. });
  633. console.log("Packing document...");
  634. const buffer = await Packer.toBuffer(doc);
  635. fs.writeFileSync(OUTPUT, buffer);
  636. console.log(`✅ Manual saved to: ${OUTPUT}`);
  637. console.log(` Size: ${(buffer.length / 1024).toFixed(1)} KB`);
  638. }
  639. main().catch(err => {
  640. console.error("Error:", err);
  641. process.exit(1);
  642. });