 core/modules/filter/css/filter.caption-rtl.css     |   18 ++
 core/modules/filter/css/filter.caption.css         |   37 ++++
 core/modules/filter/filter.module                  |   23 +++
 .../Drupal/filter/Plugin/Filter/FilterCaption.php  |  116 ++++++++++++
 .../lib/Drupal/filter/Tests/FilterUnitTest.php     |  188 ++++++++++++++++----
 .../filter/templates/filter-caption.html.twig      |   18 ++
 .../standard/config/filter.format.basic_html.yml   |    5 +
 .../standard/config/filter.format.full_html.yml    |    5 +
 core/themes/bartik/css/style.css                   |   28 +++
 9 files changed, 403 insertions(+), 35 deletions(-)

diff --git a/core/modules/filter/css/filter.caption-rtl.css b/core/modules/filter/css/filter.caption-rtl.css
new file mode 100644
index 0000000..7e43f31
--- /dev/null
+++ b/core/modules/filter/css/filter.caption-rtl.css
@@ -0,0 +1,18 @@
+/**
+ * @file
+ * Caption filter: RTL styling for displaying image captions.
+ */
+
+/**
+ * Caption alignment.
+ */
+.caption-left {
+  float: right;
+  margin-left: auto;
+  margin-right: 0;
+}
+.caption-right {
+  float: left;
+  margin-left: 0;
+  margin-right: auto;
+}
diff --git a/core/modules/filter/css/filter.caption.css b/core/modules/filter/css/filter.caption.css
new file mode 100644
index 0000000..2c6c059
--- /dev/null
+++ b/core/modules/filter/css/filter.caption.css
@@ -0,0 +1,37 @@
+/**
+ * @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 */
+}
+.caption-right {
+  float: right; /* LTR */
+  margin-right: 0; /* LTR */
+}
+.caption-center {
+  margin-left: auto;
+  margin-right: auto;
+  text-align: center;
+}
diff --git a/core/modules/filter/filter.module b/core/modules/filter/filter.module
index 263afb5..1a8e679 100644
--- a/core/modules/filter/filter.module
+++ b/core/modules/filter/filter.module
@@ -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',
+    )
   );
 }
 
@@ -1449,6 +1458,13 @@ function theme_filter_html_image_secure_image(&$variables) {
  */
 
 /**
+ * Implements hook_page_build().
+ */
+function filter_page_build(&$page) {
+  $page['#attached']['library'][] = array('filter', 'caption');
+}
+
+/**
  * Implements hook_library_info().
  */
 function filter_library_info() {
@@ -1497,6 +1513,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;
 }
diff --git a/core/modules/filter/lib/Drupal/filter/Plugin/Filter/FilterCaption.php b/core/modules/filter/lib/Drupal/filter/Plugin/Filter/FilterCaption.php
new file mode 100644
index 0000000..48fb5e1
--- /dev/null
+++ b/core/modules/filter/lib/Drupal/filter/Plugin/Filter/FilterCaption.php
@@ -0,0 +1,116 @@
+<?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.');
+    }
+  }
+}
diff --git a/core/modules/filter/lib/Drupal/filter/Tests/FilterUnitTest.php b/core/modules/filter/lib/Drupal/filter/Tests/FilterUnitTest.php
index 8b645f7..b644b10 100644
--- a/core/modules/filter/lib/Drupal/filter/Tests/FilterUnitTest.php
+++ b/core/modules/filter/lib/Drupal/filter/Tests/FilterUnitTest.php
@@ -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');
diff --git a/core/modules/filter/templates/filter-caption.html.twig b/core/modules/filter/templates/filter-caption.html.twig
new file mode 100644
index 0000000..532fb37
--- /dev/null
+++ b/core/modules/filter/templates/filter-caption.html.twig
@@ -0,0 +1,18 @@
+{#
+/**
+ * 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>
diff --git a/core/profiles/standard/config/filter.format.basic_html.yml b/core/profiles/standard/config/filter.format.basic_html.yml
index 5810786..69fc520 100644
--- a/core/profiles/standard/config/filter.format.basic_html.yml
+++ b/core/profiles/standard/config/filter.format.basic_html.yml
@@ -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'
diff --git a/core/profiles/standard/config/filter.format.full_html.yml b/core/profiles/standard/config/filter.format.full_html.yml
index 204a342..0265845 100644
--- a/core/profiles/standard/config/filter.format.full_html.yml
+++ b/core/profiles/standard/config/filter.format.full_html.yml
@@ -6,6 +6,11 @@ roles:
   - administrator
 cache: '1'
 filters:
+  filter_caption:
+    module: filter
+    status: '1'
+    weight: '9'
+    settings: {  }
   filter_htmlcorrector:
     module: filter
     status: '1'
diff --git a/core/themes/bartik/css/style.css b/core/themes/bartik/css/style.css
index a7a36a8..640bca3 100644
--- a/core/themes/bartik/css/style.css
+++ b/core/themes/bartik/css/style.css
@@ -1417,6 +1417,34 @@ 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;
+}
+
 /* -------------- Shortcut Links -------------- */
 
 .shortcut-wrapper {
