Commit 500c0820 authored by alexpott's avatar alexpott

Issue #2014895 by Wim Leers, jessebeach: Image captions & alignment.

parent 6e4b4135
/**
* @file
* Caption filter: default styling for displaying image captions.
*/
/**
* Essentials, based on http://stackoverflow.com/a/13363408.
*/
.caption {
display: table;
}
.caption > * {
display: block;
max-width: 100%;
}
.caption > figcaption {
display: table-caption;
caption-side: bottom;
max-width: none;
}
/**
* Caption alignment.
*/
.caption-left {
float: left; /* LTR */
margin-left: 0; /* LTR */
}
[dir=rtl] .caption-left {
float: right;
margin-left: auto;
margin-right: 0;
}
.caption-right {
float: right; /* LTR */
margin-right: 0; /* LTR */
}
[dir=rtl] .caption-right {
float: left;
margin-left: 0;
margin-right: auto;
}
.caption-center {
margin-left: auto;
margin-right: auto;
text-align: center;
}
......@@ -89,6 +89,15 @@ function filter_theme() {
'filter_html_image_secure_image' => array(
'variables' => array('image' => NULL),
),
'filter_caption' => array(
'variables' => array(
'node' => NULL,
'tag' => NULL,
'caption' => NULL,
'align' => NULL,
),
'template' => 'filter-caption',
)
);
}
......@@ -1576,6 +1585,13 @@ function theme_filter_html_image_secure_image(&$variables) {
* @} End of "defgroup standard_filters".
*/
/**
* Implements hook_page_build().
*/
function filter_page_build(&$page) {
$page['#attached']['library'][] = array('filter', 'caption');
}
/**
* Implements hook_library_info().
*/
......@@ -1625,6 +1641,13 @@ function filter_library_info() {
array('system', 'jquery.once'),
),
);
$libraries['caption'] = array(
'title' => 'Captions for images and alignments',
'version' => VERSION,
'css' => array(
$path . '/css/filter.caption.css',
),
);
return $libraries;
}
<?php
/**
* @file
* Contains \Drupal\filter\Plugin\Filter\FilterCaption.
*/
namespace Drupal\filter\Plugin\Filter;
use Drupal\Component\Utility\String;
use Drupal\Component\Utility\Unicode;
use Drupal\Component\Utility\Xss;
use Drupal\Core\Annotation\Translation;
use Drupal\filter\Annotation\Filter;
use Drupal\filter\Plugin\FilterBase;
/**
* Provides a filter to display image captions and align images.
*
* @Filter(
* id = "filter_caption",
* module = "filter",
* title = @Translation("Display image captions and align images"),
* description = @Translation("Uses data-caption and data-align attributes on &lt;img&gt; tags to caption and align images."),
* type = FILTER_TYPE_TRANSFORM_REVERSIBLE
* )
*/
class FilterCaption extends FilterBase {
/**
* {@inheritdoc}
*/
public function process($text, $langcode, $cache, $cache_id) {
$search = array();
$replace = array();
if (stristr($text, 'data-caption') !== FALSE || stristr($text, 'data-align') !== FALSE) {
$dom = filter_dom_load($text);
$xpath = new \DOMXPath($dom);
foreach ($xpath->query('//*[@data-caption or @data-align]') as $node) {
$caption = NULL;
$align = NULL;
// Retrieve, then remove the data-caption and data-align attributes.
if ($node->hasAttribute('data-caption')) {
$caption = String::checkPlain($node->getAttribute('data-caption'));
$node->removeAttribute('data-caption');
// Sanitize caption: decode HTML encoding, limit allowed HTML tags.
$caption = String::decodeEntities($caption);
$caption = Xss::filter($caption);
// The caption must be non-empty.
if (Unicode::strlen($caption) === 0) {
$caption = NULL;
}
}
if ($node->hasAttribute('data-align')) {
$align = $node->getAttribute('data-align');
$node->removeAttribute('data-align');
// Only allow 3 values: 'left', 'center' and 'right'.
if (!in_array($align, array('left', 'center', 'right'))) {
$align = NULL;
}
}
// If neither attribute has a value after validation, then don't
// transform the HTML.
if ($caption === NULL && $align === NULL) {
continue;
}
// Given the updated node, caption and alignment: re-render it with a
// caption.
$altered_html = theme('filter_caption', array(
'node' => $node->C14N(),
'tag' => $node->tagName,
'caption' => $caption,
'align' => $align,
));
// Load the altered HTML into a new DOMDocument and retrieve the element.
$updated_node = filter_dom_load($altered_html)->getElementsByTagName('body')
->item(0)
->childNodes
->item(0);
// Import the updated node from the new DOMDocument into the original
// one, importing also the child nodes of the updated node.
$updated_node = $dom->importNode($updated_node, TRUE);
// Finally, replace the original image node with the new image node!
$node->parentNode->replaceChild($updated_node, $node);
}
return filter_dom_serialize($dom);
}
return $text;
}
/**
* {@inheritdoc}
*/
public function tips($long = FALSE) {
if ($long) {
return t('
<p>You can add image captions and align images left, right or centered. Examples:</p>
<ul>
<li>Caption an image: <code>&lt;img src="" data-caption="This is a caption" /&gt;</code></li>
<li>Align an image: <code>&lt;img src="" data-align="center" /&gt;</code></li>
<li>Caption & align an image: <code>&lt;img src="" data-caption="Alpaca" data-align="right" /&gt;</code></li>
</ul>');
}
else {
return t('You can caption (data-caption="Text") and align images (data-align="center"), but also video, blockquotes, and so on.');
}
}
}
......@@ -9,6 +9,8 @@
use Drupal\simpletest\DrupalUnitTestBase;
use stdClass;
use Drupal\filter\FilterBag;
use Drupal\filter\Plugin\Filter\FilterCaption;
/**
* Unit tests for core filters.
......@@ -33,15 +35,121 @@ public static function getInfo() {
protected function setUp() {
parent::setUp();
config_install_default_config('module', 'system');
$manager = $this->container->get('plugin.manager.filter');
$bag = new FilterBag($manager, array());
$this->filters = $bag->getAll();
}
/**
* Tests the caption filter.
*/
function testCaptionFilter() {
$filter = $this->filters['filter_caption'];
$test = function($input) use ($filter) {
return $filter->process($input, 'und', FALSE, '');
};
// No data-caption nor data-align attributes.
$input = '<img src="llama.jpg" />';
$expected = $input;
$this->assertIdentical($expected, $test($input));
// Only data-caption attribute.
$input = '<img src="llama.jpg" data-caption="Loquacious llama!" />';
$expected = '<figure class="caption caption-img"><img src="llama.jpg" /><figcaption>Loquacious llama!</figcaption></figure>';
$this->assertIdentical($expected, $test($input));
// Empty data-caption attribute.
$input = '<img src="llama.jpg" data-caption="" />';
$expected = '<img src="llama.jpg" />';
$this->assertIdentical($expected, $test($input));
// HTML entities in the caption.
$input = '<img src="llama.jpg" data-caption="&ldquo;Loquacious llama!&rdquo;" />';
$expected = '<figure class="caption caption-img"><img src="llama.jpg" /><figcaption>“Loquacious llama!”</figcaption></figure>';
$this->assertIdentical($expected, $test($input));
// HTML encoded as HTML entities in data-caption attribute.
$input = '<img src="llama.jpg" data-caption="&lt;em&gt;Loquacious llama!&lt;/em&gt;" />';
$expected = '<figure class="caption caption-img"><img src="llama.jpg" /><figcaption><em>Loquacious llama!</em></figcaption></figure>';
$this->assertIdentical($expected, $test($input));
// HTML (not encoded as HTML entities) in data-caption attribute, which is
// not allowed by the HTML spec, but may happen when people manually write
// HTML, so we explicitly support it.
$input = '<img src="llama.jpg" data-caption="<em>Loquacious llama!</em>" />';
$expected = '<figure class="caption caption-img"><img src="llama.jpg" /><figcaption><em>Loquacious llama!</em></figcaption></figure>';
$this->assertIdentical($expected, $test($input));
// Security test: attempt an XSS.
$input = '<img src="llama.jpg" data-caption="<script>alert(\'Loquacious llama!\')</script>" />';
$expected = '<figure class="caption caption-img"><img src="llama.jpg" /><figcaption>alert(\'Loquacious llama!\')</figcaption></figure>';
$this->assertIdentical($expected, $test($input));
// Only data-align attribute: all 3 allowed values.
$input = '<img src="llama.jpg" data-align="left" />';
$expected = '<figure class="caption caption-img caption-left"><img src="llama.jpg" /></figure>';
$this->assertIdentical($expected, $test($input));
$input = '<img src="llama.jpg" data-align="center" />';
$expected = '<figure class="caption caption-img caption-center"><img src="llama.jpg" /></figure>';
$this->assertIdentical($expected, $test($input));
$input = '<img src="llama.jpg" data-align="right" />';
$expected = '<figure class="caption caption-img caption-right"><img src="llama.jpg" /></figure>';
$this->assertIdentical($expected, $test($input));
// Only data-align attribute: a disallowed value.
$input = '<img src="llama.jpg" data-align="left foobar" />';
$expected = '<img src="llama.jpg" />';
$this->assertIdentical($expected, $test($input));
// Empty data-align attribute.
$input = '<img src="llama.jpg" data-align="" />';
$expected = '<img src="llama.jpg" />';
$this->assertIdentical($expected, $test($input));
// Both data-caption and data-align attributes.
$input = '<img src="llama.jpg" data-caption="Loquacious llama!" data-align="right" />';
$expected = '<figure class="caption caption-img caption-right"><img src="llama.jpg" /><figcaption>Loquacious llama!</figcaption></figure>';
$this->assertIdentical($expected, $test($input));
// Both data-caption and data-align attributes, but a disallowed data-align
// attribute value.
$input = '<img src="llama.jpg" data-caption="Loquacious llama!" data-align="left foobar" />';
$expected = '<figure class="caption caption-img"><img src="llama.jpg" /><figcaption>Loquacious llama!</figcaption></figure>';
$this->assertIdentical($expected, $test($input));
// Ensure the filter also works with uncommon yet valid attribute quoting.
$input = '<img src=llama.jpg data-caption=\'Loquacious llama!\' data-align=right />';
$expected = '<figure class="caption caption-img caption-right"><img src="llama.jpg" /><figcaption>Loquacious llama!</figcaption></figure>';
$this->assertIdentical($expected, $test($input));
// Security test: attempt to inject an additional class.
$input = '<img src="llama.jpg" data-caption="Loquacious llama!" data-align="center another-class-here" />';
$expected = '<figure class="caption caption-img"><img src="llama.jpg" /><figcaption>Loquacious llama!</figcaption></figure>';
$this->assertIdentical($expected, $test($input));
// Security test: attempt an XSS.
$input = '<img src="llama.jpg" data-caption="Loquacious llama!" data-align="center \'onclick=\'alert(foo);" />';
$expected = '<figure class="caption caption-img"><img src="llama.jpg" /><figcaption>Loquacious llama!</figcaption></figure>';
$this->assertIdentical($expected, $test($input));
// Finally, ensure that this also works on any other tag.
$input = '<video src="llama.jpg" data-caption="Loquacious llama!" />';
$expected = '<figure class="caption caption-video"><video src="llama.jpg"></video><figcaption>Loquacious llama!</figcaption></figure>';
$this->assertIdentical($expected, $test($input));
$input = '<foobar data-caption="Loquacious llama!">baz</foobar>';
$expected = '<figure class="caption caption-foobar"><foobar>baz</foobar><figcaption>Loquacious llama!</figcaption></figure>';
$this->assertIdentical($expected, $test($input));
}
/**
* Tests the line break filter.
*/
function testLineBreakFilter() {
// Setup dummy filter object.
$filter = new stdClass();
$filter->callback = '_filter_autop';
// Get FilterAutoP object.
$filter = $this->filters['filter_autop'];
// Since the line break filter naturally needs plenty of newlines in test
// strings and expectations, we're using "\n" instead of regular newlines
......@@ -128,13 +236,15 @@ function testLineBreakFilter() {
* or better a whitelist approach should be used for that too.
*/
function testHtmlFilter() {
// Setup dummy filter object.
$filter = new stdClass();
$filter->settings = array(
'allowed_html' => '<a> <em> <strong> <cite> <blockquote> <code> <ul> <ol> <li> <dl> <dt> <dd>',
'filter_html_help' => 1,
'filter_html_nofollow' => 0,
);
// Get FilterHtml object.
$filter = $this->filters['filter_html'];
$filter->setPluginConfiguration(array(
'settings' => array(
'allowed_html' => '<a> <em> <strong> <cite> <blockquote> <code> <ul> <ol> <li> <dl> <dt> <dd>',
'filter_html_help' => 1,
'filter_html_nofollow' => 0,
)
));
// HTML filter is not able to secure some tags, these should never be
// allowed.
......@@ -173,13 +283,15 @@ function testHtmlFilter() {
* Tests the spam deterrent.
*/
function testNoFollowFilter() {
// Setup dummy filter object.
$filter = new stdClass();
$filter->settings = array(
'allowed_html' => '<a>',
'filter_html_help' => 1,
'filter_html_nofollow' => 1,
);
// Get FilterHtml object.
$filter = $this->filters['filter_html'];
$filter->setPluginConfiguration(array(
'settings' => array(
'allowed_html' => '<a>',
'filter_html_help' => 1,
'filter_html_nofollow' => 1,
)
));
// Test if the rel="nofollow" attribute is added, even if we try to prevent
// it.
......@@ -206,9 +318,8 @@ function testNoFollowFilter() {
* check_plain() is not tested here.
*/
function testHtmlEscapeFilter() {
// Setup dummy filter object.
$filter = new stdClass();
$filter->callback = '_filter_html_escape';
// Get FilterHtmlEscape object.
$filter = $this->filters['filter_html_escape'];
$tests = array(
" One. <!-- \"comment\" --> Two'.\n<p>Three.</p>\n " => array(
......@@ -224,12 +335,14 @@ function testHtmlEscapeFilter() {
* Tests the URL filter.
*/
function testUrlFilter() {
// Setup dummy filter object.
$filter = new stdClass();
$filter->callback = '_filter_url';
$filter->settings = array(
'filter_url_length' => 496,
);
// Get FilterUrl object.
$filter = $this->filters['filter_url'];
$filter->setPluginConfiguration(array(
'settings' => array(
'filter_url_length' => 496,
)
));
// @todo Possible categories:
// - absolute, mail, partial
// - characters/encoding, surrounding markup, security
......@@ -516,7 +629,11 @@ function testUrlFilter() {
$this->assertFilteredString($filter, $tests);
// URL trimming.
$filter->settings['filter_url_length'] = 20;
$filter->setPluginConfiguration(array(
'settings' => array(
'filter_url_length' => 20,
)
));
$tests = array(
'www.trimmed.com/d/ff.ext?a=1&b=2#a1' => array(
'<a href="http://www.trimmed.com/d/ff.ext?a=1&amp;b=2#a1">www.trimmed.com/d/ff...</a>' => TRUE,
......@@ -528,7 +645,7 @@ function testUrlFilter() {
/**
* Asserts multiple filter output expectations for multiple input strings.
*
* @param $filter
* @param FilterInterface $filter
* A input filter object.
* @param $tests
* An associative array, whereas each key is an arbitrary input string and
......@@ -548,8 +665,7 @@ function testUrlFilter() {
*/
function assertFilteredString($filter, $tests) {
foreach ($tests as $source => $tasks) {
$function = $filter->callback;
$result = $function($source, $filter);
$result = $filter->process($source, $filter, FALSE, '');
foreach ($tasks as $value => $is_expected) {
// Not using assertIdentical, since combination with strpos() is hard to grok.
if ($is_expected) {
......@@ -593,11 +709,13 @@ function assertFilteredString($filter, $tests) {
* - Mix of absolute and partial URLs, and e-mail addresses in one content.
*/
function testUrlFilterContent() {
// Setup dummy filter object.
$filter = new stdClass();
$filter->settings = array(
'filter_url_length' => 496,
);
// Get FilterUrl object.
$filter = $this->filters['filter_url'];
$filter->setPluginConfiguration(array(
'settings' => array(
'filter_url_length' => 496,
)
));
$path = drupal_get_path('module', 'filter') . '/tests';
$input = file_get_contents($path . '/filter.url-input.txt');
......
{#
/**
* Returns HTML for a captioned image, audio, video or other tag.
*
* Available variables
* - string node: The complete HTML tag whose contents are being captioned.
* - string tag: The name of the HTML tag whose contents are being captioned.
* - string|NULL caption: (optional) The caption text, or NULL.
* - string|NULL align: (optional) The alignment: 'left', 'center', 'right' or
* NULL.
*/
#}
<figure class="caption caption-{{ tag }} {%- if align %} caption-{{ align }} {%- endif %}">
{{ node }}
{% if caption %}
<figcaption>{{ caption }}</figcaption>
{% endif %}
</figure>
......@@ -14,6 +14,11 @@ filters:
allowed_html: '<a> <em> <strong> <cite> <blockquote> <code> <ul> <ol> <li> <dl> <dt> <dd> <h4> <h5> <h6> <p> <span> <img>'
filter_html_help: '0'
filter_html_nofollow: '0'
filter_caption:
module: filter
status: '1'
weight: '8'
settings: { }
filter_html_image_secure:
module: filter
status: '1'
......
......@@ -6,6 +6,11 @@ roles:
- administrator
cache: '1'
filters:
filter_caption:
module: filter
status: '1'
weight: '9'
settings: { }
filter_htmlcorrector:
module: filter
status: '1'
......
......@@ -1611,6 +1611,37 @@ ol.search-results {
padding-left: 0;
}
/* -------------- Captions -------------- */
.caption > * {
background: #F3F3F3;
padding: 0.5ex;
border: 1px solid #CCC;
}
.caption > figcaption {
border: 1px solid #CCC;
border-top: none;
padding-top: 0.5ex;
font-size: small;
text-align: center;
}
/* Override Bartik's default blockquote and pre styles when captioned. */
.caption-pre > pre,
.caption-blockquote > blockquote {
margin: 0;
}
.caption-blockquote > figcaption::before {
content: "— ";
}
.caption-blockquote > figcaption {
text-align: left;
}
[dir=rtl] .caption-blockquote > figcaption {
text-align: right;
}
/* -------------- Shortcut Links -------------- */
.shortcut-wrapper {
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment