loadHTML('' . $html . '', LIBXML_NONET); $body = $doc->getElementsByTagName('body')->item(0); if (!$body) return ''; self::children($body); $output = ''; foreach ($body->childNodes as $node) $output .= $doc->saveHTML($node); return trim($output); } finally { libxml_clear_errors(); libxml_use_internal_errors($previous); } } private static function children(\DOMNode $parent): void { foreach (iterator_to_array($parent->childNodes) as $node) { if ($node instanceof \DOMComment || $node instanceof \DOMProcessingInstruction) { $parent->removeChild($node); continue; } if (!$node instanceof \DOMElement) continue; $tag = strtolower($node->tagName); if (in_array($tag, self::DROP, true)) { $parent->removeChild($node); continue; } self::children($node); if (!in_array($tag, self::TAGS, true)) { while ($node->firstChild) $parent->insertBefore($node->firstChild, $node); $parent->removeChild($node); continue; } foreach (iterator_to_array($node->attributes) as $attribute) { $name = strtolower($attribute->name); $value = $attribute->value; $allowed = in_array($name, ['title', 'dir'], true); if ($name === 'style') { $value = self::style($value); $allowed = $value !== ''; } if (in_array($name, ['colspan', 'rowspan'], true)) $allowed = in_array($tag, ['td', 'th'], true) && (bool)preg_match('/^[1-9]\d{0,2}$/D', $value); if (in_array($name, ['width', 'height', 'border', 'cellpadding', 'cellspacing', 'span'], true)) $allowed = (bool)preg_match('/^\d{1,4}%?$/D', $value); if ($name === 'align') $allowed = in_array($value, ['left', 'right', 'center', 'justify'], true); if ($name === 'valign') $allowed = in_array($value, ['top', 'middle', 'bottom', 'baseline'], true); if ($name === 'scope') $allowed = $tag === 'th' && in_array($value, ['row', 'col', 'rowgroup', 'colgroup'], true); if ($name === 'href') $allowed = $tag === 'a' && self::link($value); if ($name === 'target') $allowed = $tag === 'a' && in_array($value, ['_self', '_blank'], true); if (!$allowed) $node->removeAttributeNode($attribute); else $node->setAttribute($name, $value); } if ($tag === 'a' && $node->getAttribute('target') === '_blank') $node->setAttribute('rel', 'noopener noreferrer'); } } private static function style(string $style): string { $result = []; foreach (explode(';', $style) as $declaration) { $parts = explode(':', $declaration, 2); if (count($parts) !== 2) continue; $property = strtolower(trim($parts[0])); $value = trim($parts[1]); if (!in_array($property, self::STYLES, true) || !preg_match('/^[#a-zA-Z0-9\s.,()%+\-\x27"]+$/D', $value) || preg_match('/(?:url|expression|image-set|attr)\s*\(/i', $value)) continue; $result[] = $property . ': ' . $value; } return implode('; ', $result); } private static function link(string $value): bool { if ($value === '' || preg_match('/[\x00-\x20\x7f\\\\]/', $value)) return false; if (str_starts_with($value, '#') || (str_starts_with($value, '/') && !str_starts_with($value, '//'))) return true; $parts = parse_url($value); return filter_var($value, FILTER_VALIDATE_URL) && $parts && in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true) && !isset($parts['user']) && !isset($parts['pass']); } }