Document gen fixed

This commit is contained in:
2025-10-12 17:52:17 +02:00
parent e0303ece74
commit 23f2011e33
16 changed files with 1116 additions and 88 deletions
+110
View File
@@ -0,0 +1,110 @@
<?php
namespace App\Console\Commands;
use App\Models\Contract;
use App\Models\DocumentTemplate;
use App\Services\Documents\DocumentSettings;
use App\Services\Documents\TokenScanner;
use App\Services\Documents\TokenValueResolver;
use Illuminate\Console\Command;
class DocScanCommand extends Command
{
protected $signature = 'doc:scan {contract : Contract UUID} {xml : Path to Word document.xml}';
protected $description = 'Scan a Word document.xml for tokens and resolve values against a contract UUID';
public function handle(TokenScanner $scanner, TokenValueResolver $resolver, DocumentSettings $settings): int
{
$uuid = (string) $this->argument('contract');
$xmlPath = (string) $this->argument('xml');
if (! is_file($xmlPath)) {
$this->error("XML file not found: {$xmlPath}");
return self::FAILURE;
}
$xml = file_get_contents($xmlPath);
if ($xml === false) {
$this->error('Unable to read XML file.');
return self::FAILURE;
}
$contract = Contract::where('uuid', $uuid)->first();
if (! $contract) {
$this->error("Contract not found for UUID: {$uuid}");
return self::FAILURE;
}
// Normalize common Word run boundaries so tokens appear contiguous
$norm = $this->normalizeRunsForTokens($xml);
$tokens = $scanner->scan($norm);
$this->info('Detected tokens:');
foreach ($tokens as $t) {
$this->line(" - {$t}");
}
if (empty($tokens)) {
$this->warn('No tokens detected.');
}
// Build a minimal in-memory template using global whitelist so we can resolve values
$whitelist = $settings->get()->whitelist ?? [];
if (! is_array($whitelist)) {
$whitelist = [];
}
$entities = array_keys($whitelist);
$template = new DocumentTemplate([
'entities' => $entities,
'columns' => $whitelist,
'fail_on_unresolved' => false,
'formatting_options' => [],
'meta' => [],
]);
// Resolve values using a relaxed policy to avoid exceptions on unknowns
$user = auth()->user() ?? (\App\Models\User::query()->first() ?: new \App\Models\User(['name' => 'System']));
$resolved = $resolver->resolve($tokens, $template, $contract, $user, policy: 'blank');
$values = $resolved['values'] ?? [];
$unresolved = $resolved['unresolved'] ?? [];
$this->info('Resolved values:');
foreach ($values as $k => $v) {
$short = strlen((string) $v) > 120 ? substr((string) $v, 0, 117).'...' : (string) $v;
$this->line(" - {$k} => {$short}");
}
if (! empty($unresolved)) {
$this->warn('Unresolved tokens:');
foreach ($unresolved as $u) {
$this->line(" - {$u}");
}
}
return self::SUCCESS;
}
private function normalizeRunsForTokens(string $xml): string
{
// Remove proofing error spans that may split content
$xml = preg_replace('#<w:proofErr[^>]*/>#i', '', $xml) ?? $xml;
// Iteratively collapse boundaries between text runs, even if w:rPr is present
$patterns = [
// </w:t></w:r> [optional proofErr] <w:r ...> [optional rPr] <w:t>
'#</w:t>\s*</w:r>\s*(?:<w:proofErr[^>]*/>\s*)*(?:<w:r[^>]*>\s*(?:<w:rPr>.*?</w:rPr>\s*)*)?<w:t[^>]*>#is',
];
$prev = null;
while ($prev !== $xml) {
$prev = $xml;
foreach ($patterns as $pat) {
$xml = preg_replace($pat, '', $xml) ?? $xml;
}
}
// Remove zero-width and soft hyphen characters
$xml = str_replace(["\xE2\x80\x8B", "\xC2\xAD"], '', $xml);
return $xml;
}
}
@@ -0,0 +1,152 @@
<?php
namespace App\Console\Commands;
use App\Models\DocumentTemplate;
use App\Services\Documents\TokenScanner;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use ZipArchive;
class TemplateScanCommand extends Command
{
protected $signature = 'template:scan {slug : Template slug} {--tpl-version= : Specific template version number} {--parts : Show per-part tokens}';
protected $description = 'Scan a stored DOCX template by slug/version and dump detected tokens directly from storage.';
public function handle(TokenScanner $scanner): int
{
$slug = (string) $this->argument('slug');
$version = $this->option('tpl-version');
/** @var DocumentTemplate|null $template */
$query = DocumentTemplate::query()->where('slug', $slug);
if (! empty($version)) {
$query->where('version', (int) $version);
} else {
$query->orderByDesc('version');
}
$template = $query->first();
if (! $template) {
$this->error("Template not found for slug '{$slug}'".($version ? " v{$version}" : ''));
return self::FAILURE;
}
$disk = 'public';
$path = $template->file_path;
if (! $path || ! Storage::disk($disk)->exists($path)) {
$this->error('Template file not found on disk: '.$path);
return self::FAILURE;
}
$bytes = Storage::disk($disk)->get($path);
$tmp = tempnam(sys_get_temp_dir(), 'tmpl');
file_put_contents($tmp, $bytes);
$zip = new ZipArchive;
if ($zip->open($tmp) !== true) {
$this->error('Unable to open DOCX (zip).');
return self::FAILURE;
}
// Collect parts: main + headers/footers + notes/comments
$parts = [];
$doc = $zip->getFromName('word/document.xml');
if ($doc !== false) {
$parts['word/document.xml'] = $doc;
}
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if (! is_string($name)) {
continue;
}
if (preg_match('#^word/(header\d*|footer\d*|footnotes|endnotes|comments)\.xml$#i', $name)) {
$xml = $zip->getFromName($name);
if ($xml !== false) {
$parts[$name] = $xml;
}
}
}
// Normalize and scan
$all = [];
$perPart = [];
foreach ($parts as $name => $xml) {
$norm = $this->normalizeRunsForTokens($xml);
$found = $scanner->scan($norm);
$perPart[$name] = $found;
if ($found) {
$all = array_merge($all, $found);
}
}
$union = array_values(array_unique($all));
$this->info("Template: {$template->name} (slug={$template->slug}, v{$template->version})");
$this->line('File: '.$path);
$this->line('Tokens found (union): '.count($union));
foreach ($union as $t) {
$this->line(' - '.$t);
}
if ($this->option('parts')) {
$this->line('');
$this->info('Per-part details:');
foreach ($perPart as $n => $list) {
$this->line("[{$n}] (".count($list).')');
foreach ($list as $t) {
$this->line(' - '.$t);
}
}
}
$zip->close();
@unlink($tmp);
return self::SUCCESS;
}
private function normalizeRunsForTokens(string $xml): string
{
// Remove proofing error markers
$xml = preg_replace('#<w:proofErr[^>]*/>#i', '', $xml) ?? $xml;
// Collapse boundaries between runs and inside runs (include tabs/line breaks)
$patterns = [
'#</w:t>\s*</w:r>\s*(?:<(?:w:proofErr|w:tab|w:br)[^>]*/>\s*)*(?:<w:r[^>]*>\s*(?:<w:rPr>.*?</w:rPr>\s*)*)?<w:t[^>]*>#is',
'#</w:t>\s*(?:<(?:w:proofErr|w:tab|w:br)[^>]*/>\s*)*<w:t[^>]*>#is',
];
$prev = null;
while ($prev !== $xml) {
$prev = $xml;
foreach ($patterns as $pat) {
$xml = preg_replace($pat, '', $xml) ?? $xml;
}
}
// Clean inside {{ ... }}
$xml = preg_replace_callback('/\{\{.*?\}\}/s', function (array $m) {
$inner = substr($m[0], 2, -2);
$inner = preg_replace('/<[^>]+>/', '', $inner) ?? $inner;
$inner = preg_replace('/\s+/', '', $inner) ?? $inner;
return '{{'.$inner.'}}';
}, $xml) ?? $xml;
// Clean inside { ... } if it looks like a token
$xml = preg_replace_callback('/\{[^{}]*\}/s', function (array $m) {
$raw = $m[0];
$inner = substr($raw, 1, -1);
$clean = preg_replace('/<[^>]+>/', '', $inner) ?? $inner;
$clean = preg_replace('/\s+/', '', $clean) ?? $clean;
if (preg_match('/^[a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*\.[a-zA-Z0-9_.-]+$/', $clean)) {
return '{'.$clean.'}';
}
return $raw;
}, $xml) ?? $xml;
// Remove zero-width and soft hyphen
$xml = str_replace(["\xE2\x80\x8B", "\xC2\xAD"], '', $xml);
return $xml;
}
}
@@ -133,6 +133,94 @@ public function updateSettings(UpdateDocumentTemplateRequest $request, DocumentT
return redirect()->back()->with('success', 'Nastavitve predloge shranjene.');
}
public function rescanTokens(DocumentTemplate $template)
{
$this->ensurePermission();
// Best-effort: read stored DOCX from disk and re-scan tokens
$tokens = [];
try {
/** @var TokenScanner $scanner */
$scanner = app(TokenScanner::class);
$zip = new \ZipArchive;
$tmp = tempnam(sys_get_temp_dir(), 'tmpl');
// Copy file from storage to a temp path
$disk = 'public';
$stream = \Storage::disk($disk)->get($template->file_path);
file_put_contents($tmp, $stream);
if ($zip->open($tmp) === true) {
// Collect main document and header/footer parts
$parts = [];
for ($i = 0; $i < $zip->numFiles; $i++) {
$stat = $zip->statIndex($i);
$name = $stat['name'] ?? '';
if (preg_match('#^word\/(document|header\d+|footer\d+)\.xml$#i', $name)) {
$parts[] = $name;
}
}
if (empty($parts)) {
$parts = ['word/document.xml'];
}
$found = [];
foreach ($parts as $name) {
$xml = $zip->getFromName($name);
if ($xml === false) {
continue;
}
$norm = self::normalizeDocxXmlTokens($xml);
$det = $scanner->scan($norm);
if (! empty($det)) {
$found = array_merge($found, $det);
}
}
$tokens = array_values(array_unique($found));
$zip->close();
}
} catch (\Throwable $e) {
// swallow scanning errors, keep $tokens as empty
}
if (\Schema::hasColumn('document_templates', 'tokens')) {
$template->tokens = $tokens;
}
// Auto-detect custom.* tokens and ensure meta.custom_default_types has defaults
try {
$meta = is_array($template->meta) ? $template->meta : [];
$types = isset($meta['custom_default_types']) && is_array($meta['custom_default_types']) ? $meta['custom_default_types'] : [];
$defaults = isset($meta['custom_defaults']) && is_array($meta['custom_defaults']) ? $meta['custom_defaults'] : [];
foreach (($tokens ?? []) as $tok) {
if (is_string($tok) && str_starts_with($tok, 'custom.')) {
$key = substr($tok, 7);
if ($key !== '') {
if (! array_key_exists($key, $types)) {
$types[$key] = 'string';
}
if (! array_key_exists($key, $defaults)) {
$defaults[$key] = '';
}
}
}
}
if (! empty($types)) {
$meta['custom_default_types'] = $types;
}
if (! empty($defaults)) {
$meta['custom_defaults'] = $defaults;
}
if ($meta !== ($template->meta ?? [])) {
$template->meta = $meta;
}
} catch (\Throwable $e) {
// ignore meta typing/defaults failures
}
$template->updated_by = Auth::id();
$template->save();
$count = is_array($tokens) ? count($tokens) : 0;
return back()->with('success', "Tokens posodobljeni ({$count} najdenih).");
}
public function store(StoreDocumentTemplateRequest $request)
{
$this->ensurePermission();
@@ -152,7 +240,7 @@ public function store(StoreDocumentTemplateRequest $request)
$hash = hash_file('sha256', $file->getRealPath());
$path = $file->store("document-templates/{$slug}/v{$nextVersion}", 'public');
// Scan tokens from uploaded DOCX (best effort)
// Scan tokens from uploaded DOCX (best effort) normalize XML to collapse Word run boundaries
$tokens = [];
try {
/** @var TokenScanner $scanner */
@@ -161,10 +249,31 @@ public function store(StoreDocumentTemplateRequest $request)
$tmp = tempnam(sys_get_temp_dir(), 'tmpl');
copy($file->getRealPath(), $tmp);
if ($zip->open($tmp) === true) {
$xml = $zip->getFromName('word/document.xml');
if ($xml !== false) {
$tokens = $scanner->scan($xml);
// Collect main document and header/footer parts
$parts = [];
for ($i = 0; $i < $zip->numFiles; $i++) {
$stat = $zip->statIndex($i);
$name = $stat['name'] ?? '';
if (preg_match('#^word\/(document|header\d+|footer\d+)\.xml$#i', $name)) {
$parts[] = $name;
}
}
if (empty($parts)) {
$parts = ['word/document.xml'];
}
$found = [];
foreach ($parts as $name) {
$xml = $zip->getFromName($name);
if ($xml === false) {
continue;
}
$norm = self::normalizeDocxXmlTokens($xml);
$det = $scanner->scan($norm);
if (! empty($det)) {
$found = array_merge($found, $det);
}
}
$tokens = array_values(array_unique($found));
$zip->close();
}
} catch (\Throwable $e) {
@@ -224,6 +333,36 @@ public function store(StoreDocumentTemplateRequest $request)
if (Schema::hasColumn('document_templates', 'tokens')) {
$payload['tokens'] = $tokens;
}
// Auto-add default string types for any detected custom.* tokens
try {
$meta = isset($payload['meta']) && is_array($payload['meta']) ? $payload['meta'] : [];
$types = isset($meta['custom_default_types']) && is_array($meta['custom_default_types']) ? $meta['custom_default_types'] : [];
$defaults = isset($meta['custom_defaults']) && is_array($meta['custom_defaults']) ? $meta['custom_defaults'] : [];
foreach (($tokens ?? []) as $tok) {
if (is_string($tok) && str_starts_with($tok, 'custom.')) {
$key = substr($tok, 7);
if ($key !== '') {
if (! array_key_exists($key, $types)) {
$types[$key] = 'string';
}
if (! array_key_exists($key, $defaults)) {
$defaults[$key] = '';
}
}
}
}
if (! empty($types)) {
$meta['custom_default_types'] = $types;
}
if (! empty($defaults)) {
$meta['custom_defaults'] = $defaults;
}
if ($meta !== ($payload['meta'] ?? [])) {
$payload['meta'] = $meta;
}
} catch (\Throwable $e) {
// ignore meta typing/defaults failures
}
$template = DocumentTemplate::create($payload);
return redirect()->back()->with('success', 'Predloga uspešno shranjena. (v'.$template->version.')')->with('template_id', $template->id);
@@ -235,4 +374,29 @@ private function ensurePermission(): void
abort(403);
}
}
/**
* Collapse common Word run boundaries and proofing spans so tokens like {{client.person.full_name}}
* appear contiguous in XML for scanning.
*/
private static function normalizeDocxXmlTokens(string $xml): string
{
// Remove proofing error markers
$xml = preg_replace('#<w:proofErr[^>]*/>#i', '', $xml) ?? $xml;
// Iteratively collapse boundaries between text runs, even if w:rPr is present
$patterns = [
'#</w:t>\s*</w:r>\s*(?:<w:proofErr[^>]*/>\s*)*(?:<w:r[^>]*>\s*(?:<w:rPr>.*?</w:rPr>\s*)*)?<w:t[^>]*>#is',
];
$prev = null;
while ($prev !== $xml) {
$prev = $xml;
foreach ($patterns as $pat) {
$xml = preg_replace($pat, '', $xml) ?? $xml;
}
}
// Remove zero-width and soft hyphen characters
$xml = str_replace(["\xE2\x80\x8B", "\xC2\xAD"], '', $xml);
return $xml;
}
}
@@ -1195,6 +1195,15 @@ public function show(ClientCase $clientCase)
'client' => $case->client()->with('person', fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts', 'client']))->firstOrFail(),
'client_case' => $case,
'contracts' => $contracts,
// Active document templates for contracts (latest version per slug)
'contract_doc_templates' => \App\Models\DocumentTemplate::query()
->where('active', true)
->where('core_entity', 'contract')
->orderBy('slug')
->get(['id', 'name', 'slug', 'version', 'tokens', 'meta'])
->groupBy('slug')
->map(fn ($g) => $g->sortByDesc('version')->first())
->values(),
'archive_meta' => [
'archive_segment_id' => $archiveSegmentId,
'related_tables' => $relatedArchiveTables,
@@ -23,15 +23,24 @@ public function __invoke(Request $request, Contract $contract): Response
}
$request->validate([
'template_slug' => ['required', 'string', 'exists:document_templates,slug'],
'template_version' => ['nullable', 'integer'],
'custom' => ['nullable', 'array'],
'custom.*' => ['nullable'],
]);
$template = DocumentTemplate::where('slug', $request->template_slug)
// Prefer explicitly requested version if provided and active; otherwise use latest active
$baseQuery = DocumentTemplate::query()
->where('slug', $request->template_slug)
->where('core_entity', 'contract')
->where('active', true)
->orderByDesc('version')
->firstOrFail();
->where('active', true);
if ($request->filled('template_version')) {
$template = (clone $baseQuery)->where('version', (int) $request->integer('template_version'))->first();
if (! $template) {
$template = (clone $baseQuery)->orderByDesc('version')->firstOrFail();
}
} else {
$template = $baseQuery->orderByDesc('version')->firstOrFail();
}
// Load related data minimally
$contract->load(['clientCase.client.person', 'clientCase.person', 'clientCase.client']);
@@ -47,6 +56,16 @@ public function __invoke(Request $request, Contract $contract): Response
'tokens' => $e->unresolved ?? [],
], 422);
} catch (\Throwable $e) {
try {
logger()->error('ContractDocumentGenerationController generation failed', [
'template_id' => $template->id ?? null,
'template_slug' => $template->slug ?? null,
'template_version' => $template->version ?? null,
'error' => $e->getMessage(),
]);
} catch (\Throwable $logEx) {
}
return response()->json([
'status' => 'error',
'message' => 'Generation failed.',
@@ -115,6 +134,13 @@ public function __invoke(Request $request, Contract $contract): Response
'status' => 'ok',
'document_uuid' => $doc->uuid,
'path' => $doc->path,
'stats' => $result['stats'] ?? null,
'template' => [
'id' => $template->id,
'slug' => $template->slug,
'version' => $template->version,
'file_path' => $template->file_path,
],
]);
}
}
@@ -33,7 +33,7 @@ public function rules(): array
'meta.custom_defaults' => ['nullable', 'array'],
'meta.custom_defaults.*' => ['nullable'],
'meta.custom_default_types' => ['nullable', 'array'],
'meta.custom_default_types.*' => ['nullable', 'in:string,number,date'],
'meta.custom_default_types.*' => ['nullable', 'in:string,number,date,text'],
'action_id' => ['nullable', 'integer', 'exists:actions,id'],
'decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
'activity_note_template' => ['nullable', 'string'],
+288 -35
View File
@@ -31,18 +31,80 @@ public function render(DocumentTemplate $template, Contract $contract, User $use
file_put_contents($tmpIn, $templateStream);
$zip = new ZipArchive;
$zip->open($tmpIn);
$openResult = $zip->open($tmpIn);
if ($openResult !== true) {
throw new \RuntimeException('Ne morem odpreti DOCX arhiva: code '.$openResult);
}
$docXml = $zip->getFromName('word/document.xml');
if ($docXml === false) {
throw new \RuntimeException('Manjkajoča document.xml');
}
$tokens = $this->scanner->scan($docXml);
// Determine effective unresolved policy early (template override -> global -> config)
$globalSettingsEarly = app(\App\Services\Documents\DocumentSettings::class)->get();
$effectivePolicy = $template->fail_on_unresolved ? 'fail' : ($globalSettingsEarly->unresolved_policy ?? config('documents.unresolved_policy', 'fail'));
// Resolve with support for custom.* tokens: per-generation overrides and defaults from template meta or global settings.
$customOverrides = request()->input('custom', []); // if called via HTTP context; otherwise pass explicitly from caller
// Collect all XML parts we should scan/replace: document + headers/footers + footnotes/endnotes/comments
$parts = [];
$parts['word/document.xml'] = $docXml;
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if (! is_string($name)) {
continue;
}
if (preg_match('#^word/(header\d*|footer\d*|footnotes|endnotes|comments)\.xml$#i', $name)) {
$xml = $zip->getFromName($name);
if ($xml !== false) {
$parts[$name] = $xml;
}
}
}
// Keep originals for safe fallback on write if normalization yields invalid XML
$originalParts = $parts;
// Normalize each part for scanning and replacement
$scanParts = [];
foreach ($parts as $name => $xml) {
$normalized = $this->normalizeRunsForTokens($xml);
$scanParts[$name] = $normalized; // used for scanning tokens
$parts[$name] = $normalized; // used for replacement/write-back
}
// Scan tokens across all parts (merge default scanner + brace-aware split-run scanner + text-only scanner)
$tokens = [];
foreach ($scanParts as $xml) {
$found = $this->scanner->scan($xml);
if ($found) {
$tokens = array_merge($tokens, $found);
}
$foundSplit = $this->scanBraceTokens($xml);
if ($foundSplit) {
$tokens = array_merge($tokens, $foundSplit);
}
$foundText = $this->scanTextOnlyTokens($xml);
if ($foundText) {
$tokens = array_merge($tokens, $foundText);
}
}
$tokens = array_values(array_unique($tokens));
try {
logger()->info('DocxTemplateRenderer scan', [
'template_id' => $template->id,
'template_slug' => $template->slug,
'template_version' => $template->version,
'file_path' => $template->file_path,
'tokens_found' => count($tokens),
]);
} catch (\Throwable $e) {
// swallow logging errors
}
// Policy: template flag -> global settings -> config; allow per-request override
$docSettings = app(\App\Services\Documents\DocumentSettings::class)->get();
$effectivePolicy = $template->fail_on_unresolved ? 'fail' : ($docSettings->unresolved_policy ?? config('documents.unresolved_policy', 'fail'));
$reqPolicy = request()->input('unresolved_policy');
if (in_array($reqPolicy, ['fail', 'keep', 'blank'], true)) {
$effectivePolicy = $reqPolicy;
}
// Resolve values
$customOverrides = request()->input('custom', []);
$customDefaults = is_array($template->meta['custom_defaults'] ?? null) ? $template->meta['custom_defaults'] : null;
$resolved = $this->resolver->resolve(
$tokens,
@@ -57,7 +119,18 @@ public function render(DocumentTemplate $template, Contract $contract, User $use
$values = $resolved['values'];
$initialUnresolved = $resolved['unresolved'];
$customTypes = $resolved['customTypes'] ?? [];
// Formatting options
// Explicit per-token overrides (e.g., address choices)
$tokenOverrides = request()->input('token_overrides', []);
if (is_array($tokenOverrides) && ! empty($tokenOverrides)) {
foreach ($tokenOverrides as $tok => $val) {
if ($tok && (is_scalar($val) || $val === null)) {
$values[(string) $tok] = (string) ($val ?? '');
}
}
}
// Formatting
$fmt = $template->formatting_options ?? [];
$decimals = (int) ($fmt['number_decimals'] ?? 2);
$decSep = $fmt['decimal_separator'] ?? '.';
@@ -65,83 +138,155 @@ public function render(DocumentTemplate $template, Contract $contract, User $use
$currencySymbol = $fmt['currency_symbol'] ?? null;
$currencyPos = $fmt['currency_position'] ?? 'before';
$currencySpace = (bool) ($fmt['currency_space'] ?? false);
$globalSettings = app(\App\Services\Documents\DocumentSettings::class)->get();
$globalDateFormats = $globalSettings->date_formats ?? [];
$globalDateFormats = $docSettings->date_formats ?? [];
foreach ($values as $k => $v) {
$isTypedDate = ($customTypes[$k] ?? null) === 'date';
$isTypedNumber = ($customTypes[$k] ?? null) === 'number';
// Date formatting (typed or heuristic based on key ending with _date or .date)
if (is_string($v) && ($isTypedDate || $k === 'generation.date' || preg_match('/(^|\.)[A-Za-z_]*date$/i', $k))) {
$dateFmtOverrides = $fmt['date_formats'] ?? [];
$desiredFormat = $dateFmtOverrides[$k]
?? ($globalDateFormats[$k] ?? null)
?? ($fmt['default_date_format'] ?? null)
?? ($template->date_format ?: null)
?? ($globalSettings->date_format ?? null)
?? ($docSettings->date_format ?? null)
?? config('documents.date_format', 'Y-m-d');
if ($desiredFormat) {
try {
$dt = Carbon::parse($v);
$values[$k] = $dt->format($desiredFormat);
continue; // skip numeric detection below
continue;
} catch (\Throwable $e) {
// swallow
// ignore
}
}
}
// Number formatting: only for explicitly typed numbers or common monetary fields
$isFinanceField = (bool) preg_match('/(^|\.)\b(amount|balance|total|price|cost)\b$/i', $k);
if (($isTypedNumber || $isFinanceField) && is_numeric($v)) {
$num = number_format((float) $v, $decimals, $decSep, $thouSep);
if ($currencySymbol && $isFinanceField) {
$space = $currencySpace ? ' ' : '';
if ($currencyPos === 'after') {
$num = $num.$space.$currencySymbol;
} else {
$num = $currencySymbol.$space.$num;
}
$num = $currencyPos === 'after' ? ($num.$space.$currencySymbol) : ($currencySymbol.$space.$num);
}
$values[$k] = $num;
}
}
// Replace tokens
foreach ($values as $token => $val) {
$docXml = str_replace('{{'.$token.'}}', htmlspecialchars($val), $docXml);
// Add unresolved tokens found in document but not produced in values
$resolvedTokens = array_keys($values);
$unresolvedFromDoc = array_values(array_diff($tokens, $resolvedTokens));
if (! empty($unresolvedFromDoc)) {
$initialUnresolved = array_values(array_unique(array_merge($initialUnresolved, $unresolvedFromDoc)));
}
// After replacement: check unresolved patterns
// Replace tokens in each part: support {{token}} and {token}, allow surrounding whitespace
foreach ($parts as $name => $xml) {
// Fast path for contiguous tokens
foreach ($values as $token => $val) {
$replacement = $this->sanitizeXmlText((string) $val);
$xml = str_replace('{{'.$token.'}}', $replacement, $xml);
$xml = str_replace('{'.$token.'}', $replacement, $xml);
$escapedToken = preg_quote(str_replace('.', '\\.', $token), '#');
$boundaryPatterns = [
'#\\{\\{\s*'.$escapedToken.'\s*\\}\\}#',
'#\\{\s*'.$escapedToken.'\s*\\}#',
];
foreach ($boundaryPatterns as $pat) {
$xml = preg_replace($pat, $replacement, $xml) ?? $xml;
}
}
// Slow path: single pass across brace chunks; if flattened token matches any key, replace with its value
if (! empty($values)) {
$xml = preg_replace_callback('#\\{\\{.*?\\}\\}|\\{[^{}]*\\}#s', function (array $m) use ($values) {
$chunk = $m[0];
$flat = preg_replace('/<[^>]+>/', '', $chunk) ?? $chunk;
$flat = preg_replace('/\\s+/', '', $flat) ?? $flat;
foreach ($values as $t => $v) {
if ($flat === '{{'.$t.'}}' || $flat === '{'.$t.'}') {
return $this->sanitizeXmlText((string) $v);
}
}
return $chunk;
}, $xml) ?? $xml;
}
$parts[$name] = $xml;
}
// Handle unresolved according to policy
if (! empty($initialUnresolved)) {
if ($effectivePolicy === 'blank') {
foreach ($initialUnresolved as $r) {
$docXml = str_replace('{{'.$r.'}}', '', $docXml);
foreach (array_values(array_unique($initialUnresolved)) as $r) {
foreach ($parts as $name => $xml) {
$xml = str_replace('{{'.$r.'}}', '', $xml);
$xml = str_replace('{'.$r.'}', '', $xml);
$escaped = preg_quote(str_replace('.', '\\.', $r), '#');
$xml = preg_replace('#\\{\\{\s*'.$escaped.'\s*\\}\\}#', '', $xml) ?? $xml;
$xml = preg_replace('#\\{\s*'.$escaped.'\s*\\}#', '', $xml) ?? $xml;
$parts[$name] = $xml;
}
}
} elseif ($effectivePolicy === 'keep') {
// keep unresolved markers
} else { // fail
// leave as-is
} else {
throw new UnresolvedTokensException($initialUnresolved, 'Neuspešna zamenjava tokenov');
}
}
$zip->addFromString('word/document.xml', $docXml);
$zip->close();
// Ensure each XML part is well-formed, then write back to zip (fallback to original if needed)
foreach ($parts as $name => $xml) {
if (! $this->isWellFormedXml($xml)) {
// Fallback to original part to avoid producing a broken DOCX (these parts typically had no tokens)
$fallback = $originalParts[$name] ?? null;
if (! is_string($fallback) || ! $this->isWellFormedXml($fallback)) {
try {
logger()->error('DocxTemplateRenderer invalid XML with no safe fallback', [
'part' => $name,
'template_id' => $template->id,
'template_version' => $template->version,
]);
} catch (\Throwable $e) {
}
throw new \RuntimeException("Končni XML del '{$name}' ni veljaven in ni varnega nadomestnega originala.");
}
try {
logger()->warning('DocxTemplateRenderer fallback to original part', [
'part' => $name,
'template_id' => $template->id,
'template_version' => $template->version,
]);
} catch (\Throwable $e) {
}
$zip->addFromString($name, $fallback);
} else {
$zip->addFromString($name, $xml);
}
}
$closeOk = $zip->close();
if ($closeOk !== true) {
throw new \RuntimeException('Zapiranje DOCX arhiva ni uspelo.');
}
$output = file_get_contents($tmpIn);
if ($output === false) {
throw new \RuntimeException('Bralni izhod iz začasne DOCX datoteke je spodletel.');
}
$checksum = hash('sha256', $output);
$size = strlen($output);
// Filename pattern & date format precedence: template override -> global settings -> config fallback
$globalSettings = $globalSettings ?? app(\App\Services\Documents\DocumentSettings::class)->get();
// Filename & date format
$pattern = $template->output_filename_pattern
?: ($globalSettings->file_name_pattern ?? config('documents.file_name_pattern'));
?: ($docSettings->file_name_pattern ?? config('documents.file_name_pattern'));
$dateFormat = $template->date_format
?: ($globalSettings->date_format ?? config('documents.date_format', 'Y-m-d'));
?: ($docSettings->date_format ?? config('documents.date_format', 'Y-m-d'));
$replacements = [
'{slug}' => $template->slug,
'{version}' => 'v'.$template->version,
'{generation.date}' => now()->format($dateFormat),
'{generation.timestamp}' => (string) now()->timestamp,
];
// Also allow any token ({{x.y}}) style replaced pattern variants: convert {contract.reference}
foreach ($values as $token => $val) {
$replacements['{'.$token.'}'] = Str::slug((string) $val) ?: 'value';
}
@@ -158,6 +303,114 @@ public function render(DocumentTemplate $template, Contract $contract, User $use
'relativePath' => $relativePath,
'size' => $size,
'checksum' => $checksum,
'stats' => [
'tokensFound' => count($tokens),
'resolvedCount' => count(array_intersect(array_keys($values), $tokens)),
'unresolved' => array_values(array_unique($initialUnresolved)),
],
];
}
/**
* Word may split tokens like {{client.person.full_name}} across multiple <w:r><w:t> runs.
* This method removes common run/element boundaries that appear between token braces so
* the scanner can find contiguous token strings.
*/
private function normalizeRunsForTokens(string $xml): string
{
// Non-destructive normalization: remove proofing markers and invisible characters only
$xml = preg_replace('#<w:proofErr[^>]*/>#i', '', $xml) ?? $xml;
$xml = str_replace(["\xE2\x80\x8B", "\xC2\xAD"], '', $xml); // zero-width space, soft hyphen
return $xml;
}
/**
* If normalization produced sequences like "<w:t> <w:t>" or "</w:t></w:t>", fix them.
*/
private function fixNestedTextTags(string $xml): string
{
// No-op: we no longer restructure <w:t> tags in normalization
return $xml;
}
/**
* Simple well-formedness check using DOMDocument.
*/
private function isWellFormedXml(string $xml): bool
{
$dom = new \DOMDocument('1.0', 'UTF-8');
$dom->preserveWhiteSpace = true;
$dom->formatOutput = false;
return @($dom->loadXML($xml, LIBXML_NOERROR | LIBXML_NOWARNING)) !== false;
}
/**
* Prepare text for safe inclusion in Word XML content.
*/
private function sanitizeXmlText(string $text): string
{
// Remove characters not allowed in XML 1.0
$text = preg_replace('/[^\x09\x0A\x0D\x20-\x{D7FF}\x{E000}-\x{FFFD}]/u', '', $text) ?? $text;
return htmlspecialchars($text, ENT_QUOTES | ENT_XML1, 'UTF-8');
}
/**
* Aggressive text-only token scan: strips all tags and searches for braces pairs in the raw text.
* Useful when tokens are heavily split across runs.
*
* @return string[]
*/
private function scanTextOnlyTokens(string $xml): array
{
$text = preg_replace('/<[^>]+>/', '', $xml) ?? $xml;
$found = [];
if (preg_match_all('/\{\{([^}]+)\}\}/s', $text, $m1)) {
foreach ($m1[1] as $inner) {
$tok = preg_replace('/\s+/', '', $inner) ?? $inner;
if ($tok !== '' && preg_match('/^[a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*\.[a-zA-Z0-9_.-]+$/', $tok)) {
$found[] = $tok;
}
}
}
if (preg_match_all('/\{([^{}]+)\}/s', $text, $m2)) {
foreach ($m2[1] as $inner) {
$tok = preg_replace('/\s+/', '', $inner) ?? $inner;
if ($tok !== '' && preg_match('/^[a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*\.[a-zA-Z0-9_.-]+$/', $tok)) {
$found[] = $tok;
}
}
}
return array_values(array_unique($found));
}
/**
* Finds tokens inside brace pairs even when Word has split them across runs.
* Strips XML tags from within braces and collapses whitespace to detect valid token patterns.
*
* @return string[]
*/
private function scanBraceTokens(string $xml): array
{
$tokens = [];
if (! preg_match_all('/\{\{.*?\}\}|\{[^{}]*\}/s', $xml, $matches)) {
return $tokens;
}
foreach ($matches[0] as $chunk) {
$isDouble = str_starts_with($chunk, '{{');
$inner = substr($chunk, $isDouble ? 2 : 1, $isDouble ? -2 : -1);
// Remove XML tags and whitespace inside braces
$clean = preg_replace('/<[^>]+>/', '', $inner) ?? $inner;
$clean = preg_replace('/\s+/', '', $clean) ?? $clean;
// Accept nested dotted tokens, allow dash in final segment
if ($clean !== '' && preg_match('/^[a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*\.[a-zA-Z0-9_.-]+$/', $clean)) {
$tokens[] = $clean;
}
}
return array_values(array_unique($tokens));
}
}
+12 -6
View File
@@ -4,19 +4,25 @@
class TokenScanner
{
// Allow entity.attr with attr accepting letters, digits, underscore, dot and hyphen for flexibility (e.g., custom.order-id)
private const REGEX = '/{{\s*([a-zA-Z0-9_]+\.[a-zA-Z0-9_.-]+)\s*}}/';
// Allow nested tokens like client.person.full_name or custom.order-id
// Pattern: entity(.[subentity])* . attribute
private const REGEX_DOUBLE = '/{{\s*([a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*\.[a-zA-Z0-9_.-]+)\s*}}/';
private const REGEX_SINGLE = '/\{\s*([a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*\.[a-zA-Z0-9_.-]+)\s*\}/';
/**
* @return array<int,string>
*/
public function scan(string $content): array
{
preg_match_all(self::REGEX, $content, $m);
if (empty($m[1])) {
return [];
$out = [];
if (preg_match_all(self::REGEX_DOUBLE, $content, $m1) && ! empty($m1[1])) {
$out = array_merge($out, $m1[1]);
}
if (preg_match_all(self::REGEX_SINGLE, $content, $m2) && ! empty($m2[1])) {
$out = array_merge($out, $m2[1]);
}
return array_values(array_unique($m[1]));
return array_values(array_unique($out));
}
}
+44 -10
View File
@@ -42,7 +42,7 @@ public function resolve(
$customTypes = [];
if (isset($template->meta['custom_default_types']) && is_array($template->meta['custom_default_types'])) {
foreach ($template->meta['custom_default_types'] as $k => $t) {
$t = in_array($t, ['string', 'number', 'date'], true) ? $t : 'string';
$t = in_array($t, ['string', 'number', 'date', 'text'], true) ? $t : 'string';
$customTypes[(string) $k] = $t;
}
}
@@ -57,6 +57,17 @@ public function resolve(
} else {
$templateEntities = array_keys($globalWhitelist);
}
// Normalize template tokens list (used as an allow-list if columns / global whitelist are not exhaustive)
$templateTokens = [];
$rawTemplateTokens = $template->tokens ?? null;
if (is_array($rawTemplateTokens)) {
$templateTokens = array_values(array_filter(array_map('strval', $rawTemplateTokens)));
} elseif (is_string($rawTemplateTokens)) {
$decoded = json_decode($rawTemplateTokens, true);
if (is_array($decoded)) {
$templateTokens = array_values(array_filter(array_map('strval', $decoded)));
}
}
foreach ($tokens as $token) {
[$entity,$attr] = explode('.', $token, 2);
if ($entity === 'generation') {
@@ -93,20 +104,43 @@ public function resolve(
continue;
}
if (! in_array($entity, $templateEntities, true)) {
if ($policy === 'fail') {
throw new \RuntimeException("Nedovoljen entiteta token: $entity");
}
$unresolved[] = $token;
// If the token is explicitly listed on the template's tokens, allow it
if (! $templateTokens || ! in_array($token, $templateTokens, true)) {
if ($policy === 'fail') {
throw new \RuntimeException("Nedovoljen entiteta token: $entity");
}
$unresolved[] = $token;
continue;
continue;
}
}
// Allowed attributes: merge template-declared columns with global whitelist (config + DB settings)
// Rationale: old templates may not list newly allowed attributes (like nested paths),
// so we honor both sources instead of preferring one exclusively.
$allowedFromTemplate = $template->columns[$entity] ?? [];
// Support nested dotted attributes (e.g. person.person_address.city). We allow if either the full
// dotted path is listed or if the base prefix is listed (e.g. person.person_address) and the resolver
// can handle it.
// Safely read template-declared columns
$columns = is_array($template->columns ?? null) ? $template->columns : [];
$allowedFromTemplate = $columns[$entity] ?? [];
$allowedFromGlobal = $globalWhitelist[$entity] ?? [];
$allowed = array_values(array_unique(array_merge($allowedFromTemplate, $allowedFromGlobal)));
if (! in_array($attr, $allowed, true)) {
$isAllowed = in_array($attr, $allowed, true);
if (! $isAllowed && str_contains($attr, '.')) {
// Check progressive prefixes: a.b.c -> a.b
$parts = explode('.', $attr);
while (count($parts) > 1 && ! $isAllowed) {
array_pop($parts);
$prefix = implode('.', $parts);
if (in_array($prefix, $allowed, true)) {
$isAllowed = true;
break;
}
}
}
// If still not allowed, permit tokens explicitly scanned/stored on the template
if (! $isAllowed && $templateTokens) {
$isAllowed = in_array($token, $templateTokens, true);
}
if (! $isAllowed) {
if ($policy === 'fail') {
throw new \RuntimeException("Nedovoljen stolpec token: $token");
}