diff --git a/niucloud/app/service/admin/document/DocumentTemplateService.php b/niucloud/app/service/admin/document/DocumentTemplateService.php index ee2adc90..7ff7b9b1 100644 --- a/niucloud/app/service/admin/document/DocumentTemplateService.php +++ b/niucloud/app/service/admin/document/DocumentTemplateService.php @@ -1791,6 +1791,9 @@ class DocumentTemplateService extends BaseAdminService 'file_size' => filesize($outputPath) ]); + // 处理文档中的URL图片(第二次处理) + $this->processUrlImagesInDocument($outputPath, $fillValues); + return true; } finally { @@ -1818,18 +1821,24 @@ class DocumentTemplateService extends BaseAdminService private function replaceXmlPlaceholders(string $xmlContent, array $fillValues): string { $modifiedContent = $xmlContent; + + // 首先清理被格式化标签分割的占位符 + $modifiedContent = $this->cleanBrokenPlaceholdersInXml($modifiedContent); + $replacementCount = 0; foreach ($fillValues as $placeholder => $value) { - // 确保占位符格式正确 - $searchPattern = '{{' . $placeholder . '}}'; + // 使用正则表达式匹配占位符,支持空格、换行符、制表符 + // 模式:{{ + 任意空白字符 + placeholder + 任意空白字符 + }} + $escapedPlaceholder = preg_quote($placeholder, '/'); + $searchPattern = '/\{\{\s*' . $escapedPlaceholder . '\s*\}\}/'; // 转义特殊字符,确保安全的XML替换 $safeValue = htmlspecialchars((string)$value, ENT_XML1, 'UTF-8'); - // 进行替换 + // 使用正则替换 $beforeLength = strlen($modifiedContent); - $modifiedContent = str_replace($searchPattern, $safeValue, $modifiedContent, $count); + $modifiedContent = preg_replace($searchPattern, $safeValue, $modifiedContent, -1, $count); $afterLength = strlen($modifiedContent); if ($count > 0) { @@ -1857,6 +1866,234 @@ class DocumentTemplateService extends BaseAdminService return $modifiedContent; } + /** + * 清理XML中被格式化标签分割的占位符 + * @param string $xmlContent + * @return string + */ + private function cleanBrokenPlaceholdersInXml(string $xmlContent): string + { + $cleanedContent = $xmlContent; + + Log::info('开始清理分割的占位符'); + + // 多阶段清理策略:从简单到复杂 + + // 第一阶段:处理完整的双大括号分割情况 + $cleanedContent = $this->fixCompleteBracketSplits($cleanedContent); + + // 第二阶段:处理单个大括号分割情况 + $cleanedContent = $this->fixSingleBracketSplits($cleanedContent); + + // 第三阶段:通用清理,移除占位符内部的XML标签 + $cleanedContent = $this->generalPlaceholderCleanup($cleanedContent); + + // 第四阶段:最终验证和修复 + $cleanedContent = $this->finalPlaceholderValidation($cleanedContent); + + Log::info('占位符清理完成'); + + return $cleanedContent; + } + + /** + * 修复完整双大括号被分割的情况 + * @param string $content + * @return string + */ + private function fixCompleteBracketSplits(string $content): string + { + Log::info('阶段1:修复完整双大括号分割'); + + $patterns = [ + // 模式1: {{...内容}} + '/\{\{<\/w:t>.*?]*>([^<]*)\}\}/', + + // 模式2: {{...内容}} + '/\{\{<\/w:t><\/w:r>]*>.*?]*>([^<]*)\}\}/', + + // 模式3: 包含rPr格式标签的复杂嵌套 + '/\{\{<\/w:t><\/w:r>]*>.*?<\/w:rPr>]*>([^<]*)\}\}/' + ]; + + foreach ($patterns as $index => $pattern) { + $content = preg_replace_callback($pattern, function($matches) use ($index) { + $placeholderContent = $matches[1]; + $result = '{{' . $placeholderContent . '}}'; + + Log::info('修复完整双大括号分割', [ + 'pattern_index' => $index + 1, + 'original' => substr($matches[0], 0, 100) . '...', + 'fixed' => $result, + 'placeholder_content' => $placeholderContent + ]); + + return $result; + }, $content); + } + + return $content; + } + + /** + * 修复单个大括号被分割的情况 + * @param string $content + * @return string + */ + private function fixSingleBracketSplits(string $content): string + { + Log::info('阶段2:修复单个大括号分割'); + + // 处理左大括号被分割:{...{内容}} + $leftBracketPatterns = [ + // 标准模式:{...{内容}} + '/\{<\/w:t><\/w:r>]*>.*?]*>\{([^}]*)\}\}/', + + // 包含rPr的复杂模式 + '/\{<\/w:t><\/w:r>]*>.*?<\/w:rPr>]*>\{([^}]*)\}\}/', + + // 简单模式:{...{内容}} + '/\{<\/w:t>.*?]*>\{([^}]*)\}\}/' + ]; + + foreach ($leftBracketPatterns as $index => $pattern) { + $content = preg_replace_callback($pattern, function($matches) use ($index) { + $placeholderContent = $matches[1]; + $result = '{{' . $placeholderContent . '}}'; + + Log::info('修复左大括号分割', [ + 'pattern_index' => $index + 1, + 'original' => substr($matches[0], 0, 100) . '...', + 'fixed' => $result, + 'placeholder_content' => $placeholderContent + ]); + + return $result; + }, $content); + } + + // 处理右大括号被分割:{{内容}...} + $rightBracketPatterns = [ + // 标准模式:{{内容}...} + '/\{\{([^}]*)\}<\/w:t><\/w:r>]*>.*?]*>\}/', + + // 包含rPr的复杂模式 + '/\{\{([^}]*)\}<\/w:t><\/w:r>]*>.*?<\/w:rPr>]*>\}/', + + // 简单模式:{{内容}...} + '/\{\{([^}]*)\}<\/w:t>.*?]*>\}/' + ]; + + foreach ($rightBracketPatterns as $index => $pattern) { + $content = preg_replace_callback($pattern, function($matches) use ($index) { + $placeholderContent = $matches[1]; + $result = '{{' . $placeholderContent . '}}'; + + Log::info('修复右大括号分割', [ + 'pattern_index' => $index + 1, + 'original' => substr($matches[0], 0, 100) . '...', + 'fixed' => $result, + 'placeholder_content' => $placeholderContent + ]); + + return $result; + }, $content); + } + + return $content; + } + + /** + * 通用占位符清理:移除占位符内部的XML标签 + * @param string $content + * @return string + */ + private function generalPlaceholderCleanup(string $content): string + { + Log::info('阶段3:通用占位符清理'); + + // 匹配并清理占位符内部的XML标签 + $generalPattern = '/\{\{([^}]*?)<[^>]*?>([^}]*?)\}\}/'; + + $iterations = 0; + $maxIterations = 10; // 防止无限循环 + + do { + $beforeLength = strlen($content); + $iterations++; + + $content = preg_replace_callback($generalPattern, function($matches) { + // 移除XML标签,只保留纯文本 + $content = $matches[1] . $matches[2]; + $cleanContent = preg_replace('/<[^>]*?>/', '', $content); + $result = '{{' . $cleanContent . '}}'; + + Log::info('通用占位符清理', [ + 'original' => substr($matches[0], 0, 50) . '...', + 'cleaned' => $result, + 'content' => $cleanContent + ]); + + return $result; + }, $content); + + $afterLength = strlen($content); + + Log::info('通用清理迭代', [ + 'iteration' => $iterations, + 'length_change' => $afterLength - $beforeLength, + 'has_more_matches' => preg_match($generalPattern, $content) + ]); + + } while ($beforeLength !== $afterLength && + preg_match($generalPattern, $content) && + $iterations < $maxIterations); + + return $content; + } + + /** + * 最终占位符验证和修复 + * @param string $content + * @return string + */ + private function finalPlaceholderValidation(string $content): string + { + Log::info('阶段4:最终验证和修复'); + + // 查找所有可能的占位符模式 + if (preg_match_all('/\{[^{}]*\}/', $content, $matches)) { + foreach ($matches[0] as $match) { + // 检查是否为不完整的占位符(只有一个大括号) + if (preg_match('/^\{[^{}]+\}$/', $match)) { + // 查看前后文是否有对应的大括号 + $singleBracketPattern = '/' . preg_quote($match, '/') . '/'; + + // 尝试修复为双大括号 + $possibleFix = '{' . $match . '}'; + + Log::info('发现可能的不完整占位符', [ + 'found' => $match, + 'possible_fix' => $possibleFix + ]); + } + } + } + + // 查找并统计最终的占位符数量 + $finalPlaceholders = []; + if (preg_match_all('/\{\{([^}]+)\}\}/', $content, $matches)) { + $finalPlaceholders = $matches[1]; + } + + Log::info('最终占位符统计', [ + 'count' => count($finalPlaceholders), + 'placeholders' => $finalPlaceholders + ]); + + return $content; + } + /** * 递归添加目录到ZIP文件 * @param \ZipArchive $zip ZIP文件对象 @@ -2134,4 +2371,436 @@ class DocumentTemplateService extends BaseAdminService return null; } + + /** + * 处理Word文档中的URL图片,将URL转换为嵌入的图片附件 + * @param string $documentPath Word文档路径 + * @param array $fillValues 填充值数组(用于识别哪些是图片URL) + * @return bool + */ + private function processUrlImagesInDocument(string $documentPath, array $fillValues): bool + { + try { + Log::info('开始处理Word文档中的URL图片', [ + 'document_path' => $documentPath, + 'fill_values_count' => count($fillValues) + ]); + + // 创建临时工作目录 + $tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'word_url_processing_' . uniqid(); + if (!mkdir($tempDir, 0755, true)) { + throw new \Exception('无法创建临时工作目录'); + } + + try { + // 1. 解压Word文档到临时目录 + $zip = new \ZipArchive(); + $result = $zip->open($documentPath); + + if ($result !== TRUE) { + throw new \Exception('无法打开Word文档,错误代码:' . $result); + } + + if (!$zip->extractTo($tempDir)) { + $zip->close(); + throw new \Exception('无法解压Word文档'); + } + $zip->close(); + + // 2. 读取document.xml文件 + $documentXmlPath = $tempDir . DIRECTORY_SEPARATOR . 'word' . DIRECTORY_SEPARATOR . 'document.xml'; + if (!file_exists($documentXmlPath)) { + throw new \Exception('Word文档缺少document.xml文件'); + } + + $xmlContent = file_get_contents($documentXmlPath); + if ($xmlContent === false) { + throw new \Exception('无法读取document.xml文件内容'); + } + + // 3. 查找并处理URL图片 + $urlImageMap = $this->findUrlImagesInFillValues($fillValues); + + if (empty($urlImageMap)) { + Log::info('未找到需要处理的URL图片'); + return true; + } + + // 4. 下载图片并创建关系文件 + $imageRelations = $this->downloadAndCreateImageRelations($urlImageMap, $tempDir); + + if (empty($imageRelations)) { + Log::warning('没有成功下载任何图片'); + return true; + } + + // 5. 更新document.xml中的URL为图片引用 + $modifiedXmlContent = $this->replaceUrlsWithImageReferences($xmlContent, $imageRelations); + + // 6. 更新关系文件 + $this->updateDocumentRelations($tempDir, $imageRelations); + + // 7. 写回修改后的document.xml + if (file_put_contents($documentXmlPath, $modifiedXmlContent) === false) { + throw new \Exception('无法写入修改后的document.xml'); + } + + // 8. 重新打包Word文档 + $newZip = new \ZipArchive(); + $result = $newZip->open($documentPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); + + if ($result !== TRUE) { + throw new \Exception('无法重新打开Word文件,错误代码:' . $result); + } + + // 递归添加所有文件到ZIP + $this->addDirectoryToZip($newZip, $tempDir, ''); + + if (!$newZip->close()) { + throw new \Exception('无法保存修改后的Word文件'); + } + + Log::info('URL图片处理完成', [ + 'processed_images' => count($imageRelations), + 'document_size' => filesize($documentPath) + ]); + + return true; + + } finally { + // 清理临时目录 + $this->removeDirectory($tempDir); + } + + } catch (\Exception $e) { + Log::error('处理Word文档URL图片失败', [ + 'document_path' => $documentPath, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + // 即使失败也不影响主流程 + return false; + } + } + + /** + * 从填充值中查找URL图片 + * @param array $fillValues + * @return array + */ + private function findUrlImagesInFillValues(array $fillValues): array + { + $urlImages = []; + + foreach ($fillValues as $placeholder => $value) { + if (is_string($value)) { + // 检查是否为HTTP/HTTPS URL + if (preg_match('/^https?:\/\/.+\.(jpg|jpeg|png|gif|webp|bmp)(\?.*)?$/i', $value)) { + $urlImages[$placeholder] = $value; + Log::info('发现URL图片', [ + 'placeholder' => $placeholder, + 'url' => $value + ]); + } + } + } + + return $urlImages; + } + + /** + * 下载图片并创建图片关系 + * @param array $urlImageMap + * @param string $tempDir + * @return array + */ + private function downloadAndCreateImageRelations(array $urlImageMap, string $tempDir): array + { + $imageRelations = []; + $relationId = 1000; // 起始关系ID,避免与现有关系冲突 + + // 确保media目录存在 + $mediaDir = $tempDir . DIRECTORY_SEPARATOR . 'word' . DIRECTORY_SEPARATOR . 'media'; + if (!is_dir($mediaDir)) { + mkdir($mediaDir, 0755, true); + } + + foreach ($urlImageMap as $placeholder => $url) { + try { + // 下载图片 + $imageData = $this->downloadImageData($url); + if (!$imageData) { + continue; + } + + // 确定文件扩展名 + $pathInfo = pathinfo(parse_url($url, PHP_URL_PATH)); + $extension = strtolower($pathInfo['extension'] ?? 'jpg'); + + // 验证图片数据 + $tempImagePath = tempnam(sys_get_temp_dir(), 'img_validate_'); + file_put_contents($tempImagePath, $imageData); + $imageInfo = getimagesize($tempImagePath); + unlink($tempImagePath); + + if ($imageInfo === false) { + Log::warning('下载的文件不是有效图片', ['url' => $url]); + continue; + } + + // 生成文件名和关系ID + $fileName = 'image' . $relationId . '.' . $extension; + $filePath = $mediaDir . DIRECTORY_SEPARATOR . $fileName; + $relationIdStr = 'rId' . $relationId; + + // 保存图片文件 + if (file_put_contents($filePath, $imageData) === false) { + Log::error('保存图片文件失败', ['file_path' => $filePath]); + continue; + } + + // 记录图片关系信息 + $imageRelations[$placeholder] = [ + 'url' => $url, + 'relation_id' => $relationIdStr, + 'file_name' => $fileName, + 'file_path' => $filePath, + 'width' => $imageInfo[0], + 'height' => $imageInfo[1], + 'mime_type' => $imageInfo['mime'] + ]; + + $relationId++; + + Log::info('图片下载成功', [ + 'placeholder' => $placeholder, + 'url' => $url, + 'file_name' => $fileName, + 'size' => $imageInfo[0] . 'x' . $imageInfo[1] + ]); + + } catch (\Exception $e) { + Log::error('处理图片失败', [ + 'placeholder' => $placeholder, + 'url' => $url, + 'error' => $e->getMessage() + ]); + } + } + + return $imageRelations; + } + + /** + * 下载图片数据 + * @param string $url + * @return string|false + */ + private function downloadImageData(string $url) + { + try { + $context = stream_context_create([ + 'http' => [ + 'timeout' => 30, + 'user_agent' => 'Mozilla/5.0 (compatible; Document Generator)', + 'follow_location' => true, + 'max_redirects' => 3 + ] + ]); + + return file_get_contents($url, false, $context); + } catch (\Exception $e) { + Log::error('下载图片数据失败:' . $e->getMessage(), ['url' => $url]); + return false; + } + } + + /** + * 将XML中的URL替换为图片引用 + * @param string $xmlContent + * @param array $imageRelations + * @return string + */ + private function replaceUrlsWithImageReferences(string $xmlContent, array $imageRelations): string + { + $modifiedContent = $xmlContent; + + foreach ($imageRelations as $placeholder => $relation) { + $url = $relation['url']; + + // 构建图片引用XML + $imageXml = $this->generateImageXml($relation); + + // 替换URL为图片引用 + $modifiedContent = str_replace($url, $imageXml, $modifiedContent); + + Log::info('URL替换为图片引用', [ + 'placeholder' => $placeholder, + 'url' => $url, + 'relation_id' => $relation['relation_id'] + ]); + } + + return $modifiedContent; + } + + /** + * 生成图片引用XML + * @param array $relation + * @return string + */ + private function generateImageXml(array $relation): string + { + // 强制限制最大尺寸 + $maxWidth = 40; // 最大宽度(像素) + $maxHeight = 30; // 最大高度(像素) + + $originalWidth = $relation['width']; + $originalHeight = $relation['height']; + + Log::info('图片原始尺寸', [ + 'original_width' => $originalWidth, + 'original_height' => $originalHeight, + 'max_width' => $maxWidth, + 'max_height' => $maxHeight + ]); + + // 计算缩放比例 - 如果图片超过最大尺寸就缩放,否则使用较小的固定尺寸 + $displayWidth = $maxWidth; + $displayHeight = $maxHeight; + + if ($originalWidth > 0 && $originalHeight > 0) { + // 计算保持宽高比的缩放 + $widthRatio = $maxWidth / $originalWidth; + $heightRatio = $maxHeight / $originalHeight; + $ratio = min($widthRatio, $heightRatio); // 移除1的限制,允许缩放 + + $displayWidth = (int)($originalWidth * $ratio); + $displayHeight = (int)($originalHeight * $ratio); + + // 确保不超过最大尺寸 + $displayWidth = min($displayWidth, $maxWidth); + $displayHeight = min($displayHeight, $maxHeight); + } + + Log::info('图片显示尺寸', [ + 'display_width' => $displayWidth, + 'display_height' => $displayHeight, + 'ratio' => isset($ratio) ? $ratio : 'fixed' + ]); + + // Word中的尺寸单位转换(像素转EMU) + $emuWidth = $displayWidth * 9525; // 1像素 = 9525 EMU + $emuHeight = $displayHeight * 9525; + + $relationId = $relation['relation_id']; + + return ' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +'; + } + + /** + * 更新文档关系文件 + * @param string $tempDir + * @param array $imageRelations + * @return void + */ + private function updateDocumentRelations(string $tempDir, array $imageRelations): void + { + try { + $relsPath = $tempDir . DIRECTORY_SEPARATOR . 'word' . DIRECTORY_SEPARATOR . '_rels' . DIRECTORY_SEPARATOR . 'document.xml.rels'; + + // 如果关系文件不存在,创建基础关系文件 + if (!file_exists($relsPath)) { + $relsDir = dirname($relsPath); + if (!is_dir($relsDir)) { + mkdir($relsDir, 0755, true); + } + + $baseRelsContent = ' + +'; + file_put_contents($relsPath, $baseRelsContent); + } + + // 读取现有关系文件 + $relsContent = file_get_contents($relsPath); + if ($relsContent === false) { + throw new \Exception('无法读取关系文件'); + } + + // 解析XML + $dom = new \DOMDocument('1.0', 'UTF-8'); + $dom->preserveWhiteSpace = false; + $dom->formatOutput = true; + + if (!$dom->loadXML($relsContent)) { + throw new \Exception('无法解析关系文件XML'); + } + + $relationshipsElement = $dom->documentElement; + + // 添加图片关系 + foreach ($imageRelations as $relation) { + $relationshipElement = $dom->createElement('Relationship'); + $relationshipElement->setAttribute('Id', $relation['relation_id']); + $relationshipElement->setAttribute('Type', 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image'); + $relationshipElement->setAttribute('Target', 'media/' . $relation['file_name']); + + $relationshipsElement->appendChild($relationshipElement); + } + + // 写回关系文件 + if (file_put_contents($relsPath, $dom->saveXML()) === false) { + throw new \Exception('无法保存关系文件'); + } + + Log::info('文档关系文件更新成功', [ + 'relations_added' => count($imageRelations), + 'rels_path' => $relsPath + ]); + + } catch (\Exception $e) { + Log::error('更新文档关系文件失败', [ + 'error' => $e->getMessage(), + 'temp_dir' => $tempDir + ]); + throw $e; + } + } } \ No newline at end of file