diff --git a/application/common/ProofReadService.php b/application/common/ProofReadService.php index 66171ef..2dcecf3 100644 --- a/application/common/ProofReadService.php +++ b/application/common/ProofReadService.php @@ -18,12 +18,12 @@ class ProofReadService $correctedContent = $this->checkTextFormat($correctedContent); //数字格式校对 $correctedContent = $this->checkNumberFormat($correctedContent); + //No. 123456的写法统一 + $correctedContent = $this->checkNoFormatUniformity($correctedContent); //毫升单位校对 $correctedContent = $this->checkMlUnit($correctedContent); //显著性P斜体校对 $correctedContent = $this->checkPSignificance($correctedContent); - //No. 123456的写法统一 - $correctedContent = $this->checkNoFormatUniformity($correctedContent); //图表标题一律使用全称Figure 1, Table 1.不能写成Fig. 1, Tab 1. $correctedContent = $this->checkFigureTableTitle($correctedContent); //检测参考文献是否能打开 @@ -60,41 +60,19 @@ class ProofReadService $excludeMarkers = []; // 存储 URL/DOI + / 的占位符映射 $processedHashes = []; - // 编码处理(不变) + // 编码处理 $originalEncoding = mb_detect_encoding($content, ['UTF-8', 'GBK', 'GB2312', 'ISO-8859-1'], true); if ($originalEncoding === false) { - // $converted = @mb_convert_encoding($content, 'UTF-8', 'auto'); - // $corrected = $converted !== false ? $converted : $content; - // $posStart = 0; - // $posEnd = min(20, strlen($originalContent)); - // $errors[] = $this->createError( - // '内容编码检测失败', - // '已尝试强制UTF-8编码', - // '输入内容编码无法识别,已尝试自动转换为UTF-8', - // $originalContent, - // $corrected, - // $posStart, - // $posEnd - // ); } else { $converted = @mb_convert_encoding($content, 'UTF-8', $originalEncoding); $corrected = $converted !== false ? $converted : $content; if ($converted === false) { $posStart = 0; $posEnd = min(20, strlen($originalContent)); - // $errors[] = $this->createError( - // '编码转换失败', - // '保留原始编码内容', - // "从[{$originalEncoding}]转换为UTF-8失败,保留原始内容", - // $originalContent, - // $corrected, - // $posStart, - // $posEnd - // ); } } - // 过滤 /(复杂标签优先) + // 过滤 / $mathTagRegex = '~<(wmath|math)[^>]*?>.*?~is'; if (@preg_match($mathTagRegex, '') === false) { // 正则错误处理(不变) @@ -119,34 +97,50 @@ class ProofReadService } } - // 过滤 URL/DOI(放在数学标签后) - $urlDoiRegex = '~(https?://[^\s/]{1,100}(?:/+[A-Za-z0-9\.\-]+(?:-[A-Za-z0-9\.\-]+)*)*|\b[a-zA-Z0-9\.\-]+\.[a-zA-Z]{2,}(?:/+[A-Za-z0-9\.\-]+(?:-[A-Za-z0-9\.\-]+)*)*(?=$|[\s\.,;!])|doi:\s{0,10}\d+\.\d+/[A-Za-z0-9-+×:]+(?:-[A-Za-z0-9-+×:]+)*)~iu'; + // 过滤 URL/DOI + $urlDoiRegex = '~( + https?://[^\s/]{1,100} # 协议(http/https) + 域名(非空白/字符) + (?:/+[A-Za-z0-9\.\-]+(?:-[A-Za-z0-9\.\-]+)*)* # 多级路径(支持.html后接/1/23等格式) + (?:\?[A-Za-z0-9_\-=&%\+\.\~]+)? # 可选查询参数(如?J_num=8&page=1) + (?:\#[A-Za-z0-9_\-]+)? # 可选锚点(如#section) + | + \b[a-zA-Z0-9\.\-]+\.[a-zA-Z]{2,} # 无协议域名(如example.com) + (?:/+[A-Za-z0-9\.\-]+(?:-[A-Za-z0-9\.\-]+)*)* # 无协议多级路径 + (?:\?[A-Za-z0-9_\-=&%\+\.\~]+)? # 无协议查询参数 + (?:\#[A-Za-z0-9_\-]+)? # 无协议锚点 + (?=$|[\s\.,;!]) # 结束边界(空白或标点) + | + doi:\s{0,10}\d+\.\d+/[A-Za-z0-9-+×:]+(?:-[A-Za-z0-9-+×:]+)* # DOI格式 + )~iux'; + if (@preg_match($urlDoiRegex, '') === false) { // 正则错误处理(不变) } elseif (preg_match_all($urlDoiRegex, $corrected, $matches, PREG_SET_ORDER)) { + // 按长度降序排序,优先处理长URL(避免短URL被包含时误替换) usort($matches, function($a, $b) { return strlen($b[1]) - strlen($a[1]); }); foreach ($matches as $index => $match) { $original = $match[1]; $marker = "___EXCLUDE_URL_" . time() . "_{$index}___"; $excludeMarkers[$marker] = $original; - // 【修改】使用独立偏移量 $searchOffsetForExclude + // 独立偏移量避免重复匹配,兼容特殊URL格式 $posStart = strpos($originalContent, $original, $searchOffsetForExclude); $posEnd = ($posStart !== false) ? $posStart + strlen($original) : -1; $searchOffsetForExclude = ($posEnd !== -1) ? $posEnd : $searchOffsetForExclude + strlen($original); + // 精准替换当前URL为标记(仅1次,避免全局替换干扰) $corrected = preg_replace("~" . preg_quote($original, '~') . "~u", $marker, $corrected, 1); } } - // 核心格式规则处理(关键修改) + // 核心格式规则处理(优化偏移量计算与验证逻辑) $coreRules = $this->getTextCoreRules(); foreach ($coreRules as $rule) { if (@preg_match($rule['pattern'], '') === false) { continue; } - // 【修改】确保匹配时使用原始 $corrected(未被占位符干扰),并保留偏移量 + // 匹配时保留偏移量信息,用于精准定位 $matchCount = preg_match_all( $rule['pattern'], $corrected, @@ -158,54 +152,45 @@ class ProofReadService } foreach ($matches as $match) { - $original = $match[0][0]; // 匹配到的原始内容(如 (40 × 33)) + $original = $match[0][0]; // 匹配到的原始内容 $originalLen = strlen($original); $hash = md5($original); + // 跳过已处理的内容,避免重复修正 if (isset($processedHashes[$hash])) { continue; } - // 【关键修复1:精准定位,不受其他偏移量干扰】 - $posStart = -1; - $posEnd = -1; - // 1. 先尝试用 PREG_OFFSET_CAPTURE 得到的偏移量反推原始位置 - $offsetInCorrected = $match[0][1]; // 匹配内容在 $corrected 中的偏移量 - // 2. 提取 $corrected 中匹配位置前的内容,计算其在原始文本中的长度(排除占位符影响) + $offsetInCorrected = $match[0][1]; // 匹配内容在$corrected中的偏移量 $prefixInCorrected = substr($corrected, 0, $offsetInCorrected); - // 3. 替换占位符为原始内容,得到与 $originalContent 对应的前缀 - $prefixInOriginal = strtr($prefixInCorrected, $excludeMarkers); - // 4. 原始位置 = 前缀长度(确保精准对应) + $prefixInOriginal = strtr($prefixInCorrected, $excludeMarkers); // 还原占位符为原始内容 $posStart = strlen($prefixInOriginal); $posEnd = $posStart + $originalLen; - // 【关键修复2:二次验证,确保位置正确】 - if ($posStart !== -1) { - $contentCheck = substr($originalContent, $posStart, $originalLen); - // 转换为 UTF-8 编码后用 strcmp 比较(依赖 iconv 扩展) - $contentCheckConv = iconv('UTF-8', 'UTF-8//IGNORE', $contentCheck); - $originalConv = iconv('UTF-8', 'UTF-8//IGNORE', $original); - if (strcmp($contentCheckConv, $originalConv) !== 0) { - // 二次验证失败时,用局部正则重新定位 - $localPattern = '~' . preg_quote($original, '~') . '~u'; - if (preg_match($localPattern, $originalContent, $localMatch, PREG_OFFSET_CAPTURE, $searchOffsetForCore)) { - $posStart = $localMatch[0][1]; - $posEnd = $posStart + $originalLen; - } + $contentCheck = substr($originalContent, $posStart, $originalLen); + $contentCheckConv = iconv('UTF-8', 'UTF-8//IGNORE', $contentCheck); // 忽略无效字符 + $originalConv = iconv('UTF-8', 'UTF-8//IGNORE', $original); + if (strcmp($contentCheckConv, $originalConv) !== 0) { + // 验证失败时,基于当前偏移量重新定位 + $localPattern = '~' . preg_quote($original, '~') . '~u'; + if (preg_match($localPattern, $originalContent, $localMatch, PREG_OFFSET_CAPTURE, $searchOffsetForCore)) { + $posStart = $localMatch[0][1]; + $posEnd = $posStart + $originalLen; + } else { + continue; } } - // 生成修正内容 + // 生成修正后的内容 $fixed = is_callable($rule['replacement']) ? call_user_func($rule['replacement'], $match) : preg_replace($rule['pattern'], $rule['replacement'], $original); + // 仅在内容有变化时更新 if ($original !== $fixed && $fixed !== null) { - // 【修改】更新核心规则专用偏移量 - $searchOffsetForCore = ($posEnd !== -1) ? $posEnd : $searchOffsetForCore + $originalLen; - - // 生成错误信息 + $searchOffsetForCore = $posEnd; // 更新核心规则偏移量,避免重复匹配 $currentCorrected = str_replace($original, $fixed, $corrected); + // 记录错误信息 $errors[] = $this->createError( $original, $fixed, @@ -214,7 +199,7 @@ class ProofReadService $currentCorrected, $posStart, $posEnd, - empty($rule['error_type']) ? '' : $rule['error_type'] + $rule['error_type'] ?? '' ); $processedHashes[$hash] = true; $corrected = $currentCorrected; @@ -222,196 +207,206 @@ class ProofReadService } } - // 批量还原 / 和 URL/DOI(不变) + // 批量还原 URL/DOI 和数学标签(保持不变,优化错误提示) $restoreErrors = []; if (!empty($excludeMarkers)) { $corrected = strtr($corrected, $excludeMarkers); + // 检查未正常还原的占位符 if (preg_match_all('~___EXCLUDE_(wmath|math|URL)_\d+_\d+___~', $corrected, $remaining)) { foreach ($remaining[0] as $marker) { - $original = $excludeMarkers[$marker] ?? '未知数学公式/链接'; - $restoreErrors[] = $original; - $posStart = strpos($corrected, $marker, $searchOffsetForExclude); - $posEnd = ($posStart !== false) ? $posStart + strlen($marker) : -1; - $searchOffsetForExclude = ($posEnd !== -1) ? $posEnd : $searchOffsetForExclude + strlen($marker); - $corrected = str_replace($marker, $original, $corrected); + $original = $excludeMarkers[$marker] ?? '未知内容'; + $restoreErrors[] = "未正常还原的占位符: {$marker}(原始内容: {$original})"; + $corrected = str_replace($marker, $original, $corrected); // 强制还原 } } } - // if (!empty($restoreErrors)) { - // $posStart = 0; - // $posEnd = min(50, strlen($originalContent)); - // $errors[] = $this->createError( - // '特殊内容恢复不完全', - // '已强制恢复原始内容', - // "恢复失败的内容: " . implode('; ', $restoreErrors), - // $originalContent, - // $corrected, - // $posStart, - // $posEnd - // ); - // } - $this->handleErrors($errors); return is_string($corrected) ? $corrected : $defaultReturn; } /** * 获取文本格式核心规则 */ - private function getTextCoreRules() { + private function getTextCoreRules() + { return [ - // ====================== 1. 括号内数字范围规则(优先级最高,避免与其他减号规则冲突) ====================== + // 1. 最高优先级:特殊格式排除规则(首行专属排除No.编号) [ - 'pattern' => '~(\[\s*[-]?\d+\s*)\x{2014}\s*(\d+\s*\])~u', // 匹配长划线(—) - 'replacement' => '$1-$2', // 替换为短划线(-) - 'verbatim_texts' => '带括号数字范围使用长划线(—)不规范', + 'pattern' => '~ + # 【首优先级】No.编号专属排除(如No.: 2023YJZX-LN03/13、NO: KHYJ-2023-05、no. 123-ABC/45) + # 支持变体:No.大小写、冒号可带/不带点、冒号前后空格、编号含-/_/数字/字母 + \b(?:No|NO|no)\.?:?\s* # 前缀:No./NO./no.(冒号可选,点可选,后接任意空格) + [A-Za-z0-9\-\/_]+ # 编号主体:支持字母、数字、-、/、_(覆盖2023YJZX-LN03/13) + (?:-[A-Za-z0-9\-\/_]+)* # 编号后缀:支持多段连接(如2023YJZX-LN03/13-001) + \b # 单词边界:避免编号后接多余字符(如No.: 2023abc) + | + # 括号内分数及百分比组合(如(45/45)、(15.6%, 7/45)) + \(\s*(?:\d+(?:\.\d+)?%?\s*,?\s*)?\d+(?:\.\d+)?\s*/\s*\d+(?:\.\d+)?\s*\) + | + # 独立年份范围(如1849-1850、2023 - 2025) + (?]|from\s+|:\s*|\[[MZACDP]\]\.\s*)\d{4}\s*-\s*\d{4} + (?!\d|[\+\-\*\/=<>]|[,\.:;!]|\+\d+\.) + | + # from+年份范围(如from 1849-1850) + \bfrom\s+\d{4}\s*-\s*\d{4}\b + | + # 带单位的数字范围/倍数(如50-200 nm、10×5 cm) + \b\d+\s*[-×]\s*\d+\s*[a-zA-Z%] + | + # 无No.前缀的项目编号(如2023YJZX-LN03/13、KHYJ-2023-05-01) + [A-Za-z]+-?\d+[-/]\d+[-/]\d* + | + # 参考文献格式(期刊/专著等) + \d{4},\s*\d{1,3}\(\d{1,2}\):\s*\d+-\d+(?:\+\d+)*\.|[^\n]+\[[MZACDP]\]\.\s*[^\n]+,\s*\d{4}:\s*\d+-\d+\. + ~ux', + 'replacement' => '$0', // 完全保留原始格式,不做任何修改 + 'verbatim_texts' => 'No.编号及非运算场景无需处理', + 'explanation' => 'No.系列编号(如No.: 2023YJZX-LN03/13)、括号内分数、年份范围、带单位数字范围、项目编号、参考文献等非运算场景的符号不做处理', + 'error_type' => 'exclude' + ], + + // 2. 次高优先级:数字范围规则(避免与-冲突) + [ + 'pattern' => '~(\[\s*[-]?\d+\s*)\x{2014}\s*(\d+\s*\])~u', + 'replacement' => '$1-$2', + 'verbatim_texts' => '带括号数字范围长划线不规范', 'explanation' => '带括号的数字范围应使用短划线[-]', 'error_type' => 'en-dash' ], [ - 'pattern' => '~(\[\s*[-]?\d+\s*)-\s*(\d+\s*\])~u', // 匹配连接符(-)及可能的空格 - 'replacement' => '$1-$2', // 统一为无空格短划线(-) - 'verbatim_texts' => '带括号数字范围使用连接符(-)格式不规范', - 'explanation' => '带括号的数字范围应使用短划线[-]且前后无空格', - 'error_type' => 'en-dash' - - ], - [ - 'pattern' => '~(\[\s*[-]?\d+)\s+-\s*(\d+\s*\])~u', // 短划线前多余空格 - 'replacement' => '$1-$2', // 移除前导空格 - 'verbatim_texts' => '数字范围短划线前有多余空格', - 'explanation' => '带括号数字范围的短划线[-]前不应留空格', + 'pattern' => '~(\[\s*[-]?\d+)\s*-\s*(\d+\s*\])~u', + 'replacement' => '$1-$2', + 'verbatim_texts' => '带括号数字范围短划线空格不规范', + 'explanation' => '带括号数字范围的短划线[-]前后不应留空格', 'error_type' => 'en-dash' ], [ - 'pattern' => '~(\[\s*[-]?\d+)\s*-\s+(\d+\s*\])~u', // 短划线后多余空格 - 'replacement' => '$1-$2', // 移除后导空格 - 'verbatim_texts' => '数字范围短划线后有多余空格', - 'explanation' => '带括号数字范围的短划线[-]后不应留空格', - 'error_type' => 'en-dash' - ], - - // ====================== 2. 无括号数字范围规则(次高优先级,避免与减号运算规则冲突) ====================== - [ - 'pattern' => '~(\b\d+)\s*—\s*(\d+\b)~u', // 匹配长划线(—) - 'replacement' => '$1-$2', // 替换为短划线(-) - 'verbatim_texts' => '无括号数字范围使用长划线(—)不规范', + 'pattern' => '~(\b\d+)\s*—\s*(\d+\b)~u', + 'replacement' => '$1-$2', + 'verbatim_texts' => '无括号数字范围长划线不规范', 'explanation' => '无括号的数字范围应使用短划线[-]', 'error_type' => 'bracket_en-dash' ], [ - 'pattern' => '~(\b\d+)\s*-\s*(\d+\b)~u', // 匹配连接符(-)及可能的空格 - 'replacement' => '$1-$2', // 统一为无空格短划线(-) - 'verbatim_texts' => '无括号数字范围使用连接符(-)格式不规范', - 'explanation' => '无括号的数字范围应使用短划线[-]且前后无空格', + 'pattern' => '~ + (? '$1-$2', + 'verbatim_texts' => '无括号数字范围短划线空格不规范', + 'explanation' => '无括号数字范围的短划线[-]前后不应留空格', 'error_type' => 'bracket_en-dash' ], - // ====================== 3. 运算符空格规则(按「复合→独立」顺序,避免冲突) ====================== + // 3. 核心优先级:运算符规则(精准匹配,排除No.编号干扰) [ - 'pattern' => '~(\S)\s*([<>!]=|===|!==)\s*(\S)~u', // 复合运算符(>=、<=、==、!=、===、!==) + 'pattern' => '~(\S)\s*([<>!]=|===|!==)\s*(\S)~u', 'replacement' => '$1 $2 $3', 'verbatim_texts' => '复合运算符前后空格不规范', 'explanation' => '复合运算符[>=、<=、==、!=、===、!==]前后应各留一个空格', 'error_type' => 'composite_operator' ], [ - 'pattern' => '~(?|\*|\+|-|/)(\S+?)\s*=\s*(\S+?)(?!=|<|>|\*|\+|-|/)~u', - // 捕获组说明: - // $1:等号前内容(非空字符,避免匹配空格) - // $2:等号后内容(非空字符,避免匹配空格) - // 前后否定断言:排除与其他运算符(如+=、*=)的冲突 - 'replacement' => '$1 = $2', // 正确拼接“前内容 + 规范等号 + 后内容” + 'pattern' => '~ + (?|\*|\+|-|/) + (\S+?)\s*=\s*(\S+?) + (?!=|<|>|\*|\+|-|/) + ~ux', + 'replacement' => '$1 = $2', 'verbatim_texts' => '等号前后空格不规范', - 'explanation' => '独立等号[=]前后应各留一个空格', + 'explanation' => '独立等号[=]前后应各留一个空格', 'error_type' => 'equal' ], - [ - 'pattern' => '~(\d+)\s*\+\s*(\d+)~u', // 加法运算符(+) - 'replacement' => '$1 + $2', - 'verbatim_texts' => '加法运算符前后空格不规范', - 'explanation' => '加法运算符[+]前后应各留一个空格', - 'error_type' => 'plus' - ], - [ - 'pattern' => '~(\d+)\s*\*\s*(\d+)~u', // 乘法运算符(*) - 'replacement' => '$1 * $2', - 'verbatim_texts' => '乘法运算符前后空格不规范', - 'explanation' => '乘法运算符[*]前后应各留一个空格', - 'error_type' => 'ride' - ], - [ - 'pattern' => '~(\d+)\s*/\s*(\d+)~u', // 除法运算符(/) - 'replacement' => '$1 / $2', - 'verbatim_texts' => '除法运算符前后空格不规范', - 'explanation' => '除法运算符[/]前后应各留一个空格', - 'error_type' => 'except' - ], + // 乘法(排除No.编号中的*) [ 'pattern' => '~ - (? '$1 - $2', + (? '$1 × $3', + 'verbatim_texts' => '乘法运算符格式不规范', + 'explanation' => '乘法运算应使用标准乘号[×],前后各留一个空格', + 'error_type' => 'ride' + ], + // 除法(排除No.编号中的/) + [ + 'pattern' => '~ + (? '$1 $2 $3', + 'verbatim_texts' => '除法运算符前后空格不规范', + 'explanation' => '除法运算符[/]前后应各留一个空格(纯数字运算场景)', + 'error_type' => 'except' + ], + // 加法(排除No.编号中的+) + [ + 'pattern' => '~ + (? '$1 $2 $3', + 'verbatim_texts' => '加法运算符前后空格不规范', + 'explanation' => '加法运算符[+]前后应各留一个空格(纯数字运算场景)', + 'error_type' => 'plus' + ], + // 减法(排除No.编号中的-) + [ + 'pattern' => '~ + (? '$1 $2 $3', 'verbatim_texts' => '减法运算符前后空格不规范', - 'explanation' => '减法运算符[-]前后应各留一个空格(非数字范围场景)', + 'explanation' => '减法运算符[-]前后应各留一个空格(纯数字运算场景)', 'error_type' => 'reduce' ], - // ====================== 4. 特殊符号规则(低优先级,避免干扰核心格式) ====================== + // 4. 低优先级:特殊符号规则 [ - 'pattern' => '~(\d+)\s+%~u', // 数字与百分号 + 'pattern' => '~(\d+)\s+%~u', 'replacement' => '$1%', - 'verbatim_texts' => '数字与百分号之间有多余空格', - 'explanation' => '数字与百分号[%]之间有多余空格', + 'verbatim_texts' => '数字与百分号空格不规范', + 'explanation' => '数字与百分号[%]之间不应留空格', 'error_type' => 'number_percentage' ], [ - 'pattern' => '~(\(\s*\d+)\s+×\s+(\d+\s*\))~u', // 先匹配「(数字 × 数字)」场景(带括号) - 'replacement' => '$1×$2', // 修正为「(数字×数字)」,如 (40×33) - 'verbatim_texts' => '带括号的乘号表示倍数时前后有多余空格', - 'explanation' => '带括号的乘号[×]表示倍数关系时前后有多余空格', - 'error_type' => 'multiple' - - ], - [ - 'pattern' => '~(\d+)\s+×\s+(\d+)~u', // 再匹配「数字 × 数字」场景(无括号) + 'pattern' => '~(\d+)\s+×\s+(\d+)~u', 'replacement' => '$1×$2', - 'verbatim_texts' => '乘号表示倍数时前后有多余空格', - 'explanation' => '乘号[×]表示倍数关系时前后不应留空格', + 'verbatim_texts' => '倍数乘号空格不规范', + 'explanation' => '乘号[×]表示倍数时前后不应留空格', 'error_type' => 'multiple' ], [ - 'pattern' => '~(\d+)\s*\*\s*(\d+)~u', // 星号(*)转乘号(×) - 'replacement' => '$1 × $2', - 'verbatim_texts' => '使用星号(*)作为乘法运算符不规范', - // 'explanation' => '乘法运算应使用标准乘号(×)替代星号(*),并前后留空格,如 3 × 5' - 'explanation' => '乘法运算应使用标准乘号[×]替代星号[*]', - 'error_type' => 'ride' - ], - [ - 'pattern' => '~(\d+)\s+:\s+(\d+)~u', // 比值符号(:) + 'pattern' => '~(\d+)\s+:\s+(\d+)~u', 'replacement' => '$1:$2', - 'verbatim_texts' => '比值符号前后有多余空格', - 'explanation' => '比值符号[:]前后有多余空格', + 'verbatim_texts' => '比值符号空格不规范', + 'explanation' => '比值符号[:]前后不应留空格', 'error_type' => 'biliel' ] ]; } /** - * 数字格式校对 + * 数字格式处理 */ private function checkNumberFormat($content) { $errors = []; $defaultReturn = $content; $originalContent = $content; - $searchOffset = 0; // 用于计算位置的偏移量(避免重复定位) + $searchOffset = 0; if (!is_string($content) || trim($content) === '') { $this->handleErrors($errors); @@ -421,79 +416,208 @@ class ProofReadService $correctedContent = $content; $replacements = []; $urlDoiPlaceholders = []; + $prefixFormatPlaceholders = []; + $decimalAlphaPlaceholders = []; + $dateRelatedPlaceholders = []; + $specialDecimalPlaceholders = []; + $softwareVersionPlaceholders = []; + $postalCodePlaceholders = []; // 精准保护邮编 + $bracketedNumPlaceholders = []; // 精准保护括号内数字 - // URL/DOI保护(保持不变,新增位置记录) - $urlDoiPattern = '#([^\w]|^)(https?://[^<>\s]+|doi:\s{0,10}\d{1,10}\.\d{1,10}/[A-Za-z0-9]{1,30})([^\w]|$)#i'; - if (@preg_match($urlDoiPattern, '') === false) { - // 正则错误:位置默认-1 - // $errors[] = $this->createError( - // 'URL/DOI正则错误', - // '跳过URL/DOI保护', - // "URL/DOI正则语法错误: {$urlDoiPattern}", - // $originalContent, - // $correctedContent, - // -1, - // -1 - // ); - } else { + // 保护括号内数字(仅匹配(960-1279)这类格式) + $bracketedNumPattern = '~ + \(\d+[-\d]*\d+\) # 仅匹配带括号的数字/数字范围 + ~ux'; + if (@preg_match($bracketedNumPattern, '') !== false) { $correctedContent = preg_replace_callback( - $urlDoiPattern, - function ($matches) use (&$urlDoiPlaceholders, $originalContent, &$errors, &$searchOffset) { + $bracketedNumPattern, + function ($matches) use (&$bracketedNumPlaceholders, $originalContent, &$searchOffset) { $fullMatch = $matches[0]; - $placeholder = '___URL_DOI_' . time() . '_' . uniqid() . '___'; - $urlDoiPlaceholders[$placeholder] = $fullMatch; - - // 计算URL/DOI在原始文本中的位置 + $placeholder = '___BRACKETED_NUM_' . uniqid() . '___'; + $bracketedNumPlaceholders[$placeholder] = $fullMatch; $posStart = strpos($originalContent, $fullMatch, $searchOffset); - $posEnd = ($posStart !== false) ? $posStart + strlen($fullMatch) : -1; - $searchOffset = ($posEnd !== -1) ? $posEnd : $searchOffset + strlen($fullMatch); - - // $errors[] = $this->createError( - // "URL/DOI保护: {$fullMatch}", - // "已替换为占位符", - // "保护URL/DOI内容,避免数字格式规则误处理", - // $originalContent, - // str_replace($fullMatch, $placeholder, $originalContent), - // $posStart, - // $posEnd - // ); + $searchOffset = $posStart !== false ? $posStart + strlen($fullMatch) : $searchOffset; return $placeholder; }, $correctedContent ); } - // 核心修复:处理纯小数零(如-5.0 → -5) - $decimalZeroPattern = '~(-?\d+)\.0+(?!\d|e|E)~ix'; - preg_match_all($decimalZeroPattern, $correctedContent, $matches); - - $uniqueNumbers = array_unique($matches[0]); - - foreach ($uniqueNumbers as $number) { - if (preg_match($decimalZeroPattern, $number, $numMatch)) { - $integerPart = $numMatch[1]; - $corrected = $integerPart; - $errorType = 'invalid_zero'; - if (!isset($replacements[$number])) { - $replacements[$number] = $corrected; - - // 计算小数零在原始文本中的位置 - $posStart = strpos($originalContent, $number, $searchOffset); - $posEnd = ($posStart !== false) ? $posStart + strlen($number) : -1; - $searchOffset = ($posEnd !== -1) ? $posEnd : $searchOffset + strlen($number); + // 精准保护邮编(仅匹配“地名+空格+4-6位数字” + $postalCodePattern = '~ + \b(?:[A-Za-z]+(?:\s+[A-Za-z]+)*|[\x{4e00}-\x{9fa5}]+)\s+\d{4,6}\b # 强制空格(如Jiangsu 223300、北京 100000) + |\b0\d{2,3}\d{7}\b # 兼容区号+固定电话(02588888888、01012345678) + ~uix'; + if (@preg_match($postalCodePattern, '') !== false) { + $correctedContent = preg_replace_callback( + $postalCodePattern, + function ($matches) use (&$postalCodePlaceholders, $originalContent, &$searchOffset) { + $fullMatch = $matches[0]; + $placeholder = '___POSTAL_CODE_' . uniqid() . '___'; + $postalCodePlaceholders[$placeholder] = $fullMatch; + $posStart = strpos($originalContent, $fullMatch, $searchOffset); + $searchOffset = $posStart !== false ? $posStart + strlen($fullMatch) : $searchOffset; + return $placeholder; + }, + $correctedContent + ); + } - $currentCorrected = strtr($originalContent, $replacements); - $errors[] = $this->createError( - $number, - $corrected, - "删除小数点后无效零", - $originalContent, - $currentCorrected, - $posStart, - $posEnd, - $errorType - ); - } + //保护软件版本 + $softwareVersionPattern = '~ + \b(?:[A-Za-z]+(?:\s+[A-Za-z]+)*|[\x{4e00}-\x{9fa5}]+(?:\s+[\x{4e00}-\x{9fa5}]+)*)\s+\d+\.\d+(?:\.\d+)*\b + ~uix'; + if (@preg_match($softwareVersionPattern, '') !== false) { + $correctedContent = preg_replace_callback( + $softwareVersionPattern, + function ($matches) use (&$softwareVersionPlaceholders, $originalContent, &$searchOffset) { + $fullMatch = $matches[0]; + $placeholder = '___SOFTWARE_VERSION_' . uniqid() . '___'; + $softwareVersionPlaceholders[$placeholder] = $fullMatch; + $posStart = strpos($originalContent, $fullMatch, $searchOffset); + $searchOffset = $posStart !== false ? $posStart + strlen($fullMatch) : $searchOffset; + return $placeholder; + }, + $correctedContent + ); + } + + //保护特殊小数 + $specialDecimalPattern = '~ + a=\s*[\d+\.\d+[A-Za-z]+\d*\-+]+ + |\b\d+\.\d+[A-Za-z]+\d*\b + |\b\d+\.\d+[-+]\d+\.\d+[A-Za-z]+\d*\b + |\b\d+\.\d+[-+]\d+\.\d+\b + ~ux'; + if (@preg_match($specialDecimalPattern, '') !== false) { + $correctedContent = preg_replace_callback( + $specialDecimalPattern, + function ($matches) use (&$specialDecimalPlaceholders, $originalContent, &$searchOffset) { + $fullMatch = $matches[0]; + $placeholder = '___SPECIAL_DECIMAL_' . uniqid() . '___'; + $specialDecimalPlaceholders[$placeholder] = $fullMatch; + $posStart = strpos($originalContent, $fullMatch, $searchOffset); + $searchOffset = $posStart !== false ? $posStart + strlen($fullMatch) : $searchOffset; + return $placeholder; + }, + $correctedContent + ); + } + + // 保护年份/年月格式(2023、202309、2023-0021等) + $dateRelatedPattern = '~ + \b(?:20\d{2}|20\d{2}(0[1-9]|1[0-2])|20\d{2}-00\d{2})\b(?!\s*[A-Za-z]|\.) + ~ux'; + if (@preg_match($dateRelatedPattern, '') !== false) { + $correctedContent = preg_replace_callback( + $dateRelatedPattern, + function ($matches) use (&$dateRelatedPlaceholders, $originalContent, &$searchOffset) { + $fullMatch = $matches[0]; + $placeholder = '___DATE_PROTECT_' . uniqid() . '___'; + $dateRelatedPlaceholders[$placeholder] = $fullMatch; + $posStart = strpos($originalContent, $fullMatch, $searchOffset); + $searchOffset = $posStart !== false ? $posStart + strlen($fullMatch) : $searchOffset; + return $placeholder; + }, + $correctedContent + ); + } + + //6. 保护0.00Ac类格式(如1.20mL、0.50mg,避免误删末尾零) + $decimalAlphaPattern = '~ + \b(?:\d+\.\d+[A-Za-z]+|\d+\.[A-Za-z]+)\b(?!\s*[0-9.]) + ~ux'; + if (@preg_match($decimalAlphaPattern, '') !== false) { + $correctedContent = preg_replace_callback( + $decimalAlphaPattern, + function ($matches) use (&$decimalAlphaPlaceholders, $originalContent, &$searchOffset) { + $fullMatch = $matches[0]; + $placeholder = '___DECIMAL_ALPHA_' . uniqid() . '___'; + $decimalAlphaPlaceholders[$placeholder] = $fullMatch; + $posStart = strpos($originalContent, $fullMatch, $searchOffset); + $searchOffset = $posStart !== false ? $posStart + strlen($fullMatch) : $searchOffset; + return $placeholder; + }, + $correctedContent + ); + } + + //7. 保护通用前缀格式(如ID 123、REF AB456) + $universalPrefixPattern = '~ + (?:^|\s|\() + (?:(?!No\.|NO\.|PO|SO|SN|BN|REF|ORD|ID|PID)[A-Za-z]{1,3}(?:s?\.?)) + \s* + (?:[A-Za-z]+\d+|\d+[A-Za-z]+|[A-Za-z]+\d+[A-Za-z]+|\d{1,3}(?:,\d{3})*|\d+) + (?:$|\s|\)|\,|\.) + ~ux'; + if (@preg_match($universalPrefixPattern, '') !== false) { + $correctedContent = preg_replace_callback( + $universalPrefixPattern, + function ($matches) use (&$prefixFormatPlaceholders, $originalContent, &$searchOffset) { + $fullMatch = $matches[0]; + $placeholder = '___UNIVERSAL_PREFIX_' . uniqid() . '___'; + $prefixFormatPlaceholders[$placeholder] = $fullMatch; + $posStart = strpos($originalContent, $fullMatch, $searchOffset); + $searchOffset = $posStart !== false ? $posStart + strlen($fullMatch) : $searchOffset; + return $placeholder; + }, + $correctedContent + ); + } + + // 保护URL/DOI(避免链接中的数字被误加千分位) + $urlDoiPattern = '#([^\w]|^)(https?://[^<>\s]+|doi:\s{0,10}\d{1,10}\.\d{1,10}/[A-Za-z0-9]{1,30})([^\w]|$)#i'; + if (@preg_match($urlDoiPattern, '') !== false) { + $correctedContent = preg_replace_callback( + $urlDoiPattern, + function ($matches) use (&$urlDoiPlaceholders, $originalContent, &$searchOffset) { + $fullMatch = $matches[0]; + $placeholder = '___URL_DOI_' . uniqid() . '___'; + $urlDoiPlaceholders[$placeholder] = $fullMatch; + $posStart = strpos($originalContent, $fullMatch, $searchOffset); + $searchOffset = $posStart !== false ? $posStart + strlen($fullMatch) : $searchOffset; + return $placeholder; + }, + $correctedContent + ); + } + + // 小数零处理(仅删除普通小数的无效零,跳过特殊格式) + $decimalTrailingZeroPattern = '~(-?\d+\.\d*[1-9])0+(?!\d|e|E|___DATE_PROTECT_|___DECIMAL_ALPHA_|___UNIVERSAL_PREFIX_|No\.|PO|SO|___SPECIAL_DECIMAL_|___SOFTWARE_VERSION_|___POSTAL_CODE_|___BRACKETED_NUM_|\-|\+|[A-Za-z])~ix'; + preg_match_all($decimalTrailingZeroPattern, $correctedContent, $trailingMatches); + foreach (array_unique($trailingMatches[0]) as $number) { + if (strpos($number, '___POSTAL_CODE_') !== false || strpos($number, '___BRACKETED_NUM_') !== false) { + continue; + } + if (preg_match($decimalTrailingZeroPattern, $number, $numMatch)) { + $replacements[$number] = $numMatch[1]; + $posStart = strpos($originalContent, $number, $searchOffset); + $posEnd = $posStart !== false ? $posStart + strlen($number) : -1; + $searchOffset = $posEnd !== -1 ? $posEnd : $searchOffset; + $currentCorrected = strtr($originalContent, $replacements); + $errors[] = $this->createError( + $number, $numMatch[1], "删除普通小数后末尾无效零", + $originalContent, $currentCorrected, $posStart, $posEnd, 'invalid_zero' + ); + } + } + + $decimalAllZeroPattern = '~(-?\d+)\.0+(?!\d|e|E|___DATE_PROTECT_|___DECIMAL_ALPHA_|___UNIVERSAL_PREFIX_|No\.|PO|SO|___SPECIAL_DECIMAL_|___SOFTWARE_VERSION_|___POSTAL_CODE_|___BRACKETED_NUM_|\-|\+|[A-Za-z])~ix'; + preg_match_all($decimalAllZeroPattern, $correctedContent, $allZeroMatches); + foreach (array_unique($allZeroMatches[0]) as $number) { + if (strpos($number, '___POSTAL_CODE_') !== false || strpos($number, '___BRACKETED_NUM_') !== false) { + continue; + } + if (preg_match($decimalAllZeroPattern, $number, $numMatch)) { + $replacements[$number] = $numMatch[1]; + $posStart = strpos($originalContent, $number, $searchOffset); + $posEnd = $posStart !== false ? $posStart + strlen($number) : -1; + $searchOffset = $posEnd !== -1 ? $posEnd : $searchOffset; + $currentCorrected = strtr($originalContent, $replacements); + $errors[] = $this->createError( + $number, $numMatch[1], "删除普通小数后全量无效零", + $originalContent, $currentCorrected, $posStart, $posEnd, 'invalid_zero' + ); } } $correctedContent = strtr($correctedContent, $replacements); @@ -501,54 +625,51 @@ class ProofReadService // 千分位处理 $excludePatterns = implode('|', [ 'https?://[^<>\s]+|doi:\s{0,10}\d{1,10}\.\d{1,10}/[A-Za-z0-9]{1,30}', - '\d{1,3}(,\d{3})+', '[A-Za-z]+\d+|\d+[A-Za-z]+', - '1\d{3}|2\d{3}', '\d{6}', '1[3-9]\d{9}', - '\d{3}[-\s]?\d{3}[-\s]?\d{4}', '\d{1,3}' + '20\d{2}(?:0[1-9]|1[0-2])?(?:0[1-9]|[12]\d|3[01])?|20\d{2}-00\d{2}', + '\d+\.\d+[A-Za-z]+|\d+\.[A-Za-z]+', + '\(\d+[-\d]*\d+\)', + '(?:[A-Za-z]+(?:\s+[A-Za-z]+)*|[\x{4e00}-\x{9fa5}]+)\s+\d{4,6}\b|0\d{2,3}\d{7}\b', + '(?:[A-Za-z]+(?:\s+[A-Za-z]+)*|[\x{4e00}-\x{9fa5}]+(?:\s+[\x{4e00}-\x{9fa5}]+)*)\s+\d+\.\d+(?:\.\d+)*', + '[A-Za-z]{1,3}s?\.?\s*(?:[A-Za-z]+\d+|\d+[A-Za-z]+|\d{1,3}(?:,\d{3})*|\d+)', + 'No\.?\s*\d+|PO\s*\d+|SO\s*\d+|SN\s*\d+', + 'a=\s*[\d+\.\d+[A-Za-z]+\d*\-+]+', + '___DATE_PROTECT_.*?___|___DECIMAL_ALPHA_.*?___|___UNIVERSAL_PREFIX_.*?___|___URL_DOI_.*?___|___SPECIAL_DECIMAL_.*?___|___SOFTWARE_VERSION_.*?___|___POSTAL_CODE_.*?___|___BRACKETED_NUM_.*?___' ]); $thousandPattern = sprintf( - '#\b(?!(?:%s))\d{4,}\b#ixu', + '#(?createError( - // '千分位正则错误', - // '跳过千分位处理', - // "千分位正则错误: {$thousandPattern}", - // $originalContent, - // $correctedContent, - // -1, - // -1 - // ); - } else { + if (@preg_match($thousandPattern, '') !== false) { $correctedContent = preg_replace_callback( $thousandPattern, - function (array $matches) use (&$replacements, &$errors, $originalContent, &$searchOffset): string { + function ($matches) use (&$replacements, $originalContent, &$searchOffset, &$errors) { $original = $matches[0]; - if (isset($replacements[$original]) || strpos($original, ',') !== false) { + if (preg_match('~20\d{2}(0[1-9]|1[0-2])?(0[1-9]|[12]\d|3[01])?|20\d{2}-00\d{2}|(?:[A-Za-z]+|[\x{4e00}-\x{9fa5}]+)\s*\d{4,6}\b|0\d{2,3}\d{7}\b~u', $original)) { + return $original; + } + $isProtected = strpos($original, '___DATE_PROTECT_') !== false + || strpos($original, '___DECIMAL_ALPHA_') !== false + || strpos($original, '___UNIVERSAL_PREFIX_') !== false + || strpos($original, '___SPECIAL_DECIMAL_') !== false + || strpos($original, '___SOFTWARE_VERSION_') !== false + || strpos($original, '___POSTAL_CODE_') !== false + || strpos($original, '___BRACKETED_NUM_') !== false + || strpos($original, 'No.') !== false + || strpos($original, 'PO') !== false + || strpos($original, 'SO') !== false; + if (isset($replacements[$original]) || strpos($original, ',') !== false || $isProtected) { return $original; } - $formatted = number_format($original); $replacements[$original] = $formatted; - - // 计算千分位数字在原始文本中的位置 $posStart = strpos($originalContent, $original, $searchOffset); - $posEnd = ($posStart !== false) ? $posStart + strlen($original) : -1; - $searchOffset = ($posEnd !== -1) ? $posEnd : $searchOffset + strlen($original); - + $posEnd = $posStart !== false ? $posStart + strlen($original) : -1; + $searchOffset = $posEnd !== -1 ? $posEnd : $searchOffset; $currentCorrected = strtr($originalContent, $replacements); - $errorType = 'thousandth_separator'; $errors[] = $this->createError( - $original, - $formatted, - "4位及以上整数添加千分位分隔符", - $originalContent, - $currentCorrected, - $posStart, - $posEnd, - $errorType - + $original, $formatted, "四位及以上的数字需要每三位加一个逗号", + $originalContent, $currentCorrected, $posStart, $posEnd, 'thousandth_separator' ); return $formatted; }, @@ -556,136 +677,291 @@ class ProofReadService ); } - // 恢复URL/DOI(新增位置记录) - $restoreFailed = []; - if (!empty($urlDoiPlaceholders)) { - $correctedContent = strtr($correctedContent, $urlDoiPlaceholders); - - if (preg_match_all('#___URL_DOI_.*?___#', $correctedContent, $remaining)) { - foreach ($remaining[0] as $marker) { - $original = $urlDoiPlaceholders[$marker] ?? '未知链接'; - $restoreFailed[] = $original; + // 恢复所有保护内容(按优先级反向,避免相互干扰) + $correctedContent = strtr($correctedContent, $bracketedNumPlaceholders); + $correctedContent = strtr($correctedContent, $postalCodePlaceholders); + $correctedContent = strtr($correctedContent, $softwareVersionPlaceholders); + $correctedContent = strtr($correctedContent, $specialDecimalPlaceholders); + $correctedContent = strtr($correctedContent, $dateRelatedPlaceholders); + $correctedContent = strtr($correctedContent, $decimalAlphaPlaceholders); + $correctedContent = strtr($correctedContent, $prefixFormatPlaceholders); + $correctedContent = strtr($correctedContent, $urlDoiPlaceholders); - // 计算残留占位符的位置 - $posStart = strpos($correctedContent, $marker, $searchOffset); - $posEnd = ($posStart !== false) ? $posStart + strlen($marker) : -1; - $searchOffset = ($posEnd !== -1) ? $posEnd : $searchOffset + strlen($marker); - - $correctedContent = str_replace($marker, $original, $correctedContent); - // $errors[] = $this->createError( - // "残留URL/DOI占位符: {$marker}", - // "已恢复为原始内容", - // "URL/DOI恢复不完全,已强制恢复", - // $originalContent, - // $correctedContent, - // $posStart, - // $posEnd - // ); - } - } - } + // 清理残留占位符(防止异常情况下占位符未替换) + $correctedContent = preg_replace('~___(BRACKETED_NUM|POSTAL_CODE|SOFTWARE_VERSION|SPECIAL_DECIMAL|DATE_PROTECT|DECIMAL_ALPHA|UNIVERSAL_PREFIX|URL_DOI)_.*?___~', '', $correctedContent); $this->handleErrors($errors); return is_string($correctedContent) ? $correctedContent : $defaultReturn; } - /** - * 时间单位缩写校对 + * No. 123456格式统一 */ - private function checkTimeUnitAbbreviations($content) { - // 初始化错误数组(统一格式) + private function checkNoFormatUniformity($content) { $errors = []; - // 严格输入验证:空内容/非字符串直接返回 if (!is_string($content) || trim($content) === '') { $this->handleErrors($errors); return $content; } $corrected = $content; - $replaceMap = []; // 存储替换映射 + $replaceMap = []; $originalContent = $corrected; - $searchOffset = 0; // 用于计算错误位置的偏移量(避免重复定位) + $searchOffset = 0; - // 定义时间单位转换规则 + // 关键:精准排除规则 + $postalCodePattern = '~(?:[A-Za-z]+(?:\s+[A-Za-z]+)*|[\x{4e00}-\x{9fa5}]+)\s+\d{4,6}\b~u'; // 邮编 + $areaCodePattern = '~0\d{2,3}\d{7}\b~u'; // 区号 + $urlPattern = '~https?://[^<>\s]+~i'; // URL(如https://test.com/10.1101/2024.11.10) + $doiPattern = '~doi:\s{0,10}\d{1,10}\.\d{1,10}/[A-Za-z0-9\-_]+~i'; // DOI(如doi:11.1/1-1-1-1-9_2) + + $batchNumberRules = [ + [ + 'name' => 'No.前缀批号', + 'pattern' => '~ + \b + (?:[Nn][Oo]\.|[Nn][Oo]|NO\.|NO) + \s* + (\d+[A-Za-z0-9\-_]*) + \b + (?!\s*[年月日]|20\d{2}(?:0[1-9]|1[0-2])?|\.\d+|20\d{2}-00\d{2} + |https?://[^<>\s]+ # 排除URL + |doi:\s{0,10}\d{1,10}\.\d{1,10}/[A-Za-z0-9\-_]+) # 排除DOI + ~ux', + 'standardPrefix' => 'No.', + 'spaceAfterPrefix' => true, + 'description' => '带No.前缀的编号(如No. 123、NO.45-A)' + ], + [ + 'name' => '业务前缀批号', + 'pattern' => '~ + \b + (PO|SO|SN|BN|REF|ORD|ID|PID) + \s* + (\d+[A-Za-z0-9\-_]*) + \b + (?!\s*[年月日]|20\d{2}(?:0[1-9]|1[0-2])?|\.\d+|20\d{2}-00\d{2} + |https?://[^<>\s]+ # 排除URL + |doi:\s{0,10}\d{1,10}\.\d{1,10}/[A-Za-z0-9\-_]+) # 排除DOI + ~iux', + 'standardPrefix' => function($match) { + return strtoupper($match[1]); + }, + 'spaceAfterPrefix' => true, + 'description' => '带业务前缀的编号' + ], + // [ + // 'name' => '多段式批号', + // 'pattern' => '~ + // \b + // (?:\d+[A-Za-z]?[-_/])+ + // \d+[A-Za-z]? + // \b + // (?!\s*[年月日]|20\d{2}(?:0[1-9]|1[0-2])?|20\d{2}-00\d{2} + // |\d+\.\d+[A-Za-z]+ + // |https?://[^<>\s]+ # 排除URL + // |doi:\s{0,10}\d{1,10}\.\d{1,10}/[A-Za-z0-9\-_]+) # 排除DOI + // ~ux', + // 'standardize' => function($original) use ($postalCodePattern, $areaCodePattern, $urlPattern, $doiPattern) { + // // 排除URL、DOI、邮编、区号、日期 + // if (preg_match('~20\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])?|20\d{2}-00\d{2}~', $original) + // || preg_match($postalCodePattern, $original) + // || preg_match($areaCodePattern, $original) + // || preg_match($urlPattern, $original) + // || preg_match($doiPattern, $original)) { + // return $original; + // } + // return preg_replace(['~[-_/]+~', '~\s+~'], ['-', ''], $original); + // }, + // 'description' => '多段式编号(如2023-AB-123、XY_456-78)' + // ], + [ + 'name' => '混合批号', + 'pattern' => '~ + \b + (?: + \d{6,}(?!20\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])?|20\d{2}-00\d{2} + |(?<=[A-Za-z\s])\d{4,6}\b # 排除邮编 + |0\d{2,3}\d{7}\b # 排除区号 + |https?://[^<>\s]+ # 排除URL + |doi:\s{0,10}\d{1,10}\.\d{1,10}/[A-Za-z0-9\-_]+) # 排除DOI + |[A-Za-z]{2,}\d{4,} + |[A-Za-z0-9]{8,}(?!20\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])?|20\d{2}-00\d{2} + |https?://[^<>\s]+ # 排除URL + |doi:\s{0,10}\d{1,10}\.\d{1,10}/[A-Za-z0-9\-_]+) # 排除DOI + ) + \b + (?!\s*[年月日]) + (?!\.\d+) + (?!\d+\.\d+[A-Za-z]+) + (?!(?:^|\s|\()(?:[A-Za-z]{1,3}(?:s?\.?))\s*) + ~ux', + 'standardize' => function($original) use ($postalCodePattern, $areaCodePattern, $urlPattern, $doiPattern) { + // 排除URL、DOI、邮编、区号、日期 + if (preg_match('~20\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])?|20\d{2}-00\d{2}~', $original) + || preg_match($postalCodePattern, $original) + || preg_match($areaCodePattern, $original) + || preg_match($urlPattern, $original) + || preg_match($doiPattern, $original)) { + return $original; + } + return ctype_digit($original) ? $original : $original; + }, + 'description' => '纯数字/字母混合编号' + ] + ]; + + foreach ($batchNumberRules as $rule) { + if (@preg_match($rule['pattern'], '') === false) continue; + if (preg_match_all($rule['pattern'], $corrected, $matches, PREG_SET_ORDER)) { + foreach ($matches as $match) { + $originalFull = $match[0]; + $fixedFull = $originalFull; + + // 核心排除逻辑:新增URL和DOI的判断 + if (preg_match($postalCodePattern, $originalFull) + || preg_match($areaCodePattern, $originalFull) + || preg_match('~20\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])?|20\d{2}-00\d{2}~', $originalFull) + || preg_match($urlPattern, $originalFull) // 跳过URL + || preg_match($doiPattern, $originalFull)) { // 跳过DOI + continue; + } + + if (isset($rule['standardPrefix'])) { + preg_match($rule['pattern'], $originalFull, $parts); + $body = $parts[1]; + $standardPrefix = is_callable($rule['standardPrefix']) ? $rule['standardPrefix']($parts) : $rule['standardPrefix']; + $space = $rule['spaceAfterPrefix'] ? ' ' : ''; + $fixedFull = $standardPrefix . $space . $body; + } elseif (isset($rule['standardize']) && is_callable($rule['standardize'])) { + $fixedFull = $rule['standardize']($originalFull); + } + + if ($originalFull !== $fixedFull && !isset($replaceMap[$originalFull])) { + $replaceMap[$originalFull] = $fixedFull; + $posStart = strpos($originalContent, $originalFull, $searchOffset); + $posEnd = $posStart !== false ? $posStart + strlen($originalFull) : -1; + $searchOffset = $posEnd !== -1 ? $posEnd : $searchOffset; + $errorHash = md5($originalFull . $fixedFull); + $errors[$errorHash] = $this->createError( + $originalFull, $fixedFull, + "{$rule['description']}格式不规范,标准格式为「{$fixedFull}」", + $originalContent, strtr($originalContent, $replaceMap), + $posStart, $posEnd, $rule['name'] + ); + } + } + } + } + + $corrected = !empty($replaceMap) ? strtr($corrected, $replaceMap) : $corrected; + $this->handleErrors($errors); + return $corrected; + } + /** + * 时间单位缩写校对 + */ + private function checkTimeUnitAbbreviations($content) { + $errors = []; + if (!is_string($content) || trim($content) === '') { + $this->handleErrors($errors); + return $content; + } + + $corrected = $content; + $replaceMap = []; + $originalContent = $corrected; + $searchOffset = 0; + + // 定义时间单位规则 $timeUnits = [ [ 'full' => 'hour', 'plural' => 'hours', 'abbr' => 'h', - 'description' => '小时' + 'description' => '小时', + 'cn_full' => '小时', // 中文全称 + 'cn_plural' => '小时' // 中文单复数同形 ], [ 'full' => 'minute', 'plural' => 'minutes', 'abbr' => 'min', - 'description' => '分钟' + 'description' => '分钟', + 'cn_full' => '分钟', + 'cn_plural' => '分钟' ], [ 'full' => 'second', 'plural' => 'seconds', 'abbr' => 's', - 'description' => '秒' + 'description' => '秒', + 'cn_full' => '秒', + 'cn_plural' => '秒' ] ]; foreach ($timeUnits as $unit) { - // 合并所有匹配模式为单一正则 - $fullPattern = $unit['full'] . 's?'; - $capitalizedPattern = ucfirst($unit['full']) . 's?'; - $abbrPattern = $unit['abbr'] . '|' . strtoupper($unit['abbr']); - - $combinedPattern = "~(\d+(?:\.\d+)?)(?:\s+|)(" . - "{$fullPattern}|{$capitalizedPattern}|{$abbrPattern}" . - ")\b~i"; + $pattern = "~ + (?createError( - // '时间单位正则错误', - // "跳过{$unit['description']}单位校验", - // "{$unit['description']}单位匹配正则语法错误:{$combinedPattern},已跳过该单位校验", - // $originalContent, - // $corrected, - // -1, - // -1 - // ); + if (@preg_match($pattern, '') === false) { continue; } - // 单次匹配所有相关模式 - if (preg_match_all($combinedPattern, $corrected, $matches, PREG_SET_ORDER)) { + // 仅匹配纯时间场景,排除所有干扰 + if (preg_match_all($pattern, $corrected, $matches, PREG_SET_ORDER)) { foreach ($matches as $match) { - $original = $match[0]; // 原始错误内容(如 "5 Hour"、"3 MIN") - $number = $match[1]; // 数字部分(如 "5"、"3") - $unitPart = $match[2]; // 单位部分(如 "Hour"、"MIN") - $fixed = $number . strtolower($unit['abbr']); // 修正后内容(如 "5h"、"3min") + $original = $match[0]; // 原始内容(如"5 Hour"、"3 分钟"、"2.5 S") + $number = $match[1]; // 数字部分 + $unitPart = $match[2]; // 单位部分 + $fixed = $number . strtolower($unit['abbr']); // 标准格式(5h、3min、2.5s) - // 仅处理需要修正的情况 + // 仅处理非标准格式 if ($original !== $fixed) { - // 确定错误类型(细化错误原因) - if (stripos($unitPart, $unit['full']) !== false) { - $errorReason = "应使用缩写形式'{$unit['abbr']}'"; + // 细化错误原因 + if (stripos($unitPart, $unit['full']) !== false || strpos($unitPart, $unit['cn_full']) !== false) { + $errorReason = "应使用缩写'{$unit['abbr']}'(不使用全称'{$unitPart}')"; } elseif (strpos($original, ' ') !== false) { - $errorReason = "数字与缩写间不应有空格"; + $errorReason = "数字与单位间不应有空格"; } else { - $errorReason = "单位缩写应使用小写'{$unit['abbr']}'"; + $errorReason = "单位缩写应小写'{$unit['abbr']}'(不使用'{$unitPart}')"; } - // 计算错误内容在原始文本中的位置 + // 计算位置(避免重复定位) $posStart = strpos($originalContent, $original, $searchOffset); $posEnd = ($posStart !== false) ? $posStart + strlen($original) : -1; - $searchOffset = ($posEnd !== -1) ? $posEnd : $searchOffset + strlen($original); // 更新偏移量 + $searchOffset = ($posEnd !== -1) ? $posEnd : $searchOffset + strlen($original); - // 错误信息去重(基于原始内容+修正内容哈希) + // 错误去重 $errorHash = md5($original . $fixed); - $errorType = empty( $unit['full']) ? '' : $unit['full']; + $errorType = $unit['full']; if (!isset($errors[$errorHash])) { $errors[$errorHash] = $this->createError( $original, $fixed, - "{$unit['description']}单位格式不规范:{$errorReason},正确格式为[数字{$unit['abbr']}]", + "{$unit['description']}格式不规范:{$errorReason},标准格式为[数字{$unit['abbr']}](如3h、2.5min)", $originalContent, strtr($originalContent, $replaceMap + [$original => $fixed]), $posStart, @@ -694,7 +970,7 @@ class ProofReadService ); } - // 记录替换映射(去重,避免重复替换) + // 记录替换映射 if (!isset($replaceMap[$original])) { $replaceMap[$original] = $fixed; } @@ -703,13 +979,12 @@ class ProofReadService } } - // 批量高效替换 + // 批量替换并处理错误 if (!empty($replaceMap)) { $corrected = strtr($corrected, $replaceMap); } - - // 统一处理错误 $this->handleErrors($errors); + return $corrected; } @@ -729,39 +1004,37 @@ class ProofReadService $originalContent = $corrected; // 保存完整原始内容 $searchOffset = 0; // 用于计算错误位置的偏移量(避免重复定位) - // 优化正则规则(精准匹配毫升单位,支持数字前缀和纯单位场景) - $mlPattern = '/\b(\d+(?:\.\d+)?\s*)?(ml)\b/i'; + // 优化正则规则: + // 1. 排除字母后接ML(如Yeh ML,ML为人名缩写) + // 2. 精准匹配毫升单位(支持数字前缀如“5ml”“3.0 ML”,或纯单位如“ml”“ML”) + $mlPattern = '/ + (?createError( - // '毫升单位正则错误', - // '跳过毫升单位校验', - // "毫升单位匹配正则语法错误:{$mlPattern},已跳过该校验流程", - // $originalContent, - // $corrected, - // -1, - // -1 - // ); + } elseif (preg_match_all($mlPattern, $corrected, $allMatches, PREG_SET_ORDER)) { foreach ($allMatches as $matchItem) { - $originalFull = $matchItem[0]; // 原始错误内容(如 "5ml"、" ML") - $prefix = $matchItem[1] ?? ''; // 数字前缀(如 "5"、"3.0 ") - $originalUnit = strtolower($matchItem[2]); // 单位部分("ml") + $originalFull = $matchItem[0]; // 原始错误内容(如 "5ml"、" ML"、"2.5 mL") + $prefix = $matchItem[1] ?? ''; // 数字前缀(如 "5"、"3.0 "、"") + $originalUnit = strtolower($matchItem[2]); // 单位部分(统一转小写为"ml") - // 标准毫升单位格式(L大写) + // 标准毫升单位格式(L大写为"mL") $fixedFull = "{$prefix}mL"; $errorType = 'mL'; - // 仅处理与标准格式不一致的场景 + + // 仅处理与标准格式不一致的场景(避免无意义替换) if ($originalFull !== $fixedFull) { - // 计算错误内容在原始文本中的位置 + // 计算错误内容在原始文本中的精准位置(基于偏移量避免重复) $posStart = strpos($originalContent, $originalFull, $searchOffset); $posEnd = ($posStart !== false) ? $posStart + strlen($originalFull) : -1; - $searchOffset = ($posEnd !== -1) ? $posEnd : $searchOffset + strlen($originalFull); // 更新偏移量 + $searchOffset = ($posEnd !== -1) ? $posEnd : $searchOffset + strlen($originalFull); - // 错误去重(哈希机制) + // 错误去重(通过“原始内容+修正内容”的哈希避免重复记录) $errorHash = md5($originalFull . $fixedFull); if (!isset($errors[$errorHash])) { $errors[$errorHash] = $this->createError( @@ -776,14 +1049,14 @@ class ProofReadService ); } - // 记录替换映射(去重) + // 记录替换映射(去重,避免同一内容多次替换) if (!isset($replaceMap[$originalFull])) { $replaceMap[$originalFull] = $fixedFull; } } } - // 批量替换 + // 批量替换所有不规范单位(高效处理,避免循环替换) if (!empty($replaceMap)) { $corrected = strtr($corrected, $replaceMap); } @@ -814,16 +1087,7 @@ class ProofReadService // 正则有效性校验 if (@preg_match($pValuePattern, '') === false) { - // // 正则错误:位置默认-1 - // $errors[] = $this->createError( - // 'P值正则错误', - // '跳过P值斜体校验', - // "P值匹配正则语法错误:{$pValuePattern},已跳过该校验流程", - // $originalContent, - // $corrected, - // -1, - // -1 - // ); + } elseif (preg_match_all($pValuePattern, $corrected, $allMatches, PREG_SET_ORDER)) { foreach ($allMatches as $matchItem) { $original = $matchItem[0]; // 原始P值内容(如 "P=0.05"、"p < 0.01") @@ -874,100 +1138,6 @@ class ProofReadService return $corrected; } - /** - * No. 123456格式统一 - */ - private function checkNoFormatUniformity($content) { - $errors = []; - // 严格输入验证:空内容/非字符串直接返回(保持与checkTextFormat一致) - if (!is_string($content) || trim($content) === '') { - $this->handleErrors($errors); - return $content; - } - - $corrected = $content; - $replaceMap = []; - $originalContent = $corrected; // 备份完整原始内容,用于错误信息的"original"字段 - $searchOffset = 0; // 用于计算错误位置的偏移量(避免重复定位同一错误) - - // 正则规则(精准匹配No.格式,包含大小写、空格、数字场景,如 NO.123、no. 456 等) - $combinedPattern = '/\b([Nn][Oo]\.)(\s*)(\d+)\b/'; - // 正则有效性校验(避免无效正则导致崩溃,与checkTextFormat逻辑一致) - if (@preg_match($combinedPattern, '') === false) { - // // 正则错误:位置默认-1 - // $errorHash = md5('no_format_regex_error'); - // $errors[$errorHash] = $this->createError( - // 'No.格式正则错误', // verbatim_texts:具体错误标识 - // '跳过No.格式校验', // revised_content:处理结果 - // "No.格式匹配正则语法错误:{$combinedPattern},已跳过该校验流程", // explanation:错误说明 - // $originalContent, // original:完整原始内容 - // $corrected, // corrected:当前完整修正内容(未处理,故与原始一致) - // -1, // position_start:默认-1(定位失败) - // -1 // position_end:默认-1(定位失败) - // ); - } elseif (preg_match_all($combinedPattern, $corrected, $matches, PREG_SET_ORDER)) { - foreach ($matches as $item) { - $originalFull = $item[0]; // 匹配到的单个错误片段(如 "NO.123"、"no. 456") - $originalPrefix = $item[1]; // 前缀部分(如 "NO."、"no.") - $spaces = $item[2]; // 空格部分(如空、单个空格、多个空格) - $number = $item[3]; // 数字部分(如 "123"、"456") - - // 标准化格式:No.(首字母大写+o小写+点) + 1个空格 + 数字 - $fixedPrefix = 'No.'; - $fixedSpaced = ' '; - $fixedFull = "{$fixedPrefix}{$fixedSpaced}{$number}"; // 单个错误片段的修正结果 - - // 仅处理与标准格式不一致的场景(避免无意义的替换和错误记录) - if ($originalFull !== $fixedFull) { - // 计算错误片段在完整原始文本中的位置 - $posStart = strpos($originalContent, $originalFull, $searchOffset); - $posEnd = ($posStart !== false) ? $posStart + strlen($originalFull) : -1; - $searchOffset = ($posEnd !== -1) ? $posEnd : $searchOffset + strlen($originalFull); // 更新偏移量,避免重复定位 - - // 细化错误原因(分场景说明,提升可读性) - $errorReasons = []; - if ($originalPrefix !== $fixedPrefix) { - $errorReasons[] = "前缀格式不规范(应使用 \"No.\",当前为 \"{$originalPrefix}\")"; - } - if (trim($spaces) !== $fixedSpaced || strlen($spaces) !== 1) { - $errorReasons[] = empty($spaces) - ? '缺少空格(.后需加1个空格)' - : "空格数量不规范(当前为 " . strlen($spaces) . " 个,应保留1个空格)"; - } - - // 记录替换映射(去重,避免重复处理相同错误片段) - if (!isset($replaceMap[$originalFull])) { - $replaceMap[$originalFull] = $fixedFull; - } - - // 错误信息去重(基于单个错误片段的原始值+修正值哈希,避免重复记录) - $errorHash = md5($originalFull . $fixedFull); - $errorType = 'No.'; - if (!isset($errors[$errorHash])) { - $errors[$errorHash] = $this->createError( - $originalFull, // verbatim_texts:具体错误片段 - $fixedFull, // revised_content:错误片段的修正结果 - 'No. 格式不规范,正确格式为「No. 数字」', // explanation:错误说明 - // 'No. 格式不规范:' . implode(',', $errorReasons) . ',正确格式为「No. 数字」', // explanation:错误说明 - $originalContent, // original:完整原始内容(整个输入文本) - strtr($originalContent, $replaceMap), // corrected:完整修正内容(基于当前替换映射生成) - $posStart, // position_start:错误起始位置 - $posEnd, // position_end:错误结束位置 - $errorType //错误类型 - ); - } - } - } - // 批量替换所有错误片段(高效处理,避免循环内重复替换) - if (!empty($replaceMap)) { - $corrected = strtr($corrected, $replaceMap); - } - } - - $this->handleErrors($errors); - return $corrected; - } - /** * 图表标题一律使用全称Figure 1, Table 1.不能写成Fig. 1, Tab 1. */ @@ -989,15 +1159,7 @@ class ProofReadService // 正则有效性校验 if (@preg_match($titlePattern, '') === false) { - // $errors[] = $this->createError( - // '图表标题正则错误', - // '跳过图表标题格式校验', - // "图表标题匹配正则语法错误:{$titlePattern},已跳过该校验流程", - // $originalContent, - // $corrected, - // -1, - // -1 - // ); + } else { // 全局匹配所有图表标题格式 $matchCount = preg_match_all($titlePattern, $corrected, $allMatches, PREG_SET_ORDER);