const fs = require('fs') const path = require('path') const JSZip = require('jszip') const { DOMParser } = require('@xmldom/xmldom') const { PNG } = require('pngjs') const root = path.resolve(__dirname, '..', '..') const docxPath = process.env.MES_MANUAL_DOCX ? path.resolve(process.env.MES_MANUAL_DOCX) : path.resolve(root, 'doc', '大连元利流体技术有限公司MES系统用户操作手册_V1.1_2026-07-23.docx') const catalog = JSON.parse(fs.readFileSync(path.resolve(__dirname, 'full-page-catalog.json'), 'utf8')) const dialogAudit = JSON.parse(fs.readFileSync(path.resolve(__dirname, 'manual-dialog-audit.json'), 'utf8')) const screenshotsDir = path.resolve(__dirname, 'full-screenshots') function localName(node) { return node && (node.localName || String(node.nodeName || '').split(':').pop()) } function elementChildren(node, name) { if (!node || !node.childNodes) return [] return [...node.childNodes].filter(child => child.nodeType === 1 && (!name || localName(child) === name)) } function descendants(node, name, output = []) { for (const child of elementChildren(node)) { if (!name || localName(child) === name) output.push(child) descendants(child, name, output) } return output } function textOf(node) { return descendants(node, 't').map(item => item.textContent || '').join('').trim() } function firstDescendant(node, name) { return descendants(node, name, [])[0] || null } function fail(message) { throw new Error(message) } function imagesMatch(actual, expected) { if (actual.equals(expected)) return true const actualPng = PNG.sync.read(actual) const expectedPng = PNG.sync.read(expected) if (actualPng.width === expectedPng.width && actualPng.height === expectedPng.height) { return actualPng.data.equals(expectedPng.data) } const actualRatio = actualPng.width / actualPng.height const expectedRatio = expectedPng.width / expectedPng.height if (Math.abs(actualRatio - expectedRatio) > 0.002) return false // WPS downsamples embedded PNGs to their displayed size when saving. Compare // sampled RGB values against a bilinear resize of the captured source image. let difference = 0 let samples = 0 const stride = 2 for (let y = 0; y < actualPng.height; y += stride) { const sourceY = (y + 0.5) * expectedPng.height / actualPng.height - 0.5 const y0 = Math.max(0, Math.floor(sourceY)) const y1 = Math.min(expectedPng.height - 1, y0 + 1) const fractionY = sourceY - y0 for (let x = 0; x < actualPng.width; x += stride) { const sourceX = (x + 0.5) * expectedPng.width / actualPng.width - 0.5 const x0 = Math.max(0, Math.floor(sourceX)) const x1 = Math.min(expectedPng.width - 1, x0 + 1) const fractionX = sourceX - x0 for (let channel = 0; channel < 3; channel++) { const topLeft = expectedPng.data[(y0 * expectedPng.width + x0) * 4 + channel] const topRight = expectedPng.data[(y0 * expectedPng.width + x1) * 4 + channel] const bottomLeft = expectedPng.data[(y1 * expectedPng.width + x0) * 4 + channel] const bottomRight = expectedPng.data[(y1 * expectedPng.width + x1) * 4 + channel] const expectedValue = (topLeft * (1 - fractionX) + topRight * fractionX) * (1 - fractionY) + (bottomLeft * (1 - fractionX) + bottomRight * fractionX) * fractionY difference += Math.abs(actualPng.data[(y * actualPng.width + x) * 4 + channel] - expectedValue) samples++ } } } return difference / samples < 2 } function main() { const zip = new JSZip(fs.readFileSync(docxPath)) const parser = new DOMParser() const document = parser.parseFromString(zip.file('word/document.xml').asText(), 'application/xml') const relationships = parser.parseFromString(zip.file('word/_rels/document.xml.rels').asText(), 'application/xml') const body = firstDescendant(document.documentElement, 'body') const bodyChildren = elementChildren(body) const fullText = textOf(document.documentElement) const relationshipTargets = new Map() for (const relationship of elementChildren(relationships.documentElement, 'Relationship')) { relationshipTargets.set(relationship.getAttribute('Id'), relationship.getAttribute('Target').replace(/^\.\//, '')) } const chapterByModule = { '设备管理': 7, '研发管理': 8, '工艺管理': 9, '计划排产': 10, '生产管理': 11, '异常提醒': 12, '质量管理': 13, '销售订单管理': 14 } let verifiedScreenshots = 0 let verifiedActionTables = 0 for (const page of catalog) { const section = `${chapterByModule[page.module]}.${page.pageIndex}` const pageStart = bodyChildren.findIndex(node => localName(node) === 'p' && textOf(node) === `${section} ${page.title}`) if (pageStart < 0) fail(`页面标题缺失:${section} ${page.title}`) let pageEnd = bodyChildren.findIndex((node, index) => index > pageStart && localName(node) === 'p' && /^\d+\.\d+ /.test(textOf(node))) if (pageEnd < 0) pageEnd = bodyChildren.length const imageParagraph = bodyChildren.slice(pageStart, pageEnd).find(node => localName(node) === 'p' && firstDescendant(node, 'blip')) if (!imageParagraph) fail(`页面截图缺失:${section} ${page.title}`) const blip = firstDescendant(imageParagraph, 'blip') const relationshipId = blip.getAttribute('r:embed') || blip.getAttributeNS('http://schemas.openxmlformats.org/officeDocument/2006/relationships', 'embed') const target = relationshipTargets.get(relationshipId) if (!target || !zip.file(`word/${target}`)) fail(`页面截图关系缺失:${section} ${page.title}`) const embedded = zip.file(`word/${target}`).asNodeBuffer() const expected = fs.readFileSync(path.resolve(screenshotsDir, page.screenshot)) if (!imagesMatch(embedded, expected)) fail(`页面截图未更新:${section} ${page.title}(${target})`) verifiedScreenshots++ const actionHeadingIndex = bodyChildren.findIndex((node, index) => index > pageStart && index < pageEnd && localName(node) === 'p' && textOf(node) === `${section}.3 功能按钮与操作` ) const actionTable = actionHeadingIndex >= 0 ? bodyChildren.slice(actionHeadingIndex + 1, pageEnd).find(node => localName(node) === 'tbl') : null const actionHeaders = actionTable ? elementChildren(elementChildren(actionTable, 'tr')[0], 'tc').map(textOf) : [] if (actionHeaders.join('|') !== '操作入口|执行结果|业务影响与注意事项') { const subsectionHeadings = bodyChildren.slice(pageStart, pageEnd) .filter(node => localName(node) === 'p' && textOf(node).startsWith(`${section}.`)) .map(textOf) fail(`操作结果与影响表缺失:${section} ${page.title};子节:${subsectionHeadings.join(' | ')}`) } verifiedActionTables++ } let verifiedDialogPages = 0 for (const item of dialogAudit.filter(page => page.dialogs.length)) { const page = catalog.find(entry => entry.module === item.module && entry.title === item.title) if (!page) fail(`弹窗审计页未纳入目录:${item.module}/${item.title}`) const section = `${chapterByModule[item.module]}.${page.pageIndex}` const pageStart = bodyChildren.findIndex(node => localName(node) === 'p' && textOf(node) === `${section} ${item.title}`) let pageEnd = bodyChildren.findIndex((node, index) => index > pageStart && localName(node) === 'p' && /^\d+\.\d+ /.test(textOf(node))) if (pageEnd < 0) pageEnd = bodyChildren.length const pageText = bodyChildren.slice(pageStart, pageEnd).map(textOf).join('\n') const hasDialogCoverage = pageText.includes('弹窗') || (section === '9.3' && pageText.includes('编辑生产工艺与保存时机')) if (!hasDialogCoverage) fail(`弹窗说明缺失:${section} ${item.title}`) verifiedDialogPages++ } const requiredPhrases = [ '9.3.4 编辑生产工艺与保存时机', '左侧工序列表', '上移/下移', '保存为标准工艺', '替换该物料原有的标准工艺路线', '不会自动生成生产任务', '下发任务(单工序行)', '操作入口', '执行结果', '业务影响与注意事项', '10.3.4 可编辑字段与编辑弹窗', '计划开始', '计划完成', '外协分组', '修改后立即保存', '编辑备注信息弹窗', '上游业务数据', '本页维护结果', '下游业务影响' ] for (const phrase of requiredPhrases) if (!fullText.includes(phrase)) fail(`关键内容缺失:${phrase}`) const forbiddenPhrases = [ '页面路由', '只读查询过程/接口', '写入过程/接口', '技术核对说明', '本文档编制未实际执行', '未产生数据', '不产生数据', '正式环境只读截图', '后台过程', '存储过程', '核对内容后使用弹窗中的', '保存提交' ] const presentForbiddenPhrases = forbiddenPhrases.filter(phrase => fullText.includes(phrase)) if (presentForbiddenPhrases.length) fail(`仍含不应出现的措辞:${presentForbiddenPhrases.join('、')}`) const implementationNames = [...new Set(catalog.flatMap(page => [ page.route, ...(page.readProcedures || []), ...(page.writeProcedures || []) ]).filter(Boolean))] const leakedNames = implementationNames.filter(name => fullText.includes(name)) if (leakedNames.length) fail(`仍含内部路由或过程名称:${leakedNames.join('、')}`) const processPopupCaptionIndex = bodyChildren.findIndex(node => localName(node) === 'p' && /^图 9\.3-2/.test(textOf(node))) const processPopupImageParagraph = bodyChildren.slice(0, processPopupCaptionIndex).reverse() .find(node => localName(node) === 'p' && firstDescendant(node, 'blip')) if (processPopupCaptionIndex < 0 || !processPopupImageParagraph) fail('9.3 编辑生产工艺弹窗截图未嵌入文档') const processPopupBlip = firstDescendant(processPopupImageParagraph, 'blip') const processPopupRelationshipId = processPopupBlip.getAttribute('r:embed') || processPopupBlip.getAttributeNS('http://schemas.openxmlformats.org/officeDocument/2006/relationships', 'embed') const processPopupTarget = relationshipTargets.get(processPopupRelationshipId) const processPopupEntry = processPopupTarget && zip.file(`word/${processPopupTarget}`) const processPopupExpected = fs.readFileSync(path.resolve(screenshotsDir, '03-03-process-edit-dialog.png')) if (!processPopupEntry || !imagesMatch(processPopupEntry.asNodeBuffer(), processPopupExpected)) fail('9.3 编辑生产工艺弹窗截图内容不正确') const popupCaptionIndex = bodyChildren.findIndex(node => localName(node) === 'p' && /^图 10\.3-2/.test(textOf(node))) const popupImageParagraph = bodyChildren.slice(0, popupCaptionIndex).reverse().find(node => localName(node) === 'p' && firstDescendant(node, 'blip')) if (popupCaptionIndex < 0 || !popupImageParagraph) fail('10.3 编辑弹窗截图未嵌入文档') const popupBlip = firstDescendant(popupImageParagraph, 'blip') const popupRelationshipId = popupBlip.getAttribute('r:embed') || popupBlip.getAttributeNS('http://schemas.openxmlformats.org/officeDocument/2006/relationships', 'embed') const popupTarget = relationshipTargets.get(popupRelationshipId) const popupEntry = popupTarget && zip.file(`word/${popupTarget}`) const popupExpected = fs.readFileSync(path.resolve(screenshotsDir, '04-03-plan-edit-dialog.png')) if (!popupEntry || !imagesMatch(popupEntry.asNodeBuffer(), popupExpected)) fail('10.3 编辑弹窗截图内容不正确') console.log(`Verified screenshots: ${verifiedScreenshots}`) console.log(`Verified action tables: ${verifiedActionTables}`) console.log(`Verified dialog pages: ${verifiedDialogPages}`) console.log('Forbidden wording: 0') console.log('Internal routes/procedures: 0') console.log('Section 9.3: complete') console.log('Section 10.3: complete') } try { main() } catch (error) { console.error(error.stack || error.message) process.exitCode = 1 }