filter.module

Same filename in other branches
  1. 7.x modules/filter/filter.module
  2. 9 core/modules/filter/filter.module
  3. 8.9.x core/modules/filter/filter.module
  4. 10 core/modules/filter/filter.module

File

core/modules/filter/filter.module

View source
<?php


/**
 * @file
 */
use Drupal\Component\Utility\Html;
use Drupal\Component\Utility\Unicode;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Template\Attribute;
use Drupal\filter\FilterFormatInterface;
use Drupal\user\Entity\Role;
use Drupal\user\RoleInterface;

/**
 * Retrieves a list of enabled text formats, ordered by weight.
 *
 * @param \Drupal\Core\Session\AccountInterface|null $account
 *   (optional) If provided, only those formats that are allowed for this user
 *   account will be returned. All enabled formats will be returned otherwise.
 *   Defaults to NULL.
 *
 * @return \Drupal\filter\FilterFormatInterface[]
 *   An array of text format objects, keyed by the format ID and ordered by
 *   weight.
 *
 * @see filter_formats_reset()
 */
function filter_formats(?AccountInterface $account = NULL) {
    $formats =& drupal_static(__FUNCTION__, []);
    // All available formats are cached for performance.
    if (!isset($formats['all'])) {
        $language_interface = \Drupal::languageManager()->getCurrentLanguage();
        if ($cache = \Drupal::cache()->get("filter_formats:{$language_interface->getId()}")) {
            $formats['all'] = $cache->data;
        }
        else {
            $formats['all'] = \Drupal::entityTypeManager()->getStorage('filter_format')
                ->loadByProperties([
                'status' => TRUE,
            ]);
            uasort($formats['all'], 'Drupal\\Core\\Config\\Entity\\ConfigEntityBase::sort');
            \Drupal::cache()->set("filter_formats:{$language_interface->getId()}", $formats['all'], Cache::PERMANENT, \Drupal::entityTypeManager()->getDefinition('filter_format')
                ->getListCacheTags());
        }
    }
    // If no user was specified, return all formats.
    if (!isset($account)) {
        return $formats['all'];
    }
    // Build a list of user-specific formats.
    $account_id = $account->id();
    if (!isset($formats['user'][$account_id])) {
        $formats['user'][$account_id] = [];
        foreach ($formats['all'] as $format) {
            if ($format->access('use', $account)) {
                $formats['user'][$account_id][$format->id()] = $format;
            }
        }
    }
    return $formats['user'][$account_id];
}

/**
 * Resets the text format caches.
 *
 * @see filter_formats()
 */
function filter_formats_reset() {
    drupal_static_reset('filter_formats');
}

/**
 * Retrieves a list of roles that are allowed to use a given text format.
 *
 * @param \Drupal\filter\FilterFormatInterface $format
 *   An object representing the text format.
 *
 * @return array
 *   An array of role names, keyed by role ID.
 */
function filter_get_roles_by_format(FilterFormatInterface $format) {
    // Handle the fallback format upfront (all roles have access to this format).
    if ($format->isFallbackFormat()) {
        return array_map(fn(RoleInterface $role) => $role->label(), Role::loadMultiple());
    }
    // Do not list any roles if the permission does not exist.
    $permission = $format->getPermissionName();
    if (empty($permission)) {
        return [];
    }
    $roles = array_filter(Role::loadMultiple(), fn(RoleInterface $role) => $role->hasPermission($permission));
    return array_map(fn(RoleInterface $role) => $role->label(), $roles);
}

/**
 * Retrieves a list of text formats that are allowed for a given role.
 *
 * @param string $rid
 *   The user role ID to retrieve text formats for.
 *
 * @return \Drupal\filter\FilterFormatInterface[]
 *   An array of text format objects that are allowed for the role, keyed by
 *   the text format ID and ordered by weight.
 */
function filter_get_formats_by_role($rid) {
    $formats = [];
    foreach (filter_formats() as $format) {
        $roles = filter_get_roles_by_format($format);
        if (isset($roles[$rid])) {
            $formats[$format->id()] = $format;
        }
    }
    return $formats;
}

