first commit
This commit is contained in:
8
app/Http/Controllers/Controller.php
Normal file
8
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
289
app/Http/Controllers/DocImportController.php
Normal file
289
app/Http/Controllers/DocImportController.php
Normal file
@@ -0,0 +1,289 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\DocumentTemplate;
|
||||
use App\Services\DocxParser;
|
||||
use App\Services\DocxVariableExtractor;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use PhpOffice\PhpWord\IOFactory;
|
||||
|
||||
class DocImportController extends Controller
|
||||
{
|
||||
public function show($id, Request $request)
|
||||
{
|
||||
$template = DocumentTemplate::findOrFail($id);
|
||||
|
||||
$urlFile = \Storage::temporaryUrl($template->source_path, now()->addMinutes(2));
|
||||
|
||||
return response()->json([
|
||||
'id' => $template->id,
|
||||
'name' => $template->name,
|
||||
'description' => $template->description,
|
||||
'file_url' => $urlFile,
|
||||
'variables' => $template->variables,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'id' => 'required|numeric',
|
||||
'file' => 'nullable|file|mimes:docx|max:10240',
|
||||
'name' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'variables' => 'nullable|array',
|
||||
]);
|
||||
|
||||
$template = DocumentTemplate::findOrFail($data['id']);
|
||||
|
||||
if ($request->hasFile('file')) {
|
||||
$dirName = pathinfo($template->source_path, PATHINFO_DIRNAME);
|
||||
$fileName = pathinfo($template->source_path, PATHINFO_BASENAME);
|
||||
$file = $request->file('file');
|
||||
$file->move("$dirName", $fileName);
|
||||
$template->update([
|
||||
'source_path' => "$dirName/$fileName",
|
||||
]);
|
||||
}
|
||||
|
||||
$template->update([
|
||||
'name' => $data['name'],
|
||||
'description' => $data['description'],
|
||||
'variables' => $data['variables'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request, DocxParser $parser)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'file' => 'required|file|mimes:docx|max:10240',
|
||||
'name' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'variables' => 'nullable|array',
|
||||
]);
|
||||
|
||||
$file = $request->file('file');
|
||||
$templateFolderName = md5(uniqid(rand(), true));
|
||||
$templateFileName = 'source.' . $file->getClientOriginalExtension();
|
||||
$laravelPath = 'templates/' . $templateFolderName;
|
||||
$file->move("storage/$laravelPath", $templateFileName);
|
||||
|
||||
$template = DocumentTemplate::create([
|
||||
'name' => $data['name'] ?? 'Тест',
|
||||
'description' => $data['description'],
|
||||
'content' => 'content',
|
||||
'variables' => $data['variables'] ?? [],
|
||||
'source_path' => "storage/$laravelPath" . '/' . $templateFileName,
|
||||
]);
|
||||
}
|
||||
|
||||
private function cleanHtml($html) : string
|
||||
{
|
||||
// Убираем ненужные теги и атрибуты
|
||||
$html = preg_replace('/<!DOCTYPE[^>]*>/', '', $html);
|
||||
$html = preg_replace('/<html[^>]*>/', '', $html);
|
||||
$html = preg_replace('/<\/html>/', '', $html);
|
||||
$html = preg_replace('/<head>.*?<\/head>/si', '', $html);
|
||||
$html = preg_replace('/<body[^>]*>/', '', $html);
|
||||
$html = preg_replace('/<\/body>/', '', $html);
|
||||
|
||||
// Убираем пустые теги и лишние пробелы
|
||||
$html = preg_replace('/<p[^>]*>(\s| )*<\/p>/', '', $html);
|
||||
$html = preg_replace('/<br\s*\/?>\s*<br\s*\/?>/', '<br>', $html);
|
||||
|
||||
// Очищаем ссылки ConsultantPlus
|
||||
$html = preg_replace('/<a[^>]*consultantplus[^>]*>([^<]*)<\/a>/', '$1', $html);
|
||||
|
||||
// Упрощаем теги font
|
||||
$html = preg_replace('/<font[^>]*face="([^"]*)"[^>]*>/', '<span style="font-family: $1">', $html);
|
||||
// $html = preg_replace('/<font[^>]*size="([^"]*)"[^>]*>/', '<span style="font-size: $1pt">', $html);
|
||||
$html = preg_replace('/<font[^>]*color="([^"]*)"[^>]*>/', '<span style="color: $1">', $html);
|
||||
$html = str_replace('</font>', '</span>', $html);
|
||||
|
||||
// Обрабатываем вложенные font теги
|
||||
$html = preg_replace('/<span[^>]*><span[^>]*>/', '<span>', $html);
|
||||
$html = preg_replace('/<\/span><\/span>/', '</span>', $html);
|
||||
|
||||
// Заменяем закладки и якоря
|
||||
$html = preg_replace('/<a name="[^"]*"><\/a>/', '', $html);
|
||||
|
||||
// Обрабатываем шаблонные переменные {{ }}
|
||||
$html = preg_replace('/<span lang="en-US"><b>\{\{<\/b><\/span>/', '{{', $html);
|
||||
$html = preg_replace('/<span lang="ru-RU"><b>([^<]*)<\/b><\/span><span lang="en-US"><b>\}\}<\/b><\/span>/', '$1}}', $html);
|
||||
|
||||
// Улучшаем таблицы
|
||||
$html = preg_replace('/<table[^>]*>/', '<table class="docx-table">', $html);
|
||||
$html = preg_replace('/<td[^>]*>/', '<td class="docx-td">', $html);
|
||||
$html = preg_replace('/<th[^>]*>/', '<th class="docx-th">', $html);
|
||||
|
||||
// Убираем лишние классы western
|
||||
$html = str_replace('class="western"', '', $html);
|
||||
|
||||
// Стили для красивого отображения
|
||||
$styles = '
|
||||
<style>
|
||||
.libreoffice-preview {
|
||||
font-family: "Times New Roman", serif;
|
||||
line-height: 1.2;
|
||||
padding: 2cm 1.5cm 2cm 3cm;
|
||||
background: white;
|
||||
max-width: 210mm;
|
||||
margin: 0 auto;
|
||||
color: #00000a;
|
||||
font-size: 12pt;
|
||||
}
|
||||
|
||||
.libreoffice-preview p {
|
||||
// margin: 12px 0;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.libreoffice-preview p.align-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.libreoffice-preview p.align-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.libreoffice-preview p.align-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.libreoffice-preview b, .libreoffice-preview strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.libreoffice-preview i, .libreoffice-preview em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.libreoffice-preview u {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Таблицы */
|
||||
.docx-table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 15px 0;
|
||||
border: 1px solid #000000;
|
||||
}
|
||||
|
||||
.docx-td, .docx-th {
|
||||
border: 1px solid #000000;
|
||||
padding: 8px 12px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.docx-th {
|
||||
background-color: #f5f5f5;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Заголовки */
|
||||
.libreoffice-preview h1, .libreoffice-preview h2, .libreoffice-preview h3 {
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Отступы для списков */
|
||||
.libreoffice-preview ul, .libreoffice-preview ol {
|
||||
margin: 10px 0;
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
.libreoffice-preview li {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
/* Шаблонные переменные */
|
||||
.template-var {
|
||||
background-color: #fff3cd;
|
||||
border: 1px dashed #ffc107;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-weight: bold;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
/* Подписи */
|
||||
.signature {
|
||||
margin-top: 40px;
|
||||
border-top: 1px solid #000;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
/* Реквизиты */
|
||||
.requisites {
|
||||
font-size: 10pt;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Отступы для абзацев с отступами */
|
||||
.indent-1 { text-indent: 1.25cm; }
|
||||
.indent-095 { text-indent: 0.95cm; }
|
||||
.indent-1cm { text-indent: 1cm; }
|
||||
|
||||
/* Отступы */
|
||||
.margin-bottom-035 { margin-bottom: 0.35cm; }
|
||||
.margin-top-021 { margin-top: 0.21cm; }
|
||||
</style>
|
||||
';
|
||||
|
||||
// Добавляем классы для выравнивания
|
||||
$html = preg_replace('/<p[^>]*align="center"[^>]*>/', '<p class="align-center">', $html);
|
||||
$html = preg_replace('/<p[^>]*align="left"[^>]*>/', '<p class="align-left">', $html);
|
||||
$html = preg_replace('/<p[^>]*align="right"[^>]*>/', '<p class="align-right">', $html);
|
||||
|
||||
// Добавляем классы для отступов
|
||||
$html = preg_replace('/style="[^"]*text-indent:\s*1\.25cm[^"]*"/', 'class="indent-1"', $html);
|
||||
$html = preg_replace('/style="[^"]*text-indent:\s*0\.95cm[^"]*"/', 'class="indent-095"', $html);
|
||||
$html = preg_replace('/style="[^"]*text-indent:\s*1cm[^"]*"/', 'class="indent-1cm"', $html);
|
||||
|
||||
// Обрабатываем шаблонные переменные
|
||||
$html = preg_replace('/\{\{([^}]+)\}\}/', '<span class="template-var">{{$1}}</span>', $html);
|
||||
|
||||
return '<div class="libreoffice-preview">' . $html . '</div>' . $styles;
|
||||
}
|
||||
|
||||
public function previewVariables(Request $request, DocxVariableExtractor $extractor)
|
||||
{
|
||||
$rules = [
|
||||
'name' => 'required|string',
|
||||
'doc_file' => 'required|file|mimes:docx,doc|max:10240',
|
||||
];
|
||||
$messages = [
|
||||
'name.required' => 'Наименование не может быть пустым',
|
||||
'doc_file.required' => 'Вы не приложили документ',
|
||||
];
|
||||
|
||||
$validator = \Validator::make($request->all(), $rules, $messages);
|
||||
|
||||
$validator->validate();
|
||||
|
||||
try {
|
||||
$file = $request->file('doc_file');
|
||||
$tempPath = $file->getRealPath();
|
||||
|
||||
$variables = $extractor->extractVariables($tempPath);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'variables' => $variables,
|
||||
'count' => count($variables)
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Ошибка при анализе файла: ' . $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
90
app/Http/Controllers/DocumentController.php
Normal file
90
app/Http/Controllers/DocumentController.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\DocumentTemplate;
|
||||
use App\Services\DocxParser;
|
||||
use App\Services\PageBreaker;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Inertia\Inertia;
|
||||
use PhpOffice\PhpWord\IOFactory;
|
||||
|
||||
class DocumentController extends Controller
|
||||
{
|
||||
public function show($id, Request $request)
|
||||
{
|
||||
$template = DocumentTemplate::find($id);
|
||||
|
||||
return Inertia::render('ContractGenerator', [
|
||||
'template' => $template
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Предпросмотр документа с подставленными значениями
|
||||
*/
|
||||
public function preview(Request $request, $id)
|
||||
{
|
||||
$request->validate([
|
||||
'variables' => 'nullable|array'
|
||||
]);
|
||||
|
||||
try {
|
||||
$template = DocumentTemplate::findOrFail($id);
|
||||
|
||||
// Генерируем DOCX с подставленными значениями
|
||||
$docxPath = $template->generateDocument($request->variables);
|
||||
|
||||
// Конвертируем в PDF
|
||||
$pdfPath = $template->convertToPdf($docxPath);
|
||||
|
||||
// Регистрируем функцию для удаления после завершения
|
||||
register_shutdown_function(function () use ($docxPath, $pdfPath) {
|
||||
File::delete($docxPath);
|
||||
File::delete($pdfPath);
|
||||
});
|
||||
|
||||
// Отдаем PDF
|
||||
return response()->file($pdfPath, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'inline; filename="preview.pdf"'
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Скачивание готового документа
|
||||
*/
|
||||
public function download(Request $request, $id)
|
||||
{
|
||||
$request->validate([
|
||||
'variables' => 'nullable|array'
|
||||
]);
|
||||
|
||||
try {
|
||||
$template = DocumentTemplate::findOrFail($id);
|
||||
$docxPath = $template->generateDocument($request->variables);
|
||||
|
||||
return response()->download($docxPath,
|
||||
$template->name . '.docx',
|
||||
[
|
||||
'Content-Type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'Content-Disposition' => 'attachment; filename="' . $template->name . '.docx"'
|
||||
]
|
||||
);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
24
app/Http/Controllers/DocumentEditorController.php
Normal file
24
app/Http/Controllers/DocumentEditorController.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\DocumentTemplate;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class DocumentEditorController extends Controller
|
||||
{
|
||||
public function editor(Request $request)
|
||||
{
|
||||
$templateId = $request->get('templateId', null);
|
||||
|
||||
$template = null;
|
||||
if ($templateId !== null) {
|
||||
$template = DocumentTemplate::find($templateId);
|
||||
}
|
||||
|
||||
return Inertia::render('TemplateEditor', [
|
||||
'template' => $template,
|
||||
]);
|
||||
}
|
||||
}
|
||||
21
app/Http/Controllers/EditorController.php
Normal file
21
app/Http/Controllers/EditorController.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\DocumentTemplate;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EditorController extends Controller
|
||||
{
|
||||
public function save(Request $request)
|
||||
{
|
||||
$template = DocumentTemplate::find($request->get('id'));
|
||||
$data = [
|
||||
'content' => $request->get('content'),
|
||||
'variables_config' => $request->get('variables_config'),
|
||||
];
|
||||
$template->update($data);
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
}
|
||||
19
app/Http/Controllers/WorkspaceController.php
Normal file
19
app/Http/Controllers/WorkspaceController.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\DocumentTemplate;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class WorkspaceController extends Controller
|
||||
{
|
||||
public function showTemplates()
|
||||
{
|
||||
$activeTemplates = DocumentTemplate::all();
|
||||
|
||||
return Inertia::render('Index', [
|
||||
'templates' => $activeTemplates
|
||||
]);
|
||||
}
|
||||
}
|
||||
43
app/Http/Middleware/HandleInertiaRequests.php
Normal file
43
app/Http/Middleware/HandleInertiaRequests.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Middleware;
|
||||
|
||||
class HandleInertiaRequests extends Middleware
|
||||
{
|
||||
/**
|
||||
* The root template that's loaded on the first page visit.
|
||||
*
|
||||
* @see https://inertiajs.com/server-side-setup#root-template
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $rootView = 'app';
|
||||
|
||||
/**
|
||||
* Determines the current asset version.
|
||||
*
|
||||
* @see https://inertiajs.com/asset-versioning
|
||||
*/
|
||||
public function version(Request $request): ?string
|
||||
{
|
||||
return parent::version($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the props that are shared by default.
|
||||
*
|
||||
* @see https://inertiajs.com/shared-data
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function share(Request $request): array
|
||||
{
|
||||
return [
|
||||
...parent::share($request),
|
||||
//
|
||||
];
|
||||
}
|
||||
}
|
||||
68
app/Models/DocumentTemplate.php
Normal file
68
app/Models/DocumentTemplate.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Services\DocxTemplateProcessor;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class DocumentTemplate extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'description',
|
||||
'content',
|
||||
'variables',
|
||||
'source_path'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'variables' => 'array'
|
||||
];
|
||||
|
||||
/**
|
||||
* Подстановка значений в DOCX шаблон
|
||||
*/
|
||||
public function generateDocument($data)
|
||||
{
|
||||
$tempDir = storage_path('app/temp/' . uniqid());
|
||||
mkdir($tempDir, 0755, true);
|
||||
|
||||
// Копируем исходный шаблон
|
||||
$templatePath = $tempDir . '/template.docx';
|
||||
copy($this->source_path, $templatePath);
|
||||
|
||||
$docx = new DocxTemplateProcessor();
|
||||
|
||||
// Подставляем значения
|
||||
$changedDocxPath = $docx->processWithPhpWord($templatePath, $data);
|
||||
|
||||
// Возвращаем путь к сгенерированному файлу
|
||||
return $changedDocxPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Конвертация в PDF для предпросмотра
|
||||
*/
|
||||
public function convertToPdf($docxPath)
|
||||
{
|
||||
$pdfPath = str_replace('.docx', '.pdf', $docxPath);
|
||||
|
||||
// Надо добавить....
|
||||
$home = config('libreoffice.home');
|
||||
$user = config('libreoffice.user');
|
||||
putenv("HOME=$home");
|
||||
putenv("USER=$user");
|
||||
$command = "libreoffice --headless --convert-to pdf --outdir " .
|
||||
escapeshellarg(dirname($pdfPath)) . " " .
|
||||
escapeshellarg($docxPath);
|
||||
|
||||
$result = shell_exec($command . " 2>&1");
|
||||
|
||||
if (!file_exists($pdfPath)) {
|
||||
throw new \Exception("PDF conversion failed: " . $result);
|
||||
}
|
||||
|
||||
return $pdfPath;
|
||||
}
|
||||
}
|
||||
10
app/Models/DocumentTemplateVariable.php
Normal file
10
app/Models/DocumentTemplateVariable.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class DocumentTemplateVariable extends Model
|
||||
{
|
||||
//
|
||||
}
|
||||
48
app/Models/User.php
Normal file
48
app/Models/User.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
}
|
||||
24
app/Providers/AppServiceProvider.php
Normal file
24
app/Providers/AppServiceProvider.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
644
app/Services/DocxParser.php
Normal file
644
app/Services/DocxParser.php
Normal file
@@ -0,0 +1,644 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use PhpOffice\PhpWord\IOFactory;
|
||||
use PhpOffice\PhpWord\PhpWord;
|
||||
//use DOMDocument;
|
||||
use Exception;
|
||||
|
||||
class DocxParser
|
||||
{
|
||||
public function parse($filePath)
|
||||
{
|
||||
try {
|
||||
$phpWord = IOFactory::load($filePath);
|
||||
$sections = $phpWord->getSections();
|
||||
|
||||
$template = [
|
||||
'metadata' => [
|
||||
'title' => basename($filePath, '.docx'),
|
||||
'created_at' => now()->toISOString(),
|
||||
'source' => 'docx',
|
||||
'total_pages' => 0
|
||||
],
|
||||
'structure' => []
|
||||
];
|
||||
|
||||
$sectionElements = [];
|
||||
foreach ($sections as $sectionIndex => $section) {
|
||||
foreach ($section->getElements() as $elementIndex => $element) {
|
||||
// $sectionElements[] = $this->parseElement($element);
|
||||
array_push($sectionElements, $this->parseElement($element, $elementIndex));
|
||||
}
|
||||
}
|
||||
|
||||
$template['structure']['elements'] = $sectionElements;
|
||||
|
||||
// dd($template);
|
||||
|
||||
// Извлекаем переменные
|
||||
$template['variables_config'] = $this->extractVariables($template['structure']);
|
||||
|
||||
return $template;
|
||||
|
||||
} catch (Exception $e) {
|
||||
\Log::error("Ошибка парсинга DOCX: " . $e->getMessage(), $e->getTrace());
|
||||
}
|
||||
}
|
||||
|
||||
private function parseElement($element, $elementIndex = 0)
|
||||
{
|
||||
$parsedElements = [];
|
||||
|
||||
$elementType = get_class($element);
|
||||
|
||||
switch ($elementType) {
|
||||
case 'PhpOffice\PhpWord\Element\Text':
|
||||
$parsedElements = $this->parseTextElement($element, $elementIndex++);
|
||||
break;
|
||||
|
||||
case 'PhpOffice\PhpWord\Element\TextRun':
|
||||
$textRunElements = $this->parseTextRun($element, $elementIndex);
|
||||
|
||||
// Получаем стиль параграфа для TextRun
|
||||
$paragraphStyle = $element->getParagraphStyle();
|
||||
|
||||
// Если TextRun содержит только один элемент, добавляем его напрямую
|
||||
if (count($textRunElements) === 1) {
|
||||
$textRunElements[0]['style'] = $this->parseParagraphStyle($paragraphStyle);
|
||||
$isHeading = $this->isTextRunHeading($textRunElements[0]);
|
||||
$textRunElements[0]['is_heading'] = $isHeading;
|
||||
$textRunElements[0]['type'] = $isHeading ? 'heading' : 'paragraph';
|
||||
|
||||
$parsedElements = $textRunElements[0];
|
||||
$elementIndex++;
|
||||
} else {
|
||||
// Иначе создаем контейнер TextRun
|
||||
$parsedElements = [
|
||||
'id' => 'textrun-' . $elementIndex++,
|
||||
'type' => 'text_run',
|
||||
'elements' => $textRunElements,
|
||||
'style' => $this->parseParagraphStyle($paragraphStyle)
|
||||
// 'formatting' => $this->getTextRunFormatting($element)
|
||||
];
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PhpOffice\PhpWord\Element\TextBreak':
|
||||
$parsedElements = [
|
||||
'id' => 'break-' . $elementIndex++,
|
||||
'type' => 'line_break',
|
||||
'content' => '<br>'
|
||||
];
|
||||
break;
|
||||
|
||||
case 'PhpOffice\PhpWord\Element\Title':
|
||||
$parsedElements = $this->parseTitleElement($element, $elementIndex++);
|
||||
break;
|
||||
|
||||
case 'PhpOffice\PhpWord\Element\Table':
|
||||
$parsedElements = $this->parseTableElement($element, $elementIndex++);
|
||||
break;
|
||||
|
||||
// case 'PhpOffice\PhpWord\Element\Image':
|
||||
// $parsedElements[] = $this->parseImageElement($element, $elementIndex++);
|
||||
// break;
|
||||
|
||||
default:
|
||||
// Пропускаем неизвестные элементы
|
||||
break;
|
||||
}
|
||||
|
||||
return $parsedElements;
|
||||
}
|
||||
|
||||
private function getTextRunFormatting($textRun)
|
||||
{
|
||||
$style = $textRun->getParagraphStyle();
|
||||
return $this->parseStyle($style);
|
||||
}
|
||||
|
||||
private function parseTextElement($element, $index)
|
||||
{
|
||||
$text = $element->getText();
|
||||
$style = $element->getFontStyle();
|
||||
// Ищем переменные в тексте (например: {{variable}} или [VARIABLE])
|
||||
$variables = $this->extractVariablesFromText($text);
|
||||
|
||||
return [
|
||||
'id' => 'element-' . $index,
|
||||
'type' => empty($variables) ? 'paragraph' : 'variable',
|
||||
'content' => $text,
|
||||
'formatting' => $this->parseStyle($style),
|
||||
'variables' => $variables,
|
||||
'is_inline' => true
|
||||
];
|
||||
}
|
||||
|
||||
public function isTextRunHeading($element) {
|
||||
if ($element['formatting'] && $element['style']) {
|
||||
if ($element['formatting']['bold'] && $element['style']['align'] === 'center') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function parseParagraphStyle($style)
|
||||
{
|
||||
if (!$style) return null;
|
||||
|
||||
$paragraphStyle = [];
|
||||
|
||||
// Выравнивание
|
||||
if ($style->getAlignment()) {
|
||||
$alignment = $style->getAlignment();
|
||||
$paragraphStyle['align'] = $this->mapAlignment($alignment);
|
||||
}
|
||||
|
||||
// Отступы
|
||||
if ($style->getIndentation()) {
|
||||
$indentation = $style->getIndentation();
|
||||
$paragraphStyle['indent'] = [
|
||||
'left' => $indentation->getLeft(),
|
||||
'right' => $indentation->getRight(),
|
||||
'firstLine' => ($indentation->getFirstLine() / 1440) * 96, //\PhpOffice\PhpWord\Shared\Converter::twip($indentation->getFirstLine())
|
||||
];
|
||||
}
|
||||
|
||||
// dd($style->getLineHeight());
|
||||
|
||||
// Междустрочный интервал
|
||||
if ($style->getLineHeight()) {
|
||||
$paragraphStyle['lineHeight'] = $style->getLineHeight();
|
||||
}
|
||||
|
||||
// Интервалы до и после
|
||||
if ($style->getSpaceBefore()) {
|
||||
$paragraphStyle['spaceBefore'] = $style->getSpaceBefore();
|
||||
}
|
||||
|
||||
if ($style->getSpaceAfter()) {
|
||||
$paragraphStyle['spaceAfter'] = $style->getSpaceAfter();
|
||||
}
|
||||
|
||||
// Табуляция
|
||||
if ($style->getTabs()) {
|
||||
$paragraphStyle['tabs'] = $style->getTabs();
|
||||
}
|
||||
|
||||
return !empty($paragraphStyle) ? $paragraphStyle : null;
|
||||
}
|
||||
|
||||
private function mapAlignment($alignment)
|
||||
{
|
||||
$mapping = [
|
||||
\PhpOffice\PhpWord\SimpleType\Jc::START => 'left',
|
||||
\PhpOffice\PhpWord\SimpleType\Jc::END => 'right',
|
||||
\PhpOffice\PhpWord\SimpleType\Jc::CENTER => 'center',
|
||||
\PhpOffice\PhpWord\SimpleType\Jc::BOTH => 'justify',
|
||||
\PhpOffice\PhpWord\SimpleType\Jc::DISTRIBUTE => 'distribute'
|
||||
];
|
||||
|
||||
return $mapping[$alignment] ?? 'left';
|
||||
}
|
||||
|
||||
public function parseTextRun($textRun, $startIndex)
|
||||
{
|
||||
$elements = [];
|
||||
$index = $startIndex;
|
||||
|
||||
foreach ($textRun->getElements() as $runElement) {
|
||||
if (get_class($runElement) === 'PhpOffice\PhpWord\Element\Text') {
|
||||
$elements[] = $this->parseTextElement($runElement, $index++);
|
||||
}
|
||||
}
|
||||
|
||||
return $elements;
|
||||
}
|
||||
|
||||
private function parseTitleElement($element, $index)
|
||||
{
|
||||
$text = $element->getText();
|
||||
$level = $element->getDepth() + 1;
|
||||
|
||||
return [
|
||||
'id' => 'heading-' . $index,
|
||||
'type' => 'heading',
|
||||
'level' => $level,
|
||||
'content' => $text,
|
||||
'formatting' => [
|
||||
'bold' => true,
|
||||
'fontSize' => $this->getHeadingSize($level)
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
private function parseTableElement($table, $index)
|
||||
{
|
||||
$rows = [];
|
||||
$rowIndex = 0;
|
||||
$tableIndex = $index;
|
||||
|
||||
foreach ($table->getRows() as $row) {
|
||||
$cells = [];
|
||||
$cellIndex = 0;
|
||||
foreach ($row->getCells() as $cell) {
|
||||
$styles = $this->getCellStyles($cell);
|
||||
$cellElements = [];
|
||||
foreach ($cell->getElements() as $cellElement) {
|
||||
$cellElements[] = $this->parseElement($cellElement, $index);
|
||||
$index++;
|
||||
}
|
||||
|
||||
$cells[] = [
|
||||
'id' => 'cell-' . $rowIndex . '-' . $cellIndex,
|
||||
'elements' => $cellElements,
|
||||
// 'variables' => $this->extra($cellElements),
|
||||
'style' => $styles,
|
||||
'width' => ($cell->getWidth() / 1440) * 96
|
||||
];
|
||||
$cellIndex++;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'id' => 'row-' . $rowIndex,
|
||||
'cells' => $cells
|
||||
];
|
||||
$rowIndex++;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => 'table-' . $tableIndex,
|
||||
'type' => 'table',
|
||||
'rows' => $rows,
|
||||
'cols' => count($rows[0]['cells'] ?? []),
|
||||
// 'content' => $this->generateTableHTML($rows)
|
||||
];
|
||||
}
|
||||
|
||||
private function getCellStyles($cell)
|
||||
{
|
||||
$style = $cell->getStyle();
|
||||
|
||||
return [
|
||||
'borderTopSize' => $style->getBorderTopSize(),
|
||||
'borderTopColor' => $style->getBorderTopColor(),
|
||||
'borderTopStyle' => $style->getBorderTopStyle(),
|
||||
'borderLeftSize' => $style->getBorderLeftSize(),
|
||||
'borderLeftColor' => $style->getBorderLeftColor(),
|
||||
'borderLeftStyle' => $style->getBorderLeftStyle(),
|
||||
'borderRightSize' => $style->getBorderRightSize(),
|
||||
'borderRightColor' => $style->getBorderRightColor(),
|
||||
'borderRightStyle' => $style->getBorderRightStyle(),
|
||||
'borderBottomSize' => $style->getBorderBottomSize(),
|
||||
'borderBottomColor' => $style->getBorderBottomColor(),
|
||||
'borderBottomStyle' => $style->getBorderBottomStyle(),
|
||||
];
|
||||
}
|
||||
|
||||
private function parseImageElement($element, $index)
|
||||
{
|
||||
return [
|
||||
'id' => 'image-' . $index,
|
||||
'type' => 'image',
|
||||
'src' => $element->getSource(),
|
||||
'width' => $element->getWidth(),
|
||||
'height' => $element->getHeight(),
|
||||
'alt' => $element->getAlt() ?? 'Image'
|
||||
];
|
||||
}
|
||||
|
||||
private function parseStyle($style)
|
||||
{
|
||||
if (!$style) return [];
|
||||
|
||||
return [
|
||||
'bold' => $style->isBold(),
|
||||
'italic' => $style->isItalic(),
|
||||
'underline' => $style->getUnderline(),
|
||||
'fontSize' => $style->getSize(),
|
||||
'fontColor' => $style->getColor(),
|
||||
'fontFamily' => $style->getName()
|
||||
];
|
||||
}
|
||||
|
||||
// Формирование HTML
|
||||
public function generateParagraphStyle($paragraphStyle)
|
||||
{
|
||||
if (empty($paragraphStyle)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$styles = [];
|
||||
|
||||
// Выравнивание
|
||||
if (!empty($paragraphStyle['align'])) {
|
||||
$styles[] = "text-align: {$paragraphStyle['align']}";
|
||||
}
|
||||
|
||||
// Междустрочный интервал
|
||||
if (!empty($paragraphStyle['lineHeight'])) {
|
||||
$lineHeight = $paragraphStyle['lineHeight'];
|
||||
if (is_numeric($lineHeight)) {
|
||||
$styles[] = "line-height: {$lineHeight}";
|
||||
} else {
|
||||
$styles[] = "line-height: {$lineHeight}";
|
||||
}
|
||||
}
|
||||
|
||||
// Отступы
|
||||
if (!empty($paragraphStyle['spaceBefore'])) {
|
||||
$styles[] = "margin-top: {$paragraphStyle['spaceBefore']}pt";
|
||||
}
|
||||
|
||||
if (!empty($paragraphStyle['spaceAfter'])) {
|
||||
$styles[] = "margin-bottom: {$paragraphStyle['spaceAfter']}pt";
|
||||
}
|
||||
|
||||
// Отступы первой строки
|
||||
if (!empty($paragraphStyle['indent'])) {
|
||||
$indent = $paragraphStyle['indent'];
|
||||
|
||||
if (!empty($indent['left'])) {
|
||||
$styles[] = "margin-left: {$indent['left']}pt";
|
||||
}
|
||||
|
||||
if (!empty($indent['right'])) {
|
||||
$styles[] = "margin-right: {$indent['right']}pt";
|
||||
}
|
||||
|
||||
if (!empty($indent['firstLine'])) {
|
||||
$styles[] = "text-indent: {$indent['firstLine']}pt";
|
||||
}
|
||||
}
|
||||
|
||||
// Табуляция
|
||||
if (!empty($paragraphStyle['tabs'])) {
|
||||
$tabStops = [];
|
||||
foreach ($paragraphStyle['tabs'] as $tab) {
|
||||
if (!empty($tab['position'])) {
|
||||
$position = $tab['position'];
|
||||
$type = $tab['type'] ?? 'left';
|
||||
$tabStops[] = "{$position}pt {$type}";
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($tabStops)) {
|
||||
$styles[] = "tab-stops: " . implode(', ', $tabStops);
|
||||
}
|
||||
}
|
||||
|
||||
return implode('; ', $styles);
|
||||
}
|
||||
|
||||
public function generateTextRunStyle($formatting)
|
||||
{
|
||||
if (empty($formatting)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$styles = [];
|
||||
|
||||
// Размер шрифта
|
||||
if (!empty($formatting['fontSize'])) {
|
||||
$fontSize = $formatting['fontSize'];
|
||||
if (is_numeric($fontSize)) {
|
||||
$styles[] = "font-size: {$fontSize}pt";
|
||||
} else {
|
||||
$styles[] = "font-size: {$fontSize}";
|
||||
}
|
||||
}
|
||||
|
||||
// Цвет шрифта
|
||||
if (!empty($formatting['fontColor'])) {
|
||||
$styles[] = "color: {$formatting['fontColor']}";
|
||||
}
|
||||
|
||||
// Цвет фона
|
||||
if (!empty($formatting['backgroundColor'])) {
|
||||
$styles[] = "background-color: {$formatting['backgroundColor']}";
|
||||
}
|
||||
|
||||
// Шрифт
|
||||
if (!empty($formatting['fontFamily'])) {
|
||||
$styles[] = "font-family: '{$formatting['fontFamily']}'";
|
||||
}
|
||||
|
||||
// Жирный текст
|
||||
if (!empty($formatting['bold']) && $formatting['bold']) {
|
||||
$styles[] = "font-weight: bold";
|
||||
}
|
||||
|
||||
// Курсив
|
||||
if (!empty($formatting['italic']) && $formatting['italic']) {
|
||||
$styles[] = "font-style: italic";
|
||||
}
|
||||
|
||||
// Подчеркивание
|
||||
if (!empty($formatting['underline']) && $formatting['underline']) {
|
||||
$styles[] = "text-decoration: underline";
|
||||
}
|
||||
|
||||
// Зачеркивание
|
||||
if (!empty($formatting['strikethrough']) && $formatting['strikethrough']) {
|
||||
$styles[] = "text-decoration: line-through";
|
||||
}
|
||||
|
||||
// Надстрочный индекс
|
||||
if (!empty($formatting['superscript']) && $formatting['superscript']) {
|
||||
$styles[] = "vertical-align: super";
|
||||
$styles[] = "font-size: smaller";
|
||||
}
|
||||
|
||||
// Подстрочный индекс
|
||||
if (!empty($formatting['subscript']) && $formatting['subscript']) {
|
||||
$styles[] = "vertical-align: sub";
|
||||
$styles[] = "font-size: smaller";
|
||||
}
|
||||
|
||||
// Тень текста
|
||||
if (!empty($formatting['shadow']) && $formatting['shadow']) {
|
||||
$styles[] = "text-shadow: 1px 1px 2px rgba(0,0,0,0.3)";
|
||||
}
|
||||
|
||||
// Трансформация текста
|
||||
if (!empty($formatting['textTransform'])) {
|
||||
$styles[] = "text-transform: {$formatting['textTransform']}";
|
||||
}
|
||||
|
||||
// Межбуквенный интервал
|
||||
if (!empty($formatting['letterSpacing'])) {
|
||||
$styles[] = "letter-spacing: {$formatting['letterSpacing']}pt";
|
||||
}
|
||||
|
||||
// Межсловный интервал
|
||||
if (!empty($formatting['wordSpacing'])) {
|
||||
$styles[] = "word-spacing: {$formatting['wordSpacing']}pt";
|
||||
}
|
||||
|
||||
return implode('; ', $styles);
|
||||
}
|
||||
|
||||
public function replaceVariables($content, $formData)
|
||||
{
|
||||
return $content;
|
||||
if (empty($content)) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
// Заменяем плейсхолдеры вида {{variable}}
|
||||
foreach ($formData as $key => $value) {
|
||||
if (!empty($value)) {
|
||||
$content = str_replace("{{{$key}}}", $value, $content);
|
||||
$content = str_replace("[[{$key}]]", $value, $content);
|
||||
$content = str_replace("__{$key}__", $value, $content);
|
||||
}
|
||||
}
|
||||
|
||||
// Заменяем span-плейсхолдеры с data-variable
|
||||
$dom = new DOMDocument();
|
||||
@$dom->loadHTML('<?xml encoding="UTF-8"><div>' . $content . '</div>');
|
||||
|
||||
$xpath = new DOMXPath($dom);
|
||||
$placeholders = $xpath->query('//*[@data-variable]');
|
||||
|
||||
foreach ($placeholders as $placeholder) {
|
||||
$variableName = $placeholder->getAttribute('data-variable');
|
||||
$value = $formData[$variableName] ?? '';
|
||||
|
||||
if (!empty($value)) {
|
||||
// Создаем текстовый узел с значением
|
||||
$textNode = $dom->createTextNode($value);
|
||||
$placeholder->parentNode->replaceChild($textNode, $placeholder);
|
||||
}
|
||||
}
|
||||
|
||||
// Извлекаем HTML обратно
|
||||
$html = $dom->saveHTML();
|
||||
$html = preg_replace('/^<!DOCTYPE.*?>\n?/', '', $html);
|
||||
$html = preg_replace('/<\?xml encoding="UTF-8"\?>\n?/', '', $html);
|
||||
$html = preg_replace('/^<div>/', '', $html);
|
||||
$html = preg_replace('/<\/div>$/', '', $html);
|
||||
|
||||
return trim($html);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private function extractVariablesFromText($text)
|
||||
{
|
||||
$variables = [];
|
||||
|
||||
// Ищем шаблоны переменных: {{variable}}, [VARIABLE], ${variable}, etc.
|
||||
$patterns = [
|
||||
'/\{\{(\w+)\}\}/',
|
||||
'/\[(\w+)\]/',
|
||||
'/\$(\w+)/',
|
||||
'/%(\w+)%/'
|
||||
];
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
if (preg_match_all($pattern, $text, $matches)) {
|
||||
foreach ($matches[1] as $match) {
|
||||
$variables[] = $match;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_unique($variables);
|
||||
}
|
||||
|
||||
public function extractVariables($structure)
|
||||
{
|
||||
$variables = [];
|
||||
|
||||
foreach ($structure['elements'] as $element) {
|
||||
if (!empty($element['variables'])) {
|
||||
foreach ($element['variables'] as $varName) {
|
||||
if (!isset($variables[$varName])) {
|
||||
$variables[$varName] = [
|
||||
'type' => 'text',
|
||||
'label' => $this->formatVariableLabel($varName),
|
||||
'default' => ''
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!array_key_exists('type', $element)) dd($element);
|
||||
// Для таблиц
|
||||
if ($element['type'] === 'table' && !empty($element['rows'])) {
|
||||
foreach ($element['rows'] as $row) {
|
||||
foreach ($row['cells'] as $cell) {
|
||||
if (!empty($cell['variables'])) {
|
||||
foreach ($cell['variables'] as $varName) {
|
||||
if (!isset($variables[$varName])) {
|
||||
$variables[$varName] = [
|
||||
'type' => 'text',
|
||||
'label' => $this->formatVariableLabel($varName),
|
||||
'default' => ''
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $variables;
|
||||
}
|
||||
|
||||
private function formatVariableLabel($varName)
|
||||
{
|
||||
return ucfirst(str_replace(['_', '-'], ' ', $varName));
|
||||
}
|
||||
|
||||
private function getHeadingSize($level)
|
||||
{
|
||||
$sizes = [
|
||||
1 => '24pt',
|
||||
2 => '20pt',
|
||||
3 => '18pt',
|
||||
4 => '16pt',
|
||||
5 => '14pt',
|
||||
6 => '12pt'
|
||||
];
|
||||
|
||||
return $sizes[$level] ?? '16pt';
|
||||
}
|
||||
|
||||
private function generateTableHTML($rows)
|
||||
{
|
||||
$html = '<table style="width: 100%; border-collapse: collapse; margin: 1em 0;">';
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$html .= '<tr>';
|
||||
foreach ($row['cells'] as $cell) {
|
||||
$html .= '<td style="border: 1px solid #000; padding: 8px;">';
|
||||
$html .= htmlspecialchars($cell['content']);
|
||||
$html .= '</td>';
|
||||
}
|
||||
$html .= '</tr>';
|
||||
}
|
||||
|
||||
$html .= '</table>';
|
||||
return $html;
|
||||
}
|
||||
|
||||
// Метод для обработки загруженного файла
|
||||
public function parseUploadedFile($uploadedFile)
|
||||
{
|
||||
$tempPath = $uploadedFile->getRealPath();
|
||||
$extension = $uploadedFile->getClientOriginalExtension();
|
||||
|
||||
if ($extension !== 'docx') {
|
||||
throw new Exception("Поддерживаются только файлы .docx");
|
||||
}
|
||||
|
||||
return $this->parse($tempPath);
|
||||
}
|
||||
}
|
||||
56
app/Services/DocxTemplateProcessor.php
Normal file
56
app/Services/DocxTemplateProcessor.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use PhpOffice\PhpWord\TemplateProcessor;
|
||||
|
||||
class DocxTemplateProcessor
|
||||
{
|
||||
/**
|
||||
* Подстановка значений в DOCX файл
|
||||
*/
|
||||
public function processWithPhpWord($templatePath, $data): string
|
||||
{
|
||||
try {
|
||||
$templateProcessor = new TemplateProcessor($templatePath);
|
||||
|
||||
foreach ($data as $value) {
|
||||
if (array_key_exists('value', $value)) {
|
||||
if (isset($value['value'])) {
|
||||
$templateProcessor->setValue($value['name'], $value['value']);
|
||||
} else {
|
||||
$templateProcessor->setValue($value['value'], $value['value']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$outputPath = storage_path('app/temp/' . uniqid() . '.docx');
|
||||
$templateProcessor->saveAs($outputPath);
|
||||
|
||||
return $outputPath;
|
||||
} finally {
|
||||
$this->cleanupTemplate($templatePath);
|
||||
}
|
||||
}
|
||||
|
||||
protected function cleanupTemplate($templatePath): void
|
||||
{
|
||||
try {
|
||||
if (file_exists($templatePath)) {
|
||||
unlink($templatePath);
|
||||
}
|
||||
|
||||
$templateDir = dirname($templatePath);
|
||||
if (is_dir($templateDir) && $this->isDirEmpty($templateDir)) {
|
||||
rmdir($templateDir);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
\Log::warning("Template cleanup warning: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
protected function isDirEmpty($dir): bool
|
||||
{
|
||||
return count(scandir($dir)) == 2; // только . и ..
|
||||
}
|
||||
}
|
||||
249
app/Services/DocxVariableExtractor.php
Normal file
249
app/Services/DocxVariableExtractor.php
Normal file
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use PhpOffice\PhpWord\IOFactory;
|
||||
|
||||
class DocxVariableExtractor
|
||||
{
|
||||
public function extractVariables($docxPath): array
|
||||
{
|
||||
$phpWord = IOFactory::load($docxPath);
|
||||
$variables = [];
|
||||
$textBuffer = '';
|
||||
|
||||
foreach ($phpWord->getSections() as $section) {
|
||||
$this->extractFromSection($section, $variables, $textBuffer);
|
||||
}
|
||||
|
||||
// Проверяем остаток в буфере
|
||||
$this->extractFromText($textBuffer, $variables);
|
||||
|
||||
// Убираем дубликаты по полю 'name'
|
||||
$uniqueVariables = [];
|
||||
$seenNames = [];
|
||||
|
||||
foreach ($variables as $variable) {
|
||||
if (!in_array($variable['name'], $seenNames)) {
|
||||
$uniqueVariables[] = $variable;
|
||||
$seenNames[] = $variable['name'];
|
||||
}
|
||||
}
|
||||
|
||||
return $uniqueVariables;
|
||||
}
|
||||
|
||||
private function extractFromSection($section, array &$variables, string &$textBuffer): void
|
||||
{
|
||||
foreach ($section->getElements() as $element) {
|
||||
$this->extractFromElement($element, $variables, $textBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
private function extractFromElement($element, array &$variables, string &$textBuffer): void
|
||||
{
|
||||
$elementType = get_class($element);
|
||||
|
||||
switch ($elementType) {
|
||||
case 'PhpOffice\PhpWord\Element\TextRun':
|
||||
$this->extractFromTextRun($element, $variables, $textBuffer);
|
||||
break;
|
||||
|
||||
case 'PhpOffice\PhpWord\Element\Text':
|
||||
$textBuffer .= $element->getText();
|
||||
$this->extractFromText($textBuffer, $variables);
|
||||
break;
|
||||
|
||||
case 'PhpOffice\PhpWord\Element\Table':
|
||||
$this->extractFromTable($element, $variables, $textBuffer);
|
||||
break;
|
||||
|
||||
case 'PhpOffice\PhpWord\Element\Header':
|
||||
case 'PhpOffice\PhpWord\Element\Footer':
|
||||
$this->extractFromSection($element, $variables, $textBuffer);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Сбрасываем буфер при смене типа элемента
|
||||
$textBuffer = '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private function extractFromTextRun($textRun, array &$variables, string &$textBuffer): void
|
||||
{
|
||||
foreach ($textRun->getElements() as $element) {
|
||||
if ($element instanceof \PhpOffice\PhpWord\Element\Text) {
|
||||
$textBuffer .= $element->getText();
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем буфер после каждого TextRun
|
||||
$this->extractFromText($textBuffer, $variables);
|
||||
$textBuffer = ''; // Сбрасываем буфер после TextRun
|
||||
}
|
||||
|
||||
private function extractFromTable($table, array &$variables, string &$textBuffer): void
|
||||
{
|
||||
foreach ($table->getRows() as $row) {
|
||||
foreach ($row->getCells() as $cell) {
|
||||
$cellBuffer = '';
|
||||
foreach ($cell->getElements() as $element) {
|
||||
if ($element instanceof \PhpOffice\PhpWord\Element\Text) {
|
||||
$cellBuffer .= $element->getText();
|
||||
} elseif ($element instanceof \PhpOffice\PhpWord\Element\TextRun) {
|
||||
foreach ($element->getElements() as $textElement) {
|
||||
if ($textElement instanceof \PhpOffice\PhpWord\Element\Text) {
|
||||
$cellBuffer .= $textElement->getText();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->extractFromText($cellBuffer, $variables);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function extractFromText(string $text, array &$variables): void
|
||||
{
|
||||
// Ищем переменные в формате ${variable_name}
|
||||
preg_match_all('/\$\{\s*([a-zA-Zа-яА-ЯёЁ0-9_]+)\s*\}/u', $text, $matches);
|
||||
if (!empty($matches[1])) {
|
||||
foreach ($matches[0] as $index => $fullMatch) {
|
||||
$variables[] = [
|
||||
'name' => $fullMatch, // Полное выражение с ${}
|
||||
'label' => $matches[1][$index], // Только содержимое внутри {}
|
||||
'type' => 'text'
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Альтернативный метод: чтение напрямую из XML
|
||||
public function extractVariablesFromXml($docxPath): array
|
||||
{
|
||||
$variables = [];
|
||||
|
||||
// Временная распаковка DOCX
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($docxPath) === TRUE) {
|
||||
// Читаем document.xml
|
||||
$documentXml = $zip->getFromName('word/document.xml');
|
||||
|
||||
if ($documentXml) {
|
||||
// Ищем все текстовые узлы
|
||||
preg_match_all('/(\{\{\s*[a-zA-Zа-яА-ЯёЁ0-9_]+\s*\}\})/u', $documentXml, $matches);
|
||||
|
||||
foreach ($matches[0] as $match) {
|
||||
// Извлекаем имя переменной
|
||||
preg_match('/\{\{\s*([a-zA-Zа-яА-ЯёЁ0-9_]+)\s*\}\}/u', $match, $varMatches);
|
||||
if (isset($varMatches[1])) {
|
||||
$variables[] = $varMatches[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Также проверяем headers и footers
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$filename = $zip->getNameIndex($i);
|
||||
if (preg_match('/word\/header\d+\.xml/', $filename) ||
|
||||
preg_match('/word\/footer\d+\.xml/', $filename)) {
|
||||
$content = $zip->getFromName($filename);
|
||||
preg_match_all('/(\{\{\s*[a-zA-Zа-яА-ЯёЁ0-9_]+\s*\}\})/u', $content, $matches);
|
||||
|
||||
foreach ($matches[0] as $match) {
|
||||
preg_match('/\{\{\s*([a-zA-Zа-яА-ЯёЁ0-9_]+)\s*\}\}/u', $match, $varMatches);
|
||||
if (isset($varMatches[1])) {
|
||||
$variables[] = $varMatches[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
return array_unique($variables);
|
||||
}
|
||||
|
||||
// Комбинированный метод
|
||||
public function extractVariablesCombined($docxPath): array
|
||||
{
|
||||
$variables1 = $this->extractVariables($docxPath);
|
||||
$variables2 = $this->extractVariablesFromXml($docxPath);
|
||||
|
||||
return array_unique(array_merge($variables1, $variables2));
|
||||
}
|
||||
|
||||
public function validateVariables(array $variables): array
|
||||
{
|
||||
$validated = [];
|
||||
|
||||
foreach ($variables as $variable) {
|
||||
if (preg_match('/^[a-zA-Zа-яА-ЯёЁ0-9_]+$/u', $variable)) {
|
||||
$validated[] = $variable;
|
||||
}
|
||||
}
|
||||
|
||||
return array_unique($validated);
|
||||
}
|
||||
|
||||
public function getVariablesWithExamples($docxPath): array
|
||||
{
|
||||
$variables = $this->extractVariablesCombined($docxPath);
|
||||
$result = [];
|
||||
|
||||
foreach ($variables as $variable) {
|
||||
$result[$variable] = [
|
||||
'name' => $variable,
|
||||
'human_name' => $this->makeHumanReadable($variable),
|
||||
'example' => $this->generateExample($variable),
|
||||
'type' => $this->detectType($variable)
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function makeHumanReadable(string $variable): string
|
||||
{
|
||||
$readable = str_replace('_', ' ', $variable);
|
||||
$readable = mb_strtolower($readable, 'UTF-8');
|
||||
$readable = mb_convert_case($readable, MB_CASE_TITLE, 'UTF-8');
|
||||
|
||||
return $readable;
|
||||
}
|
||||
|
||||
private function generateExample(string $variable): string
|
||||
{
|
||||
$examples = [
|
||||
'name' => 'Иван Иванов',
|
||||
'date' => '15.01.2024',
|
||||
'number' => '123-2024',
|
||||
'company' => 'ООО "Рога и копыта"',
|
||||
'address' => 'г. Москва, ул. Примерная, д. 1',
|
||||
'amount' => '100 000 руб.',
|
||||
'quantity' => '5',
|
||||
'price' => '20 000 руб.'
|
||||
];
|
||||
|
||||
foreach ($examples as $key => $example) {
|
||||
if (stripos($variable, $key) !== false) {
|
||||
return $example;
|
||||
}
|
||||
}
|
||||
|
||||
return 'Пример значения';
|
||||
}
|
||||
|
||||
private function detectType(string $variable): string
|
||||
{
|
||||
if (stripos($variable, 'date') !== false) return 'date';
|
||||
if (stripos($variable, 'amount') !== false || stripos($variable, 'price') !== false) return 'money';
|
||||
if (stripos($variable, 'quantity') !== false || stripos($variable, 'number') !== false) return 'number';
|
||||
if (stripos($variable, 'list') !== false || stripos($variable, 'items') !== false) return 'array';
|
||||
|
||||
return 'text';
|
||||
}
|
||||
}
|
||||
239
app/Services/PageBreaker.php
Normal file
239
app/Services/PageBreaker.php
Normal file
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use DOMDocument;
|
||||
use DOMXPath;
|
||||
|
||||
class PageBreaker
|
||||
{
|
||||
private $maxPageHeight; // Максимальная высота страницы в px
|
||||
private $currentPageHeight;
|
||||
private $pages;
|
||||
private $currentPageContent;
|
||||
|
||||
public function __construct($maxPageHeight = 1122) // 29.7cm * 37.8px/cm ≈ 1122px
|
||||
{
|
||||
$this->maxPageHeight = $maxPageHeight;
|
||||
$this->currentPageHeight = 0;
|
||||
$this->pages = [];
|
||||
$this->currentPageContent = '';
|
||||
}
|
||||
|
||||
public function splitIntoPages($htmlContent)
|
||||
{
|
||||
if (empty($htmlContent)) {
|
||||
return [['content' => '', 'pageNumber' => 1]];
|
||||
}
|
||||
|
||||
$dom = new DOMDocument();
|
||||
@$dom->loadHTML('<?xml encoding="UTF-8"><div id="content">' . $htmlContent . '</div>');
|
||||
|
||||
$this->pages = [];
|
||||
$this->currentPageHeight = 0;
|
||||
$this->currentPageContent = '';
|
||||
|
||||
$body = $dom->getElementById('content');
|
||||
if ($body) {
|
||||
$this->processNode($body);
|
||||
}
|
||||
|
||||
// Добавляем последнюю страницу
|
||||
if (!empty($this->currentPageContent)) {
|
||||
$this->addPage();
|
||||
}
|
||||
|
||||
return $this->pages;
|
||||
}
|
||||
|
||||
private function processNode($node)
|
||||
{
|
||||
$nodeName = strtolower($node->nodeName);
|
||||
|
||||
// Элементы, которые нельзя разрывать
|
||||
$unbreakableElements = ['table', 'tr', 'img', 'pre', 'code'];
|
||||
|
||||
if (in_array($nodeName, $unbreakableElements)) {
|
||||
$this->processUnbreakableElement($node);
|
||||
} else {
|
||||
$this->processBreakableElement($node);
|
||||
}
|
||||
}
|
||||
|
||||
private function processUnbreakableElement($node)
|
||||
{
|
||||
$elementHtml = $this->getOuterHTML($node);
|
||||
$estimatedHeight = $this->estimateElementHeight($elementHtml);
|
||||
|
||||
// Если элемент не помещается на текущую страницу
|
||||
if ($this->currentPageHeight + $estimatedHeight > $this->maxPageHeight && $this->currentPageHeight > 0) {
|
||||
$this->addPage();
|
||||
}
|
||||
|
||||
$this->currentPageContent .= $elementHtml;
|
||||
$this->currentPageHeight += $estimatedHeight;
|
||||
}
|
||||
|
||||
private function processBreakableElement($node)
|
||||
{
|
||||
if ($node->nodeType === XML_TEXT_NODE) {
|
||||
$this->processTextNode($node);
|
||||
return;
|
||||
}
|
||||
|
||||
// Для breakable элементов обрабатываем детей по отдельности
|
||||
foreach ($node->childNodes as $child) {
|
||||
$this->processNode($child);
|
||||
}
|
||||
|
||||
// Добавляем закрывающий тег после обработки всех детей
|
||||
if ($node->nodeType === XML_ELEMENT_NODE) {
|
||||
$this->currentPageContent .= '</' . $node->nodeName . '>';
|
||||
}
|
||||
}
|
||||
|
||||
private function processTextNode($node)
|
||||
{
|
||||
$text = $node->textContent;
|
||||
$words = preg_split('/(\s+)/', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
|
||||
|
||||
foreach ($words as $word) {
|
||||
if (trim($word) === '') {
|
||||
$this->addContent($word, 4); // Примерная высота пробела
|
||||
continue;
|
||||
}
|
||||
|
||||
$wordHeight = $this->estimateTextHeight($word);
|
||||
|
||||
// Если слово не помещается на текущую страницу
|
||||
if ($this->currentPageHeight + $wordHeight > $this->maxPageHeight && $this->currentPageHeight > 0) {
|
||||
$this->addPage();
|
||||
}
|
||||
|
||||
$this->addContent($word, $wordHeight);
|
||||
}
|
||||
}
|
||||
|
||||
private function addContent($content, $height)
|
||||
{
|
||||
$this->currentPageContent .= $content;
|
||||
$this->currentPageHeight += $height;
|
||||
}
|
||||
|
||||
private function addPage()
|
||||
{
|
||||
$this->pages[] = [
|
||||
'content' => $this->currentPageContent,
|
||||
'pageNumber' => count($this->pages) + 1,
|
||||
'height' => $this->currentPageHeight
|
||||
];
|
||||
|
||||
$this->currentPageContent = '';
|
||||
$this->currentPageHeight = 0;
|
||||
}
|
||||
|
||||
private function estimateElementHeight($html)
|
||||
{
|
||||
// Упрощенная оценка высоты элемента
|
||||
$lineHeight = 20; // Примерная высота строки в px
|
||||
$lines = substr_count($html, '<br') + substr_count($html, '</p') + substr_count($html, '</div') + 1;
|
||||
|
||||
return $lines * $lineHeight;
|
||||
}
|
||||
|
||||
private function estimateTextHeight($text)
|
||||
{
|
||||
// Оценка высоты текста based на количество символов
|
||||
$avgCharWidth = 8; // Средняя ширина символа в px
|
||||
$lineHeight = 20; // Высота строки в px
|
||||
$maxWidth = 500; // Максимальная ширина контента в px
|
||||
|
||||
$estimatedWidth = strlen($text) * $avgCharWidth;
|
||||
$lines = ceil($estimatedWidth / $maxWidth);
|
||||
|
||||
return $lines * $lineHeight;
|
||||
}
|
||||
|
||||
private function getOuterHTML($node)
|
||||
{
|
||||
$doc = new DOMDocument();
|
||||
$doc->appendChild($doc->importNode($node, true));
|
||||
return $doc->saveHTML();
|
||||
}
|
||||
|
||||
// Альтернативный метод: разделение по количеству символов
|
||||
public function splitByCharacterCount($htmlContent, $charsPerPage = 3000)
|
||||
{
|
||||
$pages = [];
|
||||
$currentPage = '';
|
||||
$charCount = 0;
|
||||
|
||||
// Разделяем HTML на теги и текст
|
||||
$tokens = preg_split('/(<[^>]+>)/', $htmlContent, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
|
||||
|
||||
$openTags = [];
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
// Если это открывающий тег
|
||||
if (preg_match('/^<([^\/][^>]*)>$/', $token, $matches)) {
|
||||
$tagName = strtolower(explode(' ', $matches[1])[0]);
|
||||
$openTags[] = $tagName;
|
||||
$currentPage .= $token;
|
||||
}
|
||||
// Если это закрывающий тег
|
||||
elseif (preg_match('/^<\/([^>]+)>$/', $token, $matches)) {
|
||||
array_pop($openTags);
|
||||
$currentPage .= $token;
|
||||
}
|
||||
// Если это текст
|
||||
else {
|
||||
$text = $token;
|
||||
$textLength = mb_strlen($text);
|
||||
|
||||
if ($charCount + $textLength > $charsPerPage && $charCount > 0) {
|
||||
// Закрываем открытые теги
|
||||
$currentPage .= $this->closeOpenTags($openTags);
|
||||
|
||||
$pages[] = [
|
||||
'content' => $currentPage,
|
||||
'pageNumber' => count($pages) + 1
|
||||
];
|
||||
|
||||
$currentPage = $this->reopenTags($openTags) . $text;
|
||||
$charCount = $textLength;
|
||||
} else {
|
||||
$currentPage .= $text;
|
||||
$charCount += $textLength;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Добавляем последнюю страницу
|
||||
if (!empty($currentPage)) {
|
||||
$pages[] = [
|
||||
'content' => $currentPage,
|
||||
'pageNumber' => count($pages) + 1
|
||||
];
|
||||
}
|
||||
|
||||
return $pages;
|
||||
}
|
||||
|
||||
private function closeOpenTags($openTags)
|
||||
{
|
||||
$html = '';
|
||||
for ($i = count($openTags) - 1; $i >= 0; $i--) {
|
||||
$html .= '</' . $openTags[$i] . '>';
|
||||
}
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function reopenTags($openTags)
|
||||
{
|
||||
$html = '';
|
||||
foreach ($openTags as $tag) {
|
||||
$html .= '<' . $tag . '>';
|
||||
}
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user