311 lines
14 KiB
JavaScript
311 lines
14 KiB
JavaScript
// darkBidChecker.js
|
||
// 逻辑已迁移至项目内 Python:modules/dark_bid_format_check.py(本文件保留作参考)
|
||
// 依赖: jsdom (npm install jsdom)
|
||
const { JSDOM } = require('jsdom');
|
||
|
||
/**
|
||
* 暗标格式检查器
|
||
* @param {string} htmlContent - 技术暗标的HTML内容(由Word/PDF导出的完整HTML)
|
||
* @param {Object} options - 可选配置
|
||
* @returns {Object} 符合格式的JSON检查报告
|
||
*/
|
||
function checkTechnicalBid(htmlContent, options = {}) {
|
||
const dom = new JSDOM(htmlContent);
|
||
const document = dom.window.document;
|
||
const styleSheets = Array.from(document.styleSheets);
|
||
|
||
// 辅助函数:获取元素实际渲染样式(jsdom支持有限,但可获取内联和style标签定义)
|
||
function getStyle(element, property) {
|
||
return dom.window.getComputedStyle(element).getPropertyValue(property);
|
||
}
|
||
|
||
// 结果收集器
|
||
const results = {
|
||
overall: true,
|
||
details: [],
|
||
violations: []
|
||
};
|
||
|
||
// 通用添加结果方法
|
||
function addResult(ruleName, passed, message, elements = []) {
|
||
results.details.push({ rule: ruleName, passed, message });
|
||
if (!passed) {
|
||
results.overall = false;
|
||
results.violations.push({ rule: ruleName, message, elements: elements.map(el => el.outerHTML.slice(0, 200)) });
|
||
}
|
||
}
|
||
|
||
// ========== 1. 检查是否存在投标人身份信息 ==========
|
||
function checkIdentityInfo() {
|
||
const bodyText = document.body.innerText;
|
||
// 公司名称模式(可扩展)
|
||
const companyPattern = /(?:我公司|本公司|[((]?[A-Za-z\u4e00-\u9fa5]+(?:集团|股份|有限|责任|公司)[))]?)/g;
|
||
// 地址模式(省市区路号等)
|
||
const addrPattern = /(?:省|市|区|县|镇|路|街|大道|号|大厦|楼|层)[\u4e00-\u9fa50-9]+/g;
|
||
// 总监/专监真实姓名模式(除甲、乙等代称外)
|
||
const namePattern = /(?:总监理工程师|专业监理工程师|技术负责人|项目经理)[::]\s*[^甲乙丙丁戊己庚辛壬癸\s]{2,4}(?=[,。;\s]|$)/g;
|
||
|
||
let foundCompany = false;
|
||
let foundAddr = false;
|
||
let foundRealName = false;
|
||
|
||
if (companyPattern.test(bodyText)) foundCompany = true;
|
||
if (addrPattern.test(bodyText)) foundAddr = true;
|
||
if (namePattern.test(bodyText)) foundRealName = true;
|
||
|
||
// 检查图片alt或title是否包含公司标识
|
||
const images = document.querySelectorAll('img');
|
||
let hasLogo = false;
|
||
images.forEach(img => {
|
||
const alt = img.alt || '';
|
||
const src = img.src || '';
|
||
if (/logo|商标|微标|公司|品牌/i.test(alt) || /logo/i.test(src)) hasLogo = true;
|
||
});
|
||
|
||
const passed = !(foundCompany || foundAddr || foundRealName || hasLogo);
|
||
addResult('身份信息隐藏', passed,
|
||
passed ? '未发现投标人身份信息' : '发现投标人身份信息(公司名/地址/真实姓名/商标)');
|
||
}
|
||
|
||
// ========== 2. 标题格式检查(三号黑体,非斜体,无下划线等) ==========
|
||
function checkHeadings() {
|
||
// 标题选择器:h1-h6 或任何 role="heading" 或具有大纲级别样式的元素
|
||
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6, [role="heading"], .heading, .title');
|
||
let allValid = true;
|
||
const invalidHeadings = [];
|
||
|
||
headings.forEach(heading => {
|
||
const fontSize = getStyle(heading, 'font-size');
|
||
const fontFamily = getStyle(heading, 'font-family').toLowerCase();
|
||
const fontStyle = getStyle(heading, 'font-style');
|
||
const textDecoration = getStyle(heading, 'text-decoration');
|
||
const color = getStyle(heading, 'color');
|
||
const fontWeight = getStyle(heading, 'font-weight');
|
||
|
||
// 三号 ≈ 16pt (21.33px) 允许误差 ±2px
|
||
const sizeOk = Math.abs(parseFloat(fontSize) - 21.33) <= 3;
|
||
const fontOk = fontFamily.includes('黑体') || fontFamily.includes('simhei') || fontFamily.includes('microsoft yahei');
|
||
const styleOk = fontStyle !== 'italic';
|
||
const decorOk = !textDecoration.includes('underline');
|
||
const colorOk = color === 'rgb(0, 0, 0)' || color === '#000000';
|
||
const weightOk = fontWeight !== '400' && fontWeight !== 'normal'; // 黑体通常加粗
|
||
|
||
if (!(sizeOk && fontOk && styleOk && decorOk && colorOk && weightOk)) {
|
||
allValid = false;
|
||
invalidHeadings.push(heading);
|
||
}
|
||
});
|
||
|
||
addResult('标题格式', allValid,
|
||
allValid ? '所有标题符合三号黑体要求' : '部分标题字号/字体/颜色/下划线不符合要求', invalidHeadings);
|
||
}
|
||
|
||
// ========== 3. 正文格式检查(四号宋体,首行缩进2字符,行距固定值26磅,无着色等) ==========
|
||
function checkBodyText() {
|
||
const bodyElements = document.querySelectorAll('p, div, span, li, td, th');
|
||
let allValid = true;
|
||
const invalidElements = [];
|
||
// 排除标题、页眉页脚、目录等
|
||
const excludeSelectors = 'h1, h2, h3, h4, h5, h6, .header, .footer, .toc, .目录';
|
||
|
||
bodyElements.forEach(el => {
|
||
if (el.matches(excludeSelectors)) return;
|
||
const text = el.innerText.trim();
|
||
if (text.length === 0) return;
|
||
|
||
const fontSize = getStyle(el, 'font-size');
|
||
const fontFamily = getStyle(el, 'font-family').toLowerCase();
|
||
const color = getStyle(el, 'color');
|
||
const textIndent = getStyle(el, 'text-indent');
|
||
const lineHeight = getStyle(el, 'line-height');
|
||
const textDecoration = getStyle(el, 'text-decoration');
|
||
const fontWeight = getStyle(el, 'font-weight');
|
||
const fontStyle = getStyle(el, 'font-style');
|
||
|
||
// 四号 ≈ 14pt (18.67px)
|
||
const sizeOk = Math.abs(parseFloat(fontSize) - 18.67) <= 2;
|
||
const fontOk = fontFamily.includes('宋体') || fontFamily.includes('simsun') || fontFamily.includes('serif');
|
||
const colorOk = color === 'rgb(0, 0, 0)' || color === '#000000';
|
||
// 首行缩进2字符(以em为单位,2em代表2个汉字)
|
||
const indentOk = parseFloat(textIndent) >= 1.8 && parseFloat(textIndent) <= 2.2;
|
||
// 行距固定值26磅 (34.67px)
|
||
const lineHeightOk = Math.abs(parseFloat(lineHeight) - 34.67) <= 2;
|
||
const decorOk = !textDecoration.includes('underline');
|
||
const weightOk = fontWeight === '400' || fontWeight === 'normal';
|
||
const styleOk = fontStyle !== 'italic';
|
||
|
||
if (!(sizeOk && fontOk && colorOk && indentOk && lineHeightOk && decorOk && weightOk && styleOk)) {
|
||
allValid = false;
|
||
invalidElements.push(el);
|
||
}
|
||
});
|
||
|
||
addResult('正文格式', allValid,
|
||
allValid ? '所有正文符合四号宋体/缩进/行距/颜色要求' : '部分正文段落格式不符合要求', invalidElements);
|
||
}
|
||
|
||
// ========== 4. 目录检查(无页码,无页眉页脚) ==========
|
||
function checkTOC() {
|
||
const tocElements = document.querySelectorAll('.toc, .table-of-contents, .目录, [role="directory"]');
|
||
let noPageNumbers = true;
|
||
let noHeaderFooter = true;
|
||
|
||
tocElements.forEach(toc => {
|
||
const text = toc.innerText;
|
||
// 检查是否存在页码(数字独立在行尾或制表符后)
|
||
if (/\d+$/.test(text.trim()) || /\.{2,}\s*\d+/.test(text)) {
|
||
noPageNumbers = false;
|
||
}
|
||
// 检查内部是否有页眉页脚元素
|
||
if (toc.querySelector('.header, .footer, .page-header, .page-footer')) {
|
||
noHeaderFooter = false;
|
||
}
|
||
});
|
||
|
||
// 若没有目录元素,按规则应存在目录但不得有页码,这里假设必须存在目录(招标要求通常有目录)
|
||
if (tocElements.length === 0) {
|
||
addResult('目录要求', false, '未检测到目录,请确保包含目录且目录无页码无页眉页脚');
|
||
} else {
|
||
const passed = noPageNumbers && noHeaderFooter;
|
||
addResult('目录要求', passed,
|
||
passed ? '目录符合无页码、无页眉页脚要求' : '目录中存在页码或页眉页脚');
|
||
}
|
||
}
|
||
|
||
// ========== 5. 图表位置及图表内文字格式 ==========
|
||
function checkChartsAndTables() {
|
||
// 定位附件/附表章节
|
||
const appendix = document.querySelector('#appendix, .appendix, .attachment, 附件, 附表');
|
||
const isInAppendix = (el) => appendix && appendix.contains(el);
|
||
|
||
const allTables = document.querySelectorAll('table');
|
||
const allImages = document.querySelectorAll('img');
|
||
const allFigures = document.querySelectorAll('figure, .chart');
|
||
let illegalCharts = [];
|
||
|
||
// 正文中不允许有图表,除非在附件内
|
||
[...allTables, ...allImages, ...allFigures].forEach(chart => {
|
||
if (!isInAppendix(chart)) {
|
||
illegalCharts.push(chart);
|
||
}
|
||
});
|
||
|
||
let chartTextValid = true;
|
||
// 附件内图表文字需五号宋体
|
||
if (appendix) {
|
||
const chartTexts = appendix.querySelectorAll('table, td, th, figcaption, .chart-text');
|
||
chartTexts.forEach(el => {
|
||
const fontSize = getStyle(el, 'font-size');
|
||
const fontFamily = getStyle(el, 'font-family').toLowerCase();
|
||
const color = getStyle(el, 'color');
|
||
const sizeOk = Math.abs(parseFloat(fontSize) - 10.5) <= 1.5; // 五号=10.5pt
|
||
const fontOk = fontFamily.includes('宋体') || fontFamily.includes('simsun');
|
||
const colorOk = color === 'rgb(0, 0, 0)' || color === '#000000';
|
||
if (!(sizeOk && fontOk && colorOk)) chartTextValid = false;
|
||
});
|
||
}
|
||
|
||
const chartsPassed = illegalCharts.length === 0 && chartTextValid;
|
||
addResult('图表规范', chartsPassed,
|
||
chartsPassed ? '图表仅出现在附件/附表内,且图表文字符合五号宋体' :
|
||
`正文中发现${illegalCharts.length}个图表或附件内图表文字格式错误`, illegalCharts);
|
||
}
|
||
|
||
// ========== 6. 全文字体颜色检查(无彩色,无着重号,无下划线) ==========
|
||
function checkColorsAndDecorations() {
|
||
const allElements = document.querySelectorAll('*');
|
||
let colorViolations = [];
|
||
let decorationViolations = [];
|
||
|
||
allElements.forEach(el => {
|
||
const color = getStyle(el, 'color');
|
||
if (color !== 'rgb(0, 0, 0)' && color !== '#000000' && color !== 'black') {
|
||
if (el.innerText.trim().length > 0) colorViolations.push(el);
|
||
}
|
||
const textDecor = getStyle(el, 'text-decoration');
|
||
if (textDecor.includes('underline')) decorationViolations.push(el);
|
||
// 着重号检测(一般使用伪元素或border-bottom,简单检测样式)
|
||
const borderBottom = getStyle(el, 'border-bottom-style');
|
||
if (borderBottom === 'solid' || borderBottom === 'dotted') {
|
||
decorationViolations.push(el);
|
||
}
|
||
});
|
||
|
||
const passed = colorViolations.length === 0 && decorationViolations.length === 0;
|
||
addResult('颜色与装饰', passed,
|
||
passed ? '无彩色文字、无下划线、无着重号' :
|
||
`发现${colorViolations.length}处彩色文字,${decorationViolations.length}处下划线/着重号`,
|
||
[...colorViolations, ...decorationViolations]);
|
||
}
|
||
|
||
// ========== 7. 页面设置检查(A4纵向,页边距) ==========
|
||
function checkPageSetup() {
|
||
// 检查@page规则或根元素margin
|
||
let pageValid = true;
|
||
let marginTop, marginBottom, marginLeft, marginRight;
|
||
// 尝试从styleSheets中获取@page
|
||
let pageRule = null;
|
||
for (let sheet of styleSheets) {
|
||
try {
|
||
const rules = sheet.cssRules || sheet.rules;
|
||
for (let rule of rules) {
|
||
if (rule.type === CSSRule.PAGE_RULE) {
|
||
pageRule = rule.style;
|
||
break;
|
||
}
|
||
}
|
||
} catch(e) { /* 跨域限制忽略 */ }
|
||
}
|
||
|
||
if (pageRule) {
|
||
marginTop = pageRule.marginTop;
|
||
marginBottom = pageRule.marginBottom;
|
||
marginLeft = pageRule.marginLeft;
|
||
marginRight = pageRule.marginRight;
|
||
const size = pageRule.size;
|
||
if (size && size.toLowerCase() !== 'a4') pageValid = false;
|
||
} else {
|
||
// 检查body或根容器的margin
|
||
const bodyStyle = getStyle(document.body, 'margin');
|
||
if (bodyStyle) {
|
||
const margins = bodyStyle.split(' ');
|
||
// 简单近似
|
||
marginTop = margins[0];
|
||
marginBottom = margins[2] || margins[0];
|
||
marginLeft = margins[3] || margins[1];
|
||
marginRight = margins[1];
|
||
} else {
|
||
pageValid = false;
|
||
}
|
||
}
|
||
|
||
const topOk = marginTop === '2.54cm' || parseFloat(marginTop) === 2.54;
|
||
const bottomOk = marginBottom === '2.54cm' || parseFloat(marginBottom) === 2.54;
|
||
const leftOk = marginLeft === '3.18cm' || parseFloat(marginLeft) === 3.18;
|
||
const rightOk = marginRight === '3.18cm' || parseFloat(marginRight) === 3.18;
|
||
const pageOrientation = document.documentElement.style.width === 'auto' ? '纵向' : '横向'; // 近似
|
||
|
||
const passed = topOk && bottomOk && leftOk && rightOk && pageOrientation !== '横向';
|
||
addResult('页面设置', passed,
|
||
passed ? '页面设置符合A4纵向/边距要求' : '页面边距或纸张方向不符合要求');
|
||
}
|
||
|
||
// 执行所有检查
|
||
checkIdentityInfo();
|
||
checkHeadings();
|
||
checkBodyText();
|
||
checkTOC();
|
||
checkChartsAndTables();
|
||
checkColorsAndDecorations();
|
||
checkPageSetup();
|
||
|
||
return results;
|
||
}
|
||
|
||
// 导出函数供外部使用
|
||
module.exports = { checkTechnicalBid };
|
||
|
||
// ==================== 使用示例 ====================
|
||
// 假设已有htmlContent(技术暗标HTML字符串)
|
||
// const report = checkTechnicalBid(htmlContent);
|
||
// console.log(JSON.stringify(report, null, 2));
|