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('#]*/>#i', '', $xml) ?? $xml; // Collapse boundaries between runs and inside runs (include tabs/line breaks) $patterns = [ '#\s*\s*(?:<(?:w:proofErr|w:tab|w:br)[^>]*/>\s*)*(?:]*>\s*(?:.*?\s*)*)?]*>#is', '#\s*(?:<(?:w:proofErr|w:tab|w:br)[^>]*/>\s*)*]*>#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; } }