智慧教务系统
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

2806 lines
104 KiB

<?php
// +----------------------------------------------------------------------
// | Niucloud-admin 企业快速开发的多应用管理平台
// +----------------------------------------------------------------------
// | 官方网址:https://www.niucloud.com
// +----------------------------------------------------------------------
// | niucloud团队 版权所有 开源版本可自由商用
// +----------------------------------------------------------------------
// | Author: Niucloud Team
// +----------------------------------------------------------------------
namespace app\service\admin\document;
use app\model\contract\Contract;
use app\model\document\DocumentGenerateLog;
use app\model\document\DocumentDataSourceConfig;
use core\base\BaseAdminService;
use PhpOffice\PhpWord\IOFactory;
use PhpOffice\PhpWord\PhpWord;
use PhpOffice\PhpWord\TemplateProcessor;
use think\facade\Filesystem;
use think\facade\Log;
use think\Response;
/**
* Word文档模板解析服务层
* Class DocumentTemplateService
* @package app\service\admin\document
*/
class DocumentTemplateService extends BaseAdminService
{
protected $contractModel;
protected $logModel;
protected $dataSourceModel;
protected $site_id;
public function __construct()
{
parent::__construct();
$this->contractModel = new Contract();
$this->logModel = new DocumentGenerateLog();
$this->dataSourceModel = new DocumentDataSourceConfig();
$this->site_id = 1; // 默认站点ID
}
/**
* 获取模板列表
* @param array $where
* @return array
*/
public function getPage(array $where = [])
{
$field = 'id,contract_name,contract_template,contract_status,contract_type,remarks,original_filename,file_size,placeholders,created_at,updated_at';
$order = 'id desc';
$search_model = $this->contractModel->withSearch(["contract_status", "contract_type", "created_at"], $where)->field($field)->order($order);
$list = $this->pageQuery($search_model);
// 处理数据格式
if (!empty($list['data'])) {
foreach ($list['data'] as &$item) {
$item['placeholders'] = $item['placeholders'] ? json_decode($item['placeholders'], true) : [];
$item['file_size_formatted'] = $this->formatFileSize($item['file_size']);
}
}
return $list;
}
/**
* 获取模板详情
* @param int $id
* @return array
*/
public function getInfo(int $id)
{
$field = 'id,contract_name,contract_template,contract_content,contract_status,contract_type,remarks,placeholder_config,original_filename,file_size,file_hash,placeholders,created_at,updated_at';
$info = $this->contractModel->field($field)->where([['id', "=", $id]])->findOrEmpty()->toArray();
if (!empty($info)) {
$info['placeholder_config'] = $info['placeholder_config'] ? json_decode($info['placeholder_config'], true) : [];
$info['placeholders'] = $info['placeholders'] ? json_decode($info['placeholders'], true) : [];
$info['file_size_formatted'] = $this->formatFileSize($info['file_size']);
// 获取数据源配置信息,从placeholder_config字段获取
$dataSourceConfigs = [];
if (!empty($info['placeholder_config'])) {
// 转换placeholder_config格式为前端需要的data_source_configs格式
foreach ($info['placeholder_config'] as $placeholder => $config) {
$dataSourceConfigs[] = [
'id' => 0,
'placeholder' => $placeholder,
'data_type' => $config['data_type'] ?? 'user_input',
'table_name' => $config['table_name'] ?? '',
'field_name' => $config['field_name'] ?? '',
'system_function' => $config['system_function'] ?? '',
'user_input_value' => $config['user_input_value'] ?? '',
'sign_party' => $config['sign_party'] ?? '',
'field_type' => $config['field_type'] ?? 'text',
'is_required' => $config['is_required'] ?? 0,
'default_value' => $config['default_value'] ?? ''
];
}
}
$info['data_source_configs'] = $dataSourceConfigs;
// 如果没有数据源配置,但有占位符,则创建默认配置
if (empty($dataSourceConfigs) && !empty($info['placeholders'])) {
$defaultConfigs = [];
foreach ($info['placeholders'] as $placeholder) {
$defaultConfigs[] = [
'id' => 0,
'placeholder' => $placeholder,
'data_type' => 'user_input',
'table_name' => '',
'field_name' => '',
'system_function' => '',
'user_input_value' => '',
'sign_party' => '',
'field_type' => 'text',
'is_required' => 0,
'default_value' => ''
];
}
$info['data_source_configs'] = $defaultConfigs;
}
}
return $info;
}
/**
* 保存数据源配置
* @param int $contractId 合同ID
* @param array $configs 配置数据
* @return bool
* @throws \Exception
*/
public function saveDataSourceConfig(int $contractId, array $configs): bool
{
// 验证合同是否存在
$contract = $this->contractModel->find($contractId);
if (!$contract) {
throw new \Exception('合同不存在');
}
// 开启事务
\think\facade\Db::startTrans();
try {
// 删除现有配置
$this->dataSourceModel->where('contract_id', $contractId)->delete();
// 批量插入新配置
if (!empty($configs)) {
$insertData = [];
foreach ($configs as $config) {
// 验证必需字段
if (empty($config['placeholder'])) {
throw new \Exception('占位符不能为空');
}
$insertData[] = [
'contract_id' => $contractId,
'placeholder' => $config['placeholder'],
'table_name' => $config['table_name'] ?? '',
'field_name' => $config['field_name'] ?? '',
'field_type' => $config['field_type'] ?? 'string',
'is_required' => $config['is_required'] ?? 0,
'default_value' => $config['default_value'] ?? '',
'created_at' => date('Y-m-d H:i:s')
];
}
$result = $this->dataSourceModel->insertAll($insertData);
if (!$result) {
throw new \Exception('保存配置失败');
}
}
// 提交事务
\think\facade\Db::commit();
return true;
} catch (\Exception $e) {
// 回滚事务
\think\facade\Db::rollback();
throw $e;
}
}
/**
* 上传Word模板文件
* @param $file
* @param array $data
* @return array
* @throws \Exception
*/
public function uploadTemplate($file, array $data = [])
{
// 验证文件类型
$allowedTypes = ['docx', 'doc'];
$extension = strtolower($file->getOriginalExtension());
if (!in_array($extension, $allowedTypes)) {
throw new \Exception('只支持 .docx 和 .doc 格式的Word文档');
}
// 获取文件信息
$fileSize = $file->getSize();
$realPath = $file->getRealPath();
// 验证文件大小 (最大10MB)
$maxSize = 10 * 1024 * 1024;
if ($fileSize > $maxSize) {
throw new \Exception('文件大小不能超过10MB');
}
// 生成文件hash防重复
$fileHash = md5_file($realPath);
try {
// 生成保存路径
$uploadDir = 'contract_templates/' . date('Ymd');
$uploadPath = public_path() . '/upload/' . $uploadDir;
// 确保目录存在
if (!is_dir($uploadPath)) {
mkdir($uploadPath, 0777, true);
}
// 生成文件名
$fileName = md5(time() . $file->getOriginalName()) . '.' . $extension;
$fullPath = $uploadPath . '/' . $fileName;
$savePath = $uploadDir . '/' . $fileName;
// 移动文件到目标位置
if (!move_uploaded_file($realPath, $fullPath)) {
throw new \Exception('文件保存失败');
}
// 解析Word文档内容和占位符
$parseResult = $this->parseWordTemplate($fullPath);
// 准备保存到数据库的数据
$saveData = [
'contract_name' => !empty($data['contract_name']) ? $data['contract_name'] : pathinfo($file->getOriginalName(), PATHINFO_FILENAME),
'contract_template' => $savePath,
'contract_content' => $parseResult['content'],
'contract_status' => 'draft',
'contract_type' => !empty($data['contract_type']) ? $data['contract_type'] : 'general',
'original_filename' => $file->getOriginalName(),
'file_size' => $fileSize,
'file_hash' => $fileHash,
'placeholders' => json_encode($parseResult['placeholders']),
'remarks' => !empty($data['remarks']) ? $data['remarks'] : '',
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
];
// 保存到数据库
$template = $this->contractModel->create($saveData);
return [
'id' => $template->id,
'template_name' => $saveData['contract_name'],
'file_path' => $savePath,
'placeholders' => $parseResult['placeholders'],
'placeholder_count' => count($parseResult['placeholders'])
];
} catch (\Exception $e) {
// 如果保存失败,删除已上传的文件
if (isset($fullPath) && file_exists($fullPath)) {
unlink($fullPath);
}
throw new \Exception('模板上传失败:' . $e->getMessage());
}
}
/**
* 解析Word模板占位符
* @param array $data
* @return array
* @throws \Exception
*/
public function parsePlaceholder(array $data)
{
if (!empty($data['template_id'])) {
// 从数据库获取模板信息
$template = $this->contractModel->find($data['template_id']);
if (!$template) {
throw new \Exception('模板不存在');
}
$templatePath = public_path() . '/upload/' . $template['contract_template'];
} else if (!empty($data['template_path'])) {
$templatePath = $data['template_path'];
} else {
throw new \Exception('请提供模板文件路径或模板ID');
}
if (!file_exists($templatePath)) {
throw new \Exception('模板文件不存在');
}
return $this->parseWordTemplate($templatePath);
}
/**
* 解析Word模板文件内容
* @param string $filePath
* @return array
* @throws \Exception
*/
private function parseWordTemplate(string $filePath)
{
try {
// 读取Word文档
$templateProcessor = new TemplateProcessor($filePath);
// 获取文档内容(简化版本,实际可能需要更复杂的解析)
$content = $this->extractWordContent($filePath);
// 提取占位符 - 匹配 {{...}} 格式
$placeholders = $this->extractPlaceholders($content);
return [
'content' => $content,
'placeholders' => $placeholders
];
} catch (\Exception $e) {
Log::error('Word模板解析失败:' . $e->getMessage());
throw new \Exception('Word模板解析失败:' . $e->getMessage());
}
}
/**
* 提取Word文档内容
* @param string $filePath
* @return string
*/
private function extractWordContent(string $filePath)
{
try {
$phpWord = IOFactory::load($filePath);
$content = '';
// 遍历所有章节
foreach ($phpWord->getSections() as $section) {
foreach ($section->getElements() as $element) {
if (method_exists($element, 'getText')) {
// 使用反射获取文本内容
$text = '';
if($element instanceof \PhpOffice\PhpWord\Element\Text) {
$text = $element->getText();
} else if($element instanceof \PhpOffice\PhpWord\Element\TextRun) {
foreach($element->getElements() as $item) {
if($item instanceof \PhpOffice\PhpWord\Element\Text) {
$text .= $item->getText();
}
}
}
$content .= $text . "\n";
}
}
}
return $content;
} catch (\Exception $e) {
Log::error('提取Word内容失败:' . $e->getMessage());
return '';
}
}
/**
* 从内容中提取占位符
* @param string $content
* @return array
*/
private function extractPlaceholders(string $content)
{
$placeholders = [];
// 匹配 {{变量名}} 格式的占位符
if (preg_match_all('/\{\{([^}]+)\}\}/', $content, $matches)) {
foreach ($matches[1] as $placeholder) {
$placeholder = trim($placeholder);
if (!in_array($placeholder, $placeholders)) {
$placeholders[] = $placeholder;
}
}
}
return $placeholders;
}
/**
* 保存占位符配置
* @param array $data
* @return bool
* @throws \Exception
*/
public function savePlaceholderConfig(int $templateId, array $configs)
{
$template = $this->contractModel->find($templateId);
if (!$template) {
throw new \Exception('模板不存在');
}
// 转换配置数据格式以支持三种数据类型:database, system, user_input
$configData = [];
foreach ($configs as $config) {
$placeholder = $config['placeholder'];
$dataType = $config['data_type'] ?? 'user_input';
$configData[$placeholder] = [
'data_type' => $dataType,
'table_name' => $config['table_name'] ?? '',
'field_name' => $config['field_name'] ?? '',
'system_function' => $config['system_function'] ?? '',
'user_input_value' => $config['user_input_value'] ?? '',
'sign_party' => $config['sign_party'] ?? '',
'field_type' => $config['field_type'] ?? 'text',
'is_required' => $config['is_required'] ?? 0,
'default_value' => $config['default_value'] ?? ''
];
}
// 开启事务
\think\facade\Db::startTrans();
try {
// 1. 保存配置到合同表的placeholder_config字段(保持兼容性)
$template->placeholder_config = json_encode($configData);
$template->updated_at = date('Y-m-d H:i:s');
$template->save();
// 2. 同时保存到独立的数据源配置表(用户期望的表)
$this->saveConfigToDataSourceTable($templateId, $configData);
\think\facade\Db::commit();
return true;
} catch (\Exception $e) {
\think\facade\Db::rollback();
throw $e;
}
}
/**
* 保存配置到数据源配置表
* @param int $contractId 合同ID
* @param array $config 配置数据
* @return void
* @throws \Exception
*/
private function saveConfigToDataSourceTable(int $contractId, array $config): void
{
// 删除现有配置
$this->dataSourceModel->where('contract_id', $contractId)->delete();
// 转换配置格式并保存
if (!empty($config)) {
$insertData = [];
foreach ($config as $placeholder => $settings) {
$insertData[] = [
'contract_id' => $contractId,
'placeholder' => $placeholder,
'data_type' => $settings['data_type'] ?? 'user_input',
'table_name' => $settings['table_name'] ?? '',
'field_name' => $settings['field_name'] ?? '',
'system_function' => $settings['system_function'] ?? '',
'sign_party' => $settings['sign_party'] ?? '',
'field_type' => $settings['field_type'] ?? 'text',
'is_required' => $settings['is_required'] ?? 0,
'default_value' => $settings['default_value'] ?? '',
'created_at' => date('Y-m-d H:i:s')
];
}
if (!empty($insertData)) {
$this->dataSourceModel->insertAll($insertData);
}
}
}
/**
* 获取可用数据源列表
* @return array
*/
public function getDataSources()
{
return [
'tables' => $this->getAvailableTables(),
'system_functions' => $this->getSystemFunctions()
];
}
/**
* 获取可用数据表配置
* @return array
*/
public function getAvailableTables()
{
return [
'school_student' => [
'label' => '学员表',
'fields' => [
'id' => '学员ID',
'name' => '学员姓名',
'gender' => '性别',
'age' => '年龄',
'birthday' => '生日',
'emergency_contact' => '紧急联系人',
'contact_phone' => '联系人电话',
'status' => '学员状态',
'trial_class_count' => '体验课次数',
'created_at' => '创建时间',
'updated_at' => '修改时间'
]
],
'school_customer_resources' => [
'label' => '客户资源表',
'fields' => [
'id' => '编号',
'name' => '姓名',
'phone_number' => '联系电话',
'gender' => '性别',
'age' => '年龄',
'source_channel' => '来源渠道',
'source' => '来源',
'consultant' => '顾问',
'demand' => '需求',
'purchasing_power' => '购买力',
'initial_intent' => '客户初步意向度',
'trial_class_count' => '体验课次数',
'created_at' => '创建时间'
]
],
'school_order_table' => [
'label' => '订单表',
'fields' => [
'id' => '订单编号',
'payment_id' => '支付编号',
'order_type' => '订单类型',
'order_status' => '订单状态',
'payment_type' => '付款类型',
'order_amount' => '订单金额',
'discount_amount' => '优惠金额',
'payment_time' => '支付时间',
'created_at' => '创建时间',
'remark' => '订单备注'
]
],
'school_course' => [
'label' => '课程表',
'fields' => [
'id' => '课程编号',
'course_name' => '课程名称',
'course_type' => '课程类型',
'duration' => '课程时长',
'session_count' => '课时数量',
'single_session_count' => '单次消课数量',
'gift_session_count' => '赠送课时数量',
'price' => '课程价格',
'internal_reminder' => '内部提醒课时',
'customer_reminder' => '客户提醒课时',
'status' => '课程状态',
'created_at' => '创建时间'
]
],
'school_personnel' => [
'label' => '人员表',
'fields' => [
'id' => 'ID',
'name' => '姓名',
'gender' => '性别',
'phone' => '电话',
'email' => '邮箱',
'wx' => '微信号',
'address' => '家庭住址',
'education' => '学历',
'employee_number' => '员工编号',
'account_type' => '账号类型',
'status' => '状态',
'join_time' => '入职时间',
'create_time' => '创建时间'
]
]
];
}
/**
* 获取系统函数配置
* @return array
*/
public function getSystemFunctions()
{
return [
'current_date' => '当前日期',
'current_time' => '当前时间',
'current_datetime' => '当前日期时间',
'current_year' => '当前年份',
'current_month' => '当前月份',
'current_day' => '当前日',
'random_number' => '随机编号',
'contract_generate_time' => '合同生成时间',
'system_name' => '系统名称',
'current_user' => '当前用户',
'current_campus' => '当前校区',
// 签名占位符
'employee_signature' => '员工签名位置',
'student_signature' => '学员签名位置'
];
}
/**
* 生成Word文档
* @param array $data
* @return array
* @throws \Exception
*/
public function generateDocument(array $data)
{
$template = $this->contractModel->find($data['template_id']);
if (!$template) {
throw new \Exception('模板不存在');
}
if (empty($template['placeholder_config'])) {
throw new \Exception('模板尚未配置占位符');
}
// 创建生成记录
$logData = [
'site_id' => $this->site_id,
'template_id' => $data['template_id'],
'user_id' => $this->uid,
'user_type' => 1,
'fill_data' => json_encode($data['fill_data']),
'status' => 'pending',
'completed_at' => date('Y-m-d H:i:s')
];
$log = $this->logModel->create($logData);
try {
// 更新状态为处理中
$log->status = 'processing';
$log->process_start_time = date('Y-m-d H:i:s');
$log->save();
// 准备填充数据
$placeholderConfig = json_decode($template['placeholder_config'], true);
// 检查是否传递了 use_direct_values 参数,如果是则直接使用传递的值
if (!empty($data['use_direct_values']) && $data['use_direct_values'] === true) {
// 直接使用传递的 fill_data 作为填充值,不进行二次处理
$fillValues = $data['fill_data'];
Log::info('使用直接填充模式', [
'template_id' => $data['template_id'],
'fill_data_count' => count($fillValues),
'fill_data_keys' => array_keys($fillValues)
]);
} else {
// 使用原有的配置处理模式
$fillValues = $this->prepareFillData($placeholderConfig, $data['fill_data']);
}
// 生成文档
$templatePath = public_path() . '/upload/' . $template['contract_template'];
$outputFileName = $data['output_filename'] ?: ($template['contract_name'] . '_' . date('YmdHis') . '.docx');
// 使用系统临时目录,避免权限问题
$tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'niucloud_documents';
$outputPath = 'generated_documents/' . date('Y/m/') . $outputFileName;
// 尝试创建临时目录
$fullOutputPath = '';
$useTemp = false;
if (!is_dir($tempDir)) {
if (@mkdir($tempDir, 0755, true) || is_dir($tempDir)) {
$fullOutputPath = $tempDir . DIRECTORY_SEPARATOR . $outputFileName;
$useTemp = true;
}
} else {
$fullOutputPath = $tempDir . DIRECTORY_SEPARATOR . $outputFileName;
$useTemp = true;
}
// 如果临时目录创建失败,尝试使用项目根目录下的temp目录
if (!$useTemp) {
$projectTempDir = dirname(app()->getRootPath()) . DIRECTORY_SEPARATOR . 'temp';
if (!is_dir($projectTempDir)) {
if (@mkdir($projectTempDir, 0755, true) || is_dir($projectTempDir)) {
$fullOutputPath = $projectTempDir . DIRECTORY_SEPARATOR . $outputFileName;
$useTemp = true;
}
} else {
$fullOutputPath = $projectTempDir . DIRECTORY_SEPARATOR . $outputFileName;
$useTemp = true;
}
}
// 如果所有目录创建都失败,抛出异常
if (!$useTemp || empty($fullOutputPath)) {
throw new \Exception('无法创建临时文档存储目录,请检查系统权限');
}
// 预处理:修复被格式化分割的占位符
$fixedTemplatePath = $this->fixBrokenPlaceholders($templatePath);
// 使用 PhpWord 模板处理器
$templateProcessor = new TemplateProcessor($fixedTemplatePath);
// 智能处理占位符,根据类型使用不同的处理方法
$this->processPlaceholders($templateProcessor, $fillValues, $placeholderConfig);
$templateProcessor->saveAs($fullOutputPath);
// 生成文档后,尝试复制到public目录供下载
$publicOutputPath = public_path() . '/upload/' . $outputPath;
$publicOutputDir = dirname($publicOutputPath);
$copySuccess = false;
// 尝试创建public目录并复制文件
if (@mkdir($publicOutputDir, 0755, true) || is_dir($publicOutputDir)) {
if (@copy($fullOutputPath, $publicOutputPath)) {
$copySuccess = true;
}
}
if ($copySuccess) {
// 复制成功,删除临时文件
@unlink($fullOutputPath);
// 更新生成记录
$log->status = 'completed';
$log->generated_file_path = $outputPath;
$log->generated_file_name = $outputFileName;
$log->process_end_time = date('Y-m-d H:i:s');
$log->save();
return [
'log_id' => $log->id,
'file_path' => $outputPath,
'file_name' => $outputFileName,
'download_url' => url('/upload/' . $outputPath)
];
} else {
// 复制失败,使用runtime目录作为替代方案
$runtimeDir = runtime_path() . 'generated_documents' . DIRECTORY_SEPARATOR;
if (!is_dir($runtimeDir)) {
@mkdir($runtimeDir, 0755, true);
}
$runtimePath = $runtimeDir . $outputFileName;
$runtimeCopySuccess = false;
// 尝试复制到runtime目录
if (@copy($fullOutputPath, $runtimePath)) {
$runtimeCopySuccess = true;
@unlink($fullOutputPath); // 删除临时文件
}
// 更新生成记录
$log->status = 'completed';
$log->generated_file_path = $runtimeCopySuccess ? 'runtime/generated_documents/' . $outputFileName : 'temp/' . $outputFileName;
$log->generated_file_name = $outputFileName;
$log->temp_file_path = $runtimeCopySuccess ? $runtimePath : $fullOutputPath;
$log->process_end_time = date('Y-m-d H:i:s');
$log->save();
return [
'log_id' => $log->id,
'file_path' => $runtimeCopySuccess ? 'runtime/generated_documents/' . $outputFileName : 'temp/' . $outputFileName,
'file_name' => $outputFileName,
'download_url' => url('/adminapi/document/download/' . $log->id),
'temp_file_path' => $runtimeCopySuccess ? $runtimePath : $fullOutputPath,
'message' => $runtimeCopySuccess ? '文档已生成,使用临时下载链接' : '文档已生成,但无法复制到公共目录,请联系管理员处理权限问题'
];
}
} catch (\Exception $e) {
// 更新记录为失败状态
$log->status = 'failed';
$log->error_msg = $e->getMessage();
$log->process_end_time = date('Y-m-d H:i:s');
$log->save();
throw new \Exception('文档生成失败:' . $e->getMessage());
}
}
/**
* 准备填充数据
* @param array $placeholderConfig
* @param array $userFillData
* @return array
*/
private function prepareFillData(array $placeholderConfig, array $userFillData)
{
$fillValues = [];
foreach ($placeholderConfig as $placeholder => $config) {
$value = '';
// 检查data_type字段是否存在,如果不存在则默认为user_input
$dataType = $config['data_type'] ?? 'user_input';
switch ($dataType) {
case 'user_input':
// 用户输入的数据
$value = $userFillData[$placeholder] ?? $config['default_value'] ?? '';
break;
case 'database':
// 从数据库获取数据
$value = $this->getDataFromDatabase($config, $userFillData);
break;
case 'system':
// 系统函数获取数据
$value = $this->getSystemData($config);
break;
case 'sign_img':
// 签名图片
$value = $this->getSignatureImage($config, $userFillData);
break;
case 'signature':
// 电子签名
$value = $this->getElectronicSignature($config, $userFillData);
break;
default:
// 默认使用用户输入
$value = $userFillData[$placeholder] ?? $config['default_value'] ?? '';
break;
}
// 应用处理函数
if (!empty($config['process_function'])) {
$value = $this->applyProcessFunction($value, $config['process_function']);
}
$fillValues[$placeholder] = $value;
}
return $fillValues;
}
/**
* 从数据库获取数据
* @param array $config
* @param array $userFillData
* @return string
*/
private function getDataFromDatabase(array $config, array $userFillData)
{
try {
$tableName = $config['table_name'];
$fieldName = $config['field_name'];
// 改进的数据库查询逻辑,支持条件查询
$model = \think\facade\Db::connect();
$query = $model->table($tableName);
// 如果有传入的查询条件(比如学员ID),使用条件查询
if (!empty($userFillData) && is_array($userFillData)) {
foreach ($userFillData as $key => $value) {
// 支持常见的查询字段
if (in_array($key, ['id', 'student_id', 'user_id', 'person_id', 'contract_id'])) {
$query->where($key, $value);
break; // 只使用第一个匹配的条件
}
}
}
$result = $query->field($fieldName)->find();
Log::info('数据库查询', [
'table' => $tableName,
'field' => $fieldName,
'result' => $result,
'user_data' => $userFillData
]);
return $result[$fieldName] ?? $config['default_value'] ?? '';
} catch (\Exception $e) {
Log::error('数据库查询失败:' . $e->getMessage(), [
'table' => $config['table_name'] ?? '',
'field' => $config['field_name'] ?? '',
'config' => $config
]);
return $config['default_value'] ?? '';
}
}
/**
* 获取系统数据
* @param array $config
* @return string
*/
private function getSystemData(array $config)
{
try {
$systemFunction = $config['system_function'] ?? '';
switch ($systemFunction) {
case 'current_date':
return date('Y-m-d');
case 'current_datetime':
return date('Y-m-d H:i:s');
case 'current_time':
return date('H:i:s');
case 'current_year':
return date('Y');
case 'current_month':
return date('m');
case 'current_day':
return date('d');
case 'timestamp':
return time();
default:
return $config['default_value'] ?? '';
}
} catch (\Exception $e) {
Log::error('系统函数调用失败:' . $e->getMessage());
return $config['default_value'] ?? '';
}
}
/**
* 获取签名图片
* @param array $config
* @param array $userFillData
* @return string
*/
private function getSignatureImage(array $config, array $userFillData)
{
try {
$placeholder = $config['placeholder'] ?? '';
$signatureImagePath = $userFillData[$placeholder] ?? $config['default_value'] ?? '';
// 如果是相对路径,转换为绝对路径
if ($signatureImagePath && !str_starts_with($signatureImagePath, 'http')) {
$signatureImagePath = public_path() . '/uploads/' . ltrim($signatureImagePath, '/');
}
return $signatureImagePath;
} catch (\Exception $e) {
Log::error('签名图片获取失败:' . $e->getMessage());
return $config['default_value'] ?? '';
}
}
/**
* 获取电子签名
* @param array $config
* @param array $userFillData
* @return string
*/
private function getElectronicSignature(array $config, array $userFillData)
{
try {
$placeholder = $config['placeholder'] ?? '';
$signatureData = $userFillData[$placeholder] ?? $config['default_value'] ?? '';
// 如果是base64编码的签名数据,可以直接返回或进行处理
if ($signatureData && str_starts_with($signatureData, 'data:image/')) {
// 处理base64图片数据
return $signatureData;
}
// 如果是签名文本或其他格式
return $signatureData;
} catch (\Exception $e) {
Log::error('电子签名获取失败:' . $e->getMessage());
return $config['default_value'] ?? '';
}
}
/**
* 应用处理函数
* @param mixed $value
* @param string $functionName
* @return string
*/
private function applyProcessFunction($value, string $functionName)
{
switch ($functionName) {
case 'formatDate':
return $value ? date('Y年m月d日', strtotime($value)) : '';
case 'formatDateTime':
return $value ? date('Y年m月d日 H:i', strtotime($value)) : '';
case 'formatNumber':
return is_numeric($value) ? number_format($value, 2) : $value;
case 'toUpper':
return strtoupper($value);
case 'toLower':
return strtolower($value);
default:
return $value;
}
}
/**
* 下载生成的文档
* @param int $logId
* @return Response
* @throws \Exception
*/
public function downloadDocument(int $logId)
{
$log = $this->logModel->find($logId);
if (!$log) {
throw new \Exception('记录不存在');
}
if ($log['status'] !== 'completed') {
throw new \Exception('文档尚未生成完成');
}
// 优先尝试从public目录下载
$filePath = public_path() . '/upload/' . $log['generated_file_path'];
// 如果public目录文件不存在,尝试从临时文件路径下载
if (!file_exists($filePath) && !empty($log['temp_file_path'])) {
$filePath = $log['temp_file_path'];
}
// 如果还是不存在,尝试从runtime目录
if (!file_exists($filePath)) {
$runtimePath = runtime_path() . 'generated_documents' . DIRECTORY_SEPARATOR . $log['generated_file_name'];
if (file_exists($runtimePath)) {
$filePath = $runtimePath;
}
}
if (!file_exists($filePath)) {
throw new \Exception('文件不存在或已被删除');
}
// 更新下载统计
$log->download_count = $log->download_count + 1;
$log->last_download_time = date('Y-m-d H:i:s');
$log->save();
// 返回文件下载响应
return download($filePath, $log['generated_file_name']);
}
/**
* 获取生成记录
* @param array $where
* @return array
*/
public function getGenerateLog(array $where = [])
{
$field = 'id,template_id,user_id,user_type,generated_file_name,status,download_count,created_at,process_start_time,process_end_time';
$order = 'id desc';
$searchModel = $this->logModel
->alias('log')
->join('school_contract template', 'log.template_id = template.id')
->field($field . ',template.contract_name')
->where('log.site_id', $this->site_id)
->order($order);
// 添加搜索条件
if (!empty($where['template_id'])) {
$searchModel->where('log.template_id', $where['template_id']);
}
if (!empty($where['status'])) {
$searchModel->where('log.status', $where['status']);
}
return $this->pageQuery($searchModel);
}
/**
* 修复被Word格式化分割的占位符
* Word在编辑时会在占位符中插入格式化标签,导致{{placeholder}}被分割
* 这个方法会创建一个修复后的临时文件
*
* @param string $templatePath 原始模板文件路径
* @return string 修复后的模板文件路径
*/
private function fixBrokenPlaceholders(string $templatePath)
{
try {
// 创建临时文件
$tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'niucloud_templates';
if (!is_dir($tempDir)) {
mkdir($tempDir, 0755, true);
}
$fixedTemplatePath = $tempDir . DIRECTORY_SEPARATOR . 'fixed_' . basename($templatePath);
// 复制原文件到临时位置
if (!copy($templatePath, $fixedTemplatePath)) {
Log::warning('无法创建临时模板文件,使用原始模板', ['template_path' => $templatePath]);
return $templatePath;
}
// 打开ZIP文件进行修复
$zip = new \ZipArchive();
if ($zip->open($fixedTemplatePath) === TRUE) {
$content = $zip->getFromName('word/document.xml');
if ($content !== false) {
$originalLength = strlen($content);
$fixedContent = $content;
// 第一步:修复跨XML标签的占位符(最常见的模式)
// 模式1:修复 <w:t>{{</w:t>...<w:t>内容}}</w:t> 这种分割
$pattern1 = '/<w:t[^>]*>\{\{<\/w:t>([^<]*)<w:t[^>]*>([^<]*)\}\}<\/w:t>/';
$fixedContent = preg_replace_callback($pattern1, function($matches) {
$placeholder = '{{' . $matches[1] . $matches[2] . '}}';
return '<w:t>' . $placeholder . '</w:t>';
}, $fixedContent);
// 模式2:修复 <w:t>{</w:t>...<w:t>{内容}}</w:t> 这种分割
$pattern2 = '/<w:t[^>]*>\{<\/w:t>([^<]*)<w:t[^>]*>\{([^<]*)\}\}<\/w:t>/';
$fixedContent = preg_replace_callback($pattern2, function($matches) {
$placeholder = '{{' . $matches[1] . $matches[2] . '}}';
return '<w:t>' . $placeholder . '</w:t>';
}, $fixedContent);
// 模式3:修复三段式分割 <w:t>{{</w:t>...<w:t>中间</w:t>...<w:t>}}</w:t>
$pattern3 = '/<w:t[^>]*>\{\{<\/w:t>([^<]*)<w:t[^>]*>([^<]*)<\/w:t>([^<]*)<w:t[^>]*>([^<]*)\}\}<\/w:t>/';
$fixedContent = preg_replace_callback($pattern3, function($matches) {
$placeholder = '{{' . $matches[1] . $matches[2] . $matches[4] . '}}';
return '<w:t>' . $placeholder . '</w:t>';
}, $fixedContent);
// 第二步:处理更复杂的格式化分割(包含rPr标签的)
// 匹配包含格式化信息的分割模式
$complexPattern = '/\{\{[^}]*?(?:<\/w:t><\/w:r><w:r[^>]*?>(?:<w:rPr[^>]*?>.*?<\/w:rPr>)?<w:t[^>]*?>)[^}]*?\}\}/s';
$fixedContent = preg_replace_callback($complexPattern, function($matches) {
$placeholder = $matches[0];
// 移除所有XML标签,保留纯文本
$cleaned = preg_replace('/<[^>]*?>/', '', $placeholder);
// 验证是否为有效占位符
if (!preg_match('/^\{\{[^}]+\}\}$/', $cleaned)) {
return $placeholder;
}
// 保持原有的第一个w:t标签结构
if (preg_match('/^([^<]*<w:t[^>]*>)/', $placeholder, $tagMatch)) {
return $tagMatch[1] . $cleaned . '</w:t>';
}
return $cleaned;
}, $fixedContent);
// 第三步:修复任何剩余的基本分割模式
$basicPattern = '/\{\{[^}]*?<[^>]*?>[^}]*?\}\}/';
$fixedContent = preg_replace_callback($basicPattern, function($matches) {
$placeholder = $matches[0];
$cleaned = preg_replace('/<[^>]*?>/', '', $placeholder);
if (!preg_match('/^\{\{[^}]+\}\}$/', $cleaned)) {
return $placeholder;
}
return $cleaned;
}, $fixedContent);
// 将修复后的内容写回ZIP文件
if ($zip->addFromString('word/document.xml', $fixedContent)) {
$zip->close();
Log::info('占位符修复完成', [
'template_file' => $templatePath,
'fixed_file' => $fixedTemplatePath,
'original_length' => $originalLength,
'fixed_length' => strlen($fixedContent),
'size_change' => strlen($fixedContent) - $originalLength
]);
return $fixedTemplatePath;
} else {
$zip->close();
Log::warning('无法写入修复后的内容,使用原始模板', ['template_path' => $templatePath]);
return $templatePath;
}
} else {
$zip->close();
Log::warning('无法读取document.xml,使用原始模板', ['template_path' => $templatePath]);
return $templatePath;
}
} else {
Log::warning('无法打开Word文档,使用原始模板', ['template_path' => $templatePath]);
return $templatePath;
}
} catch (\Exception $e) {
Log::error('修复占位符失败', [
'template_path' => $templatePath,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
// 修复失败不影响主流程,返回原始模板路径
return $templatePath;
}
}
/**
* 格式化文件大小
* @param int $size
* @return string
*/
private function formatFileSize(int $size)
{
$units = ['B', 'KB', 'MB', 'GB'];
$index = 0;
while ($size >= 1024 && $index < count($units) - 1) {
$size /= 1024;
$index++;
}
return round($size, 2) . ' ' . $units[$index];
}
/**
* 预览模板
* @param int $id
* @return array
* @throws \Exception
*/
public function previewTemplate(int $id)
{
$template = $this->contractModel->find($id);
if (!$template) {
throw new \Exception('模板不存在');
}
return [
'content' => $template['contract_content'],
'placeholders' => json_decode($template['placeholders'], true) ?: []
];
}
/**
* 删除模板
* @param int $id
* @return bool
* @throws \Exception
*/
public function delete(int $id)
{
$template = $this->contractModel->find($id);
if (!$template) {
throw new \Exception('模板不存在');
}
// 删除关联的生成记录和文件
$logs = $this->logModel->where('template_id', $id)->select();
foreach ($logs as $log) {
if ($log['generated_file_path']) {
$filePath = public_path() . '/upload/' . $log['generated_file_path'];
if (file_exists($filePath)) {
unlink($filePath);
}
}
}
$this->logModel->where('template_id', $id)->delete();
// 删除模板文件
if ($template['contract_template']) {
$templatePath = public_path() . '/upload/' . $template['contract_template'];
if (file_exists($templatePath)) {
unlink($templatePath);
}
}
// 删除数据库记录
return $template->delete();
}
/**
* 复制模板
* @param int $id
* @return array
* @throws \Exception
*/
public function copy(int $id)
{
$template = $this->contractModel->find($id);
if (!$template) {
throw new \Exception('模板不存在');
}
$newData = $template->toArray();
unset($newData['id']);
$newData['contract_name'] = $newData['contract_name'] . '_副本';
$newData['created_at'] = date('Y-m-d H:i:s');
$newData['updated_at'] = date('Y-m-d H:i:s');
$newTemplate = $this->contractModel->create($newData);
return ['id' => $newTemplate->id];
}
/**
* 批量删除生成记录
* @param array $ids
* @return bool
*/
public function batchDeleteLog(array $ids)
{
$logs = $this->logModel->whereIn('id', $ids)->select();
foreach ($logs as $log) {
if ($log['generated_file_path']) {
$filePath = public_path() . '/upload/' . $log['generated_file_path'];
if (file_exists($filePath)) {
unlink($filePath);
}
}
}
return $this->logModel->whereIn('id', $ids)->delete();
}
/**
* 编辑模板
* @param int $id
* @param array $data
* @return bool
* @throws \Exception
*/
public function update(int $id, array $data)
{
$template = $this->contractModel->find($id);
if (!$template) {
throw new \Exception('模板不存在');
}
// 更新基本信息
if (isset($data['contract_name'])) {
$template->contract_name = $data['contract_name'];
}
if (isset($data['contract_type'])) {
$template->contract_type = $data['contract_type'];
}
if (isset($data['remarks'])) {
$template->remarks = $data['remarks'];
}
$template->updated_at = date('Y-m-d H:i:s');
return $template->save();
}
/**
* 更新模板状态
* @param int $id
* @param string $status
* @return bool
* @throws \Exception
*/
public function updateStatus(int $id, string $status)
{
$template = $this->contractModel->find($id);
if (!$template) {
throw new \Exception('模板不存在');
}
$template->contract_status = $status;
$template->updated_at = date('Y-m-d H:i:s');
return $template->save();
}
/**
* 重新识别占位符
* @param int $id 模板ID
* @return array
* @throws \Exception
*/
public function reidentifyPlaceholders(int $id)
{
$template = $this->contractModel->find($id);
if (!$template) {
throw new \Exception('模板不存在');
}
// 检查模板文件路径是否存在
if (empty($template['contract_template'])) {
throw new \Exception('模板未上传Word文档,无法识别占位符');
}
// 检查模板文件是否存在
$templatePath = public_path() . '/upload/' . $template['contract_template'];
if (!file_exists($templatePath)) {
throw new \Exception('模板文件不存在:' . $template['contract_template']);
}
// 检查是否为文件而不是目录
if (is_dir($templatePath)) {
throw new \Exception('模板路径指向的是目录而不是文件:' . $template['contract_template']);
}
try {
// 重新解析Word文档内容和占位符
$parseResult = $this->parseWordTemplate($templatePath);
// 更新数据库中的占位符列表
$template->placeholders = json_encode($parseResult['placeholders']);
$template->contract_content = $parseResult['content'];
$template->updated_at = date('Y-m-d H:i:s');
$template->save();
// 获取现有的占位符配置
$existingConfig = [];
if ($template['placeholder_config']) {
$existingConfig = json_decode($template['placeholder_config'], true) ?: [];
}
// 为新的占位符创建默认配置,保留现有配置
$newConfig = [];
foreach ($parseResult['placeholders'] as $placeholder) {
if (isset($existingConfig[$placeholder])) {
// 保留现有配置
$newConfig[$placeholder] = $existingConfig[$placeholder];
} else {
// 为新占位符创建默认配置
$newConfig[$placeholder] = [
'data_type' => 'user_input',
'table_name' => '',
'field_name' => '',
'system_function' => '',
'user_input_value' => '',
'sign_party' => '',
'field_type' => 'text',
'is_required' => 0,
'default_value' => ''
];
}
}
// 更新占位符配置
if (!empty($newConfig)) {
$template->placeholder_config = json_encode($newConfig);
$template->save();
// 同步更新数据源配置表
$this->saveConfigToDataSourceTable($id, $newConfig);
}
return [
'placeholders' => $parseResult['placeholders'],
'placeholder_count' => count($parseResult['placeholders']),
'new_placeholders' => array_diff($parseResult['placeholders'], array_keys($existingConfig)),
'removed_placeholders' => array_diff(array_keys($existingConfig), $parseResult['placeholders'])
];
} catch (\Exception $e) {
Log::error('重新识别占位符失败:' . $e->getMessage());
throw new \Exception('重新识别占位符失败:' . $e->getMessage());
}
}
/**
* 更新模板Word文档
* @param int $id 模板ID
* @param $file 上传的文件
* @return array
* @throws \Exception
*/
public function updateTemplateFile(int $id, $file)
{
$template = $this->contractModel->find($id);
if (!$template) {
throw new \Exception('模板不存在');
}
// 验证文件类型
$allowedTypes = ['docx', 'doc'];
$extension = strtolower($file->getOriginalExtension());
if (!in_array($extension, $allowedTypes)) {
throw new \Exception('只支持 .docx 和 .doc 格式的Word文档');
}
// 获取文件信息
$fileSize = $file->getSize();
$realPath = $file->getRealPath();
// 验证文件大小 (ORM大10MB)
$maxSize = 10 * 1024 * 1024;
if ($fileSize > $maxSize) {
throw new \Exception('文件大小不能超过10MB');
}
// 生成文件hash防重复
$fileHash = md5_file($realPath);
try {
// 删除旧文件
if ($template['contract_template']) {
$oldFilePath = public_path() . '/upload/' . $template['contract_template'];
if (file_exists($oldFilePath) && is_file($oldFilePath)) {
unlink($oldFilePath);
}
}
// 生成保存路径
$uploadDir = 'contract_templates/' . date('Ymd');
$uploadPath = public_path() . '/upload/' . $uploadDir;
// 确保目录存在
if (!is_dir($uploadPath)) {
mkdir($uploadPath, 0777, true);
}
// 生成文件名
$fileName = md5(time() . $file->getOriginalName()) . '.' . $extension;
$fullPath = $uploadPath . '/' . $fileName;
$savePath = $uploadDir . '/' . $fileName;
// 移动文件到目标位置
if (!move_uploaded_file($realPath, $fullPath)) {
throw new \Exception('文件保存失败');
}
// 解析Word文档内容和占位符
$parseResult = $this->parseWordTemplate($fullPath);
// 更新数据库记录
$template->contract_template = $savePath;
$template->contract_content = $parseResult['content'];
$template->original_filename = $file->getOriginalName();
$template->file_size = $fileSize;
$template->file_hash = $fileHash;
$template->placeholders = json_encode($parseResult['placeholders']);
$template->updated_at = date('Y-m-d H:i:s');
$template->save();
// 获取现有的占位符配置
$existingConfig = [];
if ($template['placeholder_config']) {
$existingConfig = json_decode($template['placeholder_config'], true) ?: [];
}
// 为新的占位符创建默认配置,保留现有配置
$newConfig = [];
foreach ($parseResult['placeholders'] as $placeholder) {
if (isset($existingConfig[$placeholder])) {
// 保留现有配置
$newConfig[$placeholder] = $existingConfig[$placeholder];
} else {
// 为新占位符创建默认配置
$newConfig[$placeholder] = [
'data_type' => 'user_input',
'table_name' => '',
'field_name' => '',
'system_function' => '',
'user_input_value' => '',
'sign_party' => '',
'field_type' => 'text',
'is_required' => 0,
'default_value' => ''
];
}
}
// 更新占位符配置
if (!empty($newConfig)) {
$template->placeholder_config = json_encode($newConfig);
$template->save();
// 同步更新数据源配置表
$this->saveConfigToDataSourceTable($id, $newConfig);
}
return [
'id' => $template->id,
'template_name' => $template->contract_name,
'file_path' => $savePath,
'placeholders' => $parseResult['placeholders'],
'placeholder_count' => count($parseResult['placeholders']),
'new_placeholders' => array_diff($parseResult['placeholders'], array_keys($existingConfig)),
'removed_placeholders' => array_diff(array_keys($existingConfig), $parseResult['placeholders'])
];
} catch (\Exception $e) {
// 如果保存失败,删除已上传的文件
if (isset($fullPath) && file_exists($fullPath)) {
unlink($fullPath);
}
throw new \Exception('模板文档更新失败:' . $e->getMessage());
}
}
/**
* 使用XML字符串操作生成Word文档
* 此方法直接读取Word模板的XML内容,进行字符串替换,然后保存为新的Word文档
* @param array $data
* @return array
* @throws \Exception
*/
public function generateDocumentByXmlString(array $data)
{
$template = $this->contractModel->find($data['template_id']);
if (!$template) {
throw new \Exception('模板不存在');
}
if (empty($template['placeholder_config'])) {
throw new \Exception('模板尚未配置占位符');
}
// 创建生成记录
$logData = [
'site_id' => $this->site_id,
'template_id' => $data['template_id'],
'user_id' => $this->uid,
'user_type' => 1,
'fill_data' => json_encode($data['fill_data']),
'status' => 'pending',
'completed_at' => date('Y-m-d H:i:s')
];
$log = $this->logModel->create($logData);
try {
// 更新状态为处理中
$log->status = 'processing';
$log->process_start_time = date('Y-m-d H:i:s');
$log->save();
// 准备填充数据
$placeholderConfig = json_decode($template['placeholder_config'], true);
if (!empty($data['use_direct_values']) && $data['use_direct_values'] === true) {
$fillValues = $data['fill_data'];
Log::info('使用直接填充模式(XML字符串方法)', [
'template_id' => $data['template_id'],
'fill_data_count' => count($fillValues),
'fill_data_keys' => array_keys($fillValues)
]);
} else {
$fillValues = $this->prepareFillData($placeholderConfig, $data['fill_data']);
}
// 原始模板路径
$templatePath = public_path() . '/upload/' . $template['contract_template'];
$outputFileName = $data['output_filename'] ?: ($template['contract_name'] . '_' . date('YmdHis') . '.docx');
// 生成输出路径
$outputPath = 'generated_documents/' . date('Y/m/') . $outputFileName;
$publicOutputPath = public_path() . '/upload/' . $outputPath;
$publicOutputDir = dirname($publicOutputPath);
// 确保输出目录存在
if (!is_dir($publicOutputDir)) {
if (!mkdir($publicOutputDir, 0755, true) && !is_dir($publicOutputDir)) {
throw new \Exception('无法创建输出目录:' . $publicOutputDir);
}
}
// 使用XML字符串方法生成文档
$success = $this->processWordDocumentXml($templatePath, $publicOutputPath, $fillValues);
if (!$success) {
throw new \Exception('XML字符串处理失败');
}
// 更新生成记录
$log->status = 'completed';
$log->generated_file_path = $outputPath;
$log->generated_file_name = $outputFileName;
$log->process_end_time = date('Y-m-d H:i:s');
$log->save();
Log::info('XML字符串方法生成Word文档成功', [
'template_id' => $data['template_id'],
'output_path' => $outputPath,
'fill_values_count' => count($fillValues)
]);
return [
'log_id' => $log->id,
'file_path' => $outputPath,
'file_name' => $outputFileName,
'download_url' => url('/upload/' . $outputPath)
];
} catch (\Exception $e) {
// 更新记录为失败状态
$log->status = 'failed';
$log->error_msg = $e->getMessage();
$log->process_end_time = date('Y-m-d H:i:s');
$log->save();
Log::error('XML字符串方法生成Word文档失败', [
'template_id' => $data['template_id'],
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
throw new \Exception('文档生成失败:' . $e->getMessage());
}
}
/**
* 处理Word文档的XML内容进行占位符替换
* @param string $templatePath 模板文件路径
* @param string $outputPath 输出文件路径
* @param array $fillValues 填充值数组
* @return bool
* @throws \Exception
*/
private function processWordDocumentXml(string $templatePath, string $outputPath, array $fillValues): bool
{
try {
// 检查模板文件是否存在
if (!file_exists($templatePath)) {
throw new \Exception('模板文件不存在:' . $templatePath);
}
// 创建临时工作目录
$tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'word_processing_' . uniqid();
if (!mkdir($tempDir, 0755, true)) {
throw new \Exception('无法创建临时工作目录');
}
try {
// 1. 解压Word文档到临时目录
$zip = new \ZipArchive();
$result = $zip->open($templatePath);
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文件内容');
}
Log::info('读取XML内容成功', [
'original_length' => strlen($xmlContent),
'placeholders_to_replace' => array_keys($fillValues)
]);
// 3. 进行占位符替换
$modifiedXmlContent = $this->replaceXmlPlaceholders($xmlContent, $fillValues);
Log::info('XML占位符替换完成', [
'original_length' => strlen($xmlContent),
'modified_length' => strlen($modifiedXmlContent),
'size_change' => strlen($modifiedXmlContent) - strlen($xmlContent)
]);
// 4. 写回修改后的document.xml
if (file_put_contents($documentXmlPath, $modifiedXmlContent) === false) {
throw new \Exception('无法写入修改后的document.xml');
}
// 5. 重新打包为Word文档
$newZip = new \ZipArchive();
$result = $newZip->open($outputPath, \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('Word文档重新打包成功', [
'output_path' => $outputPath,
'file_size' => filesize($outputPath)
]);
// 处理文档中的URL图片(第二次处理)
$this->processUrlImagesInDocument($outputPath, $fillValues);
return true;
} finally {
// 清理临时目录
$this->removeDirectory($tempDir);
}
} catch (\Exception $e) {
Log::error('处理Word文档XML失败', [
'template_path' => $templatePath,
'output_path' => $outputPath,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
throw $e;
}
}
/**
* 在XML内容中替换占位符
* @param string $xmlContent 原始XML内容
* @param array $fillValues 替换值数组
* @return string 替换后的XML内容
*/
private function replaceXmlPlaceholders(string $xmlContent, array $fillValues): string
{
$modifiedContent = $xmlContent;
// 首先清理被格式化标签分割的占位符
$modifiedContent = $this->cleanBrokenPlaceholdersInXml($modifiedContent);
$replacementCount = 0;
foreach ($fillValues as $placeholder => $value) {
// 使用正则表达式匹配占位符,支持空格、换行符、制表符
// 模式:{{ + 任意空白字符 + placeholder + 任意空白字符 + }}
$escapedPlaceholder = preg_quote($placeholder, '/');
$searchPattern = '/\{\{\s*' . $escapedPlaceholder . '\s*\}\}/';
// 转义特殊字符,确保安全的XML替换
$safeValue = htmlspecialchars((string)$value, ENT_XML1, 'UTF-8');
// 使用正则替换
$beforeLength = strlen($modifiedContent);
$modifiedContent = preg_replace($searchPattern, $safeValue, $modifiedContent, -1, $count);
$afterLength = strlen($modifiedContent);
if ($count > 0) {
$replacementCount += $count;
Log::info('占位符替换成功', [
'placeholder' => $placeholder,
'value' => $value,
'safe_value' => $safeValue,
'replacement_count' => $count,
'content_length_change' => $afterLength - $beforeLength
]);
} else {
Log::warning('占位符未找到', [
'placeholder' => $placeholder,
'search_pattern' => $searchPattern
]);
}
}
Log::info('所有占位符处理完成', [
'total_replacements' => $replacementCount,
'processed_placeholders' => count($fillValues)
]);
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>...<w:t>内容}}
'/\{\{<\/w:t>.*?<w:t[^>]*>([^<]*)\}\}/',
// 模式2: {{</w:t></w:r><w:r>...<w:t>内容}}
'/\{\{<\/w:t><\/w:r><w:r[^>]*>.*?<w:t[^>]*>([^<]*)\}\}/',
// 模式3: 包含rPr格式标签的复杂嵌套
'/\{\{<\/w:t><\/w:r><w:r[^>]*><w:rPr>.*?<\/w:rPr><w:t[^>]*>([^<]*)\}\}/'
];
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:修复单个大括号分割');
// 处理左大括号被分割:{</w:t>...<w:t>{内容}}
$leftBracketPatterns = [
// 标准模式:{</w:t></w:r><w:r>...<w:t>{内容}}
'/\{<\/w:t><\/w:r><w:r[^>]*>.*?<w:t[^>]*>\{([^}]*)\}\}/',
// 包含rPr的复杂模式
'/\{<\/w:t><\/w:r><w:r[^>]*><w:rPr>.*?<\/w:rPr><w:t[^>]*>\{([^}]*)\}\}/',
// 简单模式:{</w:t>...<w:t>{内容}}
'/\{<\/w:t>.*?<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);
}
// 处理右大括号被分割:{{内容}</w:t>...<w:t>}
$rightBracketPatterns = [
// 标准模式:{{内容}</w:t></w:r><w:r>...<w:t>}
'/\{\{([^}]*)\}<\/w:t><\/w:r><w:r[^>]*>.*?<w:t[^>]*>\}/',
// 包含rPr的复杂模式
'/\{\{([^}]*)\}<\/w:t><\/w:r><w:r[^>]*><w:rPr>.*?<\/w:rPr><w:t[^>]*>\}/',
// 简单模式:{{内容}</w:t>...<w:t>}
'/\{\{([^}]*)\}<\/w:t>.*?<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文件对象
* @param string $sourcePath 源目录路径
* @param string $relativePath ZIP内的相对路径
* @return void
*/
private function addDirectoryToZip(\ZipArchive $zip, string $sourcePath, string $relativePath): void
{
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($sourcePath, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $file) {
$filePath = $file->getRealPath();
$relativeName = $relativePath . substr($filePath, strlen($sourcePath) + 1);
// 在Windows系统中统一使用正斜杠
$relativeName = str_replace('\\', '/', $relativeName);
if ($file->isDir()) {
// 添加目录
$zip->addEmptyDir($relativeName);
} else {
// 添加文件
$zip->addFile($filePath, $relativeName);
}
}
}
/**
* 递归删除目录
* @param string $dir 要删除的目录路径
* @return void
*/
private function removeDirectory(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$files = array_diff(scandir($dir), ['.', '..']);
foreach ($files as $file) {
$filePath = $dir . DIRECTORY_SEPARATOR . $file;
if (is_dir($filePath)) {
$this->removeDirectory($filePath);
} else {
unlink($filePath);
}
}
rmdir($dir);
}
/**
* 智能处理占位符,根据类型使用不同的处理方法
* @param TemplateProcessor $templateProcessor
* @param array $fillValues
* @param array $placeholderConfig
* @return void
*/
private function processPlaceholders(TemplateProcessor $templateProcessor, array $fillValues, array $placeholderConfig)
{
// 如果没有配置信息,使用简单的文本替换
if (empty($placeholderConfig)) {
foreach ($fillValues as $placeholder => $value) {
$templateProcessor->setValue($placeholder, $value);
}
return;
}
$placeholderConfig = is_string($placeholderConfig) ? json_decode($placeholderConfig, true) : $placeholderConfig;
foreach ($fillValues as $placeholder => $value) {
$config = $placeholderConfig[$placeholder] ?? [];
$fieldType = $config['field_type'] ?? 'text';
$dataType = $config['data_type'] ?? 'user_input';
Log::info('处理占位符', [
'placeholder' => $placeholder,
'value' => $value,
'field_type' => $fieldType,
'data_type' => $dataType
]);
try {
// 根据字段类型选择处理方式
if ($fieldType === 'image' || $dataType === 'sign_img' || $dataType === 'signature') {
// 处理图片类型
$this->setImageValue($templateProcessor, $placeholder, $value);
} else {
// 处理文本类型
$templateProcessor->setValue($placeholder, $value);
}
} catch (\Exception $e) {
Log::error('占位符处理失败', [
'placeholder' => $placeholder,
'value' => $value,
'error' => $e->getMessage()
]);
// 如果图片处理失败,尝试作为文本处理
$templateProcessor->setValue($placeholder, $value);
}
}
}
/**
* 设置图片占位符的值
* @param TemplateProcessor $templateProcessor
* @param string $placeholder
* @param mixed $value
* @return void
*/
private function setImageValue(TemplateProcessor $templateProcessor, string $placeholder, $value)
{
if (empty($value)) {
// 如果值为空,设置为空文本
$templateProcessor->setValue($placeholder, '');
return;
}
$imagePath = null;
try {
// 判断图片数据类型并处理
if (is_string($value)) {
if (str_starts_with($value, 'data:image/')) {
// 处理base64图片数据
$imagePath = $this->saveBase64Image($value);
} elseif (str_starts_with($value, 'http://') || str_starts_with($value, 'https://')) {
// 处理网络图片URL
$imagePath = $this->downloadImage($value);
} elseif (file_exists($value)) {
// 处理本地文件路径
$imagePath = $value;
} elseif (file_exists(public_path() . '/' . ltrim($value, '/'))) {
// 处理相对路径
$imagePath = public_path() . '/' . ltrim($value, '/');
}
}
if ($imagePath && file_exists($imagePath)) {
// 验证图片文件
$imageInfo = getimagesize($imagePath);
if ($imageInfo === false) {
throw new \Exception('无效的图片文件');
}
// 设置图片,限制尺寸
$templateProcessor->setImageValue($placeholder, [
'path' => $imagePath,
'width' => 100, // 可以根据需要调整
'height' => 100,
'ratio' => true
]);
Log::info('图片占位符设置成功', [
'placeholder' => $placeholder,
'image_path' => $imagePath,
'image_size' => $imageInfo
]);
// 如果是临时文件,标记稍后删除
if (str_contains($imagePath, sys_get_temp_dir())) {
register_shutdown_function(function() use ($imagePath) {
if (file_exists($imagePath)) {
@unlink($imagePath);
}
});
}
} else {
// 如果无法处理为图片,使用文本替换
$templateProcessor->setValue($placeholder, $value);
Log::warning('图片处理失败,使用文本替换', [
'placeholder' => $placeholder,
'value' => $value
]);
}
} catch (\Exception $e) {
Log::error('图片设置失败', [
'placeholder' => $placeholder,
'value' => $value,
'error' => $e->getMessage()
]);
// 如果图片处理失败,使用文本替换
$templateProcessor->setValue($placeholder, $value);
}
}
/**
* 保存base64图片数据到临时文件
* @param string $base64Data
* @return string|null
*/
private function saveBase64Image(string $base64Data): ?string
{
try {
// 解析base64数据
if (preg_match('/^data:image\/(\w+);base64,(.+)$/', $base64Data, $matches)) {
$imageType = $matches[1];
$imageData = base64_decode($matches[2]);
if ($imageData === false) {
throw new \Exception('base64解码失败');
}
// 创建临时文件
$tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'niucloud_images';
if (!is_dir($tempDir)) {
mkdir($tempDir, 0755, true);
}
$fileName = uniqid('img_') . '.' . $imageType;
$filePath = $tempDir . DIRECTORY_SEPARATOR . $fileName;
if (file_put_contents($filePath, $imageData) === false) {
throw new \Exception('保存图片文件失败');
}
return $filePath;
}
} catch (\Exception $e) {
Log::error('保存base64图片失败:' . $e->getMessage());
}
return null;
}
/**
* 下载网络图片到临时文件
* @param string $url
* @return string|null
*/
private function downloadImage(string $url): ?string
{
try {
// 创建临时文件
$tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'niucloud_images';
if (!is_dir($tempDir)) {
mkdir($tempDir, 0755, true);
}
$pathInfo = pathinfo(parse_url($url, PHP_URL_PATH));
$extension = $pathInfo['extension'] ?? 'jpg';
$fileName = uniqid('img_') . '.' . $extension;
$filePath = $tempDir . DIRECTORY_SEPARATOR . $fileName;
// 下载图片
$context = stream_context_create([
'http' => [
'timeout' => 30,
'user_agent' => 'Mozilla/5.0 (compatible; Document Generator)',
]
]);
$imageData = file_get_contents($url, false, $context);
if ($imageData === false) {
throw new \Exception('下载图片失败');
}
if (file_put_contents($filePath, $imageData) === false) {
throw new \Exception('保存图片文件失败');
}
// 验证是否为有效图片
if (getimagesize($filePath) === false) {
unlink($filePath);
throw new \Exception('下载的文件不是有效图片');
}
return $filePath;
} catch (\Exception $e) {
Log::error('下载图片失败:' . $e->getMessage(), ['url' => $url]);
}
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 '<w:r>
<w:drawing>
<wp:inline distT="0" distB="0" distL="0" distR="0">
<wp:extent cx="' . $emuWidth . '" cy="' . $emuHeight . '"/>
<wp:effectExtent l="0" t="0" r="0" b="0"/>
<wp:docPr id="1" name="图片"/>
<wp:cNvGraphicFramePr>
<a:graphicFrameLocks xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" noChangeAspect="1"/>
</wp:cNvGraphicFramePr>
<a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
<a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">
<pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
<pic:nvPicPr>
<pic:cNvPr id="1" name="图片"/>
<pic:cNvPicPr/>
</pic:nvPicPr>
<pic:blipFill>
<a:blip r:embed="' . $relationId . '"/>
<a:stretch>
<a:fillRect/>
</a:stretch>
</pic:blipFill>
<pic:spPr>
<a:xfrm>
<a:off x="0" y="0"/>
<a:ext cx="' . $emuWidth . '" cy="' . $emuHeight . '"/>
</a:xfrm>
<a:prstGeom prst="rect">
<a:avLst/>
</a:prstGeom>
</pic:spPr>
</pic:pic>
</a:graphicData>
</a:graphic>
</wp:inline>
</w:drawing>
</w:r>';
}
/**
* 更新文档关系文件
* @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 = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
</Relationships>';
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;
}
}
}