/**
 * Returns the ID of the default text format for a particular user.
 *
 * The default text format is the first available format that the user is
 * allowed to access, when the formats are ordered by weight. It should
 * generally be used as a default choice when presenting the user with a list
 * of possible text formats (for example, in a node creation form).
 *
 * Conversely, when existing content that does not have an assigned text format
 * needs to be filtered for display, the default text format is the wrong
 * choice, because it is not guaranteed to be consistent from user to user, and
 * some trusted users may have an unsafe text format set by default, which
 * should not be used on text of unknown origin. Instead, the fallback format
 * returned by filter_fallback_format() should be used, since that is intended
 * to be a safe, consistent format that is always available to all users.
 *
 * @param \Drupal\Core\Session\AccountInterface|null $account
 *   (optional) The user account to check. Defaults to the currently logged-in
 *   user. Defaults to NULL.
 *
 * @return string
 *   The ID of the user's default text format.
 *
 * @see filter_fallback_format()
 */
function filter_default_format(?AccountInterface $account = NULL) {
    if (!isset($account)) {
        $account = \Drupal::currentUser();
    }
    // Get a list of formats for this user, ordered by weight. The first one
    // available is the user's default format.
    $formats = filter_formats($account);
    $format = reset($formats);
    return $format->id();
}

/**
 * Returns the ID of the fallback text format that all users have access to.
 *
 * The fallback text format is a regular text format in every respect, except
 * it does not participate in the filter permission system and cannot be
 * disabled. It needs to exist because any user who has permission to create
 * formatted content must always have at least one text format they can use.
 *
 * Because the fallback format is available to all users, it should always be
 * configured securely. For example, when the Filter module is installed, this
 * format is initialized to output plain text. Installation profiles and site
 * administrators have the freedom to configure it further.
 *
 * Note that the fallback format is completely distinct from the default format,
 * which differs per user and is simply the first format which that user has
 * access to. The default and fallback formats are only guaranteed to be the
 * same for users who do not have access to any other format; otherwise, the
 * fallback format's weight determines its placement with respect to the user's
 * other formats.
 *
 * Any modules implementing a format deletion functionality must not delete this
 * format.
 *
 * @return string|null
 *   The ID of the fallback text format.
 *
 * @see hook_filter_format_disable()
 * @see filter_default_format()
 */
function filter_fallback_format() {
    // This variable is automatically set in the database for all installations
    // of Drupal. In the event that it gets disabled or deleted somehow, there
    // is no safe default to return, since we do not want to risk making an
    // existing (and potentially unsafe) text format on the site automatically
    // available to all users. Returning NULL at least guarantees that this
    // cannot happen.
    return \Drupal::config('filter.settings')->get('fallback_format');
}

/**
 * Runs all the enabled filters on a piece of text.
 *
 * Note: Because filters can inject JavaScript or execute PHP code, security is
 * vital here. When a user supplies a text format, you should validate it using
 * $format->access() before accepting/using it. This is normally done in the
 * validation stage of the Form API. You should for example never make a
 * preview of content in a disallowed format.
 *
 * Note: this function should only be used when filtering text for use elsewhere
 * than on a rendered HTML page. If this is part of an HTML page, then a
 * renderable array with a #type 'processed_text' element should be used instead
 * of this, because that will allow cacheability metadata to be set and bubbled
 * up and attachments to be associated (assets, placeholders, etc.). In other
 * words: if you are presenting the filtered text in an HTML page, the only way
 * this will be presented correctly, is by using the 'processed_text' element.
 *
 * @param string $text
 *   The text to be filtered.
 * @param string|null $format_id
 *   (optional) The machine name of the filter format to be used to filter the
 *   text. Defaults to the fallback format. See filter_fallback_format().
 * @param string $langcode
 *   (optional) The language code of the text to be filtered, e.g. 'en' for
 *   English. This allows filters to be language-aware so language-specific
 *   text replacement can be implemented. Defaults to an empty string.
 * @param array $filter_types_to_skip
 *   (optional) An array of filter types to skip, or an empty array (default)
 *   to skip no filter types. All of the format's filters will be applied,
 *   except for filters of the types that are marked to be skipped.
 *   FilterInterface::TYPE_HTML_RESTRICTOR is the only type that cannot be
 *   skipped.
 *
 * @return \Drupal\Component\Render\MarkupInterface
 *   The filtered text.
 *
 * @see \Drupal\filter\Plugin\FilterInterface::process()
 *
 * @ingroup sanitization
 */
