From 962dfd465e639638a177c9053d399d667acae67b Mon Sep 17 00:00:00 2001 From: chengxl Date: Mon, 13 Oct 2025 16:17:26 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A0=A1=E5=AF=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- application/common/ProofReadService.php | 1236 +++++++++++++++++++++++ 1 file changed, 1236 insertions(+) create mode 100644 application/common/ProofReadService.php diff --git a/application/common/ProofReadService.php b/application/common/ProofReadService.php new file mode 100644 index 0000000..2a21ac3 --- /dev/null +++ b/application/common/ProofReadService.php @@ -0,0 +1,1236 @@ +errors = []; + $this->excludedFormats = []; + $correctedContent = $content; + + //时间单位缩写校对 + $correctedContent = $this->checkTimeUnitAbbreviations($correctedContent); + //横线/运算符校对 + $correctedContent = $this->checkTextFormat($correctedContent); + //数字格式校对 + $correctedContent = $this->checkNumberFormat($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); + //检测参考文献是否能打开 + // $correctedContent = $this->checkDoi($correctedContent); + //判断是否为空错误信息 + if(empty($this->errors)){ + return []; + } + return [ + 'proof_before' => $content, + 'proof_after' => $correctedContent, + 'errors' => $this->errors + ]; + } + + /** + * 横线/运算符校对/数字和单位(高可用版) + */ + private function checkTextFormat($content) { + // 初始化错误数组 + $errors = []; + $defaultReturn = $content; + $originalContent = $content; // 保存完整原始内容 + $searchOffsetForExclude = 0; // 【新增】仅用于「特殊内容过滤」的偏移量 + $searchOffsetForCore = 0; // 【新增】仅用于「核心规则处理」的偏移量 + + // 验证数据 + if (!is_string($content) || trim($content) === '') { + $this->handleErrors($errors); + return $defaultReturn; + } + + $corrected = $content; + $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) { + // 正则错误处理(不变) + } elseif (preg_match_all($mathTagRegex, $corrected, $matches, PREG_SET_ORDER)) { + usort($matches, function($a, $b) { + return strlen($b[0]) - strlen($a[0]); + }); + + foreach ($matches as $index => $match) { + $fullTag = $match[0]; + $tagType = $match[1]; + $marker = "___EXCLUDE_{$tagType}_" . time() . "_{$index}___"; + $excludeMarkers[$marker] = $fullTag; + + // 【修改】使用独立偏移量 $searchOffsetForExclude + $posStart = strpos($originalContent, $fullTag, $searchOffsetForExclude); + $posEnd = ($posStart !== false) ? $posStart + strlen($fullTag) : -1; + $searchOffsetForExclude = ($posEnd !== -1) ? $posEnd : $searchOffsetForExclude + strlen($fullTag); + + $safeFullTag = preg_quote($fullTag, '~'); + $corrected = preg_replace("~{$safeFullTag}~u", $marker, $corrected, 1); + } + } + + // 过滤 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'; + if (@preg_match($urlDoiRegex, '') === false) { + // 正则错误处理(不变) + } elseif (preg_match_all($urlDoiRegex, $corrected, $matches, PREG_SET_ORDER)) { + 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 + $posStart = strpos($originalContent, $original, $searchOffsetForExclude); + $posEnd = ($posStart !== false) ? $posStart + strlen($original) : -1; + $searchOffsetForExclude = ($posEnd !== -1) ? $posEnd : $searchOffsetForExclude + strlen($original); + + $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, + $matches, + PREG_SET_ORDER | PREG_OFFSET_CAPTURE + ); + if ($matchCount === 0) { + continue; + } + + foreach ($matches as $match) { + $original = $match[0][0]; // 匹配到的原始内容(如 (40 × 33)) + $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 中匹配位置前的内容,计算其在原始文本中的长度(排除占位符影响) + $prefixInCorrected = substr($corrected, 0, $offsetInCorrected); + // 3. 替换占位符为原始内容,得到与 $originalContent 对应的前缀 + $prefixInOriginal = strtr($prefixInCorrected, $excludeMarkers); + // 4. 原始位置 = 前缀长度(确保精准对应) + $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; + } + } + } + + // 生成修正内容 + $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; + + // 生成错误信息 + $currentCorrected = str_replace($original, $fixed, $corrected); + $errors[] = $this->createError( + $original, + $fixed, + $rule['explanation'], + $originalContent, + $currentCorrected, + $posStart, + $posEnd + ); + $processedHashes[$hash] = true; + $corrected = $currentCorrected; + } + } + } + + // 批量还原 / 和 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); + } + } + } + + // 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() { + return [ + // ====================== 1. 括号内数字范围规则(优先级最高,避免与其他减号规则冲突) ====================== + [ + 'pattern' => '~(\[\s*[-]?\d+\s*)\x{2014}\s*(\d+\s*\])~u', // 匹配长划线(—) + 'replacement' => '$1-$2', // 替换为短划线(-) + 'verbatim_texts' => '带括号数字范围使用长划线(—)不规范', + 'explanation' => '带括号的数字范围应使用短划线(-),如 [1-5]' + ], + [ + 'pattern' => '~(\[\s*[-]?\d+\s*)-\s*(\d+\s*\])~u', // 匹配连接符(-)及可能的空格 + 'replacement' => '$1-$2', // 统一为无空格短划线(-) + 'verbatim_texts' => '带括号数字范围使用连接符(-)格式不规范', + 'explanation' => '带括号的数字范围应使用短划线(-)且前后无空格,如 [2-5]' + ], + [ + 'pattern' => '~(\[\s*[-]?\d+)\s+-\s*(\d+\s*\])~u', // 短划线前多余空格 + 'replacement' => '$1-$2', // 移除前导空格 + 'verbatim_texts' => '数字范围短划线前有多余空格', + 'explanation' => '带括号数字范围的短划线(-)前不应留空格,如 [3-5]' + ], + [ + 'pattern' => '~(\[\s*[-]?\d+)\s*-\s+(\d+\s*\])~u', // 短划线后多余空格 + 'replacement' => '$1-$2', // 移除后导空格 + 'verbatim_texts' => '数字范围短划线后有多余空格', + 'explanation' => '带括号数字范围的短划线(-)后不应留空格,如 [4-5]' + ], + + // ====================== 2. 无括号数字范围规则(次高优先级,避免与减号运算规则冲突) ====================== + [ + 'pattern' => '~(\b\d+)\s*—\s*(\d+\b)~u', // 匹配长划线(—) + 'replacement' => '$1-$2', // 替换为短划线(-) + 'verbatim_texts' => '无括号数字范围使用长划线(—)不规范', + 'explanation' => '无括号的数字范围应使用短划线(-),如 5-6' + ], + [ + 'pattern' => '~(\b\d+)\s*-\s*(\d+\b)~u', // 匹配连接符(-)及可能的空格 + 'replacement' => '$1-$2', // 统一为无空格短划线(-) + 'verbatim_texts' => '无括号数字范围使用连接符(-)格式不规范', + 'explanation' => '无括号的数字范围应使用短划线(-)且前后无空格,如 5-7' + ], + + // ====================== 3. 运算符空格规则(按「复合→独立」顺序,避免冲突) ====================== + [ + 'pattern' => '~(\S)\s*([<>!]=|===|!==)\s*(\S)~u', // 复合运算符(>=、<=、==、!=、===、!==) + 'replacement' => '$1 $2 $3', + 'verbatim_texts' => '复合运算符前后空格不规范', + 'explanation' => '复合运算符(>=、<=、==、!=、===、!==)前后应各留一个空格,如 x >= 5' + ], + [ + 'pattern' => '~(?|\*|\+|-|/)(\S+?)\s*=\s*(\S+?)(?!=|<|>|\*|\+|-|/)~u', + // 捕获组说明: + // $1:等号前内容(非空字符,避免匹配空格) + // $2:等号后内容(非空字符,避免匹配空格) + // 前后否定断言:排除与其他运算符(如+=、*=)的冲突 + 'replacement' => '$1 = $2', // 正确拼接“前内容 + 规范等号 + 后内容” + 'verbatim_texts' => '等号前后空格不规范', + 'explanation' => '独立等号(=)前后应各留一个空格,如 a = 3' // 移除无效的$1/$2 + ], + [ + 'pattern' => '~(\d+)\s*\+\s*(\d+)~u', // 加法运算符(+) + 'replacement' => '$1 + $2', + 'verbatim_texts' => '加法运算符前后空格不规范', + 'explanation' => '加法运算符(+)前后应各留一个空格,如 2 + 3' + ], + [ + 'pattern' => '~(\d+)\s*\*\s*(\d+)~u', // 乘法运算符(*) + 'replacement' => '$1 * $2', + 'verbatim_texts' => '乘法运算符前后空格不规范', + 'explanation' => '乘法运算符(*)前后应各留一个空格,如 3 * 4' + ], + [ + 'pattern' => '~(\d+)\s*/\s*(\d+)~u', // 除法运算符(/) + 'replacement' => '$1 / $2', + 'verbatim_texts' => '除法运算符前后空格不规范', + 'explanation' => '除法运算符(/)前后应各留一个空格,如 8 / 2' + ], + [ + 'pattern' => '~ + (? '$1 - $2', + 'verbatim_texts' => '减法运算符前后空格不规范', + 'explanation' => '减法运算符(-)前后应各留一个空格(非数字范围场景),如 5 - 3' + ], + + // ====================== 4. 特殊符号规则(低优先级,避免干扰核心格式) ====================== + [ + 'pattern' => '~(\d+)\s+%~u', // 数字与百分号 + 'replacement' => '$1%', + 'verbatim_texts' => '数字与百分号之间有多余空格', + 'explanation' => '数字与百分号(%)之间不应留空格,如 50%' + ], + [ + 'pattern' => '~(\(\s*\d+)\s+×\s+(\d+\s*\))~u', // 先匹配「(数字 × 数字)」场景(带括号) + 'replacement' => '$1×$2', // 修正为「(数字×数字)」,如 (40×33) + 'verbatim_texts' => '带括号的乘号表示倍数时前后有多余空格', + 'explanation' => '带括号的乘号(×)表示倍数关系时前后不应留空格,如 (3×5)' + ], + [ + 'pattern' => '~(\d+)\s+×\s+(\d+)~u', // 再匹配「数字 × 数字」场景(无括号) + 'replacement' => '$1×$2', + 'verbatim_texts' => '乘号表示倍数时前后有多余空格', + 'explanation' => '乘号(×)表示倍数关系时前后不应留空格,如 3×5' + ], + [ + 'pattern' => '~(\d+)\s*\*\s*(\d+)~u', // 星号(*)转乘号(×) + 'replacement' => '$1 × $2', + 'verbatim_texts' => '使用星号(*)作为乘法运算符不规范', + 'explanation' => '乘法运算应使用标准乘号(×)替代星号(*),并前后留空格,如 3 × 5' + ], + [ + 'pattern' => '~(\d+)\s+:\s+(\d+)~u', // 比值符号(:) + 'replacement' => '$1:$2', + 'verbatim_texts' => '比值符号前后有多余空格', + 'explanation' => '比值符号(:)前后不应留空格,如 1:2' + ] + ]; + } + /** + * 数字格式校对 + */ + private function checkNumberFormat($content) { + $errors = []; + $defaultReturn = $content; + $originalContent = $content; + $searchOffset = 0; // 用于计算位置的偏移量(避免重复定位) + + if (!is_string($content) || trim($content) === '') { + $this->handleErrors($errors); + return $defaultReturn; + } + + $correctedContent = $content; + $replacements = []; + $urlDoiPlaceholders = []; + + // 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 { + $correctedContent = preg_replace_callback( + $urlDoiPattern, + function ($matches) use (&$urlDoiPlaceholders, $originalContent, &$errors, &$searchOffset) { + $fullMatch = $matches[0]; + $placeholder = '___URL_DOI_' . time() . '_' . uniqid() . '___'; + $urlDoiPlaceholders[$placeholder] = $fullMatch; + + // 计算URL/DOI在原始文本中的位置 + $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 + // ); + 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; + + 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); + + $currentCorrected = strtr($originalContent, $replacements); + $errors[] = $this->createError( + $number, + $corrected, + "删除小数点后无效零(原始:{$number} → 修正:{$corrected})", + $originalContent, + $currentCorrected, + $posStart, + $posEnd + ); + } + } + } + $correctedContent = strtr($correctedContent, $replacements); + + // 千分位处理 + $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}' + ]); + $thousandPattern = sprintf( + '#\b(?!(?:%s))\d{4,}\b#ixu', + str_replace('#', '\#', $excludePatterns) + ); + + if (@preg_match($thousandPattern, '') === false) { + $errors[] = $this->createError( + '千分位正则错误', + '跳过千分位处理', + "千分位正则错误: {$thousandPattern}", + $originalContent, + $correctedContent, + -1, + -1 + ); + } else { + $correctedContent = preg_replace_callback( + $thousandPattern, + function (array $matches) use (&$replacements, &$errors, $originalContent, &$searchOffset): string { + $original = $matches[0]; + if (isset($replacements[$original]) || strpos($original, ',') !== false) { + 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); + + $currentCorrected = strtr($originalContent, $replacements); + $errors[] = $this->createError( + $original, + $formatted, + "4位及以上整数添加千分位分隔符", + $originalContent, + $currentCorrected, + $posStart, + $posEnd + ); + return $formatted; + }, + $correctedContent + ); + } + + // 恢复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; + + // 计算残留占位符的位置 + $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 + ); + } + } + } + + $this->handleErrors($errors); + return is_string($correctedContent) ? $correctedContent : $defaultReturn; + } + + /** + * 时间单位缩写校对 + */ + 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' => '小时' + ], + [ + 'full' => 'minute', + 'plural' => 'minutes', + 'abbr' => 'min', + 'description' => '分钟' + ], + [ + 'full' => 'second', + 'plural' => 'seconds', + 'abbr' => 's', + 'description' => '秒' + ] + ]; + + 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"; + + // 正则有效性校验 + if (@preg_match($combinedPattern, '') === false) { + // 正则错误:位置默认-1 + $errorHash = md5('time_unit_regex_error_' . $unit['description']); + // $errors[$errorHash] = $this->createError( + // '时间单位正则错误', + // "跳过{$unit['description']}单位校验", + // "{$unit['description']}单位匹配正则语法错误:{$combinedPattern},已跳过该单位校验", + // $originalContent, + // $corrected, + // -1, + // -1 + // ); + continue; + } + + // 单次匹配所有相关模式 + if (preg_match_all($combinedPattern, $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") + + // 仅处理需要修正的情况 + if ($original !== $fixed) { + // 确定错误类型(细化错误原因) + if (stripos($unitPart, $unit['full']) !== false) { + $errorReason = "应使用缩写形式'{$unit['abbr']}'"; + } elseif (strpos($original, ' ') !== false) { + $errorReason = "数字与缩写间不应有空格"; + } else { + $errorReason = "单位缩写应使用小写'{$unit['abbr']}'"; + } + + // 计算错误内容在原始文本中的位置 + $posStart = strpos($originalContent, $original, $searchOffset); + $posEnd = ($posStart !== false) ? $posStart + strlen($original) : -1; + $searchOffset = ($posEnd !== -1) ? $posEnd : $searchOffset + strlen($original); // 更新偏移量 + + // 错误信息去重(基于原始内容+修正内容哈希) + $errorHash = md5($original . $fixed); + if (!isset($errors[$errorHash])) { + $errors[$errorHash] = $this->createError( + $original, + $fixed, + "{$unit['description']}单位格式不规范:{$errorReason},正确格式为'数字{$unit['abbr']}'", + $originalContent, + strtr($originalContent, $replaceMap + [$original => $fixed]), + $posStart, + $posEnd + ); + } + + // 记录替换映射(去重,避免重复替换) + if (!isset($replaceMap[$original])) { + $replaceMap[$original] = $fixed; + } + } + } + } + } + + // 批量高效替换 + if (!empty($replaceMap)) { + $corrected = strtr($corrected, $replaceMap); + } + + // 统一处理错误 + $this->handleErrors($errors); + return $corrected; + } + + + /** + * 毫升单位校对 + */ + private function checkMlUnit($content) { + $errors = []; + if (!is_string($content) || trim($content) === '') { + $this->handleErrors($errors); + return $content; + } + + $corrected = $content; + $replaceMap = []; + $originalContent = $corrected; // 保存完整原始内容 + $searchOffset = 0; // 用于计算错误位置的偏移量(避免重复定位) + + // 优化正则规则(精准匹配毫升单位,支持数字前缀和纯单位场景) + $mlPattern = '/\b(\d+(?:\.\d+)?\s*)?(ml)\b/i'; + + // 正则有效性校验 + if (@preg_match($mlPattern, '') === false) { + // // 正则错误:位置默认-1 + // $errorHash = md5('ml_unit_regex_error'); + // $errors[$errorHash] = $this->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") + + // 标准毫升单位格式(L大写) + $fixedFull = "{$prefix}mL"; + + // 仅处理与标准格式不一致的场景 + if ($originalFull !== $fixedFull) { + // 计算错误内容在原始文本中的位置 + $posStart = strpos($originalContent, $originalFull, $searchOffset); + $posEnd = ($posStart !== false) ? $posStart + strlen($originalFull) : -1; + $searchOffset = ($posEnd !== -1) ? $posEnd : $searchOffset + strlen($originalFull); // 更新偏移量 + + // 错误去重(哈希机制) + $errorHash = md5($originalFull . $fixedFull); + if (!isset($errors[$errorHash])) { + $errors[$errorHash] = $this->createError( + $originalFull, + $fixedFull, + '毫升单位格式不规范,标准写法为"mL"', + $originalContent, + strtr($originalContent, $replaceMap + [$originalFull => $fixedFull]), + $posStart, + $posEnd + ); + } + + // 记录替换映射(去重) + if (!isset($replaceMap[$originalFull])) { + $replaceMap[$originalFull] = $fixedFull; + } + } + } + + // 批量替换 + if (!empty($replaceMap)) { + $corrected = strtr($corrected, $replaceMap); + } + } + + $this->handleErrors($errors); + return $corrected; + } + + + /** + * 显著性P斜体校对 + */ + private function checkPSignificance($content) { + $errors = []; + if (!is_string($content) || trim($content) === '') { + $this->handleErrors($errors); + return $content; + } + + $corrected = $content; + $replaceMap = []; + $originalContent = $corrected; // 保存完整原始内容 + $searchOffset = 0; // 用于计算错误位置的偏移量(避免重复定位) + + // 优化正则规则(覆盖P/p全场景,支持科学计数法) + $pValuePattern = '/\b([Pp])(\s*=?\s*)(\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\b/'; + + // 正则有效性校验 + 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") + $pChar = $matchItem[1]; // P/p字符(如 "P"、"p") + $separator = $matchItem[2];// 分隔符(如 "="、" < ") + $number = $matchItem[3]; // 数值部分(如 "0.05"、"1.2e-3") + + // 生成修正内容(仅P/p加斜体) + $fixed = "{$pChar}{$separator}{$number}"; + + // 仅处理有变化的场景 + if ($original !== $fixed) { + // 计算原始P值内容在完整原始文本中的位置 + $posStart = strpos($originalContent, $original, $searchOffset); + $posEnd = ($posStart !== false) ? $posStart + strlen($original) : -1; + $searchOffset = ($posEnd !== -1) ? $posEnd : $searchOffset + strlen($original); // 更新偏移量 + + // 错误去重(哈希机制) + $errorHash = md5($original . $fixed); + if (!isset($errors[$errorHash])) { + $errors[$errorHash] = $this->createError( + $original, + $fixed, + '显著性P值格式不规范,P/p应使用斜体', + $originalContent, + strtr($originalContent, $replaceMap + [$original => $fixed]), + $posStart, + $posEnd + ); + } + + // 记录替换映射(去重) + if (!isset($replaceMap[$original])) { + $replaceMap[$original] = $fixed; + } + } + } + + // 批量替换 + if (!empty($replaceMap)) { + $corrected = strtr($corrected, $replaceMap); + } + } + + $this->handleErrors($errors); + 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); + if (!isset($errors[$errorHash])) { + $errors[$errorHash] = $this->createError( + $originalFull, // verbatim_texts:具体错误片段 + $fixedFull, // revised_content:错误片段的修正结果 + 'No. 格式不规范:' . implode(',', $errorReasons) . ',正确格式为「No. 数字」', // explanation:错误说明 + $originalContent, // original:完整原始内容(整个输入文本) + strtr($originalContent, $replaceMap), // corrected:完整修正内容(基于当前替换映射生成) + $posStart, // position_start:错误起始位置 + $posEnd // position_end:错误结束位置 + ); + } + } + } + // 批量替换所有错误片段(高效处理,避免循环内重复替换) + if (!empty($replaceMap)) { + $corrected = strtr($corrected, $replaceMap); + } + } + + $this->handleErrors($errors); + return $corrected; + } + + /** + * 图表标题一律使用全称Figure 1, Table 1.不能写成Fig. 1, Tab 1. + */ + private function checkFigureTableTitle($content) { + $errors = []; + // 严格输入验证:空内容/非字符串直接返回 + if (!is_string($content) || trim($content) === '') { + $this->handleErrors($errors); + return $content; + } + + $corrected = $content; + $replaceMap = []; + $originalContent = $corrected; // 备份原始内容,用于错误信息 + $searchOffset = 0; // 错误位置计算偏移量,避免重复定位 + + // 图表标题匹配正则(支持 Fig/Figs/Tab/Tabs、特殊空格、数字范围) + $titlePattern = '/(?createError( + // '图表标题正则错误', + // '跳过图表标题格式校验', + // "图表标题匹配正则语法错误:{$titlePattern},已跳过该校验流程", + // $originalContent, + // $corrected, + // -1, + // -1 + // ); + } else { + // 全局匹配所有图表标题格式 + $matchCount = preg_match_all($titlePattern, $corrected, $allMatches, PREG_SET_ORDER); + + if ($matchCount > 0) { + foreach ($allMatches as $matchItem) { + $originalFull = $matchItem[0]; // 完整错误片段(如 "Fig 1"、"Tabs-2") + $abbrBase = $matchItem[1]; // 缩写主体(Fig/Figs/Tab/Tabs) + $dot = $matchItem[2]; // 可能的点(.) + $space = $matchItem[3]; // 可能的空格(含特殊空格) + $number = $matchItem[4]; // 数字部分(支持范围如 "2-3") + + // 确定全称及错误描述 + switch (strtolower($abbrBase)) { + case 'fig': + $fullName = 'Figure'; + $errorDesc = '图表标题使用缩写"Fig",应改为全称"Figure"'; + break; + case 'figs': + $fullName = 'Figures'; + $errorDesc = '图表标题复数使用缩写"Figs",应改为全称"Figures"'; + break; + case 'tab': + $fullName = 'Table'; + $errorDesc = '表格标题使用缩写"Tab",应改为全称"Table"'; + break; + case 'tabs': + $fullName = 'Tables'; + $errorDesc = '表格标题复数使用缩写"Tabs",应改为全称"Tables"'; + break; + default: + $fullName = ''; + $errorDesc = ''; + continue 2; // 修复警告:跳出 switch + 跳过当前 foreach 迭代 + } + + // 生成标准格式(全称 + 单个空格 + 数字) + $fixed = "{$fullName} {$number}"; + + // 仅处理需要修正的场景(避免无意义操作) + if ($originalFull !== $fixed) { + // 计算错误片段在原始文本中的位置 + $posStart = strpos($originalContent, $originalFull, $searchOffset); + $posEnd = ($posStart !== false) ? $posStart + strlen($originalFull) : -1; + $searchOffset = ($posEnd !== -1) ? $posEnd : $searchOffset + strlen($originalFull); + + // 错误信息去重(基于原始+修正内容哈希) + $errorHash = md5($originalFull . $fixed); + if (!isset($errors[$errorHash])) { + // 生成临时修正内容,用于错误信息预览 + $tempReplace = $replaceMap; + $tempReplace[$originalFull] = $fixed; + $currentCorrected = strtr($originalContent, $tempReplace); + + $errors[$errorHash] = $this->createError( + $originalFull, + $fixed, + $errorDesc, + $originalContent, + $currentCorrected, + $posStart, + $posEnd + ); + } + + // 记录替换规则(去重,避免重复替换) + if (!isset($replaceMap[$originalFull])) { + $replaceMap[$originalFull] = $fixed; + } + } + } + + // 批量执行所有替换(高效处理) + if (!empty($replaceMap)) { + $corrected = strtr($corrected, $replaceMap); + } + } + } + + // 处理错误信息(需确保 handleErrors 方法已实现) + $this->handleErrors($errors); + return $corrected; + } + + + + /** + * 添加错误信息 + */ + private function addError($error = []) { + if (!empty($error) && is_array($error)) { + // 确保错误信息结构完整 + $safeError = array_merge([ + 'verbatim_texts' => '', + 'revised_content' => '', + 'explanation' => '', + 'original' => '', + 'corrected' => '', + 'position_start' => '', + 'position_end' => '', + ], $error); + $this->errors[] = $safeError; + } + } + + /** + * 处理错误信息(去重和存储) + */ + private function handleErrors($errors) { + if (empty($errors)) return; + + // 错误去重 + $uniqueErrors = []; + foreach ($errors as $error) { + $errorHash = md5($error['verbatim_texts'] . $error['revised_content']. $error['position_start']. $error['position_end']); + if (!isset($uniqueErrors[$errorHash])) { + $uniqueErrors[$errorHash] = $error; + } + } + + // 批量添加错误 + foreach (array_values($uniqueErrors) as $error) { + $this->addError($error); + } + } + + /** + * 创建标准化错误信息 + */ + private function createError($verbatim, $revised, $explanation,$original,$corrected, $position_start=-1, $position_end=-1) { + return [ + 'verbatim_texts' => $verbatim, + 'revised_content' => $revised, + 'explanation' => $explanation, + 'original' => $original, + 'corrected' => $corrected, + 'position_start' => $position_start, // 错误起始位置 + 'position_end' => $position_end // 错误结束位置 + ]; + } + /** + * 检查doi链接是否都能打开 + */ + private function checkDoi($content) { + $errors = []; + if (!is_string($content) || trim($content) === '') { + $this->handleErrors($errors); // 注意:原代码笔误“handleErrorsErrors”已修正 + return $content; + } + + $corrected = $content; + $originalContent = $corrected; + $checkedDois = []; // 用于去重,避免同一DOI重复校验 + + try { + // 优化正则:匹配标准DOI格式(覆盖所有常见场景) + // 匹配规则说明: + // 1. (?createError( + 'DOI正则错误', + '跳过DOI校验', + "DOI匹配正则语法错误:{$doiPattern},已跳过该校验流程", + $originalContent, + $corrected + ); + } else { + // 匹配所有符合标准的DOI(PREG_SET_ORDER按匹配项分组) + $matchCount = preg_match_all($doiPattern, $corrected, $allMatches, PREG_SET_ORDER); + + if ($matchCount > 0) { + foreach ($allMatches as $matchItem) { + $fullDoi = strtolower($matchItem[1]) . ':' . $matchItem[2]; // 完整DOI(统一转为小写,如“doi:10.1017/abc”) + $doiCore = $matchItem[2]; // DOI核心部分(如“10.1017/abc”,用于拼接访问链接) + + // 去重:同一DOI仅校验一次 + if (isset($checkedDois[$fullDoi])) { + continue; + } + $checkedDois[$fullDoi] = true; + + // 测试DOI链接是否可访问 + $isAccessible = $this->testDoiAccessibility($doiCore); + // 生成错误/状态信息 + if ($isAccessible) { + $errorDesc = "DOI「{$fullDoi}」格式规范,且链接可正常访问"; + } else { + $errorDesc = "DOI「{$fullDoi}」格式规范,但链接无法访问(可能无效或网络问题)"; + } + + // 记录校验结果(DOI无需修正,仅记录状态) + $errors[] = $this->createError( + $fullDoi, + $fullDoi, // 修正后内容与原始一致(DOI格式无需修改) + $errorDesc, + $originalContent, + $corrected + ); + } + } else { + // 无匹配时记录提示(可选,根据业务需求决定是否保留) + $errors[] = $this->createError( + '未匹配到DOI', + '无修正', + '文本中未发现符合标准格式的DOI(如doi:10.1017/abc、DOI: 10.1038/nature12345)', + $originalContent, + $corrected + ); + } + } + + } catch (Exception $e) { + $errors[] = $this->createError( + 'DOI校验全局异常', + '已回滚原始内容', + "DOI校验出错:{$e->getMessage()}(行号:{$e->getLine()}),已恢复原始输入", + $originalContent, + $originalContent + ); + $corrected = $originalContent; + } + + $this->handleErrors($errors); + return $corrected; + } + + /** + * 测试DOI链接是否可访问(基于DOI官方解析地址) + * @param string $doiCore DOI核心部分(如“10.1017/abc”,不含“doi:”前缀) + * @return bool 可访问返回true,否则返回false + */ + private function testDoiAccessibility($doiCore) { + // 处理DOI核心部分的空格(若存在) + $doiCore = trim($doiCore); + // DOI官方解析地址:https://doi.org/ + 编码后的DOI核心部分 + $doiUrl = 'https://doi.org/' . $doiCore; + var_dump($doiUrl,$doiCore);exit; + + // 初始化cURL(支持HTTPS,忽略证书问题避免环境限制) + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $doiUrl, + CURLOPT_RETURNTRANSFER => true, // 不直接输出响应 + CURLOPT_HEADER => true, // 获取响应头(用于判断状态码) + CURLOPT_TIMEOUT => 15, // 超时时间(避免长时间阻塞) + CURLOPT_FOLLOWLOCATION => true, // 跟随301/302重定向(DOI常跳转到期刊页面) + CURLOPT_SSL_VERIFYPEER => false, // 忽略SSL证书校验(适合测试环境) + CURLOPT_SSL_VERIFYHOST => false + ]); + + curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); // 获取HTTP状态码 + curl_close($ch); + + // 状态码200-399表示可访问(200成功,3xx重定向均视为有效) + return $httpCode >= 200 && $httpCode < 400; + } +} +?>