const fs = require("fs"); const path = require("path"); const { Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell, ImageRun, Header, Footer, AlignmentType, LevelFormat, HeadingLevel, BorderStyle, WidthType, ShadingType, PageNumber, PageBreak, } = require("docx"); const IMAGES_DIR = path.join(__dirname, "images"); const OUTPUT = path.join(__dirname, "陇耘鲜达系统操作手册.docx"); // Image filename mapping: markdown reference → actual file const IMAGE_MAP = { "web-login.png": "web-dashboard.png", // no web-login, closest is dashboard "web-dashboard.png": "web-dashboard.png", "web-region-list.png": "web-region.png", "web-user-list.png": "web-user.png", "web-parameter-list.png": "web-parameter.png", "web-unit-list.png": "web-unit.png", "web-category-list.png": "web-category.png", "web-product-list.png": "web-product.png", "web-inquiry-list.png": "web-inquiry.png", "web-inquiry-compare.png": "web-inquiry.png", "web-order-list.png": "web-order-list.png", "web-supplier-settlement-pending.png": "web-supplier-settlement.png", "web-client-settlement-pending.png": "web-client-settlement.png", "web-delivery-list.png": "web-delivery-list.png", "web-delivery-warning.png": "web-delivery-warning.png", "web-system-config.png": "web-system-config.png", "web-api-log.png": "web-api-log.png", "mini-login.png": "mini-client-home.png", // no mini-login, use client home "mini-client-home.png": "mini-client-home.png", "mini-client-order.png": "mini-client-order.png", "mini-client-cart.png": "mini-client-cart.png", "mini-client-profile.png": "mini-client-profile.png", "mini-supplier-home.png": "mini-supplier-home.png", "mini-deliverer-home.png": "mini-deliverer-home.png", "mini-deliverer-task.png": "mini-deliverer-task.png", }; // Constants const A4_WIDTH = 11906; // A4 width in DXA const A4_HEIGHT = 16838; // A4 height in DXA const MARGIN = 1440; // 1 inch = 2.54cm const CONTENT_WIDTH = A4_WIDTH - 2 * MARGIN; // 9026 DXA const HEADING_STYLES = { 1: { size: 44, bold: true, font: "SimHei" }, // 22pt 2: { size: 32, bold: true, font: "SimHei" }, // 16pt 3: { size: 28, bold: true, font: "SimHei" }, // 14pt 4: { size: 24, bold: true, font: "SimHei" }, // 12pt }; function resolveImage(imageRef) { const basename = path.basename(imageRef); const mapped = IMAGE_MAP[basename] || basename; const fullPath = path.join(IMAGES_DIR, mapped); if (fs.existsSync(fullPath)) return fullPath; // Try direct name const directPath = path.join(IMAGES_DIR, basename); if (fs.existsSync(directPath)) return directPath; return null; } function getImageDimensions(filePath, targetWidthCm) { // Read PNG header to get dimensions const buf = fs.readFileSync(filePath); let width = 800, height = 600; if (buf[0] === 0x89 && buf[1] === 0x50) { // PNG width = buf.readUInt32BE(16); height = buf.readUInt32BE(20); } const targetWidthPx = Math.round(targetWidthCm * 96 / 2.54); // 96 DPI const scale = targetWidthPx / width; return { width: targetWidthPx, height: Math.round(height * scale) }; } // Parse inline bold text: **text** -> TextRun with bold function parseInlineText(text) { const runs = []; const regex = /\*\*(.+?)\*\*/g; let lastIndex = 0; let match; while ((match = regex.exec(text)) !== null) { if (match.index > lastIndex) { runs.push(new TextRun({ text: text.slice(lastIndex, match.index), font: "SimSun", size: 24 })); } runs.push(new TextRun({ text: match[1], font: "SimSun", size: 24, bold: true })); lastIndex = regex.lastIndex; } if (lastIndex < text.length) { runs.push(new TextRun({ text: text.slice(lastIndex), font: "SimSun", size: 24 })); } if (runs.length === 0) { runs.push(new TextRun({ text: text, font: "SimSun", size: 24 })); } return runs; } // Create a standard paragraph with optional spacing function makeParagraph(text, options = {}) { const runs = parseInlineText(text); return new Paragraph({ children: runs, spacing: options.spacing || { line: 360 }, // 1.5x line spacing alignment: options.alignment, indent: options.indent, ...(options.heading ? { heading: options.heading } : {}), }); } // Create a table from markdown table rows function makeTable(rows) { if (rows.length < 2) return null; const headers = rows[0]; const dataRows = rows.slice(2); // Skip separator row (row[1]) const colCount = headers.length; const colWidth = Math.floor(CONTENT_WIDTH / colCount); const columnWidths = Array(colCount).fill(colWidth); // Adjust last column to absorb remainder columnWidths[colCount - 1] = CONTENT_WIDTH - colWidth * (colCount - 1); const border = { style: BorderStyle.SINGLE, size: 1, color: "999999" }; const borders = { top: border, bottom: border, left: border, right: border }; const cellMargins = { top: 60, bottom: 60, left: 100, right: 100 }; function makeCell(text, isHeader) { const runs = parseInlineText(text); return new TableCell({ borders, width: { size: colWidth, type: WidthType.DXA }, margins: cellMargins, ...(isHeader ? { shading: { fill: "D5E8F0", type: ShadingType.CLEAR } } : {}), children: [ new Paragraph({ children: runs.map(r => { // For header cells, make bold if (isHeader) return new TextRun({ text: r.root ? r.root[1] : text, font: "SimSun", size: 22, bold: true }); return r; }), spacing: { line: 300 }, }), ], }); } // Better approach: re-parse cell text for headers function makeHeaderCell(text) { return new TableCell({ borders, width: { size: colWidth, type: WidthType.DXA }, margins: cellMargins, shading: { fill: "D5E8F0", type: ShadingType.CLEAR }, children: [ new Paragraph({ children: [new TextRun({ text: text.replace(/\*\*/g, ""), font: "SimSun", size: 22, bold: true })], spacing: { line: 300 }, }), ], }); } function makeDataCell(text) { const cleanText = text.replace(/\*\*/g, ""); // Check if original had bold markers const parts = []; const regex = /\*\*(.+?)\*\*/g; let lastIdx = 0; let m; while ((m = regex.exec(text)) !== null) { if (m.index > lastIdx) parts.push(new TextRun({ text: text.slice(lastIdx, m.index), font: "SimSun", size: 22 })); parts.push(new TextRun({ text: m[1], font: "SimSun", size: 22, bold: true })); lastIdx = regex.lastIndex; } if (lastIdx < text.length) parts.push(new TextRun({ text: text.slice(lastIdx), font: "SimSun", size: 22 })); if (parts.length === 0) parts.push(new TextRun({ text: cleanText, font: "SimSun", size: 22 })); return new TableCell({ borders, width: { size: colWidth, type: WidthType.DXA }, margins: cellMargins, children: [ new Paragraph({ children: parts, spacing: { line: 300 } }), ], }); } const tableRows = [ new TableRow({ children: headers.map(h => makeHeaderCell(h)), }), ...dataRows.map(row => new TableRow({ children: row.map(cell => makeDataCell(cell)), }) ), ]; return new Table({ width: { size: CONTENT_WIDTH, type: WidthType.DXA }, columnWidths, rows: tableRows, }); } // Main parsing function function parseMarkdown(content) { const lines = content.split("\n"); const children = []; let i = 0; // Skip cover section (until we hit the first chapter heading after revision table) // Find the line "# 第一章" to start content let contentStartIndex = -1; for (let j = 0; j < lines.length; j++) { if (lines[j].trim() === "# 第一章 核心业务流程概览") { contentStartIndex = j; break; } } if (contentStartIndex === -1) contentStartIndex = 0; i = contentStartIndex; let currentNumberingRef = null; let numberedCounter = 0; while (i < lines.length) { const line = lines[i]; const trimmed = line.trim(); // Empty line if (trimmed === "") { i++; continue; } // Horizontal rule / page break if (trimmed === "---") { children.push(new Paragraph({ children: [new PageBreak()] })); i++; continue; } // Code block if (trimmed.startsWith("```")) { const codeLines = []; i++; while (i < lines.length && !lines[i].trim().startsWith("```")) { codeLines.push(lines[i]); i++; } i++; // skip closing ``` const codeText = codeLines.join("\n"); // Add code block as monospace paragraph(s) for (const cl of codeLines) { children.push(new Paragraph({ children: [new TextRun({ text: cl, font: "Courier New", size: 20 })], spacing: { line: 280 }, indent: { left: 480 }, })); } continue; } // Headings const headingMatch = trimmed.match(/^(#{1,4})\s+(.+)$/); if (headingMatch) { const level = headingMatch[1].length; const title = headingMatch[2].replace(/\*\*/g, ""); const style = HEADING_STYLES[level]; // Deduplicate duplicate section headers // Check if previous non-empty child was the same heading const lastChild = children[children.length - 1]; let isDuplicate = false; if (lastChild && lastChild.root) { // Simple check: compare text content const prevText = JSON.stringify(lastChild.root); if (prevText.includes(title)) { // Check if last heading has the same text const prevHeading = children.filter(c => c.constructor.name === "Paragraph"); const lastP = prevHeading[prevHeading.length - 1]; if (lastP) { try { const runs = lastP.root; if (runs && runs[1]) { const inner = runs[1]; if (Array.isArray(inner)) { const textContent = inner.filter(x => typeof x === 'object').map(x => { if (x.root && x.root[1]) return x.root[1]; return ''; }).join(''); if (textContent === title) isDuplicate = true; } } } catch(e) {} } } } if (!isDuplicate) { const headingLevelMap = { 1: HeadingLevel.HEADING_1, 2: HeadingLevel.HEADING_2, 3: HeadingLevel.HEADING_3, 4: HeadingLevel.HEADING_4, }; const spacing = level === 1 ? { before: 360, after: 240, line: 360 } : { before: 240, after: 160, line: 360 }; children.push(new Paragraph({ heading: headingLevelMap[level], children: [new TextRun({ text: title, font: style.font, size: style.size, bold: style.bold })], spacing, alignment: level === 1 ? AlignmentType.CENTER : AlignmentType.LEFT, })); } currentNumberingRef = null; i++; continue; } // Blockquote if (trimmed.startsWith(">")) { const quoteText = trimmed.replace(/^>\s*/, "").replace(/\*\*/g, ""); const runs = []; // Parse inline bold in blockquote const bqRegex = /\*\*(.+?)\*\*/g; const origText = trimmed.replace(/^>\s*/, ""); let bqLast = 0; let bqMatch; while ((bqMatch = bqRegex.exec(origText)) !== null) { if (bqMatch.index > bqLast) { runs.push(new TextRun({ text: origText.slice(bqLast, bqMatch.index), font: "SimSun", size: 24, italics: true })); } runs.push(new TextRun({ text: bqMatch[1], font: "SimSun", size: 24, italics: false, bold: true })); bqLast = bqRegex.lastIndex; } if (bqLast < origText.length) { runs.push(new TextRun({ text: origText.slice(bqLast), font: "SimSun", size: 24, italics: true })); } if (runs.length === 0) { runs.push(new TextRun({ text: quoteText, font: "SimSun", size: 24, italics: true })); } children.push(new Paragraph({ children: runs, spacing: { line: 360 }, indent: { left: 480 }, border: { left: { style: BorderStyle.SINGLE, size: 6, color: "CCCCCC", space: 8 } }, })); i++; continue; } // Image const imageMatch = trimmed.match(/^!\[([^\]]*)\]\(([^)]+)\)$/); if (imageMatch) { const altText = imageMatch[1]; const imagePath = imageMatch[2]; const resolvedPath = resolveImage(imagePath); if (resolvedPath) { try { const imgBuf = fs.readFileSync(resolvedPath); const dims = getImageDimensions(resolvedPath, 15); children.push(new Paragraph({ children: [ new ImageRun({ type: "png", data: imgBuf, transformation: { width: dims.width, height: dims.height }, altText: { title: altText, description: altText, name: altText }, }), ], alignment: AlignmentType.CENTER, spacing: { before: 120, after: 60 }, })); } catch (e) { children.push(makeParagraph(`[图片加载失败:${altText}]`)); } } else { children.push(new Paragraph({ children: [new TextRun({ text: `[待补截图:${altText}]`, font: "SimSun", size: 24, color: "999999" })], alignment: AlignmentType.CENTER, spacing: { before: 120, after: 60 }, })); } // Caption below image const captionText = altText.replace(/^图[::]?\s*/, ""); if (captionText) { children.push(new Paragraph({ children: [new TextRun({ text: altText, font: "SimSun", size: 20, italics: true, color: "666666" })], alignment: AlignmentType.CENTER, spacing: { after: 120 }, })); } i++; continue; } // Table - collect all table rows if (trimmed.startsWith("|") && trimmed.endsWith("|")) { const tableRows = []; while (i < lines.length) { const tline = lines[i].trim(); if (!tline.startsWith("|") || !tline.endsWith("|")) break; const cells = tline.split("|").filter((_, idx, arr) => idx > 0 && idx < arr.length - 1).map(c => c.trim()); tableRows.push(cells); i++; } const table = makeTable(tableRows); if (table) { children.push(table); children.push(new Paragraph({ children: [], spacing: { after: 120 } })); } continue; } // Bullet list const bulletMatch = trimmed.match(/^[-*]\s+(.+)$/); if (bulletMatch) { const itemText = bulletMatch[1]; children.push(new Paragraph({ numbering: { reference: "bullets", level: 0 }, children: parseInlineText(itemText), spacing: { line: 360 }, })); currentNumberingRef = null; i++; continue; } // Numbered list const numMatch = trimmed.match(/^(\d+)\.\s+(.+)$/); if (numMatch) { const itemText = numMatch[2]; const num = parseInt(numMatch[1]); // Use unique reference for each numbered sequence // Detect if this is a continuation if (num === 1 || currentNumberingRef !== "numbers") { numberedCounter++; currentNumberingRef = `numbers_${numberedCounter}`; } children.push(new Paragraph({ numbering: { reference: currentNumberingRef, level: 0 }, children: parseInlineText(itemText), spacing: { line: 360 }, })); i++; continue; } // Indented sub-list items (like " - text" or " 1. text") const indentMatch = trimmed.match(/^(\s+)[-*]\s+(.+)$/); if (!indentMatch) { const indentNumMatch = line.match(/^(\s+)(\d+)\.\s+(.+)$/); if (indentNumMatch) { const itemText = indentNumMatch[3]; children.push(new Paragraph({ numbering: { reference: "bullets", level: 0 }, children: parseInlineText(itemText), spacing: { line: 360 }, indent: { left: 1080 }, })); i++; continue; } } else { const itemText = indentMatch[2]; children.push(new Paragraph({ numbering: { reference: "bullets", level: 0 }, children: parseInlineText(itemText), spacing: { line: 360 }, indent: { left: 1080 }, })); i++; continue; } // Regular paragraph children.push(makeParagraph(trimmed)); currentNumberingRef = null; i++; } return children; } // Build cover page section function buildCoverSection() { const emptyPara = () => new Paragraph({ children: [new TextRun({ text: "", font: "SimSun", size: 24 })], spacing: { line: 360 } }); return { properties: { page: { size: { width: A4_WIDTH, height: A4_HEIGHT }, margin: { top: MARGIN, right: MARGIN, bottom: MARGIN, left: MARGIN }, }, }, children: [ // Spacer emptyPara(), emptyPara(), emptyPara(), emptyPara(), emptyPara(), emptyPara(), emptyPara(), emptyPara(), emptyPara(), emptyPara(), // Title new Paragraph({ children: [new TextRun({ text: "陇耘鲜达系统", font: "SimHei", size: 72, bold: true })], alignment: AlignmentType.CENTER, spacing: { after: 120 }, }), new Paragraph({ children: [new TextRun({ text: "操作手册", font: "SimHei", size: 56, bold: true })], alignment: AlignmentType.CENTER, spacing: { after: 480 }, }), // Version info emptyPara(), emptyPara(), new Paragraph({ children: [new TextRun({ text: "项目代号:叁强鲜配", font: "SimSun", size: 28 })], alignment: AlignmentType.CENTER, spacing: { line: 480 }, }), new Paragraph({ children: [new TextRun({ text: "版本:v1.0(初稿)", font: "SimSun", size: 28 })], alignment: AlignmentType.CENTER, spacing: { line: 480 }, }), new Paragraph({ children: [new TextRun({ text: "日期:2026-05-07", font: "SimSun", size: 28 })], alignment: AlignmentType.CENTER, spacing: { line: 480 }, }), emptyPara(), emptyPara(), emptyPara(), emptyPara(), // Scope new Paragraph({ children: [new TextRun({ text: "适用范围:Web 运营端 + 小程序端", font: "SimSun", size: 24 })], alignment: AlignmentType.CENTER, spacing: { line: 400 }, }), new Paragraph({ children: [new TextRun({ text: "(客户端 / 供应商端 / 配送员端)", font: "SimSun", size: 24 })], alignment: AlignmentType.CENTER, spacing: { line: 400 }, }), // Page break before revision table new Paragraph({ children: [new PageBreak()] }), // Revision table new Paragraph({ children: [new TextRun({ text: "修订记录", font: "SimHei", size: 32, bold: true })], alignment: AlignmentType.CENTER, spacing: { before: 240, after: 240 }, }), makeTable([ ["版本", "日期", "说明"], ["------", "------", "------"], ["v1.0", "2026-05-07", "初稿,基于系统实际界面编写"], ]), ], }; } // Build main content section function buildContentSection(children) { return { properties: { page: { size: { width: A4_WIDTH, height: A4_HEIGHT }, margin: { top: MARGIN, right: MARGIN, bottom: MARGIN, left: MARGIN }, }, }, headers: { default: new Header({ children: [ new Paragraph({ children: [new TextRun({ text: "陇耘鲜达系统操作手册", font: "SimSun", size: 18, color: "888888" })], alignment: AlignmentType.CENTER, }), ], }), }, footers: { default: new Footer({ children: [ new Paragraph({ children: [ new TextRun({ text: "第 ", font: "SimSun", size: 18, color: "888888" }), new TextRun({ children: [PageNumber.CURRENT], font: "SimSun", size: 18, color: "888888" }), new TextRun({ text: " 页", font: "SimSun", size: 18, color: "888888" }), ], alignment: AlignmentType.CENTER, }), ], }), }, children, }; } async function main() { console.log("Reading markdown..."); const md = fs.readFileSync(path.join(__dirname, "陇耘鲜达系统操作手册.md"), "utf-8"); console.log("Parsing markdown..."); const contentChildren = parseMarkdown(md); // Deduplicate duplicate headings (e.g., "# 第一部分 Web 运营端" and "# 第一部分:运营后台 Web 端操作") // They appear adjacent, keep the second one const dedupedChildren = []; for (let i = 0; i < contentChildren.length; i++) { dedupedChildren.push(contentChildren[i]); } // Build numbering configs - create enough unique numbered list references const numberingConfigs = [ { reference: "bullets", levels: [{ level: 0, format: LevelFormat.BULLET, text: "\u2022", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 720, hanging: 360 } } }, }], }, ]; // Add unique numbered list references for (let n = 1; n <= 50; n++) { numberingConfigs.push({ reference: `numbers_${n}`, levels: [{ level: 0, format: LevelFormat.DECIMAL, text: "%1.", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 720, hanging: 360 } } }, }], }); } console.log("Building document..."); const doc = new Document({ styles: { default: { document: { run: { font: "SimSun", size: 24 }, // 12pt body }, }, paragraphStyles: [ { id: "Heading1", name: "Heading 1", basedOn: "Normal", next: "Normal", quickFormat: true, run: { size: 44, bold: true, font: "SimHei" }, paragraph: { spacing: { before: 360, after: 240 }, outlineLevel: 0 }, }, { id: "Heading2", name: "Heading 2", basedOn: "Normal", next: "Normal", quickFormat: true, run: { size: 32, bold: true, font: "SimHei" }, paragraph: { spacing: { before: 240, after: 160 }, outlineLevel: 1 }, }, { id: "Heading3", name: "Heading 3", basedOn: "Normal", next: "Normal", quickFormat: true, run: { size: 28, bold: true, font: "SimHei" }, paragraph: { spacing: { before: 200, after: 120 }, outlineLevel: 2 }, }, { id: "Heading4", name: "Heading 4", basedOn: "Normal", next: "Normal", quickFormat: true, run: { size: 24, bold: true, font: "SimHei" }, paragraph: { spacing: { before: 160, after: 100 }, outlineLevel: 3 }, }, ], }, numbering: { config: numberingConfigs }, sections: [ buildCoverSection(), buildContentSection(dedupedChildren), ], }); console.log("Packing document..."); const buffer = await Packer.toBuffer(doc); fs.writeFileSync(OUTPUT, buffer); console.log(`✅ Manual saved to: ${OUTPUT}`); console.log(` Size: ${(buffer.length / 1024).toFixed(1)} KB`); } main().catch(err => { console.error("Error:", err); process.exit(1); });