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
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;
|
|
}
|
|
}
|
|
}
|