first commit

This commit is contained in:
brusnitsyn
2025-10-31 16:48:05 +09:00
commit 8b650558e2
143 changed files with 24664 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View 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|&nbsp;)*<\/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);
}
}
}

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

View 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,
]);
}
}

View 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();
}
}

View 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
]);
}
}

View 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),
//
];
}
}

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

View 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
View 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',
];
}
}

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

View 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; // только . и ..
}
}

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

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