function check_markup($text, $format_id = NULL, $langcode = '', $filter_types_to_skip = []) {
    $build = [
        '#type' => 'processed_text',
        '#text' => $text,
        '#format' => $format_id,
        '#filter_types_to_skip' => $filter_types_to_skip,
        '#langcode' => $langcode,
    ];
    return \Drupal::service('renderer')->renderInIsolation($build);
}

/**
 * Retrieves the filter tips.
 *
 * @param string $format_id
 *   The ID of the text format for which to retrieve tips, or -1 to return tips
 *   for all formats accessible to the current user.
 * @param bool $long
 *   (optional) Boolean indicating whether the long form of tips should be
 *   returned. Defaults to FALSE.
 *
 * @return array
 *   An associative array of filtering tips, keyed by filter name. Each
 *   filtering tip is an associative array with elements:
 *   - tip: Tip text.
 *   - id: Filter ID.
 */
function _filter_tips($format_id, $long = FALSE) {
    $formats = filter_formats(\Drupal::currentUser());
    $tips = [];
    // If only listing one format, extract it from the $formats array.
    if ($format_id != -1) {
        $formats = [
            $formats[$format_id],
        ];
    }
    foreach ($formats as $format) {
        foreach ($format->filters() as $name => $filter) {
            if ($filter->status) {
                $tip = $filter->tips($long);
                if (isset($tip)) {
                    $tips[$format->label()][$name] = [
                        'tip' => [
                            '#markup' => $tip,
                        ],
                        'id' => $name,
                    ];
                }
            }
        }
    }
    return $tips;
}

/**
 * Prepares variables for text format guideline templates.
 *
 * Default template: filter-guidelines.html.twig.
 *
 * @param array $variables
 *   An associative array containing:
 *   - format: An object representing a text format.
 */
function template_preprocess_filter_guidelines(&$variables) {
    $format = $variables['format'];
    $variables['tips'] = [
        '#theme' => 'filter_tips',
        '#tips' => _filter_tips($format->id(), FALSE),
    ];
    // Add format id for filter.js.
    $variables['attributes']['data-drupal-format-id'] = $format->id();
}

/**
 * Prepares variables for text format wrapper templates.
 *
 * Default template: text-format-wrapper.html.twig.
 *
 * @param array $variables
 *   An associative array containing:
 *   - attributes: An associative array containing properties of the element.
 */
function template_preprocess_text_format_wrapper(&$variables) {
    $variables['aria_description'] = FALSE;
    // Add element class and id for screen readers.
    if (isset($variables['attributes']['aria-describedby'])) {
        $variables['aria_description'] = TRUE;
        $variables['attributes']['id'] = $variables['attributes']['aria-describedby'];
        // Remove aria-describedby attribute as it shouldn't be visible here.
        unset($variables['attributes']['aria-describedby']);
    }
}

/**
 * Prepares variables for filter tips templates.
 *
 * Default template: filter-tips.html.twig.
 *
 * @param array $variables
 *   An associative array containing:
 *   - tips: An array containing descriptions and a CSS ID in the form of
 *     'module-name/filter-id' (only used when $long is TRUE) for each
 *     filter in one or more text formats. Example:
 *     @code
 *       [
 *         'Full HTML' => [
 *           0 => [
 *             'tip' => 'Web page addresses and email addresses turn into links automatically.',
 *             'id' => 'filter/2',
 *           ],
 *         ],
 *       ];
 *     @endcode
 *   - long: (optional) Whether the passed-in filter tips contain extended
 *     explanations, i.e. intended to be output on the path 'filter/tips'
 *     (TRUE), or are in a short format, i.e. suitable to be displayed below a
 *     form element. Defaults to FALSE.
 */
