diff --git a/README.md b/README.md
index cd3e787..395f233 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,11 @@ configuration forms.
 Image styles let you create derivations of images by applying (a series of)
 effect(s) to it. Think of resizing, desaturating, masking, etc.
 
+Image Effects tries to support both the GD toolkit from Drupal core and the
+ImageMagick toolkit. However, please note that there may be effects that are
+not supported by all toolkits, or that provide different results with different
+toolkits.
+
 The effects that this module provides include:
 
 Effect name      | Description                                                                                  | GD toolkit | [ImageMagick](https://drupal.org/project/imagemagick) toolkit |
@@ -33,12 +38,12 @@ Contrast         | Supports changing contrast settings of an image. Also support
 Set canvas       | Places the source image over a colored or a transparent background of a defined size.        | X          | X                   |
 Set transparent color | Defines the color to be used for transparency in GIF images.                            | X          | X                   |
 Strip metadata   | Strips all EXIF metadata from image.                                                         | X          | X                   |
+Text overlay<sup>1</sup> | Overlays text on an image, defining text font, size and positioning.                 | X          | X<sup>2</sup>       |
 Watermark        | Place a image with transparency anywhere over a source picture.                              | X          | X                   |
 
-Image Effects tries to support both the GD toolkit from Drupal core and the
-ImageMagick toolkit. However, please note that there may be effects that are
-not supported by all toolkits, or that provide different results with different
-toolkits.
+Notes:
+<sup>1</sup> The [Textimage](https://drupal.org/project/textimage) module, if installed, allows this effect to present a preview of the text overlay.
+<sup>2</sup> The ImageMagick toolkit actually requires the GD toolkit to build the text overlay.
 
 
 ## What Image Effects is not?
diff --git a/config/schema/image_effects.schema.yml b/config/schema/image_effects.schema.yml
index 6fc82e9..5dee2ab 100644
--- a/config/schema/image_effects.schema.yml
+++ b/config/schema/image_effects.schema.yml
@@ -179,6 +179,116 @@ image.effect.image_effects_set_transparent_color:
 image.effect.image_effects_strip_metadata:
   type: sequence
 
+image.effect.image_effects_text_overlay:
+  type: mapping
+  label: 'Text overlay effect'
+  mapping:
+    text_string:
+      type: text
+      label: 'Text associated with this effect, can include tokens'
+    font:
+      type: mapping
+      mapping:
+        name:
+          type: string
+          label: 'Font name'
+        uri:
+          type: string
+          label: 'Font file URI'
+        size:
+          type: integer
+          label: 'Font size'
+        angle:
+          type: integer
+          label: 'Font orientation'
+        color:
+          type: color_hex
+          label: 'Font color'
+        stroke_mode:
+          type: string
+          label: 'Type of stroke (outline/shadow)'
+        stroke_color:
+          type: color_hex
+          label: 'Color of the stroke'
+        outline_top:
+          type: integer
+          label: 'Outline px on the top'
+        outline_right:
+          type: integer
+          label: 'Outline px on the right'
+        outline_bottom:
+          type: integer
+          label: 'Outline px on the bottom'
+        outline_left:
+          type: integer
+          label: 'Outline px on the left'
+        shadow_x_offset:
+          type: integer
+          label: 'Shadow horizontal offset in px'
+        shadow_y_offset:
+          type: integer
+          label: 'Shadow vertical offset in px'
+        shadow_width:
+          type: integer
+          label: 'Shadow width in px'
+        shadow_height:
+          type: integer
+          label: 'Shadow height in px'
+    layout:
+      type: mapping
+      mapping:
+        padding_top:
+          type: integer
+          label: 'Padding top in px'
+        padding_right:
+          type: integer
+          label: 'Padding right in px'
+        padding_bottom:
+          type: integer
+          label: 'Padding bottom in px'
+        padding_left:
+          type: integer
+          label: 'Padding left in px'
+        x_pos:
+          type: string
+          label: 'Placement on canvas, horizontal'
+        y_pos:
+          type: string
+          label: 'Placement on canvas, vertical'
+        x_offset:
+          type: integer
+          label: 'Placement on canvas, horizontal, offset'
+        y_offset:
+          type: integer
+          label: 'Placement on canvas, vertical, offset'
+        background_color:
+          type: color_hex
+          label: 'Color of bounding box'
+        overflow_action:
+          type: string
+          label: 'Action when text wrapper overflows canvas'
+        extended_color:
+          type: color_hex
+          label: 'Color to be used when extending the underlying image'
+    text:
+      type: mapping
+      mapping:
+        maximum_width:
+          type: integer
+          label: 'Maximum width in px'
+        fixed_width:
+          type: boolean
+          label: 'Fixed width flag'
+        align:
+          type: string
+          label: 'Text alignment'
+        line_spacing:
+          type: integer
+          label: 'Line spacing in px'
+        case_format:
+          type: string
+          label: 'Text format conversion'
+
 image.effect.image_effects_watermark:
   type: mapping
   label: 'Watermark image effect'
diff --git a/css/image_effects.text_overlay_preview.css b/css/image_effects.text_overlay_preview.css
new file mode 100644
index 0000000..b4b00ac
--- /dev/null
+++ b/css/image_effects.text_overlay_preview.css
@@ -0,0 +1,14 @@
+/* Text Overlay Preview */
+
+#text-overlay-preview {
+  background-image: url('../misc/images/transparent.png');
+  border: 1px solid #999;
+  margin-right: 5%;
+  padding: 25px 25px 21px;
+  text-align: center;
+}
+
+#text-overlay-preview img {
+  margin: 0;
+  padding: 0;
+}
diff --git a/image_effects.libraries.yml b/image_effects.libraries.yml
index 64dda16..80ddb2b 100644
--- a/image_effects.libraries.yml
+++ b/image_effects.libraries.yml
@@ -23,3 +23,9 @@ image_effects.html_color_selector:
   css:
     component:
       css/image_effects.html_color_selector.css: {}
+
+image_effects.text_overlay_preview:
+  version: VERSION
+  css:
+    component:
+      css/image_effects.text_overlay_preview.css: {}
diff --git a/image_effects.module b/image_effects.module
index ebabde1..f538c28 100644
--- a/image_effects.module
+++ b/image_effects.module
@@ -41,10 +41,18 @@ function image_effects_theme() {
     'image_effects_set_transparent_color_summary' => [
       'variables' => ['data' => NULL, 'effect' => []],
     ],
+    // Render a preview of the Text Overlay in the image effect UI.
+    'image_effects_text_overlay_preview' => [
+      'variables' => ['success' => FALSE, 'preview' => []],
+    ],
+    // Text overlay image effect - summary.
+    'image_effects_text_overlay_summary' => [
+      'variables' => ['data' => NULL, 'effect' => []],
+    ],
     // Watemark image effect - summary
     'image_effects_watermark_summary' => [
       'variables' => ['data' => NULL, 'effect' => []],
-    ]
+    ],
   ];
 }
 