function template_preprocess_filter_tips(&$variables) {
    $tips = $variables['tips'];
    foreach ($variables['tips'] as $name => $tip_list) {
        foreach ($tip_list as $tip_key => $tip) {
            $tip_list[$tip_key]['attributes'] = new Attribute();
        }
        $variables['tips'][$name] = [
            'attributes' => new Attribute(),
            'name' => $name,
            'list' => $tip_list,
        ];
    }
    $variables['multiple'] = count($tips) > 1;
}

/**
 * @defgroup standard_filters Standard filters
 * @{
 * Filters implemented by the Filter module.
 */

/**
 * Converts text into hyperlinks automatically.
 *
 * This filter identifies and makes clickable three types of "links".
 * - URLs like http://example.com.
 * - Email addresses like name@example.com.
 * - Web addresses without the "http://" protocol defined, like
 *   www.example.com.
 * Each type must be processed separately, as there is no one regular
 * expression that could possibly match all of the cases in one pass.
 */
function _filter_url($text, $filter) {
    // Store the current text in case any of the preg_* functions fail.
    $saved_text = $text;
    // Tags to skip and not recurse into.
    $ignore_tags = 'a|script|style|code|pre';
    // Pass length to regexp callback.
    _filter_url_trim(NULL, $filter->settings['filter_url_length']);
    // Create an array which contains the regexps for each type of link.
    // The key to the regexp is the name of a function that is used as
    // callback function to process matches of the regexp. The callback function
    // is to return the replacement for the match. The array is used and
    // matching/replacement done below inside some loops.
    $tasks = [];
    // Prepare protocols pattern for absolute URLs.
    // \Drupal\Component\Utility\UrlHelper::stripDangerousProtocols() will replace
    // any bad protocols with HTTP, so we need to support the identical list.
    // While '//' is technically optional for MAILTO only, we cannot cleanly
    // differ between protocols here without hard-coding MAILTO, so '//' is
    // optional for all protocols.
    // @see \Drupal\Component\Utility\UrlHelper::stripDangerousProtocols()
    $protocols = \Drupal::getContainer()->getParameter('filter_protocols');
    $protocols = implode(':(?://)?|', $protocols) . ':(?://)?';
    $valid_url_path_characters = "[\\p{L}\\p{M}\\p{N}!\\*\\';:=\\+,\\.\$\\/%#\\[\\]\\-_~@&]";
    // Allow URL paths to contain balanced parens
    // 1. Used in Wikipedia URLs like /Primer_(film)
    // 2. Used in IIS sessions like /S(dfd346)/
    $valid_url_balanced_parens = '\\(' . $valid_url_path_characters . '+\\)';
    // Valid end-of-path characters (so /foo. does not gobble the period).
    // 1. Allow =&# for empty URL parameters and other URL-join artifacts
    $valid_url_ending_characters = '[\\p{L}\\p{M}\\p{N}:_+~#=/]|(?:' . $valid_url_balanced_parens . ')';
    $valid_url_query_chars = '[a-zA-Z0-9!?\\*\'@\\(\\);:&=\\+\\$\\/%#\\[\\]\\-_\\.,~|]';
    $valid_url_query_ending_chars = '[a-zA-Z0-9_&=#\\/]';
    // Full path
    // and allow @ in a URL, but only in the middle. Catch things like http://example.com/@user/
    $valid_url_path = '(?:(?:' . $valid_url_path_characters . '*(?:' . $valid_url_balanced_parens . $valid_url_path_characters . '*)*' . $valid_url_ending_characters . ')|(?:@' . $valid_url_path_characters . '+\\/))';
    // Prepare domain name pattern.
    // The ICANN seems to be on track towards accepting more diverse top level
    // domains (TLDs), so this pattern has been "future-proofed" to allow for
    // TLDs of length 2-64.
    $domain = '(?:[\\p{L}\\p{M}\\p{N}._+-]+\\.)?[\\p{L}\\p{M}]{2,64}\\b';
    // Mail domains differ from the generic domain pattern, specifically:
    // A . character must be present in the string that follows the @ character.
    $email_domain = '(?:[\\p{L}\\p{M}\\p{N}._+-]+\\.)+[\\p{L}\\p{M}]{2,64}\\b';
    $ip = '(?:[0-9]{1,3}\\.){3}[0-9]{1,3}';
    $auth = '[\\p{L}\\p{M}\\p{N}:%_+*~#?&=.,/;-]+@';
    $trail = '(' . $valid_url_path . '*)?(\\?' . $valid_url_query_chars . '*' . $valid_url_query_ending_chars . ')?';
    // Match absolute URLs.
    $url_pattern = "(?:{$auth})?(?:{$domain}|{$ip})/?(?:{$trail})?";
    $pattern = "`((?:{$protocols})(?:{$url_pattern}))`u";
    $tasks['_filter_url_parse_full_links'] = $pattern;
    // Match email addresses.
    $url_pattern = "[\\p{L}\\p{M}\\p{N}._+-]{1,254}@(?:{$email_domain})";
    $pattern = "`({$url_pattern})`u";
    $tasks['_filter_url_parse_email_links'] = $pattern;
    // Match www domains.
    $url_pattern = "www\\.(?:{$domain})/?(?:{$trail})?";
    $pattern = "`({$url_pattern})`u";
    $tasks['_filter_url_parse_partial_links'] = $pattern;
    // Each type of URL needs to be processed separately. The text is joined and
    // re-split after each task, since all injected HTML tags must be correctly
    // protected before the next task.
    foreach ($tasks as $task => $pattern) {
        // HTML comments need to be handled separately, as they may contain HTML
        // markup, especially a '>'. Therefore, remove all comment contents and add
        // them back later.
        _filter_url_escape_comments('', TRUE);
        $text = is_null($text) ? '' : preg_replace_callback('`<!--(.*?)-->`s', '_filter_url_escape_comments', $text);
        // Split at all tags; ensures that no tags or attributes are processed.
        $chunks = is_null($text) ? [
            '',
        ] : preg_split('/(<.+?>)/is', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
        // Do not attempt to convert links into URLs if preg_split() fails.
        if ($chunks !== FALSE) {
            // PHP ensures that the array consists of alternating delimiters and
            // literals, and begins and ends with a literal (inserting NULL as
            // required). Therefore, the first chunk is always text:
            $chunk_type = 'text';
            // If a tag of $ignore_tags is found, it is stored in $open_tag and only
            // removed when the closing tag is found. Until the closing tag is found,
            // no replacements are made.
            $open_tag = '';
            for ($i = 0; $i < count($chunks); $i++) {
                if ($chunk_type == 'text') {
                    // Only process this text if there are no unclosed $ignore_tags.
                    if ($open_tag == '') {
                        // If there is a match, inject a link into this chunk via the callback
                        // function contained in $task.
                        $chunks[$i] = preg_replace_callback($pattern, $task, $chunks[$i]);
                    }
                    // Text chunk is done, so next chunk must be a tag.
                    $chunk_type = 'tag';
                }
                else {
                    // Only process this tag if there are no unclosed $ignore_tags.
                    if ($open_tag == '') {
                        // Check whether this tag is contained in $ignore_tags.
                        if (preg_match("`<({$ignore_tags})(?:\\s|>)`i", $chunks[$i], $matches)) {
                            $open_tag = $matches[1];
                        }
                    }
                    else {
                        if (preg_match("`<\\/{$open_tag}>`i", $chunks[$i], $matches)) {
                            $open_tag = '';
                        }
                    }
                    // Tag chunk is done, so next chunk must be text.
                    $chunk_type = 'text';
                }
            }
            $text = implode($chunks);
        }
        // Revert to the original comment contents
        _filter_url_escape_comments('', FALSE);
        $text = $text ? preg_replace_callback('`<!--(.*?)-->`', '_filter_url_escape_comments', $text) : $text;
    }
    // If there is no text at this point revert to the previous text.
    return strlen((string) $text) > 0 ? $text : $saved_text;
}

/**
 * Makes links out of absolute URLs.
 *
 * Callback for preg_replace_callback() within _filter_url().
 */
function _filter_url_parse_full_links($match) {
    // The $i:th parenthesis in the regexp contains the URL.
    $i = 1;
    $match[$i] = Html::decodeEntities($match[$i]);
    $caption = Html::escape(_filter_url_trim($match[$i]));
    $match[$i] = Html::escape($match[$i]);
    return '<a href="' . $match[$i] . '">' . $caption . '</a>';
}

/**
 * Makes links out of email addresses.
 *
 * Callback for preg_replace_callback() within _filter_url().
 */
function _filter_url_parse_email_links($match) {
    // The $i:th parenthesis in the regexp contains the URL.
    $i = 0;
    $match[$i] = Html::decodeEntities($match[$i]);
    $caption = Html::escape(_filter_url_trim($match[$i]));
    $match[$i] = Html::escape($match[$i]);
    return '<a href="mailto:' . $match[$i] . '">' . $caption . '</a>';
}

/**
 * Makes links out of domain names starting with "www.".
 *
 * Callback for preg_replace_callback() within _filter_url().
 */
function _filter_url_parse_partial_links($match) {
    // The $i:th parenthesis in the regexp contains the URL.
    $i = 1;
    $match[$i] = Html::decodeEntities($match[$i]);
    $caption = Html::escape(_filter_url_trim($match[$i]));
    $match[$i] = Html::escape($match[$i]);
    return '<a href="http://' . $match[$i] . '">' . $caption . '</a>';
}

/**
 * Escapes the contents of HTML comments.
 *
 * Callback for preg_replace_callback() within _filter_url().
 *
 * @param array $match
 *   An array containing matches to replace from preg_replace_callback(),
 *   whereas $match[1] is expected to contain the content to be filtered.
 * @param bool|null $escape
 *   (optional) A Boolean indicating whether to escape (TRUE) or unescape
 *   comments (FALSE). Defaults to NULL, indicating neither. If TRUE, statically
 *   cached $comments are reset.
 */
function _filter_url_escape_comments($match, $escape = NULL) {
    static $mode, $comments = [];
    if (isset($escape)) {
        $mode = $escape;
        if ($escape) {
            $comments = [];
        }
        return;
    }
    // Replace all HTML comments with a '<!-- [hash] -->' placeholder.
    if ($mode) {
        $content = $match[1];
        $hash = hash('sha256', $content);
        $comments[$hash] = $content;
        return "<!-- {$hash} -->";
    }
    else {
        $hash = $match[1];
        $hash = trim($hash);
        $content = $comments[$hash];
        return "<!--{$content}-->";
    }
}

/**
 * Shortens a long URL to a given length ending with an ellipsis.
 */
function _filter_url_trim($text, $length = NULL) {
    static $_length;
    if ($length !== NULL) {
        $_length = $length;
    }
    if (isset($text) && isset($_length)) {
        $text = Unicode::truncate($text, $_length, FALSE, TRUE);
    }
    return $text;
}

/**
 * Converts line breaks into <p> and <br> in an intelligent fashion.
 *
 * Based on: http://photomatt.net/scripts/autop
 */
function _filter_autop($text) {
    // All block level tags
    $block = '(?:table|thead|tfoot|caption|col|colgroup|tbody|tr|td|th|div|dl|dd|dt|ul|ol|li|pre|select|option|form|map|area|blockquote|address|math|input|p|h[1-6]|fieldset|legend|hr|article|aside|details|figcaption|figure|footer|header|hgroup|menu|nav|section|summary)';
    // Split at opening and closing PRE, SCRIPT, STYLE, OBJECT, IFRAME tags
    // and comments. We don't apply any processing to the contents of these tags
    // to avoid messing up code. We look for matched pairs and allow basic
    // nesting. For example:
    // "processed <pre> ignored <script> ignored </script> ignored </pre> processed"
    $chunks = preg_split('@(<!--.*?-->|</?(?:pre|script|style|object|iframe|drupal-media|svg|!--)[^>]*>)@i', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
    // Note: PHP ensures the array consists of alternating delimiters and literals
    // and begins and ends with a literal (inserting NULL as required).
    $ignore = FALSE;
    $ignore_tag = '';
    $output = '';
    foreach ($chunks as $i => $chunk) {
        if ($i % 2) {
            if (str_starts_with($chunk, '<!--')) {
                // Nothing to do, this is a comment.
                $output .= $chunk;
                continue;
            }
            // Opening or closing tag?
            $open = $chunk[1] != '/';
            [
                $tag,
            ] = preg_split('/[ >]/', substr($chunk, 2 - $open), 2);
            if (!$ignore) {
                if ($open) {
                    $ignore = TRUE;
                    $ignore_tag = $tag;
                }
            }
            elseif (!$open && $ignore_tag == $tag) {
                $ignore = FALSE;
                $ignore_tag = '';
            }
        }
        elseif (!$ignore) {
            // Skip if the next chunk starts with Twig theme debug.
            // @see twig_render_template()
            if (isset($chunks[$i + 1]) && $chunks[$i + 1] === '<!-- THEME DEBUG -->') {
                $chunk = rtrim($chunk, "\n");
                $output .= $chunk;
                continue;
            }
            // Skip if the preceding chunk was the end of a Twig theme debug.
            // @see twig_render_template()
            if (isset($chunks[$i - 1])) {
                if (str_starts_with($chunks[$i - 1], '<!-- BEGIN OUTPUT from ') || str_starts_with($chunks[$i - 1], '<!-- 💡 BEGIN CUSTOM TEMPLATE OUTPUT from ')) {
                    $chunk = ltrim($chunk, "\n");
                    $output .= $chunk;
                    continue;
                }
            }
            // Just to make things a little easier, pad the end
            $chunk = preg_replace('|\\n*$|', '', $chunk) . "\n\n";
            $chunk = preg_replace('|<br />\\s*<br />|', "\n\n", $chunk);
            // Space things out a little
            $chunk = preg_replace('!(<' . $block . '[^>]*>)!', "\n\$1", $chunk);
            // Space things out a little
            $chunk = preg_replace('!(</' . $block . '>)!', "\$1\n\n", $chunk);
            // Take care of duplicates
            $chunk = preg_replace("/\n\n+/", "\n\n", $chunk);
            $chunk = preg_replace('/^\\n|\\n\\s*\\n$/', '', $chunk);
            // Make paragraphs, including one at the end
            $chunk = '<p>' . preg_replace('/\\n\\s*\\n\\n?(.)/', "</p>\n<p>\$1", $chunk) . "</p>\n";
            // Problem with nested lists
            $chunk = preg_replace("|<p>(<li.+?)</p>|", "\$1", $chunk);
            $chunk = preg_replace('|<p><blockquote([^>]*)>|i', "<blockquote\$1><p>", $chunk);
            $chunk = str_replace('</blockquote></p>', '</p></blockquote>', $chunk);
            // Under certain strange conditions it could create a P of entirely whitespace
            $chunk = preg_replace('|<p>\\s*</p>\\n?|', '', $chunk);
            $chunk = preg_replace('!<p>\\s*(</?' . $block . '[^>]*>)!', "\$1", $chunk);
            $chunk = preg_replace('!(</?' . $block . '[^>]*>)\\s*</p>!', "\$1", $chunk);
            // Make line breaks
            $chunk = preg_replace('|(?<!<br />)\\s*\\n|', "<br />\n", $chunk);
            $chunk = preg_replace('!(</?' . $block . '[^>]*>)\\s*<br />!', "\$1", $chunk);
            $chunk = preg_replace('!<br />(\\s*</?(?:p|li|div|dl|dd|dt|th|pre|td|ul|ol)>)!', '$1', $chunk);
            $chunk = preg_replace('/&([^#])(?![A-Za-z0-9]{1,8};)/', '&amp;$1', $chunk);
        }
        $output .= $chunk;
    }
    return $output;
}

/**
 * Escapes all HTML tags, so they will be visible instead of being effective.
 */
function _filter_html_escape($text) {
    return trim(Html::escape($text));
}

/**
 * Process callback for local image filter.
 */
function _filter_html_image_secure_process($text) {
    // Find the path (e.g. '/') to Drupal root.
    $base_path = base_path();
    $base_path_length = mb_strlen($base_path);
    // Find the directory on the server where index.php resides.
    $local_dir = \Drupal::root() . '/';
    $html_dom = Html::load($text);
    $images = $html_dom->getElementsByTagName('img');
    
    /** @var \Drupal\Core\File\FileUrlGeneratorInterface $file_url_generator */
    $file_url_generator = \Drupal::service('file_url_generator');
    foreach ($images as $image) {
        $src = $image->getAttribute('src');
        // Transform absolute image URLs to relative image URLs: prevent problems on
        // multisite set-ups and prevent mixed content errors.
        $image->setAttribute('src', $file_url_generator->transformRelative($src));
        // Verify that $src starts with $base_path.
        // This also ensures that external images cannot be referenced.
        $src = $image->getAttribute('src');
        if (mb_substr($src, 0, $base_path_length) === $base_path) {
            // Remove the $base_path to get the path relative to the Drupal root.
            // Ensure the path refers to an actual image by prefixing the image source
            // with the Drupal root and running getimagesize() on it.
            $local_image_path = $local_dir . mb_substr($src, $base_path_length);
            $local_image_path = rawurldecode($local_image_path);
            if (@getimagesize($local_image_path)) {
                // The image has the right path. Erroneous images are dealt with below.
                continue;
            }
        }
        // Allow modules and themes to replace an invalid image with an error
        // indicator. See filter_filter_secure_image_alter().
        \Drupal::moduleHandler()->alter('filter_secure_image', $image);
    }
    $text = Html::serialize($html_dom);
    return $text;
}

/**
 * @} End of "defgroup standard_filters".
 */

Functions

Title Deprecated Summary
check_markup Runs all the enabled filters on a piece of text.
filter_default_format Returns the ID of the default text format for a particular user.
filter_fallback_format Returns the ID of the fallback text format that all users have access to.
filter_formats Retrieves a list of enabled text formats, ordered by weight.
filter_formats_reset Resets the text format caches.
filter_get_formats_by_role Retrieves a list of text formats that are allowed for a given role.
filter_get_roles_by_format Retrieves a list of roles that are allowed to use a given text format.
template_preprocess_filter_guidelines Prepares variables for text format guideline templates.
template_preprocess_filter_tips Prepares variables for filter tips templates.
template_preprocess_text_format_wrapper Prepares variables for text format wrapper templates.
_filter_autop Converts line breaks into <p> and <br> in an intelligent fashion.
_filter_html_escape Escapes all HTML tags, so they will be visible instead of being effective.
_filter_html_image_secure_process Process callback for local image filter.
_filter_tips Retrieves the filter tips.
_filter_url Converts text into hyperlinks automatically.
_filter_url_escape_comments Escapes the contents of HTML comments.
_filter_url_parse_email_links Makes links out of email addresses.
_filter_url_parse_full_links Makes links out of absolute URLs.
_filter_url_parse_partial_links Makes links out of domain names starting with "www.".
_filter_url_trim Shortens a long URL to a given length ending with an ellipsis.

Buggy or inaccurate documentation? Please file an issue. Need support? Need help programming? Connect with the Drupal community.