diff --git a/misc/images/transparent.png b/misc/images/transparent.png
new file mode 100644
index 0000000000000000000000000000000000000000..a0c757368f62c606af5e954149758563b4eb61d1
GIT binary patch
literal 2822
zcmV+h3;FbkP)<h;3K|Lk000e1NJLTq000mG000mO0ssI2kdbIM00009a7bBm000ie
z000ie0hKEb8vp<bPiaF#P*7-ZbZ>KLZ*U+<Lqi~Na&Km7Y-Iodc-oy)XH-+^7Crag
z^g>IBfRsybQWXdwQbLP>6p<z>Aqfylh#{fb6;Z(vMMVS~$e@S=j*ftg6;Uh<iVD~V
z<RPMtgQJLw%KPDaqifc@_vX$1wbwr9tn;0-&j-K=43<bUQ8j=JsX`tR;Dg7+#^K~H
zK!FM*Z~zbpvt%K2{UZSY_<lS*D<Z%Lz5oGu(+dayz)hRLFdT>f59&ghTmgWD0l;*T
zI7<kC6aYYajzXpYKt=(8otP$50H6c_V9R4-;{Z@C0AMG7=F<Rxo%or10RUT+Ar%3j
zkpLhQWr#!oXgdI`&sK^>09Y^p6lP1rIRMx#05C~cW=H_Aw*bJ-5DT&Z2n+x)QHX^p
z00esgV8|mQcmRZ%02D^@S3L16t`O%c004NIvOKvYIYoh62rY33S640`D9%Y2D-<?i
z0%4j!F2Z@488U%158(66005wo6%pWr^Zj_v4zAA5HjcIqUoGmt2LB>rV&neh&#Q1i
z007~1e$oCcFS8neI|hJl{-P!B1ZZ9hpmq0)X0i`JwE&>$+E?>%_<lS*MWK+n+1cgf
z<k(8YLR(?VSAG6x!e78w{cQPuJpA|d;J)G{fihizM+Erb!p!tcr5w+a34~(Y=8s4G
zw+sLL9n&JjNn*KJDiq^U5^;`1nvC-@r6P$!k}1U{(*I=Q-z@tBKHoI}uxdU5dyy@u
zU1J0GOD7Ombim^G008p4Z^6_k2m^p<gW=D2|L;HjN1!DDfM!XOaR2~bL?kX$%CkSm
z2mk;?pn)o|K^yeJ7%adB9Ki+L!3+FgHiSYX#KJ-lLJDMn9CBbOtb#%)hRv`YDqt_v
zKpix|QD}yfa1JiQRk#j4a1Z)n2%f<xynzV>LC6RbVIkUx0b+_+BaR3cnT7Zv!AJxW
zizFb)h!jyGOOZ85F;a?DAXP{m@;!0_Ifq<Ex{*7`05XF7hP+2Hl!3BQJ=6@fL%FCo
z8iYoo3(#bAF`ADSpqtQgv>H8(HlgRxt7s3}k3K`kFu>>-2Q$QMFfPW!La{h336o>X
zu_CMttHv6zR;&ZNiS=X8v3CR#fknUxHUxJ<AYmRsNLWl*PS{AOARHt#5!wki2?K;t
z!Y3k=s7tgax)J%r7-BLphge7~Bi0g+6E6^Zh(p9TBoc{3GAFr^0!gu?RMHaCM$&Fl
zBk3%un>0uoBa_M6WNWeqIg~6QE69c9o#eyhGvpiOA@W-aonk<7r1(?fC{oI5N*U!4
z<uv66WtcKSRim0x-Ke2d5jBrmLam{;Qm;{ms1r1GnmNsb7D-E`t)i9F8fX`2_i3-_
zbh;7Ul^#x)&{xvS=|||7=mYe33=M`AgU5(xC>fg=2N-7=cNnjjOr{yriy6mMFgG#l
znCF=fnQv8CDz++o6_Lscl}eQ+l^ZHARH>?_s@|##Rr6KLRFA1%Q+=*RRWnoLsR`7U
zt5vF<Q0r40Q)j6=sE4X&sBct1q<&fbi3VB2Ov6t@q*0);U*o*SAPZv|vv@2aYYnT0
zb%8a+Cb7-ge0D0knEf5Qi#@8Tp*ce{N;6lpQuCB%KL_KOarm5cP6_8Ir<e17iry6O
zDdH&`rZh~sF=bq9s+O0QSgS~@QL9Jmy*94xr=6y~MY~!1fet~(N+(<=M`w@D1)b+p
z*;C!83a1uLJv#NSE~;y#8=<>IcfW3@?wFpwUVxrVZ>QdQz32KIeJ}k~{cZZE^+ya?
z2D1z#2HOnI7(B%_ac?{wFUQ;QQA1tBKtrWrm0_3Rgps+?Jfqb{jYbcQX~taRB;#$y
zZN{S}1|}gUOHJxc?wV3fxuz+mJ4`!F$IZ;mqRrNsHJd##*D~ju=bP7?-?v~|cv>vB
zsJ6IeNwVZxrdjT`yl#bBIa#GxRa#xMMy;K#CDyyGyQdMSxlWT#tDe?p!?5wT$+oGt
z8L;Kp2HUQ-ZMJ=3XJQv;x5ci*?vuTfeY$;({XGW_huIFR9a<fJbF^|4I#xQ~n$Dc=
zKYhjYmgz5NSkDm8*fZm{6U!;YX`NG>(?@3)XSs8O^N5RyOM=TTmp(3=8^+zpz2r)C
z^>JO{deZfso3oq3?Wo(Y?l$ge?uXo;%ru`Vo>?<<(8I_>;8Eq#KMS9gFl*neeosSB
zfoHYnBQIkwkyowPu(zdms`p{<7e4kra-ZWq<2*OsGTvEV%s0Td$hXT+!*8Bnh2KMe
zBmZRodjHV?r+_5^X9J0WL4jKW`}lf%A-|44I@@LTvf1rHjG(ze6+w@Jt%Bvjts!X0
z?2xS?_ve_-k<Mujg;0Lz*3buG=3$G&ehepthlN*$KaOySSQ^nWmo<0M+(UEUMEXRQ
zMBbZcF;6+KElM>iKB_KiJlZ$9G`c^=E@oNG)mWWaNo-3TIW8)$Hg0Ub-~8?KhvJ>$
z3*&nim@mj(aCxE5!t{lw7O5^0EIO7zOo&c6l<+|iDySBWCGrz@C5{St!X3hAA}`T4
z(TLbXTq+(;@<=L8dXnssyft|w#WSTW<++3>sgS%(4NTpeI-VAqb|7ssJvzNHgOZVu
zaYCvgO_R1~>SyL=cFU|~g|hy|Zi}}s9+d~lYqOB71z9Z$wnC=pR9Yz4DhIM>Wmjgu
z&56o6maCpC&F##y%G;1PobR9i?GnNg;gYtchD%p19a!eQtZF&3JaKv33gZ<8D~47E
ztUS1iwkmDaPpj=$m#%)jCVEY4fnLGNg2A-`YwHVD3gv};>)hAvT~AmqS>Lr``i7kw
zJ{5_It`yrBmlc25DBO7E8;5VoznR>Ww5hAaxn$2~(q`%A-YuS64wkBy=9dm`4cXeX
z4c}I@?e+FW+b@^RDBHV(wnMq2zdX3SWv9u`%{xC-q*U}&`cyXV(%rRT*Z6MH?i+i&
z_B8C(+grT%{XWUQ+f@NoP1R=AW&26{v-dx)iK^-Nmiuj8txj!m?Z*Ss1N{dh4z}01
z)YTo*JycSU)+_5r4#yw9{+;i4Ee$peRgIj+;v;ZGdF1K$3E%e~4LaI(jC-u%2h$&R
z9cLXcYC@Xwnns&bn)_Q~Te?roKGD|d-g^8;+aC{{G(1^(O7m37Y1-+6)01cN&y1aw
zoqc{T`P^XJqPBbIW6s}d4{z_f5Om?vMgNQEJG?v2T=KYd^0M3I6IZxbny)%vZR&LD
zJpPl@Psh8QyPB@KTx+@RdcC!KX7}kEo;S|j^u2lU7XQ}Oo;f|;z4Ll+_r>@1-xl3|
zawq-H%e&ckC+@AhPrP6BK<z=<L*0kfKU@CX*zeqbYQT4(^U>T#_XdT7&;F71j}Joy
zkC~6lh7E@6o;W@^IpRNZ{ptLtL(gQ-CY~4mqW;US7Zxvm_|@yz&e53Bp_lTPlfP|z
zrTyx_>lv@x#=^!PzR7qqF<$gm`|ZJZ+;<)Cqu&ot<a{81DF0~rvGr5Xr~8u`lav1h
z1DNytV>2z=0000WV@Og>004R=004l4008;_004mL004C`008P>0026e000+nl3&F}
z0000nNkl<Zc-mq-bLI>KHehUQj8&Y8fkZH>ff&H}|Nnoi5@*kz9StS=X#fBK0RR63
Y07A70fCWh|;s5{u07*qoM6N<$f*;p9rvLx|

literal 0
HcmV?d00001

diff --git a/src/Component/TextUtility.php b/src/Component/TextUtility.php
new file mode 100644
index 0000000..32fca4b
--- /dev/null
+++ b/src/Component/TextUtility.php
@@ -0,0 +1,76 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\image_effects\Component\TextUtility.
+ */
+
+namespace Drupal\image_effects\Component;
+
+use Drupal\Component\Utility\Unicode;
+
+/**
+ * image_effects - Text handling methods.
+ */
+abstract class TextUtility {
+
+  /**
+   * Matches all 'P' Unicode character classes (punctuation)
+   */
+  const PREG_CLASS_PUNCTUATION = <<< 'EOD'
+\x{21}-\x{23}\x{25}-\x{2a}\x{2c}-\x{2f}\x{3a}\x{3b}\x{3f}\x{40}\x{5b}-\x{5d}
+\x{5f}\x{7b}\x{7d}\x{a1}\x{ab}\x{b7}\x{bb}\x{bf}\x{37e}\x{387}\x{55a}-\x{55f}
+\x{589}\x{58a}\x{5be}\x{5c0}\x{5c3}\x{5f3}\x{5f4}\x{60c}\x{60d}\x{61b}\x{61f}
+\x{66a}-\x{66d}\x{6d4}\x{700}-\x{70d}\x{964}\x{965}\x{970}\x{df4}\x{e4f}
+\x{e5a}\x{e5b}\x{f04}-\x{f12}\x{f3a}-\x{f3d}\x{f85}\x{104a}-\x{104f}\x{10fb}
+\x{1361}-\x{1368}\x{166d}\x{166e}\x{169b}\x{169c}\x{16eb}-\x{16ed}\x{1735}
+\x{1736}\x{17d4}-\x{17d6}\x{17d8}-\x{17da}\x{1800}-\x{180a}\x{1944}\x{1945}
+\x{2010}-\x{2027}\x{2030}-\x{2043}\x{2045}-\x{2051}\x{2053}\x{2054}\x{2057}
+\x{207d}\x{207e}\x{208d}\x{208e}\x{2329}\x{232a}\x{23b4}-\x{23b6}
+\x{2768}-\x{2775}\x{27e6}-\x{27eb}\x{2983}-\x{2998}\x{29d8}-\x{29db}\x{29fc}
+\x{29fd}\x{3001}-\x{3003}\x{3008}-\x{3011}\x{3014}-\x{301f}\x{3030}\x{303d}
+\x{30a0}\x{30fb}\x{fd3e}\x{fd3f}\x{fe30}-\x{fe52}\x{fe54}-\x{fe61}\x{fe63}
+\x{fe68}\x{fe6a}\x{fe6b}\x{ff01}-\x{ff03}\x{ff05}-\x{ff0a}\x{ff0c}-\x{ff0f}
+\x{ff1a}\x{ff1b}\x{ff1f}\x{ff20}\x{ff3b}-\x{ff3d}\x{ff3f}\x{ff5b}\x{ff5d}
+\x{ff5f}-\x{ff65}
+EOD;
+
+  /**
+   * Matches all 'Z' Unicode character classes (separators)
+   */
+  const PREG_CLASS_SEPARATOR = <<< 'EOD'
+\x{20}\x{a0}\x{1680}\x{180e}\x{2000}-\x{200a}\x{2028}\x{2029}\x{202f}
+\x{205f}\x{3000}
+EOD;
+
+  /**
+   * Unicode-safe preg_match().
+   *
+   * Search subject for a match to the regular expression given in pattern,
+   * but return offsets in characters, where preg_match would return offsets
+   * in bytes.
+   *
+   * @see http://php.net/manual/en/function.preg-match.php
+   * @see http://drupal.org/node/465638
+   */
+  public static function unicodePregMatch($pattern, $subject, &$matches, $flags = NULL, $offset = 0) {
+    // Convert the offset value from characters to bytes.
+    // NOTE - strlen is used on purpose here to get string length in bytes.
+    // @see https://www.drupal.org/node/465638#comment-1600860
+    $offset = strlen(Unicode::substr($subject, 0, $offset));
+
+    $return_value = preg_match($pattern, $subject, $matches, $flags, $offset);
+
+    if ($return_value && ($flags & PREG_OFFSET_CAPTURE)) {
+      foreach ($matches as &$match) {
+        // Convert the offset returned by preg_match from bytes back to
+        // characters.
+        // NOTE - substr is used on purpose here to get offset in bytes.
+        // @see https://www.drupal.org/node/465638#comment-1600860
+        $match[1] = Unicode::strlen(substr($subject, 0, $match[1]));
+      }
+    }
+    return $return_value;
+  }
+
+}
diff --git a/src/Plugin/ImageEffect/TextOverlayImageEffect.php b/src/Plugin/ImageEffect/TextOverlayImageEffect.php
new file mode 100644
index 0000000..0f47d26
--- /dev/null
+++ b/src/Plugin/ImageEffect/TextOverlayImageEffect.php
@@ -0,0 +1,1085 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\image_effects\Plugin\ImageEffect\TextOverlayImageEffect.
+ */
+
+namespace Drupal\image_effects\Plugin\ImageEffect;
+
+use Drupal\Component\Utility\NestedArray;
+use Drupal\Component\Utility\Unicode;
+use Drupal\Core\Ajax\AjaxResponse;
+use Drupal\Core\Ajax\ReplaceCommand;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Image\ImageFactory;
+use Drupal\Core\Image\ImageInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\Core\Render\BubbleableMetadata;
+use Drupal\Core\Utility\Token;
+use Drupal\image\ConfigurableImageEffectBase;
+use Drupal\image_effects\Component\ColorUtility;
+use Drupal\image_effects\Component\PositionedRectangle;
+use Drupal\image_effects\Plugin\ImageEffectsFontSelectorPluginInterface;
+use Psr\Log\LoggerInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Overlays text on the image, defining text font, size and positioning.
+ *
+ * @ImageEffect(
+ *   id = "image_effects_text_overlay",
+ *   label = @Translation("Text overlay"),
+ *   description = @Translation("Overlays text on the image, defining text font, size and positioning.")
+ * )
+ */
+class TextOverlayImageEffect extends ConfigurableImageEffectBase implements ContainerFactoryPluginInterface {
+
+  /**
+   * Stores information about image and text wrapper.
+   *
+   * @var int[]
+   */
+  protected $info = [
+    'image_xpos' => 0,
+    'image_ypos' => 0,
+  ];
+
+  /**
+   * The Image factory.
+   *
+   * @var \Drupal\Core\Image\ImageFactory
+   */
+  protected $imageFactory;
+
+  /**
+   * The font selector plugin.
+   *
+   * @var \Drupal\image_effects\Plugin\ImageEffectsFontSelectorPluginInterface
+   */
+  protected $fontSelector;
+
+  /**
+   * The token resolution service.
+   *
+   * @var \Drupal\Core\Utility\Token
+   */
+  protected $token;
+
+  /**
+   * Constructs a TextOverlayImageEffect object.
+   *
+   * @param array $configuration
+   *   A configuration array containing information about the plugin instance.
+   * @param string $plugin_id
+   *   The plugin_id for the plugin instance.
+   * @param array $plugin_definition
+   *   The plugin implementation definition.
+   * @param \Psr\Log\LoggerInterface $logger
+   *   A logger instance.
+   * @param \Drupal\Core\Image\ImageFactory $image_factory
+   *   The image factory service.
+   * @param \Drupal\image_effects\Plugin\ImageEffectsFontSelectorPluginInterface $font_selector_plugin
+   *   The font selector plugin.
+   * @param \Drupal\Core\Utility\Token $token_service
+   *   The token resolution service.
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition, LoggerInterface $logger, ImageFactory $image_factory, ImageEffectsFontSelectorPluginInterface $font_selector_plugin, Token $token_service) {
+    parent::__construct($configuration, $plugin_id, $plugin_definition, $logger);
+    $this->imageFactory = $image_factory;
+    $this->fontSelector = $font_selector_plugin;
+    $this->token = $token_service;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+    return new static(
+      $configuration,
+      $plugin_id,
+      $plugin_definition,
+      $container->get('logger.channel.image_effects'),
+      $container->get('image.factory'),
+      $container->get('plugin.manager.image_effects.font_selector')->getPlugin(),
+      $container->get('token')
+    );
+  }
+
+  /**
+   * Returns the textimage.factory service, if available.
+   *
+   * @return \Drupal\textimage\TextimageFactory|null
+   *   The textimage.factory service if available, NULL otherwise.
+   */
+  protected function getTextimageFactory() {
+    return \Drupal::hasService('textimage.factory') ? \Drupal::service('textimage.factory') : NULL;
+  }
+
+  /**
+   * Returns the token.tree_builder service, if available.
+   *
+   * @return \Drupal\token\TreeBuilderInterface|null
+   *   The token.tree_builder service if available, NULL otherwise.
+   */
+  protected function getTokenTreeBuilder() {
+    return \Drupal::hasService('token.tree_builder') ? \Drupal::service('token.tree_builder') : NULL;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function defaultConfiguration() {
+    return NestedArray::mergeDeep(
+      array(
+        'font'          => array(
+          'name'                  => '',
+          'uri'                   => '',
+          'size'                  => 16,
+          'angle'                 => 0,
+          'color'                 => '#000000FF',
+          'stroke_mode'           => 'outline',
+          'stroke_color'          => '#000000FF',
+          'outline_top'           => 0,
+          'outline_right'         => 0,
+          'outline_bottom'        => 0,
+          'outline_left'          => 0,
+          'shadow_x_offset'       => 1,
+          'shadow_y_offset'       => 1,
+          'shadow_width'          => 0,
+          'shadow_height'         => 0,
+        ),
+        'layout'       => array(
+          'padding_top'           => 0,
+          'padding_right'         => 0,
+          'padding_bottom'        => 0,
+          'padding_left'          => 0,
+          'x_pos'                 => 'center',
+          'y_pos'                 => 'center',
+          'x_offset'              => 0,
+          'y_offset'              => 0,
+          'background_color'      => NULL,
+          'overflow_action'       => 'extend',
+          'extended_color'        => NULL,
+        ),
+        'text' => array(
+          'maximum_width'         => 0,
+          'fixed_width'           => FALSE,
+          'align'                 => 'left',
+          'line_spacing'          => 0,
+          'case_format'           => '',
+        ),
+        'text_string'             => $this->t('Preview'),
+      ),
+      parent::defaultConfiguration()
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
+    $form = array();
+
+    if ($this->getTextimageFactory()) {
+      // Preview effect.
+      $this->configuration['preview_bar']['debug_visuals'] = empty($this->configuration['preview_bar']['debug_visuals']) ? FALSE : TRUE;
+      list($success, $preview) = $this->buildPreviewRender($this->configuration);
+      $form['preview'] = [
+        '#type'   => 'item',
+        '#title' => $this->t('Preview'),
+        '#theme' => 'image_effects_text_overlay_preview',
+        '#success' => $success,
+        '#preview' => $preview,
+      ];
+
+      // Preview bar.
+      $form['preview_bar'] = array(
+        '#type' => 'container',
+        '#attributes' => array(
+          'class' => array('container-inline'),
+        ),
+      );
+      // Refresh button.
+      $form['preview_bar']['preview'] = array(
+        '#type'  => 'button',
+        '#value' => $this->t('Refresh preview'),
+        '#name' => 'preview',
+        '#ajax'  => array(
+          'callback' => array($this, 'processAjaxPreview'),
+        ),
+      );
+      // Visual aids.
+      $form['preview_bar']['debug_visuals'] = array(
+        '#type' => 'checkbox',
+        '#title' => $this->t('Visual aids in preview'),
+        '#default_value' => FALSE,
+      );
+    }
+
+    // Settings.
+    $form['settings'] = array(
+      '#type' => 'vertical_tabs',
+      '#tree' => FALSE,
+    );
+
+    // Text default.
+    $form['text_default'] = array(
+      '#type'  => 'details',
+      '#title' => $this->t('Text default'),
+      '#group'   => 'settings',
+    );
+    $form['text_default']['text_string'] = array(
+      '#type'  => 'textarea',
+      '#title' => $this->t('Default text'),
+      '#default_value' => $this->configuration['text_string'],
+      '#description' => $this->t('Enter the default text string for this effect. You can also enter tokens, that will be resolved when applying the effect. <b>Note:</b> only global tokens can be resolved by standard Drupal Image field formatters and widgets. The Textimage module provides a formatter that can also resolve node, file and user tokens.'),
+      '#rows' => 3,
+      '#required' => TRUE,
+    );
+    if ($token_tree_builder = $this->getTokenTreeBuilder()) {
+      $form['text_default']['tokens'] = $token_tree_builder->buildAllRenderable();
+    }
+
+    // Font settings.
+    $form['font'] = array(
+      '#type'  => 'details',
+      '#title' => $this->t('Font settings'),
+      '#group'   => 'settings',
+    );
+    $form['font']['uri'] = $this->fontSelector->selectionElement(array(
+      '#title' => $this->t('Font'),
+      '#description' => $this->t('Select the font to be used in this image.'),
+      '#default_value' => $this->configuration['font']['uri'],
+    ));
+    $form['font']['size'] = array(
+      '#type'  => 'number',
+      '#title' => $this->t('Size'),
+      '#description'   => $this->t('Enter the size of the text to be generated.'),
+      '#default_value' => $this->configuration['font']['size'],
+      '#maxlength' => 5,
+      '#size' => 3,
+      '#required' => TRUE,
+      '#min' => 1,
+    );
+    $form['font']['angle'] = array(
+      '#type'  => 'number',
+      '#title' => $this->t('Rotation'),
+      '#maxlength' => 4,
+      '#size' => 4,
+      '#field_suffix' => $this->t('&deg;'),
+      '#description' => $this->t('Enter the angle in degrees at which the text will be displayed. Positive numbers rotate the text clockwise, negative numbers counter-clockwise.'),
+      '#default_value' => $this->configuration['font']['angle'],
+      '#min' => -360,
+      '#max' => 360,
+    );
+    $form['font']['color'] = array(
+      '#type' => 'image_effects_color',
+      '#title' => $this->t('Font color'),
+      '#description'  => $this->t('Set the font color.'),
+      '#allow_opacity' => TRUE,
+      '#default_value' => $this->configuration['font']['color'],
+    );
+    // Outline.
+    $form['font']['stroke'] = array(
+      '#type' => 'details',
+      '#title' => $this->t('Outline / Shadow'),
+      '#description'   => $this->t('Optionally add an outline or shadow around the font. Enter the information in pixels.'),
+    );
+    $stroke_options = array(
+      'outline' => $this->t('Outline'),
+      'shadow' => $this->t('Shadow'),
+    );
+    $form['font']['stroke']['mode'] = array(
+      '#type'    => 'radios',
+      '#title'   => $this->t('Mode'),
+      '#options' => $stroke_options,
+      '#default_value' => $this->configuration['font']['stroke_mode'],
+    );
+    $form['font']['stroke']['top'] = array(
+      '#type' => 'number',
+      '#title' => $this->t('Top'),
+      '#default_value' => $this->configuration['font']['outline_top'],
+      '#maxlength' => 2,
+      '#size' => 3,
+      '#field_suffix' => 'px',
+      '#min' => 0,
+      '#states' => array(
+        'visible' => array(
+          ':radio[name="data[font][stroke][mode]"]' => array('value' => 'outline'),
+        ),
+      ),
+    );
+    $form['font']['stroke']['right'] = array(
+      '#type' => 'number',
+      '#title' => $this->t('Right'),
+      '#default_value' => $this->configuration['font']['outline_right'],
+      '#maxlength' => 2,
+      '#size' => 3,
+      '#field_suffix' => 'px',
+      '#min' => 0,
+      '#states' => array(
+        'visible' => array(
+          ':radio[name="data[font][stroke][mode]"]' => array('value' => 'outline'),
+        ),
+      ),
+    );
+    $form['font']['stroke']['bottom'] = array(
+      '#type' => 'number',
+      '#title' => $this->t('Bottom'),
+      '#default_value' => $this->configuration['font']['outline_bottom'],
+      '#maxlength' => 2,
+      '#size' => 3,
+      '#field_suffix' => 'px',
+      '#min' => 0,
+      '#states' => array(
+        'visible' => array(
+          ':radio[name="data[font][stroke][mode]"]' => array('value' => 'outline'),
+        ),
+      ),
+    );
+    $form['font']['stroke']['left'] = array(
+      '#type' => 'number',
+      '#title' => $this->t('Left'),
+      '#default_value' => $this->configuration['font']['outline_left'],
+      '#maxlength' => 2,
+      '#size' => 3,
+      '#field_suffix' => 'px',
+      '#min' => 0,
+      '#states' => array(
+        'visible' => array(
+          ':radio[name="data[font][stroke][mode]"]' => array('value' => 'outline'),
+        ),
+      ),
+    );
+    $form['font']['stroke']['x_offset'] = array(
+      '#type' => 'number',
+      '#title' => $this->t('Horizontal offset'),
+      '#default_value' => $this->configuration['font']['shadow_x_offset'],
+      '#maxlength' => 3,
+      '#size' => 3,
+      '#field_suffix' => 'px',
+      '#min' => 1,
+      '#states' => array(
+        'visible' => array(
+          ':radio[name="data[font][stroke][mode]"]' => array('value' => 'shadow'),
+        ),
+      ),
+    );
+    $form['font']['stroke']['y_offset'] = array(
+      '#type' => 'number',
+      '#title' => $this->t('Vertical offset'),
+      '#default_value' => $this->configuration['font']['shadow_y_offset'],
+      '#maxlength' => 3,
+      '#size' => 3,
+      '#field_suffix' => 'px',
+      '#min' => 1,
+      '#states' => array(
+        'visible' => array(
+          ':radio[name="data[font][stroke][mode]"]' => array('value' => 'shadow'),
+        ),
+      ),
+    );
+    $form['font']['stroke']['width'] = array(
+      '#type' => 'number',
+      '#title' => $this->t('Horizontal elongation'),
+      '#default_value' => $this->configuration['font']['shadow_width'],
+      '#maxlength' => 2,
+      '#size' => 3,
+      '#field_suffix' => 'px',
+      '#min' => 0,
+      '#states' => array(
+        'visible' => array(
+          ':radio[name="data[font][stroke][mode]"]' => array('value' => 'shadow'),
+        ),
+      ),
+    );
+    $form['font']['stroke']['height'] = array(
+      '#type' => 'number',
+      '#title' => $this->t('Vertical elongation'),
+      '#default_value' => $this->configuration['font']['shadow_height'],
+      '#maxlength' => 2,
+      '#size' => 3,
+      '#field_suffix' => 'px',
+      '#min' => 0,
+      '#states' => array(
+        'visible' => array(
+          ':radio[name="data[font][stroke][mode]"]' => array('value' => 'shadow'),
+        ),
+      ),
+    );
+    $form['font']['stroke']['color'] = array(
+      '#type' => 'image_effects_color',
+      '#title' => $this->t('Color'),
+      '#description'  => $this->t('Set the outline/shadow color.'),
+      '#allow_opacity' => TRUE,
+      '#default_value' => $this->configuration['font']['stroke_color'],
+    );
+
+    // Text settings.
+    $form['text'] = array(
+      '#type'  => 'details',
+      '#title' => $this->t('Text settings'),
+      '#group'   => 'settings',
+    );
+    // Inner width.
+    $form['text']['maximum_width'] = array(
+      '#type'  => 'number',
+      '#title' => $this->t('Maximum width'),
+      '#field_suffix' => $this->t('px'),
+      '#description' => $this->t('Maximum width of the text image, inclusive of padding. Text lines wider than this will be wrapped. Set to 0 to disable wrapping. <b>Note:</b> in case of rotation, the width of the final image rendered will differ, to accomodate the rotation. If you need a strict width/height, add image resize/scale/crop effects afterwards.'),
+      '#default_value' => $this->configuration['text']['maximum_width'],
+      '#maxlength' => 4,
+      '#size' => 4,
+      '#min' => 0,
+    );
+    $form['text']['fixed_width'] = array(
+      '#type'  => 'checkbox',
+      '#title' => $this->t('Fixed width?'),
+      '#description' => $this->t('If checked, the width will always be equal to the maximum width.'),
+      '#default_value' => $this->configuration['text']['fixed_width'],
+      '#states' => array(
+        'visible' => array(
+          ':input[name="data[text][maximum_width]"]' => array('!value' => 0),
+        ),
+      ),
+    );
+    // Text alignment.
+    $form['text']['align'] = array(
+      '#type'  => 'select',
+      '#title' => $this->t('Text alignment'),
+      '#options' => array(
+        'left' => $this->t('Left'),
+        'center' => $this->t('Center'),
+        'right' => $this->t('Right'),
+      ),
+      '#default_value' => $this->configuration['text']['align'],
+      '#description' => $this->t('Select how the text should be aligned within the resulting image. The default aligns to the left.'),
+    );
+    // Line spacing (Leading).
+    $form['text']['line_spacing'] = array(
+      '#type'  => 'number',
+      '#title' => $this->t('Line spacing (Leading)'),
+      '#field_suffix'  => $this->t('px'),
+      '#default_value' => $this->configuration['text']['line_spacing'],
+      '#maxlength' => 4,
+      '#size' => 4,
+      '#description' => $this->t('Specify the space in pixels to be added between text lines (Leading). Can be negative.'),
+    );
+    $form['text']['case_format'] = array(
+      '#type'  => 'select',
+      '#title' => $this->t('Case format'),
+      '#options' => array(
+        '' => $this->t('Default'),
+        'upper' => $this->t('UPPERCASE'),
+        'lower' => $this->t('lowercase'),
+        'ucwords' => $this->t('Uppercase Words'),
+        'ucfirst' => $this->t('Uppercase first'),
+      ),
+      '#description' => $this->t('Convert the input text to a desired format. The default makes no changes to input text.'),
+      '#default_value' => $this->configuration['text']['case_format'],
+    );
+
+    // Layout settings.
+    $form['layout'] = array(
+      '#type'  => 'details',
+      '#title' => $this->t('Layout settings'),
+      '#group'   => 'settings',
+    );
+    // Position.
+    $form['layout']['position'] = array(
+      '#type' => 'details',
+      '#open' => TRUE,
+      '#title' => $this->t('Position'),
+    );
+    $form['layout']['position']['placement'] = array(
+      '#type' => 'radios',
+      '#title' => $this->t('Placement'),
+      '#options' => array(
+        'left-top' => $this->t('Top left'),
+        'center-top' => $this->t('Top center'),
+        'right-top' => $this->t('Top right'),
+        'left-center' => $this->t('Center left'),
+        'center-center' => $this->t('Center'),
+        'right-center' => $this->t('Center right'),
+        'left-bottom' => $this->t('Bottom left'),
+        'center-bottom' => $this->t('Bottom center'),
+        'right-bottom' => $this->t('Bottom right'),
+      ),
+      '#theme' => 'image_anchor',
+      '#default_value' => implode('-', array($this->configuration['layout']['x_pos'], $this->configuration['layout']['y_pos'])),
+      '#description' => $this->t('Position of the text on the underlying image.'),
+    );
+    $form['layout']['position']['x_offset'] = array(
+      '#type'  => 'number',
+      '#title' => $this->t('Horizontal offset'),
+      '#field_suffix'  => 'px',
+      '#description'   => $this->t('Additional horizontal offset from placement.'),
+      '#default_value' => $this->configuration['layout']['x_offset'],
+      '#maxlength' => 4,
+      '#size' => 4,
+    );
+    $form['layout']['position']['y_offset'] = array(
+      '#type'  => 'number',
+      '#title' => $this->t('Vertical offset'),
+      '#field_suffix'  => 'px',
+      '#description'   => $this->t('Additional vertical offset from placement.'),
+      '#default_value' => $this->configuration['layout']['y_offset'],
+      '#maxlength' => 4,
+      '#size' => 4,
+    );
+    // Overflow action.
+    $form['layout']['position']['overflow_action'] = array(
+      '#type' => 'radios',
+      '#title' => $this->t('Overflow'),
+      '#default_value' => $this->configuration['layout']['overflow_action'],
+      '#options' => array(
+        'extend' => $this->t('<b>Extend image.</b> The underlying image will be extended to fit the text.'),
+        'crop' => $this->t('<b>Crop text.</b> Only the part of the text fitting in the image is rendered.'),
+        'scaletext' => $this->t('<b>Scale text.</b> The text will be scaled to fit the underlying image.'),
+      ),
+      '#description' => $this->t('Action to take if text overflows the underlying image.'),
+    );
+    $form['layout']['position']['extended_color'] = array(
+      '#type' => 'image_effects_color',
+      '#title' => $this->t('Extended background color'),
+      '#description'  => $this->t('Set the color to be used when extending the underlying image.'),
+      '#allow_null' => TRUE,
+      '#allow_opacity' => TRUE,
+      '#default_value' => $this->configuration['layout']['extended_color'],
+      '#states' => array(
+        'visible' => array(
+          ':radio[name="data[layout][position][overflow_action]"]' => array('value' => 'extend'),
+        ),
+      ),
+    );
+    // Padding.
+    $form['layout']['padding'] = array(
+      '#type' => 'details',
+      '#open' => TRUE,
+      '#title' => $this->t('Padding'),
+      '#description' => $this->t('Specify the padding in pixels to be added around the generated text.'),
+    );
+    $form['layout']['padding']['top'] = array(
+      '#type'  => 'number',
+      '#title' => $this->t('Top'),
+      '#field_suffix'  => $this->t('px'),
+      '#default_value' => $this->configuration['layout']['padding_top'],
+      '#maxlength' => 4,
+      '#size' => 4,
+    );
+    $form['layout']['padding']['right'] = array(
+      '#type'  => 'number',
+      '#title' => $this->t('Right'),
+      '#field_suffix'  => $this->t('px'),
+      '#default_value' => $this->configuration['layout']['padding_right'],
+      '#maxlength' => 4,
+      '#size' => 4,
+    );
+    $form['layout']['padding']['bottom'] = array(
+      '#type'  => 'number',
+      '#title' => $this->t('Bottom'),
+      '#field_suffix'  => $this->t('px'),
+      '#default_value' => $this->configuration['layout']['padding_bottom'],
+      '#maxlength' => 4,
+      '#size' => 4,
+    );
+    $form['layout']['padding']['left'] = array(
+      '#type'  => 'number',
+      '#title' => $this->t('Left'),
+      '#field_suffix'  => $this->t('px'),
+      '#default_value' => $this->configuration['layout']['padding_left'],
+      '#maxlength' => 4,
+      '#size' => 4,
+    );
+    // Background color.
+    $form['layout']['background_color'] = array(
+      '#type' => 'image_effects_color',
+      '#title' => $this->t('Background color'),
+      '#description'  => $this->t('Select the color you wish to use for the background of the text.'),
+      '#allow_null' => TRUE,
+      '#allow_opacity' => TRUE,
+      '#default_value' => $this->configuration['layout']['background_color'],
+    );
+
+    return $form;
+  }
+
+  /**
+   * Preview Ajax callback.
+   */
+  public function processAjaxPreview($form, FormStateInterface $form_state) {
+    list($success, $preview) = $this->buildPreviewRender($form_state->getValue(['data', 'ajax_config']));
+    $preview_render = [
+      '#theme' => 'image_effects_text_overlay_preview',
+      '#success' => $success,
+      '#preview' => $preview,
+    ];
+    $response = new AjaxResponse();
+    $response->addCommand(new ReplaceCommand('#text-overlay-preview', $preview_render));
+    return $response;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
+    parent::validateConfigurationForm($form, $form_state);
+
+    // Get x-y position from the anchor element.
+    list($x_pos, $y_pos) = explode('-', $form_state->getValue(['layout', 'position', 'placement']));
+
+    $this->configuration = array(
+      'font'   => array(
+        'name'                 => $form_state->hasValue(['font', 'uri']) ? $this->fontSelector->getDescription($form_state->getValue(['font', 'uri'])) : NULL,
+        'uri'                  => $form_state->getValue(['font', 'uri']),
+        'size'                 => $form_state->getValue(['font', 'size']),
+        'angle'                => $form_state->getValue(['font', 'angle']),
+        'color'                => $form_state->getValue(['font', 'color']),
+        'stroke_mode'          => $form_state->getValue(['font', 'stroke', 'mode']),
+        'stroke_color'         => $form_state->getValue(['font', 'stroke', 'color']),
+        'outline_top'          => $form_state->getValue(['font', 'stroke', 'top']),
+        'outline_right'        => $form_state->getValue(['font', 'stroke', 'right']),
+        'outline_bottom'       => $form_state->getValue(['font', 'stroke', 'bottom']),
+        'outline_left'         => $form_state->getValue(['font', 'stroke', 'left']),
+        'shadow_x_offset'      => $form_state->getValue(['font', 'stroke', 'x_offset']),
+        'shadow_y_offset'      => $form_state->getValue(['font', 'stroke', 'y_offset']),
+        'shadow_width'         => $form_state->getValue(['font', 'stroke', 'width']),
+        'shadow_height'        => $form_state->getValue(['font', 'stroke', 'height']),
+      ),
+      'layout' => array(
+        'padding_top'          => $form_state->getValue(['layout', 'padding', 'top']),
+        'padding_right'        => $form_state->getValue(['layout', 'padding', 'right']),
+        'padding_bottom'       => $form_state->getValue(['layout', 'padding', 'bottom']),
+        'padding_left'         => $form_state->getValue(['layout', 'padding', 'left']),
+        'x_pos'                => $x_pos,
+        'y_pos'                => $y_pos,
+        'x_offset'             => $form_state->getValue(['layout', 'position', 'x_offset']),
+        'y_offset'             => $form_state->getValue(['layout', 'position', 'y_offset']),
+        'overflow_action'      => $form_state->getValue(['layout', 'position', 'overflow_action']),
+        'extended_color'       => $form_state->getValue(['layout', 'position', 'extended_color']),
+        'background_color'     => $form_state->getValue(['layout', 'background_color']),
+      ),
+      'text'   => array(
+        'maximum_width'        => $form_state->getValue(['text', 'maximum_width']),
+        'fixed_width'          => $form_state->getValue(['text', 'fixed_width']),
+        'align'                => $form_state->getValue(['text', 'align']),
+        'case_format'          => $form_state->getValue(['text', 'case_format']),
+        'line_spacing'         => $form_state->getValue(['text', 'line_spacing']),
+      ),
+      'text_string'            => $form_state->getValue(['text_default', 'text_string']),
+    );
+
+    // Save the updated configuration in a FormState value to enable Ajax
+    // preview generation.
+    $form_state->setValue(['ajax_config'], $this->configuration);
+    $form_state->setValue(['ajax_config', 'preview_bar', 'debug_visuals'], $form_state->getValue(['preview_bar', 'debug_visuals']));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getSummary() {
+    $data = $this->configuration;
+    $data['font_color_detail'] = array(
+      '#theme' => 'image_effects_color_detail',
+      '#color' => $data['font']['color'],
+      '#border' => TRUE,
+      '#border_color' => 'matchLuma',
+    );
+    if ($stroke_mode = $this->strokeMode()) {
+      $data['stroke_mode'] = $stroke_mode;
+      $data['stroke_color_detail'] = array(
+        '#theme' => 'image_effects_color_detail',
+        '#color' => $data['font']['stroke_color'],
+        '#border' => TRUE,
+        '#border_color' => 'matchLuma',
+      );
+    }
+    if ($data['layout']['background_color']) {
+      $data['background_color_detail'] = array(
+        '#theme' => 'image_effects_color_detail',
+        '#color' => $data['layout']['background_color'],
+        '#border' => TRUE,
+        '#border_color' => 'matchLuma',
+      );
+    }
+
+    return array(
+      '#theme' => 'image_effects_text_overlay_summary',
+      '#data' => $data,
+    ) + parent::getSummary();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function applyEffect(ImageInterface $image) {
+    // Preserve current background image dimensions.
+    if ($image->getWidth() === 1 && $image->getHeight() === 1) {
+      // Special case: when width and height of the source image is 1, we set
+      // starting width and height within the effect to be zero. This way we
+      // avoid that text overlays that extend the source image lead to images
+      // with a single transparent or colored pixel in the center of the image.
+      $image_width = 0;
+      $image_height = 0;
+    }
+    else {
+      $image_width = $image->getWidth();
+      $image_height = $image->getHeight();
+    }
+
+    $this->info['image_width'] = $image_width;
+    $this->info['image_height'] = $image_height;
+
+    // Get the text wrapper Image object.
+    if (!$wrapper = $this->getTextWrapper()) {
+      return FALSE;
+    }
+
+    // Determine if background image needs resizing.
+    if ($this->configuration['layout']['overflow_action'] == 'extend') {
+      // The size of the frame sides for color filling.
+      $this->info['frame_top'] = 0;
+      $this->info['frame_right'] = 0;
+      $this->info['frame_bottom'] = 0;
+      $this->info['frame_left'] = 0;
+
+      // Check wrapper image overflowing the original image.
+      if ($this->canvasResizeNeeded($wrapper)) {
+        // Apply set_canvas, transparent background.
+        $data = [
+          'width' => $this->info['image_width'],
+          'height' => $this->info['image_height'],
+          'x_pos' => $this->info['image_xpos'],
+          'y_pos' => $this->info['image_ypos'],
+        ];
+        if (!$image->apply('set_canvas', $data)) {
+          return FALSE;
+        }
+        // Color fill the frame with extended color.
+        if ($main_bg_color = $this->configuration['layout']['extended_color']) {
+          // Top rectangle.
+          $rectangle = new PositionedRectangle();
+          if ($this->info['frame_top']) {
+            $rectangle->setFromCorners([
+              'c_a' => [0, $this->info['frame_top'] - 1],
+              'c_b' => [$this->info['image_width'] - 1, $this->info['frame_top'] - 1],
+              'c_c' => [$this->info['image_width'] - 1, 0],
+              'c_d' => [0, 0],
+            ]);
+            if (!$image->apply('draw_rectangle', ['rectangle' => $rectangle, 'fill_color' => $main_bg_color])) {
+              return FALSE;
+            };
+          }
+          // Bottom rectangle.
+          if ($this->info['frame_bottom']) {
+            $rectangle->setFromCorners([
+              'c_a' => [0, $this->info['image_height'] - 1],
+              'c_b' => [$this->info['image_width'] - 1, $this->info['image_height'] - 1],
+              'c_c' => [$this->info['image_width'] - 1, $image_height + $this->info['frame_top']],
+              'c_d' => [0, $image_height + $this->info['frame_top']],
+            ]);
+            if (!$image->apply('draw_rectangle', ['rectangle' => $rectangle, 'fill_color' => $main_bg_color])) {
+              return FALSE;
+            };
+          }
+          // Left rectangle.
+          if ($this->info['frame_left']) {
+            $rectangle->setFromCorners([
+              'c_a' => [0, $this->info['frame_top'] + $image_height - 1],
+              'c_b' => [$this->info['frame_left'] - 1, $this->info['frame_top'] + $image_height - 1],
+              'c_c' => [$this->info['frame_left'] - 1, $this->info['frame_top']],
+              'c_d' => [0, $this->info['frame_top']],
+            ]);
+            if (!$image->apply('draw_rectangle', ['rectangle' => $rectangle, 'fill_color' => $main_bg_color])) {
+              return FALSE;
+            };
+          }
+          // Right rectangle.
+          if ($this->info['frame_right']) {
+            $rectangle->setFromCorners([
+              'c_a' => [$this->info['frame_left'] + $image_width, $this->info['frame_top'] + $image_height - 1],
+              'c_b' => [$this->info['image_width'] - 1, $this->info['frame_top'] + $image_height - 1],
+              'c_c' => [$this->info['image_width'] - 1, $this->info['frame_top']],
+              'c_d' => [$this->info['frame_left'] + $image_width, $this->info['frame_top']],
+            ]);
+            if (!$image->apply('draw_rectangle', ['rectangle' => $rectangle, 'fill_color' => $main_bg_color])) {
+              return FALSE;
+            };
+          }
+        }
+      }
+    }
+    else {
+      // Nothing to do, just place the wrapper at offset required.
+      $x_offset = ceil(image_filter_keyword($this->configuration['layout']['x_pos'], $image_width, $wrapper->getWidth()));
+      $y_offset = ceil(image_filter_keyword($this->configuration['layout']['y_pos'], $image_height, $wrapper->getHeight()));
+      $this->info['wrapper_xpos'] = $x_offset + $this->configuration['layout']['x_offset'];
+      $this->info['wrapper_ypos'] = $y_offset + $this->configuration['layout']['y_offset'];
+    }
+
+    // Finally, lay the wrapper over the source image.
+    if (!$image->apply('watermark', [
+        'watermark_image' => $wrapper,
+        'x_offset' => $this->info['wrapper_xpos'],
+        'y_offset' => $this->info['wrapper_ypos'],
+      ])) {
+      return FALSE;
+    }
+
+    return TRUE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function transformDimensions(array &$dimensions, $uri) {
+    // Dimensions are potentially affected only if the effect is set to
+    // autoextend the background image in case of wrapper overflow. Also,
+    // current dimensions must be known.
+    if ($dimensions['width'] && $dimensions['height'] && $this->configuration['layout']['overflow_action'] == 'extend') {
+
+      $this->info['image_width'] = $dimensions['width'];
+      $this->info['image_height'] = $dimensions['height'];
+
+      // Get the text wrapper Image object.
+      if (!$wrapper = $this->getTextWrapper()) {
+        return;
+      }
+
+      // Checks if resizing needed.
+      if ($this->canvasResizeNeeded($wrapper)) {
+        $dimensions['width'] = $this->info['image_width'];
+        $dimensions['height'] = $this->info['image_height'];
+      }
+    }
+  }
+
+  /**
+   * Get the image containing the text.
+   *
+   * This is separated from ::applyEffect() so that it can also be used
+   * by the ::transformDimensions() method.
+   */
+  protected function getTextWrapper() {
+    // If the effect is executed outside of the context of Textimage
+    // (e.g. by the core Image module), then the text_string has not been
+    // pre-processed to translate tokens or apply text conversion. Do it here.
+    $textimage_factory = $this->getTextimageFactory();
+    if (!$textimage_factory || $textimage_factory->getState('building_module') !== 'textimage') {
+      // Replace any tokens in text with run-time values.
+      $this->configuration['text_string'] = $this->token->replace($this->configuration['text_string']);
+
+      // Convert case, if requested.
+      if ($this->configuration['text']['case_format']) {
+        $method_map = [
+          'upper' => 'strtoupper',
+          'lower' => 'strtolower',
+          'ucwords' => 'ucwords',
+          'ucfirst' => 'ucfirst',
+        ];
+        $this->configuration['text_string'] = Unicode::{$method_map[$this->configuration['text']['case_format']]}($this->configuration['text_string']);
+      }
+    }
+
+    // Create the wrapper image object from scratch.
+    $wrapper = $this->imageFactory->get();
+
+    // Return the wrapper built by the toolkit operation.
+    $ret = $wrapper->apply('text_to_wrapper', [
+      'font_uri'                   => $this->configuration['font']['uri'],
+      'font_size'                  => $this->configuration['font']['size'],
+      'font_angle'                 => $this->configuration['font']['angle'],
+      'font_color'                 => $this->configuration['font']['color'],
+      'font_stroke_mode'           => $this->configuration['font']['stroke_mode'],
+      'font_stroke_color'          => $this->configuration['font']['stroke_color'],
+      'font_outline_top'           => $this->configuration['font']['outline_top'],
+      'font_outline_right'         => $this->configuration['font']['outline_right'],
+      'font_outline_bottom'        => $this->configuration['font']['outline_bottom'],
+      'font_outline_left'          => $this->configuration['font']['outline_left'],
+      'font_shadow_x_offset'       => $this->configuration['font']['shadow_x_offset'],
+      'font_shadow_y_offset'       => $this->configuration['font']['shadow_y_offset'],
+      'font_shadow_width'          => $this->configuration['font']['shadow_width'],
+      'font_shadow_height'         => $this->configuration['font']['shadow_height'],
+      'layout_padding_top'         => $this->configuration['layout']['padding_top'],
+      'layout_padding_right'       => $this->configuration['layout']['padding_right'],
+      'layout_padding_bottom'      => $this->configuration['layout']['padding_bottom'],
+      'layout_padding_left'        => $this->configuration['layout']['padding_left'],
+      'layout_x_pos'               => $this->configuration['layout']['x_pos'],
+      'layout_y_pos'               => $this->configuration['layout']['y_pos'],
+      'layout_x_offset'            => $this->configuration['layout']['x_offset'],
+      'layout_y_offset'            => $this->configuration['layout']['y_offset'],
+      'layout_background_color'    => $this->configuration['layout']['background_color'],
+      'layout_overflow_action'     => $this->configuration['layout']['overflow_action'],
+      'text_maximum_width'         => $this->configuration['text']['maximum_width'],
+      'text_fixed_width'           => $this->configuration['text']['fixed_width'],
+      'text_align'                 => $this->configuration['text']['align'],
+      'text_line_spacing'          => $this->configuration['text']['line_spacing'],
+      'text_string'                => $this->configuration['text_string'],
+      'debug_visuals'              => isset($this->configuration['debug_visuals']) ? $this->configuration['debug_visuals'] : FALSE,
+      'canvas_width'               => $this->info['image_width'],
+      'canvas_height'              => $this->info['image_height'],
+    ]);
+
+    return $ret ? $wrapper : NULL;
+  }
+
+  /**
+   * Recalculate background image size.
+   *
+   * When wrapper overflows the original image, and autoextent is set on.
+   */
+  protected function canvasResizeNeeded(ImageInterface $wrapper) {
+
+    $resized = FALSE;
+
+    // Background image dimensions.
+    $image_width = $this->info['image_width'];
+    $image_height = $this->info['image_height'];
+
+    // Wrapper image dimensions.
+    $wrapper_width = $wrapper->getWidth();
+    $wrapper_height = $wrapper->getHeight();
+
+    // Determine wrapper offset, based on placement option.
+    // This is just taking into account the image and wrapper dimensions;
+    // additional offset explicitly specified is considered later.
+    $x_offset = ceil(image_filter_keyword($this->configuration['layout']['x_pos'], $image_width, $wrapper_width));
+    $y_offset = ceil(image_filter_keyword($this->configuration['layout']['y_pos'], $image_height, $wrapper_height));
+
+    // The position of the wrapper, once offset as per explicit
+    // input. Width and height are not relevant for the algorithm,
+    // but would be determined as follows:
+    //  'width' => ($wrapper_width < $image_width) ? $wrapper_width + abs($this->configuration['layout']['x_offset']) : $wrapper_width;
+    //  'height' = ($wrapper_height < $image_height) ? $wrapper_height + abs($this->configuration['layout']['y_offset']) : $wrapper_height;
+    $this->info['wrapper_xpos'] = $x_offset + $this->configuration['layout']['x_offset'];
+    $this->info['wrapper_ypos'] = $y_offset + $this->configuration['layout']['y_offset'];
+
+    // If offset wrapper overflows to the left, background image
+    // will be shifted to the right.
+    if ($this->info['wrapper_xpos'] < 0) {
+      $this->info['image_width'] = $image_width - $this->info['wrapper_xpos'];
+      $this->info['image_xpos'] = -$this->info['wrapper_xpos'];
+      $this->info['wrapper_xpos'] = 0;
+      $this->info['frame_left'] = $this->info['image_width'] - $image_width;
+      $resized = TRUE;
+    }
+
+    // If offset wrapper overflows to the top, background image
+    // will be shifted to the bottom.
+    if ($this->info['wrapper_ypos'] < 0) {
+      $this->info['image_height'] = $image_height - $this->info['wrapper_ypos'];
+      $this->info['image_ypos'] = -$this->info['wrapper_ypos'];
+      $this->info['wrapper_ypos'] = 0;
+      $this->info['frame_top'] = $this->info['image_height'] - $image_height;
+      $resized = TRUE;
+    }
+
+    // If offset wrapper overflows to the right, background image
+    // will be extended to the right.
+    if (($this->info['wrapper_xpos'] + $wrapper_width) > $this->info['image_width']) {
+      $tmp = $this->info['image_width'];
+      $this->info['image_width'] = $this->info['wrapper_xpos'] + $wrapper_width;
+      $this->info['frame_right'] = $this->info['image_width'] - $tmp;
+      $resized = TRUE;
+    }
+
+    // If offset wrapper overflows to the bottom, background image
+    // will be extended to the bottom.
+    if (($this->info['wrapper_ypos'] + $wrapper_height) > $this->info['image_height']) {
+      $tmp = $this->info['image_height'];
+      $this->info['image_height'] = $this->info['wrapper_ypos'] + $wrapper_height;
+      $this->info['frame_bottom'] = $this->info['image_height'] - $tmp;
+      $resized = TRUE;
+    }
+
+    return $resized;
+  }
+
+  /**
+   * Get the stroke mode for the font.
+   *
+   * @return string|null
+   *   The stroke mode.
+   */
+  protected function strokeMode() {
+    if ($this->configuration['font']['stroke_mode'] == 'outline' && ($this->configuration['font']['outline_top'] || $this->configuration['font']['outline_right'] || $this->configuration['font']['outline_bottom'] || $this->configuration['font']['outline_left'])) {
+      return $this->t('Outline');
+    }
+    else if ($this->configuration['font']['stroke_mode'] == 'shadow') {
+      return $this->t('Shadow');
+    }
+    else {
+      return NULL;
+    }
+  }
+
+  /**
+   * Builds a render array with the Text Overlay preview.
+   *
+   * Requires the Textimage module to be installed.
+   *
+   * @param array $data
+   *   An array with the plugin configuration to be used for rendering the
+   *   preview.
+   *
+   * @return array
+   *   A simple array with two elements, indicating:
+   *   - success of the preview build.
+   *   - a render array of the preview, or markup describing the failure if
+   *     the build was unsuccessful.
+   */
+  protected function buildPreviewRender($data) {
+    // If no font file specified, nothing to preview.
+    if (empty($data['font']['uri'])) {
+      return [
+        FALSE,
+        ['#markup' => $this->t("No font specified. Select a font and click on 'Refresh preview'.")],
+      ];
+    }
+    // Need the textimage.factory service to produce the preview image.
+    $textimage_factory = $this->getTextimageFactory();
+    if (!$textimage_factory) {
+      return [
+        FALSE,
+        ['#markup' => $this->t("The Textimage module is not installed. It is not possible to provide the text overlay preview image.")],
+      ];
+    }
+    $data['layout']['x_pos'] = 'center';
+    $data['layout']['y_pos'] = 'center';
+    $data['layout']['x_offset'] = 0;
+    $data['layout']['y_offset'] = 0;
+    $data['layout']['overflow_action'] = 'extend';
+    $data['layout']['extended_color'] = NULL  ;
+    $data['debug_visuals'] = $data['preview_bar']['debug_visuals'];
+    try {
+      $textimage = $textimage_factory->get()
+        ->setEffects([
+          ['id' => 'image_effects_text_overlay', 'data' => $data],
+        ])
+        ->setTemporary(TRUE)
+        ->process([$data['text_string']])
+        ->buildImage();
+      $render = [
+        '#theme' => 'textimage_formatter',
+        '#uri' => $textimage->getUri(),
+        '#width' => $textimage->getWidth(),
+        '#height' => $textimage->getHeight(),
+        '#title' => t('Text overlay preview'),
+        '#alt' => t('Text overlay preview.'),
+      ];
+      $textimage->getBubbleableMetadata()->applyTo($render);
+      return [
+        TRUE,
+        $render,
+      ];
+    }
+    catch (\Exception $e) {
+      return [
+        FALSE,
+        ['#markup' => $this->t("Could not build a preview of the text overlay.")],
+      ];
+    }
+  }
+}
diff --git a/src/Plugin/ImageToolkit/Operation/DrawEllipseTrait.php b/src/Plugin/ImageToolkit/Operation/DrawEllipseTrait.php
new file mode 100644
index 0000000..97dc3e3
--- /dev/null
+++ b/src/Plugin/ImageToolkit/Operation/DrawEllipseTrait.php
@@ -0,0 +1,38 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\image_effects\Plugin\ImageToolkit\Operation\DrawEllipseTrait.
+ */
+
+namespace Drupal\image_effects\Plugin\ImageToolkit\Operation;
+
+/**
+ * Base trait for image_effects DrawEllipse operations.
+ */
+trait DrawEllipseTrait {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function arguments() {
+    return array(
+      'cx' => array(
+        'description' => 'x-coordinate of the center.',
+      ),
+      'cy' => array(
+        'description' => 'y-coordinate of the center.',
+      ),
+      'width' => array(
+        'description' => 'The ellipse width.',
+      ),
+      'height' => array(
+        'description' => 'The ellipse height.',
+      ),
+      'color' => array(
+        'description' => 'The fill color, in RGBA format.',
+      ),
+    );
+  }
+
+}
diff --git a/src/Plugin/ImageToolkit/Operation/DrawLineTrait.php b/src/Plugin/ImageToolkit/Operation/DrawLineTrait.php
new file mode 100644
index 0000000..6763e77
--- /dev/null
+++ b/src/Plugin/ImageToolkit/Operation/DrawLineTrait.php
@@ -0,0 +1,38 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\image_effects\Plugin\ImageToolkit\Operation\DrawLineTrait.
+ */
+
+namespace Drupal\image_effects\Plugin\ImageToolkit\Operation;
+
+/**
+ * Base trait for image_effects DrawLine operations.
+ */
+trait DrawLineTrait {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function arguments() {
+    return array(
+      'x1' => array(
+        'description' => 'x-coordinate for first point.',
+      ),
+      'y1' => array(
+        'description' => 'y-coordinate for first point.',
+      ),
+      'x2' => array(
+        'description' => 'x-coordinate for second point.',
+      ),
+      'y2' => array(
+        'description' => 'y-coordinate for second point.',
+      ),
+      'color' => array(
+        'description' => 'The line color, in RGBA format.',
+      ),
+    );
+  }
+
+}
diff --git a/src/Plugin/ImageToolkit/Operation/FontOperationTrait.php b/src/Plugin/ImageToolkit/Operation/FontOperationTrait.php
new file mode 100644
index 0000000..b4ab4cc
--- /dev/null
+++ b/src/Plugin/ImageToolkit/Operation/FontOperationTrait.php
@@ -0,0 +1,85 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\image_effects\Plugin\ImageToolkit\Operation\FontOperationTrait.
+ */
+
+namespace Drupal\image_effects\Plugin\ImageToolkit\Operation;
+
+use Drupal\Core\StreamWrapper\LocalStream;
+
+/**
+ * Base trait for image toolkit operations that require font handling.
+ */
+trait FontOperationTrait {
+
+  /**
+   * The stream wrapper manager service.
+   *
+   * @var \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface
+   */
+  protected $streamWrapperManagerForFontHandling;
+
+  /**
+   * An array of resolved font file URIs.
+   *
+   * @var array
+   */
+  static $fontPaths = [];
+
+  /**
+   * Return the real path of the specified file.
+   *
+   * @param string $uri
+   *   An URI.
+   *
+   * @return string
+   *   The local path of the file.
+   */
+  protected function getRealFontPath($uri) {
+    $uri_wrapper = $this->getStreamWrapperManagerForFontHandling()->getViaUri($uri);
+    if ($uri_wrapper instanceof LocalStream) {
+      return $uri_wrapper->realpath();
+    }
+    else {
+      return is_file($uri) ? $uri : NULL;
+    }
+  }
+
+  /**
+   * Return the path of the font file.
+   *
+   * @param string $font_uri
+   *   The font URI.
+   *
+   * @return string
+   *   The local path of the font file.
+   */
+  protected function getFontPath($font_uri) {
+    if (!$font_uri) {
+      throw new \InvalidArgumentException('Font file not specified');
+    }
+    if (!isset(static::$fontPaths[$font_uri])) {
+      if (!$ret = $this->getRealFontPath($font_uri)) {
+        throw new \InvalidArgumentException("Could not find the font file {$font_uri}");
+      }
+      static::$fontPaths[$font_uri] = $ret;
+    }
+    return static::$fontPaths[$font_uri];
+  }
+
+  /**
+   * Returns the stream wrapper manager service.
+   *
+   * @return \Drupal\Core\StreamWrapper\streamWrapperManagerInterface
+   *   The stream wrapper manager service.
+   */
+  protected function getStreamWrapperManagerForFontHandling() {
+    if (!$this->streamWrapperManagerForFontHandling) {
+      $this->streamWrapperManagerForFontHandling = \Drupal::service('stream_wrapper_manager');
+    }
+    return $this->streamWrapperManagerForFontHandling;
+  }
+
+}
diff --git a/src/Plugin/ImageToolkit/Operation/ReplaceImageTrait.php b/src/Plugin/ImageToolkit/Operation/ReplaceImageTrait.php
new file mode 100644
index 0000000..28e86b8
--- /dev/null
+++ b/src/Plugin/ImageToolkit/Operation/ReplaceImageTrait.php
@@ -0,0 +1,44 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\image_effects\Plugin\ImageToolkit\Operation\ReplaceImageTrait.
+ */
+
+namespace Drupal\image_effects\Plugin\ImageToolkit\Operation;
+
+use Drupal\Core\Image\ImageInterface;
+
+/**
+ * Base trait for replace image operations.
+ */
+trait ReplaceImageTrait {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function arguments() {
+    return array(
+      'replacement_image' => array(
+        'description' => 'The image to be used to replace current one.',
+      ),
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function validateArguments(array $arguments) {
+    // Ensure replacement_image is an expected ImageInterface object.
+    if (!$arguments['replacement_image'] instanceof ImageInterface) {
+      throw new \InvalidArgumentException("Replacement image passed to the 'replace_image' operation is invalid");
+    }
+    // Ensure replacement_image is a valid image.
+    if (!$arguments['replacement_image']->isValid()) {
+      $source = $arguments['replacement_image']->getSource();
+      throw new \InvalidArgumentException("Invalid image at {$source}");
+    }
+    return $arguments;
+  }
+
+}
diff --git a/src/Plugin/ImageToolkit/Operation/TextOverlayTrait.php b/src/Plugin/ImageToolkit/Operation/TextOverlayTrait.php
new file mode 100644
index 0000000..18d95c0
--- /dev/null
+++ b/src/Plugin/ImageToolkit/Operation/TextOverlayTrait.php
@@ -0,0 +1,81 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\image_effects\Plugin\ImageToolkit\Operation\TextOverlayTrait.
+ */
+
+namespace Drupal\image_effects\Plugin\ImageToolkit\Operation;
+
+/**
+ * Base trait for image_effects TextOverlay operations.
+ */
+trait TextOverlayTrait {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function arguments() {
+    return array(
+      'font_uri' => [
+        'description' => 'Font file URI.',
+      ],
+      'font_size' => [
+        'description' => 'Font size.',
+      ],
+      'font_angle' => [
+        'description' => 'Font rotation angle.',
+      ],
+      'font_color' => [
+        'description' => 'Font color.',
+      ],
+      'font_stroke_mode' => [
+        'description' => 'Font stroke mode.',
+      ],
+      'font_stroke_color' => [
+        'description' => 'Font stroke color.',
+      ],
+      'font_outline_top' => [
+        'description' => 'Font outline top in pixels.',
+      ],
+      'font_outline_right' => [
+        'description' => 'Font outline right in pixels.',
+      ],
+      'font_outline_bottom' => [
+        'description' => 'Font outline bottom in pixels.',
+      ],
+      'font_outline_left' => [
+        'description' => 'Font outline left in pixels.',
+      ],
+      'font_shadow_x_offset' => [
+        'description' => 'Font shadow x offset in pixels.',
+      ],
+      'font_shadow_y_offset' => [
+        'description' => 'Font shadow y offset in pixels.',
+      ],
+      'font_shadow_width' => [
+        'description' => 'Font shadow width in pixels.',
+      ],
+      'font_shadow_height' => [
+        'description' => 'Font shadow height in pixels.',
+      ],
+      'text' => array(
+        'description' => 'The text string in UTF-8 encoding.',
+      ),
+      'basepoint' => array(
+        'description' => 'The basepoint of the text to be overlaid.',
+      ),
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function validateArguments(array $arguments) {
+    if (empty($arguments['font_uri'])) {
+      throw new \InvalidArgumentException("No font file URI passed to the 'text_overlay' operation");
+    }
+    return $arguments;
+  }
+
+}
diff --git a/src/Plugin/ImageToolkit/Operation/TextToWrapperTrait.php b/src/Plugin/ImageToolkit/Operation/TextToWrapperTrait.php
new file mode 100644
index 0000000..85a1b10
--- /dev/null
+++ b/src/Plugin/ImageToolkit/Operation/TextToWrapperTrait.php
@@ -0,0 +1,129 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\image_effects\Plugin\ImageToolkit\Operation\TextToWrapperTrait.
+ */
+
+namespace Drupal\image_effects\Plugin\ImageToolkit\Operation;
+
+/**
+ * Base trait for Text Overlay text-to-wrapper operations.
+ */
+trait TextToWrapperTrait {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function arguments() {
+    return [
+      'font_uri' => [
+        'description' => 'Font file URI.',
+      ],
+      'font_size' => [
+        'description' => 'Font size.',
+      ],
+      'font_angle' => [
+        'description' => 'Font rotation angle.',
+      ],
+      'font_color' => [
+        'description' => 'Font color.',
+      ],
+      'font_stroke_mode' => [
+        'description' => 'Font stroke mode.',
+      ],
+      'font_stroke_color' => [
+        'description' => 'Font stroke color.',
+      ],
+      'font_outline_top' => [
+        'description' => 'Font outline top in pixels.',
+      ],
+      'font_outline_right' => [
+        'description' => 'Font outline right in pixels.',
+      ],
+      'font_outline_bottom' => [
+        'description' => 'Font outline bottom in pixels.',
+      ],
+      'font_outline_left' => [
+        'description' => 'Font outline left in pixels.',
+      ],
+      'font_shadow_x_offset' => [
+        'description' => 'Font shadow x offset in pixels.',
+      ],
+      'font_shadow_y_offset' => [
+        'description' => 'Font shadow y offset in pixels.',
+      ],
+      'font_shadow_width' => [
+        'description' => 'Font shadow width in pixels.',
+      ],
+      'font_shadow_height' => [
+        'description' => 'Font shadow height in pixels.',
+      ],
+      'layout_padding_top' => [
+        'description' => 'Layout top padding in pixels.',
+      ],
+      'layout_padding_right' => [
+        'description' => 'Layout right padding in pixels.',
+      ],
+      'layout_padding_bottom' => [
+        'description' => 'Layout bottom padding in pixels.',
+      ],
+      'layout_padding_left' => [
+        'description' => 'Layout left padding in pixels.',
+      ],
+      'layout_x_pos' => [
+        'description' => 'Layout horizontal position.',
+      ],
+      'layout_y_pos' => [
+        'description' => 'Layout vertical position.',
+      ],
+      'layout_x_offset' => [
+        'description' => 'Layout horizontal offset.',
+      ],
+      'layout_y_offset' => [
+        'description' => 'Layout vertical offset.',
+      ],
+      'layout_background_color' => [
+        'description' => 'Layout background color.',
+      ],
+      'layout_overflow_action' => [
+        'description' => 'Layout overflow action.',
+      ],
+      'text_maximum_width' => [
+        'description' => 'Maximum width, in pixels.',
+      ],
+      'text_fixed_width' => [
+        'description' => 'Specifies if the width is fixed.',
+      ],
+      'text_align' => [
+        'description' => 'Alignment of the text lines (left/right/center).',
+      ],
+      'text_line_spacing' => [
+        'description' => 'Space between text lines (leading), pixels.',
+      ],
+      'text_string' => [
+        'description' => 'Actual text string to be placed on the image.',
+      ],
+      'canvas_width' => [
+        'description' => 'Width of the underlying image.',
+      ],
+      'canvas_height' => [
+        'description' => 'Height of the underlying image.',
+      ],
+      'debug_visuals' => [
+        'description' => 'Indicates if text bounding boxes need to be visualised. Only used in debugging.',
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function validateArguments(array $arguments) {
+    if (empty($arguments['font_uri'])) {
+      throw new \InvalidArgumentException("No font file URI passed to the 'text_to_wrapper' operation");
+    }
+    return $arguments;
+  }
+
+}
diff --git a/src/Plugin/ImageToolkit/Operation/gd/DrawEllipse.php b/src/Plugin/ImageToolkit/Operation/gd/DrawEllipse.php
new file mode 100644
index 0000000..c117f02
--- /dev/null
+++ b/src/Plugin/ImageToolkit/Operation/gd/DrawEllipse.php
@@ -0,0 +1,38 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\image_effects\Plugin\ImageToolkit\Operation\gd\DrawEllipse.
+ */
+
+namespace Drupal\image_effects\Plugin\ImageToolkit\Operation\gd;
+
+use Drupal\system\Plugin\ImageToolkit\Operation\gd\GDImageToolkitOperationBase;
+use Drupal\image_effects\Plugin\ImageToolkit\Operation\DrawEllipseTrait;
+use Drupal\image_effects\Plugin\ImageToolkit\Operation\gd\GDOperationTrait;
+
+/**
+ * Defines GD2 draw ellipse operation.
+ *
+ * @ImageToolkitOperation(
+ *   id = "image_effects_gd_draw_ellipse",
+ *   toolkit = "gd",
+ *   operation = "draw_ellipse",
+ *   label = @Translation("Draw ellipse"),
+ *   description = @Translation("Draws on the image an ellipse of the specified color.")
+ * )
+ */
+class DrawEllipse extends GDImageToolkitOperationBase {
+
+  use GDOperationTrait;
+  use DrawEllipseTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function execute(array $arguments) {
+    $color = $this->allocateColorFromRgba($arguments['color']);
+    return imagefilledellipse($this->getToolkit()->getResource(), $arguments['cx'], $arguments['cy'], $arguments['width'], $arguments['height'], $color);
+  }
+
+}
diff --git a/src/Plugin/ImageToolkit/Operation/gd/DrawLine.php b/src/Plugin/ImageToolkit/Operation/gd/DrawLine.php
new file mode 100644
index 0000000..36bc28c
--- /dev/null
+++ b/src/Plugin/ImageToolkit/Operation/gd/DrawLine.php
@@ -0,0 +1,38 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\image_effects\Plugin\ImageToolkit\Operation\gd\DrawLine.
+ */
+
+namespace Drupal\image_effects\Plugin\ImageToolkit\Operation\gd;
+
+use Drupal\system\Plugin\ImageToolkit\Operation\gd\GDImageToolkitOperationBase;
+use Drupal\image_effects\Plugin\ImageToolkit\Operation\DrawLineTrait;
+use Drupal\image_effects\Plugin\ImageToolkit\Operation\gd\GDOperationTrait;
+
+/**
+ * Defines GD2 draw line operation.
+ *
+ * @ImageToolkitOperation(
+ *   id = "image_effects_gd_draw_line",
+ *   toolkit = "gd",
+ *   operation = "draw_line",
+ *   label = @Translation("Draw line"),
+ *   description = @Translation("Draws on the image a line of the specified color.")
+ * )
+ */
+class DrawLine extends GDImageToolkitOperationBase {
+
+  use GDOperationTrait;
+  use DrawLineTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function execute(array $arguments) {
+    $color = $this->allocateColorFromRgba($arguments['color']);
+    return imageline($this->getToolkit()->getResource(), $arguments['x1'], $arguments['y1'], $arguments['x2'], $arguments['y2'], $color);
+  }
+
+}
diff --git a/src/Plugin/ImageToolkit/Operation/gd/ReplaceImage.php b/src/Plugin/ImageToolkit/Operation/gd/ReplaceImage.php
new file mode 100644
index 0000000..78bbb6b
--- /dev/null
+++ b/src/Plugin/ImageToolkit/Operation/gd/ReplaceImage.php
@@ -0,0 +1,53 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\image_effects\Plugin\ImageToolkit\Operation\gd\ReplaceImage.
+ */
+
+namespace Drupal\image_effects\Plugin\ImageToolkit\Operation\gd;
+
+use Drupal\system\Plugin\ImageToolkit\Operation\gd\GDImageToolkitOperationBase;
+use Drupal\image_effects\Plugin\ImageToolkit\Operation\ReplaceImageTrait;
+
+/**
+ * Defines GD2 image replace operation.
+ *
+ * @ImageToolkitOperation(
+ *   id = "image_effects_gd_replace_image",
+ *   toolkit = "gd",
+ *   operation = "replace_image",
+ *   label = @Translation("Replace image"),
+ *   description = @Translation("Replace the current image with another one.")
+ * )
+ */
+class ReplaceImage extends GDImageToolkitOperationBase {
+
+  use ReplaceImageTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function execute(array $arguments) {
+    // Prepare the new image.
+    $data = [
+      'width' => $arguments['replacement_image']->getWidth(),
+      'height' => $arguments['replacement_image']->getHeight(),
+      'extension' => image_type_to_extension($arguments['replacement_image']->getToolkit()->getType(), FALSE),
+      'transparent_color' => $arguments['replacement_image']->getToolkit()->getTransparentColor(),
+      'is_temp' => FALSE,
+    ];
+    if (!$this->getToolkit()->apply('create_new', $data)) {
+      return FALSE;
+    }
+
+    // Overlay replacement image.
+    $data = [
+      'watermark_image' => $arguments['replacement_image'],
+      'x_offset' => 0,
+      'y_offset' => 0,
+    ];
+    return $this->getToolkit()->apply('watermark', $data);
+  }
+
+}
diff --git a/src/Plugin/ImageToolkit/Operation/gd/TextOverlay.php b/src/Plugin/ImageToolkit/Operation/gd/TextOverlay.php
new file mode 100644
index 0000000..59c5fb1
--- /dev/null
+++ b/src/Plugin/ImageToolkit/Operation/gd/TextOverlay.php
@@ -0,0 +1,101 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\image_effects\Plugin\ImageToolkit\Operation\gd\TextOverlay.
+ */
+
+namespace Drupal\image_effects\Plugin\ImageToolkit\Operation\gd;
+
+use Drupal\image_effects\Component\ColorUtility;
+use Drupal\system\Plugin\ImageToolkit\Operation\gd\GDImageToolkitOperationBase;
+use Drupal\image_effects\Plugin\ImageToolkit\Operation\FontOperationTrait;
+use Drupal\image_effects\Plugin\ImageToolkit\Operation\TextOverlayTrait;
+use Drupal\image_effects\Plugin\ImageToolkit\Operation\gd\GDOperationTrait;
+
+/**
+ * Defines GD2 text overlay operation.
+ *
+ * @ImageToolkitOperation(
+ *   id = "image_effects_gd_text_overlay",
+ *   toolkit = "gd",
+ *   operation = "text_overlay",
+ *   label = @Translation("Text overlay"),
+ *   description = @Translation("Overlays a given text into the image.")
+ * )
+ */
+class TextOverlay extends GDImageToolkitOperationBase {
+
+  use FontOperationTrait;
+  use TextOverlayTrait;
+  use GDOperationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function execute(array $arguments) {
+    $font_file = $this->getFontPath($arguments['font_uri']);
+
+    // Overlays the text outline/shadow, if required.
+    // Credit to John Ciacia.
+    // @see http://www.johnciacia.com/2010/01/04/using-php-and-gd-to-add-border-to-text/
+    $outline = $shadow = FALSE;
+    if ($arguments['font_stroke_mode'] == 'outline' && ($arguments['font_outline_top'] || $arguments['font_outline_right'] || $arguments['font_outline_bottom'] || $arguments['font_outline_left']) && $arguments['font_stroke_color']) {
+      $outline = TRUE;
+    }
+    elseif ($arguments['font_stroke_mode'] == 'shadow' && ($arguments['font_shadow_x_offset'] || $arguments['font_shadow_y_offset'] || $arguments['font_shadow_width'] || $arguments['font_shadow_height']) && $arguments['font_stroke_color']) {
+      $shadow = TRUE;
+    }
+    if ($outline || $shadow) {
+      $stroke_color = $this->allocateColorFromRgba($arguments['font_stroke_color']);
+      if ($outline) {
+        $stroke_x_pos = $arguments['basepoint'][0];
+        $stroke_y_pos = $arguments['basepoint'][1];
+        $stroke_top = $arguments['font_outline_top'];
+        $stroke_right = $arguments['font_outline_right'];
+        $stroke_bottom = $arguments['font_outline_bottom'];
+        $stroke_left = $arguments['font_outline_left'];
+      }
+      elseif ($shadow) {
+        $stroke_x_pos = $arguments['basepoint'][0] + $arguments['font_shadow_x_offset'];
+        $stroke_y_pos = $arguments['basepoint'][1] + $arguments['font_shadow_y_offset'];
+        $stroke_top = 0;
+        $stroke_right = $arguments['font_shadow_width'];
+        $stroke_bottom = $arguments['font_shadow_height'];
+        $stroke_left = 0;
+      }
+      for ($c1 = ($stroke_x_pos - abs($stroke_left)); $c1 <= ($stroke_x_pos + abs($stroke_right)); $c1++) {
+        for ($c2 = ($stroke_y_pos - abs($stroke_top)); $c2 <= ($stroke_y_pos + abs($stroke_bottom)); $c2++) {
+          $bg = imagettftext(
+            $this->getToolkit()->getResource(),
+            $arguments['font_size'],
+            -$arguments['font_angle'],
+            $c1,
+            $c2,
+            $stroke_color,
+            $font_file,
+            $arguments['text']
+          );
+          if ($bg == FALSE) {
+            return FALSE;
+          }
+        }
+      }
+    }
+
+    // Overlays the text.
+    imagettftext(
+      $this->getToolkit()->getResource(),
+      $arguments['font_size'],
+      -$arguments['font_angle'],
+      $arguments['basepoint'][0],
+      $arguments['basepoint'][1],
+      $this->allocateColorFromRgba($arguments['font_color']),
+      $font_file,
+      $arguments['text']
+    );
+
+    return TRUE;
+  }
+
+}
diff --git a/src/Plugin/ImageToolkit/Operation/gd/TextToWrapper.php b/src/Plugin/ImageToolkit/Operation/gd/TextToWrapper.php
new file mode 100644
index 0000000..305c4a6
--- /dev/null
+++ b/src/Plugin/ImageToolkit/Operation/gd/TextToWrapper.php
@@ -0,0 +1,499 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\image_effects\Plugin\ImageToolkit\Operation\gd\TextToWrapper.
+ */
+
+namespace Drupal\image_effects\Plugin\ImageToolkit\Operation\gd;
+
+use Drupal\Component\Utility\Unicode;
+use Drupal\Core\Image\ImageInterface;
+use Drupal\system\Plugin\ImageToolkit\Operation\gd\GDImageToolkitOperationBase;
+use Drupal\image_effects\Component\ColorUtility;
+use Drupal\image_effects\Component\PositionedRectangle;
+use Drupal\image_effects\Component\TextUtility;
+use Drupal\image_effects\Plugin\ImageToolkit\Operation\FontOperationTrait;
+use Drupal\image_effects\Plugin\ImageToolkit\Operation\TextToWrapperTrait;
+use Drupal\image_effects\Plugin\ImageToolkit\Operation\gd\GDOperationTrait;
+
+/**
+ * Defines GD Text Overlay text-to-wrapper operation.
+ *
+ * @ImageToolkitOperation(
+ *   id = "image_effects_gd_text_to_wrapper",
+ *   toolkit = "gd",
+ *   operation = "text_to_wrapper",
+ *   label = @Translation("Overlays text over a wrapper image"),
+ *   description = @Translation("Overlays text over a GD resource.")
+ * )
+ */
+class TextToWrapper extends GDImageToolkitOperationBase {
+
+  use FontOperationTrait;
+  use GDOperationTrait;
+  use TextToWrapperTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function execute(array $arguments) {
+    // Determine if outline/shadow is required.
+    $outline = $shadow = FALSE;
+    if ($arguments['font_stroke_mode'] == 'outline' && ($arguments['font_outline_top'] || $arguments['font_outline_right'] || $arguments['font_outline_bottom'] || $arguments['font_outline_left']) && $arguments['font_stroke_color']) {
+      $outline = TRUE;
+    }
+    elseif ($arguments['font_stroke_mode'] == 'shadow' && ($arguments['font_shadow_x_offset'] || $arguments['font_shadow_y_offset'] || $arguments['font_shadow_width'] || $arguments['font_shadow_height']) && $arguments['font_stroke_color']) {
+      $shadow = TRUE;
+    }
+
+    // Add stroke to padding to ensure inner box includes entire font space.
+    if ($outline) {
+      $arguments['layout_padding_top'] += $arguments['font_outline_top'];
+      $arguments['layout_padding_right'] += $arguments['font_outline_right'];
+      $arguments['layout_padding_bottom'] += $arguments['font_outline_bottom'];
+      $arguments['layout_padding_left'] += $arguments['font_outline_left'];
+    }
+    elseif ($shadow) {
+      $arguments['layout_padding_top'] += ($arguments['font_shadow_y_offset'] < 0 ? -$arguments['font_shadow_y_offset'] : 0);
+      $arguments['layout_padding_right'] += ($arguments['font_shadow_x_offset'] > 0 ? $arguments['font_shadow_x_offset'] : 0);
+      $arguments['layout_padding_bottom'] += ($arguments['font_shadow_y_offset'] > 0 ? $arguments['font_shadow_y_offset'] : 0);
+      $arguments['layout_padding_left'] += ($arguments['font_shadow_x_offset'] < 0 ? -$arguments['font_shadow_x_offset'] : 0);
+      $shadow_width = ($arguments['font_shadow_x_offset'] != 0) ? $arguments['font_shadow_width'] + 1 : $arguments['font_shadow_width'];
+      $shadow_height = ($arguments['font_shadow_y_offset'] != 0) ? $arguments['font_shadow_height'] + 1 : $arguments['font_shadow_height'];
+      $net_right = $shadow_width + ($arguments['font_shadow_x_offset'] >= 0 ? 0 : $arguments['font_shadow_x_offset']);
+      $arguments['layout_padding_right'] += ($net_right > 0 ? $net_right : 0);
+      $net_bottom = $shadow_height + ($arguments['font_shadow_y_offset'] >= 0 ? 0 : $arguments['font_shadow_y_offset']);
+      $arguments['layout_padding_bottom'] += ($net_bottom > 0 ? $net_bottom : 0);
+    }
+
+    // Perform text wrapping, if necessary.
+    if ($arguments['text_maximum_width'] > 0) {
+      $arguments['text_string'] = $this->wrapText(
+        $arguments['text_string'],
+        $arguments['font_size'],
+        $arguments['font_uri'],
+        $arguments['text_maximum_width'] - $arguments['layout_padding_left'] - $arguments['layout_padding_right'] - 1,
+        $arguments['text_align']
+      );
+    }
+
+    // Load text lines to array elements.
+    $text_lines = explode("\n", $arguments['text_string']);
+    $num_lines = count($text_lines);
+
+    // Calculate bounding boxes.
+    // ---------------------------------------
+    // Inner box   - the exact bounding box of the text.
+    // Outer box   - the box where the inner box is - can be different because
+    //               of padding.
+    // Wrapper     - the canvas where the outer box is laid.
+    // ---------------------------------------
+
+    // Get inner box details, for horizontal text, unpadded.
+    // If fixed width, set to configuration, otherwise get width from the font
+    // bounding box.
+    if ($arguments['text_fixed_width'] && !empty($arguments['text_maximum_width'])) {
+      $inner_box_width = $arguments['text_maximum_width'] - $arguments['layout_padding_left'] - $arguments['layout_padding_right'];
+    }
+    else {
+      $inner_box_width = $this->getTextWidth($arguments['text_string'], $arguments['font_size'], $arguments['font_uri']);
+    }
+
+    // Determine line height.
+    $height_info = $this->getTextHeightInfo($arguments['font_size'], $arguments['font_uri']);
+    $line_height = $height_info['height'];
+
+    // Manage leading (line spacing), adding total line spacing to height.
+    $inner_box_height = ($height_info['height'] * $num_lines) + ($arguments['text_line_spacing'] * ($num_lines - 1));
+
+    // Get outer box.
+    $outer_rect = new PositionedRectangle($inner_box_width + $arguments['layout_padding_right'] + $arguments['layout_padding_left'], $inner_box_height + $arguments['layout_padding_top'] + $arguments['layout_padding_bottom']);
+    $outer_rect->rotate($arguments['font_angle']);
+    $outer_rect->translate($outer_rect->getRotationOffset());
+
+    // Get inner box.
+    $inner_rect = new PositionedRectangle($inner_box_width, $inner_box_height);
+    $inner_rect->translate([$arguments['layout_padding_left'], $arguments['layout_padding_top']]);
+    $inner_rect->rotate($arguments['font_angle']);
+    $inner_rect->translate($outer_rect->getRotationOffset());
+
+    // Set image dimensions to allow fitting the text. Explicitly setting
+    // extension to 'png' to ensure wrapper is full transparent alpha channel
+    // enabled.
+    $data = [
+      'width' => $outer_rect->getBoundingWidth(),
+      'height' => $outer_rect->getBoundingHeight(),
+      'extension' => 'png',
+      'is_temp' => FALSE,
+    ];
+    if (!$this->getToolkit()->apply('create_new', $data)) {
+      return FALSE;
+    }
+
+    // Draw and fill the outer text box, if required.
+    if ($arguments['layout_background_color']) {
+      $data_rectangle = array(
+        'rectangle' => $outer_rect,
+        'fill_color' => $arguments['layout_background_color'],
+      );
+      $this->getToolkit()->apply('draw_rectangle', $data_rectangle);
+    }
+
+    // In debug mode, visually display the text boxes.
+    if ($arguments['debug_visuals']) {
+      // Inner box.
+      $data = array(
+        'rectangle' => $inner_rect,
+        'border_color' => $arguments['layout_background_color'] ?: '#FFFFFF',
+        'border_color_luma' => TRUE,
+      );
+      $this->getToolkit()->apply('draw_rectangle', $data);
+      // Outer box.
+      $data = array(
+        'rectangle' => $outer_rect,
+        'border_color' => $arguments['layout_background_color'] ?: '#FFFFFF',
+        'border_color_luma' => TRUE,
+      );
+      $this->getToolkit()->apply('draw_rectangle', $data);
+      // Wrapper.
+      $data = array(
+        'rectangle' => new PositionedRectangle($this->getToolkit()->getWidth(), $this->getToolkit()->getHeight()),
+        'border_color' => '#000000',
+      );
+      $this->getToolkit()->apply('draw_rectangle', $data);
+    }
+
+    // Process each of the text lines.
+    $current_y = 0;
+    foreach ($text_lines as $text_line) {
+      // This text line's width.
+      $text_line_width = $this->getTextWidth($text_line, $arguments['font_size'], $arguments['font_uri']);
+      $text_line_rect = new PositionedRectangle($text_line_width, $line_height);
+      $text_line_rect->setPoint('basepoint', $height_info['basepoint']);
+
+      // Manage text alignment within the line.
+      $x_delta = $inner_rect->getWidth() - $text_line_rect->getWidth();
+      $current_y += $line_height;
+      switch ($arguments['text_align']) {
+        case 'center':
+          $x_offset = round($x_delta / 2);
+          break;
+
+        case 'right':
+          $x_offset = $x_delta;
+          break;
+
+        case 'left':
+        default:
+          $x_offset = 0;
+          break;
+
+      }
+
+      // Get details for the rotated/translated text line box.
+      $text_line_rect->translate([$arguments['layout_padding_left'] + $x_offset, $arguments['layout_padding_top'] + $current_y - $line_height]);
+      $text_line_rect->rotate($arguments['font_angle']);
+      $text_line_rect->translate($outer_rect->getRotationOffset());
+
+      // Overlay the text onto the image.
+      $data = array(
+        'text'                     => $text_line,
+        'basepoint'                => $text_line_rect->getPoint('basepoint'),
+        'font_uri'                 => $arguments['font_uri'],
+        'font_size'                => $arguments['font_size'],
+        'font_angle'               => $arguments['font_angle'],
+        'font_color'               => $arguments['font_color'],
+        'font_stroke_mode'         => $arguments['font_stroke_mode'],
+        'font_stroke_color'        => $arguments['font_stroke_color'],
+        'font_outline_top'         => $arguments['font_outline_top'],
+        'font_outline_right'       => $arguments['font_outline_right'],
+        'font_outline_bottom'      => $arguments['font_outline_bottom'],
+        'font_outline_left'        => $arguments['font_outline_left'],
+        'font_shadow_x_offset'     => $arguments['font_shadow_x_offset'],
+        'font_shadow_y_offset'     => $arguments['font_shadow_y_offset'],
+        'font_shadow_width'        => $arguments['font_shadow_width'],
+        'font_shadow_height'       => $arguments['font_shadow_height'],
+      );
+      $this->getToolkit()->apply('text_overlay', $data);
+
+      // In debug mode, display a polygon enclosing the text line.
+      if ($arguments['debug_visuals']) {
+        $this->drawDebugBox($text_line_rect, $arguments['layout_background_color'], TRUE);
+      }
+
+      // Add interline spacing (leading) before next iteration.
+      $current_y += $arguments['text_line_spacing'];
+    }
+
+    // Finalise image.
+    imagealphablending($this->getToolkit()->getResource(), TRUE);
+    imagesavealpha($this->getToolkit()->getResource(), TRUE);
+
+    // Resize the wrapper if needed.
+    if ($arguments['layout_overflow_action'] == 'scaletext') {
+      $this->resizeWrapper($arguments);
+    }
+
+    return TRUE;
+  }
+
+  /**
+   * Resizes the text wrapping image.
+   *
+   * @param array $arguments
+   *   An associative array of arguments.
+   */
+  protected function resizeWrapper(array $arguments) {
+    // Wrapper image dimensions.
+    $original_wrapper_width = $this->getToolkit()->getWidth();
+    $original_wrapper_height = $this->getToolkit()->getHeight();
+
+    // Determine wrapper offset, based on placement option and direct
+    // offset indicated in settings.
+    $wrapper_xpos = ceil(image_filter_keyword($arguments['layout_x_pos'], $arguments['canvas_width'], $original_wrapper_width)) + $arguments['layout_x_offset'];
+    $wrapper_ypos = ceil(image_filter_keyword($arguments['layout_y_pos'], $arguments['canvas_height'], $original_wrapper_height)) + $arguments['layout_y_offset'];
+
+    // Position of wrapper's bottom right point.
+    $xc_pos = $wrapper_xpos + $original_wrapper_width;
+    $yc_pos = $wrapper_ypos + $original_wrapper_height;
+
+    // Redetermine offset wrapper position and size based on
+    // background image size.
+    $wrapper_xpos = max(0, $wrapper_xpos);
+    $wrapper_ypos = max(0, $wrapper_ypos);
+    $xc_pos = min($arguments['canvas_width'], $xc_pos);
+    $yc_pos = min($arguments['canvas_height'], $yc_pos);
+    $wrapper_width = $xc_pos - $wrapper_xpos;
+    $wrapper_height = $yc_pos - $wrapper_ypos;
+
+    // If negative width/height, then the wrapper is totally
+    // overflowing the background, and we cannot resize it.
+    if ($wrapper_width < 0 || $wrapper_height < 0) {
+      return;
+    }
+
+    // Determine if scaling needed. Take the side that is shrinking
+    // most.
+    $width_resize_index = $wrapper_width / $original_wrapper_width;
+    $height_resize_index = $wrapper_height / $original_wrapper_height;
+    if ($width_resize_index < 1 || $height_resize_index < 1) {
+      if ($width_resize_index < $height_resize_index) {
+        $wrapper_height = NULL;
+      }
+      else {
+        $wrapper_width = NULL;
+      }
+      $this->getToolkit()->apply('scale', [
+        'width' => $wrapper_width,
+        'height' => $wrapper_height,
+      ]);
+    }
+  }
+
+  /**
+   * Display a polygon enclosing the text line, and conspicuous points.
+   *
+   * Credit to Ruquay K Calloway
+   *
+   * @param \Drupal\image_effects\Component\PositionedRectangle $rect
+   *   A PositionedRectangle object, including basepoint.
+   * @param string $rgba
+   *   RGBA color of the rectangle.
+   * @param bool $luma
+   *   if TRUE, convert RGBA to best match using luma.
+   *
+   * @see http://ruquay.com/sandbox/imagettf
+   */
+  protected function drawDebugBox(PositionedRectangle $rect, $rgba, $luma = FALSE) {
+
+    // Check color.
+    if (!$rgba) {
+      $rgba = '#000000FF';
+    }
+    elseif ($luma) {
+      $rgba = ColorUtility::matchLuma($rgba);
+    }
+
+    // Retrieve points.
+    $points = $this->getRectangleCorners($rect);
+
+    // Draw box.
+    $data = array(
+      'rectangle' => $rect,
+      'border_color' => $rgba,
+    );
+    $this->getToolkit()->apply('draw_rectangle', $data);
+
+    // Draw diagonal.
+    $data = array(
+      'x1' => $points[0],
+      'y1' => $points[1],
+      'x2' => $points[4],
+      'y2' => $points[5],
+      'color' => $rgba,
+    );
+    $this->getToolkit()->apply('draw_line', $data);
+
+    // Conspicuous points.
+    $orange = '#FF6400FF';
+    $yellow = '#FFFF00FF';
+    $green  = '#00FF00FF';
+    $dotsize = 6;
+
+    // Box corners.
+    for ($i = 0; $i < 8; $i += 2) {
+      $col = $i < 4 ? $orange : $yellow;
+      $data = array(
+        'cx' => $points[$i],
+        'cy' => $points[$i + 1],
+        'width' => $dotsize,
+        'height' => $dotsize,
+        'color' => $col,
+      );
+      $this->getToolkit()->apply('draw_ellipse', $data);
+    }
+
+    // Font baseline.
+    $basepoint = $rect->getPoint('basepoint');
+    $data = array(
+      'cx' => $basepoint[0],
+      'cy' => $basepoint[1],
+      'width' => $dotsize,
+      'height' => $dotsize,
+      'color' => $green,
+    );
+    $this->getToolkit()->apply('draw_ellipse', $data);
+  }
+
+  /**
+   * Wrap text for rendering at a given width.
+   *
+   * @param string $text
+   *   Text string in UTF-8 encoding.
+   * @param int $font_size
+   *   Font size.
+   * @param string $font_uri
+   *   URI of the TrueType font to use.
+   * @param int $maximum_width
+   *   Maximum width allowed for each line.
+   *
+   * @return string
+   *   Text string, with newline characters to separate each line.
+   */
+  protected function wrapText($text, $font_size, $font_uri, $maximum_width) {
+    // State variables for the search interval.
+    $end = 0;
+    $begin = 0;
+    $fit = $begin;
+
+    // Note: we count in bytes for speed reasons, but maintain character
+    // boundaries.
+    while (TRUE) {
+      // Find the next wrap point (always after trailing whitespace).
+      if (TextUtility::unicodePregMatch('/[' . TextUtility::PREG_CLASS_PUNCTUATION . '][' . TextUtility::PREG_CLASS_SEPARATOR . ']*|[' . TextUtility::PREG_CLASS_SEPARATOR . ']+/u', $text, $match, PREG_OFFSET_CAPTURE, $end)) {
+        $end = $match[0][1] + Unicode::strlen($match[0][0]);
+      }
+      else {
+        $end = Unicode::strlen($text);
+      }
+
+      // Fetch text, removing trailing white-space, and measure it.
+      $line  = preg_replace('/[' . TextUtility::PREG_CLASS_SEPARATOR . ']+$/u', '', Unicode::substr($text, $begin, $end - $begin));
+      $width = $this->getTextWidth($line, $font_size, $font_uri);
+
+      // See if line extends past the available space.
+      if ($width > $maximum_width) {
+        // If this is the first word, we need to truncate it.
+        if ($fit == $begin) {
+          // Cut off letters until it fits.
+          while (Unicode::strlen($line) > 0 && $width > $maximum_width) {
+            $line  = Unicode::substr($line, 0, -1);
+            $width = $this->getTextWidth($line, $font_size, $font_uri);
+          }
+          // If no fit was found, the image is too narrow.
+          $fit = Unicode::strlen($line) ? $begin + Unicode::strlen($line) : $end;
+        }
+        // We have a valid fit for the next line. Insert a line-break and reset
+        // the search interval.
+        if (Unicode::substr($text, $fit - 1, 1) == ' ') {
+          $first_part = Unicode::substr($text, 0, $fit - 1);
+        }
+        else {
+          $first_part = Unicode::substr($text, 0, $fit);
+        }
+        $last_part  = Unicode::substr($text, $fit);
+        $text  = $first_part . "\n" . $last_part;
+        $begin = ++$fit;
+        $end   = $begin;
+      }
+      else {
+        // We can fit this text. Wait for now.
+        $fit = $end;
+      }
+
+      if ($end == Unicode::strlen($text)) {
+        // All text fits. No more changes are needed.
+        break;
+      }
+    }
+    return $text;
+  }
+
+  /**
+   * Return the width of a text using TrueType fonts.
+   *
+   * @param string $text
+   *   A text string.
+   * @param string $font_size
+   *   The font size.
+   * @param string $font_uri
+   *   The font URI.
+   *
+   * @return int
+   *   The width of the text in pixels.
+   */
+  protected function getTextWidth($text, $font_size, $font_uri) {
+    // Get fully qualified font file information.
+    if (!$font_file = $this->getFontPath($font_uri)) {
+      return NULL;
+    }
+    // Get the bounding box for $text to get width.
+    $points = imagettfbbox($font_size, 0, $font_file, $text);
+    // Return bounding box width.
+    return (abs($points[4] - $points[6]) + 1);
+  }
+
+  /**
+   * Return the height and basepoint of a text using TrueType fonts.
+   *
+   * Need to calculate the height independently from primitive as
+   * lack of descending/ascending characters will limit the height.
+   * So to have uniformity we take a dummy string with ascending and
+   * descending characters to set to max height possible.
+   *
+   * @param string $font_size
+   *   The font size.
+   * @param string $font_uri
+   *   The font URI.
+   *
+   * @return array
+   *   An associative array with the following keys:
+   *   - 'height' the text height in pixels.
+   *   - 'basepoint' an array of x, y coordinates of the font's basepoint.
+   */
+  protected function getTextHeightInfo($font_size, $font_uri) {
+    // Get fully qualified font file information.
+    if (!$font_file = $this->getFontPath($font_uri)) {
+      return NULL;
+    }
+    // Get the bounding box for $text to get height.
+    $points = imagettfbbox($font_size, 0, $font_file, 'bdfhkltgjpqyBDFHKLTGJPQY§@çÅÀÈÉÌÒÇ');
+    $height = (abs($points[5] - $points[1]) + 1);
+    return [
+      'height' => $height,
+      'basepoint' => [$points[6], -$points[7]],
+    ];
+  }
+
+}
diff --git a/src/Plugin/ImageToolkit/Operation/imagemagick/ReplaceImage.php b/src/Plugin/ImageToolkit/Operation/imagemagick/ReplaceImage.php
new file mode 100644
index 0000000..8cd7dfb
--- /dev/null
+++ b/src/Plugin/ImageToolkit/Operation/imagemagick/ReplaceImage.php
@@ -0,0 +1,43 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\image_effects\Plugin\ImageToolkit\Operation\imagemagick\ReplaceImage.
+ */
+
+namespace Drupal\image_effects\Plugin\ImageToolkit\Operation\imagemagick;
+
+use Drupal\imagemagick\Plugin\ImageToolkit\Operation\imagemagick\ImagemagickImageToolkitOperationBase;
+use Drupal\image_effects\Plugin\ImageToolkit\Operation\ReplaceImageTrait;
+
+/**
+ * Defines Imagemagick image replace operation.
+ *
+ * @ImageToolkitOperation(
+ *   id = "image_effects_imagemagick_replace_image",
+ *   toolkit = "imagemagick",
+ *   operation = "replace_image",
+ *   label = @Translation("Replace image"),
+ *   description = @Translation("Replace the current image with another one.")
+ * )
+ */
+class ReplaceImage extends ImagemagickImageToolkitOperationBase {
+
+  use ReplaceImageTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function execute(array $arguments) {
+    $replacement = $arguments['replacement_image'];
+    $this->getToolkit()
+      ->resetArguments()
+      ->setSourceLocalPath($replacement->getToolkit()->getSourceLocalPath())
+      ->setSourceFormat($replacement->getToolkit()->getSourceFormat())
+      ->setExifOrientation($replacement->getToolkit()->getExifOrientation())
+      ->setWidth($replacement->getWidth())
+      ->setHeight($replacement->getHeight());
+    return TRUE;
+  }
+
+}
diff --git a/src/Plugin/ImageToolkit/Operation/imagemagick/TextToWrapper.php b/src/Plugin/ImageToolkit/Operation/imagemagick/TextToWrapper.php
new file mode 100644
index 0000000..f2924c2
--- /dev/null
+++ b/src/Plugin/ImageToolkit/Operation/imagemagick/TextToWrapper.php
@@ -0,0 +1,47 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\image_effects\Plugin\ImageToolkit\Operation\imagemagick\TextToWrapper.
+ */
+
+namespace Drupal\image_effects\Plugin\ImageToolkit\Operation\imagemagick;
+
+use Drupal\image_effects\Plugin\ImageToolkit\Operation\TextToWrapperTrait;
+use Drupal\imagemagick\Plugin\ImageToolkit\Operation\imagemagick\ImagemagickImageToolkitOperationBase;
+
+/**
+ * Defines Imagemagick Text Overlay text-to-wrapper operation.
+ *
+ * @ImageToolkitOperation(
+ *   id = "image_effects_imagemagick_text_to_wrapper",
+ *   toolkit = "imagemagick",
+ *   operation = "text_to_wrapper",
+ *   label = @Translation("Overlays text over an image"),
+ *   description = @Translation("Overlays text over an image.")
+ * )
+ */
+class TextToWrapper extends ImagemagickImageToolkitOperationBase {
+
+  use TextToWrapperTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function execute(array $arguments) {
+    // Get a temporary wrapper image object via the GD toolkit.
+    $gd_wrapper = \Drupal::service('image.factory')->get(NULL, 'gd');
+    $gd_wrapper->apply('text_to_wrapper', $arguments);
+    // Flush the temporary wrapper to disk, reopen via ImageMagick and return.
+    if ($gd_wrapper) {
+      $tmp_file = \Drupal::service('file_system')->tempnam('temporary://', 'image_effects_');
+      $gd_wrapper_destination = $tmp_file . '.png';
+      file_unmanaged_move($tmp_file, $gd_wrapper_destination, FILE_CREATE_DIRECTORY);
+      $gd_wrapper->save($gd_wrapper_destination);
+      $tmp_wrapper = \Drupal::service('image.factory')->get($gd_wrapper_destination, 'imagemagick');
+      return $this->getToolkit()->apply('replace_image', ['replacement_image' => $tmp_wrapper]);
+    }
+    return FALSE;
+  }
+
+}
diff --git a/src/Tests/ImageEffectsTestBase.php b/src/Tests/ImageEffectsTestBase.php
index 7a81cef..6654a6d 100644
--- a/src/Tests/ImageEffectsTestBase.php
+++ b/src/Tests/ImageEffectsTestBase.php
@@ -250,4 +250,14 @@ abstract class ImageEffectsTestBase extends WebTestBase {
     return array_values(imagecolorsforindex($toolkit->getResource(), $color_index));
   }
 
+  /**
+   * Asserts a Text overlay image.
+   */
+  protected function assertTextOverlay($image, $width, $height) {
+    $w_error = abs($image->getWidth() - $width);
+    $h_error = abs($image->getHeight() - $height);
+    $tolerance = 0.1;
+    $this->assertTrue($w_error < $width * $tolerance && $h_error < $height * $tolerance, "Width and height ({$image->getWidth()}x{$image->getHeight()}) approximate expected results ({$width}x{$height})");
+  }
+
 }
diff --git a/src/Tests/ImageEffectsTextOverlayTest.php b/src/Tests/ImageEffectsTextOverlayTest.php
new file mode 100644
index 0000000..2c8d023
--- /dev/null
+++ b/src/Tests/ImageEffectsTextOverlayTest.php
@@ -0,0 +1,95 @@
+<?php
+
+/**
+ * @file
+ * Text overlay effect test case script.
+ */
+
+namespace Drupal\image_effects\Tests;
+
+use Drupal\image\Entity\ImageStyle;
+
+/**
+ * Text overlay effect test.
+ *
+ * @group Image Effects
+ */
+class ImageEffectsTextOverlayTest extends ImageEffectsTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp() {
+    parent::setUp();
+    $this->toolkits = ['gd', 'imagemagick'];
+  }
+
+  /**
+   * Text overlay effect test.
+   */
+  public function testTextOverlayEffect() {
+    // Add Text overlay effect to the test image style.
+    $effect = [
+      'id' => 'image_effects_text_overlay',
+      'data' => [
+        'text_default][text_string' => 'the quick brown fox jumps over the lazy dog',
+        'font][uri' => drupal_get_path('module', 'image_effects') . '/tests/fonts/LinLibertineTTF_5.3.0_2012_07_02/LinLibertine_Rah.ttf',
+        'font][size' => 40,
+        'layout][position][extended_color][container][transparent' => FALSE,
+        'layout][position][extended_color][container][hex' => '#FF00FF',
+        'layout][position][extended_color][container][opacity' => 100,
+      ],
+    ];
+    $this->addEffectToTestStyle($effect);
+
+    // Test operations on toolkits.
+    $this->executeTestOnToolkits([$this, 'doTestTextOverlayOperations']);
+  }
+
+  /**
+   * Text overlay operations test.
+   */
+  public function doTestTextOverlayOperations() {
+    $image_factory = $this->container->get('image.factory');
+    $test_data = [
+      [
+        'test_file' => drupal_get_path('module', 'simpletest') . '/files/image-test.png',
+        'derivative_width' => 984,
+        'derivative_height' => 61,
+      ],
+    ];
+
+    foreach ($test_data as $data) {
+      // Get expected URIs.
+      $original_uri = file_unmanaged_copy($data['test_file'], 'public://', FILE_EXISTS_RENAME);
+      $generated_uri = 'public://styles/image_effects_test/public/'. \Drupal::service('file_system')->basename($original_uri);
+
+      // Source image.
+      $image = $image_factory->get($original_uri);
+
+      // Load Image Style and get expected derivative URL.
+      $image_style = ImageStyle::load('image_effects_test');
+      $url = file_url_transform_relative($image_style->buildUrl($original_uri));
+
+      // Check that ::applyEffect generates image with expected dimensions
+      // and colors at corners.
+      $image_style->createDerivative($original_uri, $image_style->buildUri($original_uri));
+      $derivative_image = $image_factory->get($generated_uri, 'gd');
+      $this->assertTextOverlay($derivative_image, $data['derivative_width'], $data['derivative_height']);
+      $this->assertTrue($this->colorsAreEqual($this->fuchsia, $this->getPixelColor($derivative_image, 0, 0)));
+      $this->assertTrue($this->colorsAreEqual($this->fuchsia, $this->getPixelColor($derivative_image, $derivative_image->getWidth() - 1, 0)));
+      $this->assertTrue($this->colorsAreEqual($this->fuchsia, $this->getPixelColor($derivative_image, 0, $derivative_image->getHeight() - 1)));
+      $this->assertTrue($this->colorsAreEqual($this->fuchsia, $this->getPixelColor($derivative_image, $derivative_image->getWidth() - 1, $derivative_image->getHeight() - 1)));
+
+      // Check that ::transformDimensions returns expected dimensions.
+      $variables = array(
+        '#theme' => 'image_style',
+        '#style_name' => 'image_effects_test',
+        '#uri' => $original_uri,
+        '#width' => $image->getWidth(),
+        '#height' => $image->getHeight(),
+      );
+      $this->assertEqual('<img src="' . $url . '" width="' . $derivative_image->getWidth() . '" height="' . $derivative_image->getHeight() . '" alt="" class="image-style-image-effects-test" />', $this->getImageTag($variables));
+    }
+  }
+}
diff --git a/src/Tests/ImageEffectsTextUtilityTest.php b/src/Tests/ImageEffectsTextUtilityTest.php
new file mode 100644
index 0000000..59e451e
--- /dev/null
+++ b/src/Tests/ImageEffectsTextUtilityTest.php
@@ -0,0 +1,46 @@
+<?php
+
+/**
+ * @file
+ * Image Effects test case script.
+ */
+
+namespace Drupal\image_effects\Tests;
+
+use Drupal\simpletest\KernelTestBase;
+use Drupal\image_effects\Component\TextUtility;
+
+/**
+ * Tests the UTF-8 character-based wrapper of the preg_match function.
+ *
+ * @group Image Effects
+ */
+class ImageEffectsTextUtilityTest extends KernelTestBase {
+
+  /**
+   * Performs the tests for the offset argument.
+   */
+  public function testOffsetArgument() {
+    // Character 'п' is 2 bytes long and preg_match() would start from the
+    // second 'п' character and not from the first 'z'.
+    $result = TextUtility::unicodePregMatch('/п/u', 'ппzz', $matches, NULL, 2);
+    $this->assertFalse($result, 'String was skipped using character-based offset.');
+
+    // Again, character 'п' is 2 bytes long and we skip 1 character, so
+    // preg_match() would fail, because the string with byte offset 1 is not a
+    // valid UTF-8 string.
+    $result = TextUtility::unicodePregMatch('/.*$/u', 'пzz', $matches, NULL, 1);
+    $this->assertTrue($result && $matches[0] === 'zz', 'String was matched using character-based offset.');
+  }
+
+  /**
+   * Performs the tests for the captured offset.
+   */
+  public function testCapturedOffset() {
+    // Character 'п' is 2 bytes long and non-unicode preg_match would return
+    // 2 here.
+    $result = TextUtility::unicodePregMatch('/z/u', 'пz', $matches, PREG_OFFSET_CAPTURE);
+    $this->assertTrue($result && $matches[0][1] === 1, 'Returned offset is character-based.');
+  }
+
+}
diff --git a/templates/image-effects-text-overlay-preview.html.twig b/templates/image-effects-text-overlay-preview.html.twig
new file mode 100644
index 0000000..7c869df
--- /dev/null
+++ b/templates/image-effects-text-overlay-preview.html.twig
@@ -0,0 +1,15 @@
+{#
+/**
+ * @file
+ * Default theme implementation to display a text overlay preview theme.
+ *
+ * Available variables:
+ * - preview: A Textimage formatter array.
+ *
+ * @ingroup theme
+ */
+#}
+{% if success %}
+  {{ attach_library('image_effects/image_effects.text_overlay_preview') }}
+{% endif %}
+<div id="text-overlay-preview">{{ preview }}</div>
diff --git a/templates/image-effects-text-overlay-summary.html.twig b/templates/image-effects-text-overlay-summary.html.twig
new file mode 100644
index 0000000..4cac7ff
--- /dev/null
+++ b/templates/image-effects-text-overlay-summary.html.twig
@@ -0,0 +1,63 @@
+{#
+/**
+ * @file
+ * Default theme implementation for a summary of a Text Overlay effect.
+ *
+ * Available variables:
+ * - data: The effect configuration, including:
+ *   - text_string: The text associated with this effect, can include tokens
+ *   - font: The font data, including:
+ *     - name: Font name
+ *     - uri: Font file URI
+ *     - size: Font size
+ *     - angle: Font orientation
+ *     - color: Font color
+ *     - stroke_mode: Type of stroke (outline/shadow)
+ *     - stroke_color: Color of the stroke
+ *     - outline_top: Outline px on the top
+ *     - outline_right: Outline px on the right
+ *     - outline_bottom: Outline px on the bottom
+ *     - outline_left: Outline px on the left
+ *     - shadow_x_offset: Shadow horizontal offset in px
+ *     - shadow_y_offset: Shadow vertical offset in px
+ *     - shadow_width: Shadow width in px
+ *     - shadow_height: Shadow height in px
+ *   - layout: The text layout information, including:
+ *     - padding_top: Padding top in px
+ *     - padding_right: Padding right in px
+ *     - padding_bottom: Padding bottom in px
+ *     - padding_left: Padding left in px
+ *     - x_pos: Placement on canvas, horizontal
+ *     - y_pos: Placement on canvas, vertical
+ *     - x_offset: Placement on canvas, horizontal, offset
+ *     - y_offset: Placement on canvas, vertical, offset
+ *     - background_color: Color of bounding box
+ *     - overflow_action: Action when text wrapper overflows canvas
+ *     - extended_color: Color to be used when extending the underlying image
+ *   - text: The text information, including:
+ *     - maximum_width: Maximum width in px
+ *     - fixed_width: Fixed width flag
+ *     - align: Text alignment
+ *     - line_spacing: Line spacing in px
+ *     - case_format: Text format conversion
+ * - effect: The effect information, including:
+ *   - id: The effect identifier.
+ *   - label: The effect name.
+ *   - description: The effect description.
+ *
+ * @ingroup themeable
+ */
+#}
+{% spaceless %}
+  - {{ 'Font'|t }}: {{ data.font.name }} - {{ 'Size'|t  }}: {{ data.font.size }}
+  {% if data.font.angle %}
+    - {{ 'Rotate'|t }}: {{ data.font.angle }}°
+  {% endif %}
+  - {{ 'Color'|t }}: {{ data.font_color_detail }}
+  {% if data.stroke_mode %}
+    - {{ data.stroke_mode }}: {{ data.stroke_color_detail }}
+  {% endif %}
+  {% if data.background_color_detail %}
+    - {{ 'Background color'|t }}: {{ data.background_color_detail }}
+  {% endif %}
+{% endspaceless %}
