From 9b50cda010f1cdace3525cfa092692203181ff3c Mon Sep 17 00:00:00 2001
From: Bernd Oliver Suenderhauf <bos@suenderhauf.de>
Date: Wed, 5 Jun 2019 00:33:00 +0200
Subject: [PATCH] Issue #3048947 by Pancho, wizonesolutions: Refactor
 HandlePdfController::populatePdf()

---
 fillpdf.services.yml                          |   2 +-
 src/Controller/HandlePdfController.php        | 231 +++++++-----------
 src/FieldMapping.php                          |  16 +-
 src/FieldMapping/ImageFieldMapping.php        |  24 +-
 src/FieldMapping/TextFieldMapping.php         |  15 +-
 src/FillPdfBackendManager.php                 |   3 +
 src/FillPdfBackendPluginInterface.php         |  21 +-
 src/FillPdfContextManagerInterface.php        |   6 +-
 src/OutputHandler.php                         |   2 +-
 .../FillPdfServiceFillPdfBackend.php          |  28 ++-
 .../FillPdfBackend/LocalFillPdfBackend.php    |  20 +-
 src/Plugin/FillPdfBackend/LocalService.php    |  51 +---
 .../FillPdfBackend/PdftkFillPdfBackend.php    |  12 +-
 src/TokenResolver.php                         | 184 ++++++++++++--
 src/TokenResolverInterface.php                |  29 ++-
 15 files changed, 374 insertions(+), 270 deletions(-)

diff --git a/fillpdf.services.yml b/fillpdf.services.yml
index 4906028..a538517 100644
--- a/fillpdf.services.yml
+++ b/fillpdf.services.yml
@@ -35,7 +35,7 @@ services:
 
   fillpdf.token_resolver:
     class: Drupal\fillpdf\TokenResolver
-    arguments: ["@token", "@token.entity_mapper"]
+    arguments: ["@token", "@token.entity_mapper", "@module_handler"]
 
   fillpdf.entity_helper:
     class: Drupal\fillpdf\EntityHelper
diff --git a/src/Controller/HandlePdfController.php b/src/Controller/HandlePdfController.php
index 9239958..01640ac 100644
--- a/src/Controller/HandlePdfController.php
+++ b/src/Controller/HandlePdfController.php
@@ -2,23 +2,24 @@
 
 namespace Drupal\fillpdf\Controller;
 
+use Drupal\Component\Plugin\PluginInspectionInterface;
+use Drupal\Core\Link;
 use Drupal\Core\Url;
 use Drupal\Core\Controller\ControllerBase;
 use Drupal\Core\StreamWrapper\StreamWrapperInterface;
-use Drupal\file\Entity\File;
-use Drupal\file\FileInterface;
-use Drupal\fillpdf\Component\Helper\FillPdfMappingHelper;
-use Drupal\fillpdf\Entity\FillPdfForm;
 use Drupal\fillpdf\FillPdfBackendManager;
 use Drupal\fillpdf\FillPdfContextManagerInterface;
 use Drupal\fillpdf\FillPdfFormInterface;
 use Drupal\fillpdf\FillPdfLinkManipulatorInterface;
-use Drupal\fillpdf\Plugin\FillPdfActionPluginManager;
 use Drupal\fillpdf\TokenResolverInterface;
+use Drupal\fillpdf\Component\Helper\FillPdfMappingHelper;
+use Drupal\fillpdf\Entity\FillPdfForm;
+use Drupal\fillpdf\FieldMapping\ImageFieldMapping;
+use Drupal\fillpdf\FieldMapping\TextFieldMapping;
+use Drupal\fillpdf\Plugin\FillPdfActionPluginManager;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 use Symfony\Component\HttpFoundation\RedirectResponse;
 use Symfony\Component\HttpFoundation\RequestStack;
-use Drupal\Core\Link;
 
 /**
  * Class HandlePdfController.
@@ -117,125 +118,61 @@ class HandlePdfController extends ControllerBase {
    *   validation.
    */
   public function populatePdf() {
-    $context = $this->linkManipulator->parseRequest($this->requestStack->getCurrentRequest());
-
     $config = $this->config('fillpdf.settings');
-    $fillpdf_service = $config->get('backend');
 
-    // Load the backend plugin.
-    /** @var \Drupal\fillpdf\FillPdfBackendPluginInterface $backend */
-    $backend = $this->backendManager->createInstance($fillpdf_service, $config->get());
+    $context = $this->linkManipulator->parseRequest($this->requestStack->getCurrentRequest());
+    $entities = $this->contextManager->loadEntities($context);
 
-    // @todo: Emit event (or call alter hook?) before populating PDF.
-    // Rename fillpdf_merge_fields_alter() to fillpdf_populate_fields_alter().
     $fillpdf_form = FillPdfForm::load($context['fid']);
     $form_replacements = FillPdfMappingHelper::parseReplacements($fillpdf_form->replacements->value);
-    $fields = $fillpdf_form->getFormFields();
 
-    // Populate entities array based on what user passed in.
-    $entities = $this->contextManager->loadEntities($context);
-
-    $field_mapping = [
-      'fields' => [],
-      'images' => [],
-    ];
-
-    $mapped_fields = &$field_mapping['fields'];
-    $image_data = &$field_mapping['images'];
-    foreach ($fields as $pdf_key => $field) {
+    // Populate mappings array.
+    $field_mappings = [];
+    foreach ($fillpdf_form->getFormFields() as $pdf_key => $field) {
       if ($context['sample']) {
-        $mapped_fields[$pdf_key] = $pdf_key;
+        $field_mappings[$pdf_key] = new TextFieldMapping($pdf_key);
       }
       else {
-        // Get image fields attached to the entity and derive their token names
-        // based on the entity types we are working with at the moment.
-        $fill_pattern = count($field->value) ? $field->value->value : '';
-        $is_image_token = FALSE;
-        foreach ($entities as $entity_type => $entities_of_that_type) {
-          $lifo_entities = array_reverse($entities_of_that_type);
-          foreach ($lifo_entities as $entity) {
-            if (method_exists($entity, 'getFields')) {
-              // Translate entity type into token type.
-              $token_type = $this->tokenResolver->getEntityMapper()->getTokenTypeForEntityType($entity_type);
-              /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
-              foreach ($entity->getFields() as $field_name => $field_data) {
-                $field_definition = $field_data->getFieldDefinition();
-                if ($field_definition->getType() === 'image') {
-                  if ($fill_pattern === "[{$token_type}:{$field_name}]") {
-                    // It's a match!
-                    $is_image_token = TRUE;
-                    if (count($entity->{$field_name})) {
-                      $image_file = File::load($entity->{$field_name}->target_id);
-                      $this->processImageTokens($image_file, $mapped_fields, $pdf_key, $image_data);
-                    }
-                  }
-                }
-              }
-            }
-            // Also support (non-chained) Webform submission element tokens,
-            // which are not fields.
-            if ($entity_type === 'webform_submission') {
-              // We can iterate over this submission's Webform's elements and
-              // manually match for patterns. We support signature and image
-              // fields.
-              /** @var \Drupal\webform\WebformSubmissionInterface $entity */
-              $webform = $entity->getWebform();
-              $webform_field_data = array_filter($webform->getElementsInitializedFlattenedAndHasValue(), function ($value) {
-                return (!empty($value) && $value['#type'] === 'webform_image_file');
-              });
-              $submission_values = $entity->getData();
-              $data_keys = array_keys($webform_field_data);
-              foreach ($data_keys as $webform_field_name) {
-                if ($fill_pattern === "[webform_submission:values:{$webform_field_name}]") {
-                  $webform_image_file = File::load($submission_values[$webform_field_name]);
-                  if (!$webform_image_file) {
-                    break;
-                  }
-
-                  $is_image_token = TRUE;
-                  $this->processImageTokens($webform_image_file, $mapped_fields, $pdf_key, $image_data);
-                }
-              }
-            }
-          }
-        }
-
-        if (!$is_image_token) {
-          // Resolve tokens.
-          $replaced_string = $this->tokenResolver->replace($fill_pattern, $entities);
-
-          // Replace <br /> occurrences with newlines.
-          $replaced_string = preg_replace('|<br />|', '
-', $replaced_string);
-
-          // Apply transformations with field-level replacements taking
-          // precedence over form-level replacements.
-          $field_replacements = FillPdfMappingHelper::parseReplacements($field->replacements->value) + $form_replacements;
-          if (isset($field_replacements[$replaced_string])) {
-            $replaced_string = $field_replacements[$replaced_string];
-          }
-
-          // Apply prefix and suffix, if applicable.
-          if (isset($replaced_string) && $replaced_string) {
-            if ($field->prefix->value) {
-              $replaced_string = $field->prefix->value . $replaced_string;
-            }
-            if ($field->suffix->value) {
-              $replaced_string .= $field->suffix->value;
-            }
-          }
-
-          $mapped_fields[$pdf_key] = $replaced_string;
-        }
+        $options = [];
+        // Our pdftk backend doesn't support image stamping, so at least for
+        // this backend we already know which type of content we can expect.
+        $options['content'] = $config->get('backend') == 'pdftk' ? 'text' : '';
+
+        // Prepare transformations with field-level replacements taking
+        // precedence over form-level replacements.
+        $options['replacements'] = FillPdfMappingHelper::parseReplacements($field->replacements->value) + $form_replacements;
+
+        // Add prefix and suffix.
+        $options['prefix'] = $field->prefix->value;
+        $options['suffix'] = $field->suffix->value;
+
+        // Resolve tokens.
+        $text = count($field->value) ? $field->value->value : '';
+        $field_mappings[$pdf_key] = $this->tokenResolver->replace($text, $entities, $options);
       }
     }
 
-    $title_pattern = $fillpdf_form->title->value;
     // Generate the filename of downloaded PDF from title of the PDF set in
     // admin/structure/fillpdf/%fid.
-    $filename = $this->buildFilename($title_pattern, $entities);
+    $filename = $this->buildFilename($fillpdf_form->title->value, $entities);
+
+    // Now load the backend plugin.
+    $backend = $this->backendManager->createInstance($config->get('backend'), $config->get());
+
+    // Turn FieldMapping objects back into legacy field mapping arrays for BC.
+    // Current backend plugins implement PluginInspectionInterface and are at
+    // API version 4.5.
+    // @todo Remove this BC layer before 8.x-5.x.
+    $is_current_api = $backend instanceof PluginInspectionInterface && $backend->getPluginDefinition()['api'] == 4.5;
+    if (!$is_current_api) {
+      $field_mappings = static::legacyFieldMapping($field_mappings);
+    }
+
+    // @todo: Emit event (or call alter hook?) before populating PDF.
+    // Rename fillpdf_merge_fields_alter() to fillpdf_populate_fields_alter().
+    /** @var \Drupal\fillpdf\FillPdfBackendPluginInterface $backend */
+    $populated_pdf = $backend->populateWithFieldData($fillpdf_form, $field_mappings, $context);
 
-    $populated_pdf = $backend->populateWithFieldData($fillpdf_form, $field_mapping, $context);
     if (empty($populated_pdf)) {
       $this->messenger()->addError($this->t('Merging the FillPDF Form failed.'));
       return new RedirectResponse(Url::fromRoute('<front>')->toString());
@@ -254,10 +191,13 @@ class HandlePdfController extends ControllerBase {
    *   The original filename without tokens being replaced.
    * @param \Drupal\Core\Entity\EntityInterface[] $entities
    *   An array of entities to be used for replacing tokens.
+   *
+   * @return string
+   *   The token-replaced filename.
    */
   protected function buildFilename($original, array $entities) {
     // Replace tokens *before* sanitization.
-    $original = $this->tokenResolver->replace($original, $entities);
+    $original = (string) $this->tokenResolver->replace($original, $entities, ['content' => 'text']);
 
     $output_name = str_replace(' ', '_', $original);
     $output_name = preg_replace('/\.pdf$/i', '', $output_name);
@@ -355,44 +295,43 @@ class HandlePdfController extends ControllerBase {
   }
 
   /**
-   * Processes image tokens.
+   * Converts FieldMapping objects into legacy field mapping arrays.
+   *
+   * @param \Drupal\fillpdf\FieldMapping[] $field_mappings
+   *   The field mappings.
+   *
+   * @return array
+   *   A nested associative array containing:
+   *     - 'fields': Associative array of text mappings keyed by pdf_key
+   *     - 'images': A nested associative array keyed by pdf_key, the values
+   *       being themselves associative arrays with the following keys:
+   *       - 'data': The base64 encoded image data
+   *       - 'filenamehash': md5-hash of the filename with the original
+   *         extension.
    *
-   * @param \Drupal\file\FileInterface $image_file
-   *   Image file object.
-   * @param string[] $mapped_fields
-   *   Array of mapped fields.
-   * @param string $pdf_key
-   *   PDF key.
-   * @param array[] $image_data
-   *   Array of image data, keyed by the PDF key.
+   * @internal
+   * @todo Remove in 8.x-5.x. Backends have different requirements, so should
+   *   handle image files themselves.
    */
-  protected function processImageTokens(FileInterface $image_file, array &$mapped_fields, $pdf_key, array &$image_data) {
-    $backend = $this->config('fillpdf.settings')->get('backend');
-
-    // @todo Refactor in 8.x-5.x. Pdftk doesn't support image stamping.
-    if ($backend == 'pdftk') {
-      return;
-    }
-
-    $image_path = $image_file->getFileUri();
-    $mapped_fields[$pdf_key] = "{image}{$image_path}";
-
-    // @todo Refactor in 8.x-5.x. Local and LocalService backends handle image
-    // files themselves. So this only remains in place for FillPdfService
-    // and possible third-party backend plugins.
-    if (in_array($backend, ['local_service', 'local'])) {
-      return;
-    }
-
-    $image_path_info = pathinfo($image_path);
-    // Store the image data to transmit to the remote service if necessary.
-    $file_data = file_get_contents($image_path);
-    if ($file_data) {
-      $image_data[$pdf_key] = [
-        'data' => base64_encode($file_data),
-        'filenamehash' => md5($image_path_info['filename']) . '.' . $image_path_info['extension'],
-      ];
+  private static function legacyFieldMapping(array $field_mappings) {
+    $legacy_mapping = [];
+    foreach ($field_mappings as $pdf_key => $mapping) {
+      if ($mapping instanceof TextFieldMapping) {
+        $legacy_mapping['fields'][$pdf_key] = (string) $mapping->getData();
+      }
+      elseif ($mapping instanceof ImageFieldMapping) {
+        $uri = (string) $mapping->getUri();
+        if ($uri) {
+          $legacy_mapping['fields'][$pdf_key] = "{image}{$uri}";
+          $image_path_info = pathinfo($uri);
+          $legacy_mapping['images'][$pdf_key] = [
+            'data' => base64_encode($mapping->getData()),
+            'filenamehash' => md5($image_path_info['filename']) . '.' . $image_path_info['extension'],
+          ];
+        }
+      }
     }
+    return $legacy_mapping;
   }
 
 }
diff --git a/src/FieldMapping.php b/src/FieldMapping.php
index 8b6470e..63c6ea2 100644
--- a/src/FieldMapping.php
+++ b/src/FieldMapping.php
@@ -13,16 +13,28 @@ namespace Drupal\fillpdf;
 abstract class FieldMapping {
 
   /**
-   * @var mixed
-   *
    * The primary value of the mapping.
+   *
+   * @var string
    */
   protected $data;
 
+  /**
+   * Constructs a FieldMapping object.
+   *
+   * @param string $data
+   *   String containing the data.
+   */
   public function __construct($data) {
     $this->data = $data;
   }
 
+  /**
+   * Returns the data.
+   *
+   * @return string
+   *   String containing the data.
+   */
   public function getData() {
     return $this->data;
   }
diff --git a/src/FieldMapping/ImageFieldMapping.php b/src/FieldMapping/ImageFieldMapping.php
index 2f30013..5f2615d 100644
--- a/src/FieldMapping/ImageFieldMapping.php
+++ b/src/FieldMapping/ImageFieldMapping.php
@@ -21,6 +21,13 @@ class ImageFieldMapping extends FieldMapping {
    */
   protected $extension;
 
+  /**
+   * The image file's URI.
+   *
+   * @var string
+   */
+  protected $uri;
+
   /**
    * Constructs an ImageFieldMapping object.
    *
@@ -31,18 +38,21 @@ class ImageFieldMapping extends FieldMapping {
    *   (optional) The original extension corresponding to the image data. If the
    *   backend doesn't need to know the extension and you don't want extensions
    *   to be checked, you can leave it blank.
+   * @param string $uri
+   *   (optional) URI of the image.
    *
    * @throws \InvalidArgumentException
    *   If the extension isn't one of 'jpg', 'png', or 'gif'.
    */
-  public function __construct($data, $extension = NULL) {
+  public function __construct($data, $extension = NULL, $uri = NULL) {
     parent::__construct($data);
 
     if (isset($extension) && !in_array($extension, ['jpg', 'png', 'gif'])) {
       throw new \InvalidArgumentException('Extension must be one of: jpg, png, gif.');
     }
-
     $this->extension = $extension;
+
+    $this->uri = $uri;
   }
 
   /**
@@ -55,4 +65,14 @@ class ImageFieldMapping extends FieldMapping {
     return $this->extension;
   }
 
+  /**
+   * Gets the image file's URI.
+   *
+   * @return string
+   *   The file's URI.
+   */
+  public function getUri() {
+    return $this->uri;
+  }
+
 }
diff --git a/src/FieldMapping/TextFieldMapping.php b/src/FieldMapping/TextFieldMapping.php
index 00fcc5d..6fd4585 100644
--- a/src/FieldMapping/TextFieldMapping.php
+++ b/src/FieldMapping/TextFieldMapping.php
@@ -4,13 +4,20 @@ namespace Drupal\fillpdf\FieldMapping;
 
 use Drupal\fillpdf\FieldMapping;
 
+/**
+ * Represents a mapping between a PDF text field and a merge value.
+ *
+ * TextFieldMapping objects are immutable; replace the value by calling the
+ * constructor again if the value needs to change.
+ */
 final class TextFieldMapping extends FieldMapping {
 
   /**
-   * @var string
+   * Constructs an TextFieldMapping object.
+   *
+   * @param string $data
+   *   String containing the text data.
    */
-  protected $data;
-
   public function __construct($data) {
     // Ensure data is a string.
     parent::__construct((string) $data);
@@ -19,7 +26,7 @@ final class TextFieldMapping extends FieldMapping {
   /**
    * @return string
    */
-  public function getData() {
+  public function __toString() {
     return parent::getData();
   }
 
diff --git a/src/FillPdfBackendManager.php b/src/FillPdfBackendManager.php
index 8bb0f49..1eb68cb 100644
--- a/src/FillPdfBackendManager.php
+++ b/src/FillPdfBackendManager.php
@@ -46,6 +46,9 @@ class FillPdfBackendManager extends DefaultPluginManager {
       if (!isset($definition['weight'])) {
         $definitions[$id]['weight'] = 0;
       }
+      if (!isset($definition['api'])) {
+        $definitions[$id]['api'] = 4;
+      }
     }
 
     uasort($definitions, function ($a, $b) {
diff --git a/src/FillPdfBackendPluginInterface.php b/src/FillPdfBackendPluginInterface.php
index af5e0e4..87ca0bc 100644
--- a/src/FillPdfBackendPluginInterface.php
+++ b/src/FillPdfBackendPluginInterface.php
@@ -33,23 +33,8 @@ interface FillPdfBackendPluginInterface {
    * @param \Drupal\fillpdf\FillPdfFormInterface $fillpdf_form
    *   The FillPdfForm referencing the file whose field values are going to be
    *   populated.
-   * @param array $field_mapping
-   *   An array of fields mapping PDF field keys to the values with which they
-   *   should be replaced. Example array:
-   *   @code
-   *   [
-   *     'values' => [
-   *       'Field 1' => 'value',
-   *       'Checkbox Field' => 'On',
-   *     ],
-   *     'images' => [
-   *       'Image Field 1' => [
-   *         'data' => base64_encode($file_data),
-   *         'filenamehash' => md5($image_path_info['filename']) . '.' . $image_path_info['extension'],
-   *       ],
-   *     ],
-   *   ]
-   *   @endcode
+   * @param \Drupal\fillpdf\FieldMapping[] $field_mappings
+   *   An array of field mapping objects.
    * @param array $context
    *   The request context as returned by
    *   FillPdfLinkManipulatorInterface::parseLink().
@@ -60,6 +45,6 @@ interface FillPdfBackendPluginInterface {
    *
    * @see \Drupal\fillpdf\FillPdfLinkManipulatorInterface::parseLink()
    */
-  public function populateWithFieldData(FillPdfFormInterface $fillpdf_form, array $field_mapping, array $context);
+  public function populateWithFieldData(FillPdfFormInterface $fillpdf_form, array $field_mappings, array $context);
 
 }
diff --git a/src/FillPdfContextManagerInterface.php b/src/FillPdfContextManagerInterface.php
index 4548130..f329e97 100644
--- a/src/FillPdfContextManagerInterface.php
+++ b/src/FillPdfContextManagerInterface.php
@@ -19,9 +19,9 @@ interface FillPdfContextManagerInterface {
    *   The FillPDF request context as returned by
    *   FillPdfLinkManipulatorInterface::parseLink().
    *
-   * @return \Drupal\Core\Entity\EntityInterface[]
-   *   An array of entity objects indexed by their IDs. Returns an empty array
-   *   if no matching entities are found.
+   * @return \Drupal\Core\Entity\EntityInterface[][]
+   *   Multidimensional array of entities, keyed by ID and grouped by entity
+   *   type. Returns an empty array if no matching entities are found.
    *
    * @see \Drupal\fillpdf\FillPdfLinkManipulatorInterface::parseLink()
    */
diff --git a/src/OutputHandler.php b/src/OutputHandler.php
index 9428cf7..c471908 100644
--- a/src/OutputHandler.php
+++ b/src/OutputHandler.php
@@ -110,7 +110,7 @@ class OutputHandler implements OutputHandlerInterface {
    *   The normalized URI
    */
   protected function processDestinationPath($destination_path, array $entities, $scheme = 'public') {
-    $destination_path = $this->tokenResolver->replace($destination_path, $entities);
+    $destination_path = (string) $this->tokenResolver->replace($destination_path, $entities, ['content' => 'text']);
     return FillPdf::buildFileUri($scheme, $destination_path);
   }
 
diff --git a/src/Plugin/FillPdfBackend/FillPdfServiceFillPdfBackend.php b/src/Plugin/FillPdfBackend/FillPdfServiceFillPdfBackend.php
index 3eb58a7..2dccf93 100644
--- a/src/Plugin/FillPdfBackend/FillPdfServiceFillPdfBackend.php
+++ b/src/Plugin/FillPdfBackend/FillPdfServiceFillPdfBackend.php
@@ -6,10 +6,13 @@ use Drupal\Core\Plugin\PluginBase;
 use Drupal\file\Entity\File;
 use Drupal\fillpdf\FillPdfBackendPluginInterface;
 use Drupal\fillpdf\FillPdfFormInterface;
+use Drupal\fillpdf\FieldMapping\ImageFieldMapping;
+use Drupal\fillpdf\FieldMapping\TextFieldMapping;
 
 /**
  * @Plugin(
  *   id = "fillpdf_service",
+ *   api = 4.5,
  *   label = @Translation("FillPDF Service"),
  *   description = @Translation(
  *     "No technical prerequisites. Sign up for <a href=':url'>FillPDF Service</a>.",
@@ -80,25 +83,28 @@ class FillPdfServiceFillPdfBackend extends PluginBase implements FillPdfBackendP
   /**
    * {@inheritdoc}
    */
-  public function populateWithFieldData(FillPdfFormInterface $fillpdf_form, array $field_mapping, array $context) {
+  public function populateWithFieldData(FillPdfFormInterface $fillpdf_form, array $field_mappings, array $context) {
     /** @var \Drupal\file\FileInterface $original_file */
     $original_file = File::load($fillpdf_form->file->target_id);
     $original_pdf = file_get_contents($original_file->getFileUri());
     $api_key = $this->configuration['fillpdf_service_api_key'];
 
-    // Anonymize image data from the fields array; we should not send the real
-    // filename to FillPDF Service. We do this in the specific backend because
-    // other plugin types may need the filename on the local system.
-    foreach ($field_mapping['fields'] as $field_name => &$field) {
-      if (!empty($field_mapping['images'][$field_name])) {
-        // TODO: TEST.
-        $field_path_info = pathinfo($field_mapping['images'][$field_name]['filenamehash']);
-        $field = '{image}' . md5($field_path_info['filename']) . '.' . $field_path_info['extension'];
+    $fields = $images = [];
+    foreach ($field_mappings as $pdf_key => $mapping) {
+      if ($mapping instanceof TextFieldMapping) {
+        $fields[$pdf_key] = (string) $mapping;
+      }
+      elseif ($mapping instanceof ImageFieldMapping) {
+        // Anonymize image data from the fields array; we should not send the
+        // real filename to FillPDF Service. We do this in the specific backend
+        // because other plugin types may need the filename on the local system.
+        $field_path_info = pathinfo($mapping->getUri());
+        $fields[$pdf_key] = '{image}' . md5($field_path_info['filename']) . '.' . $field_path_info['extension'];
+        $images[$pdf_key] = $mapping->getData();
       }
     }
-    unset($field);
 
-    $result = $this->xmlRpcRequest('merge_pdf_v3', base64_encode($original_pdf), $field_mapping['fields'], $api_key, $context['flatten'], $field_mapping['images']);
+    $result = $this->xmlRpcRequest('merge_pdf_v3', base64_encode($original_pdf), $fields, $api_key, $context['flatten'], $images);
 
     if ($result->error === FALSE && $result->data) {
       $populated_pdf = base64_decode($result->data);
diff --git a/src/Plugin/FillPdfBackend/LocalFillPdfBackend.php b/src/Plugin/FillPdfBackend/LocalFillPdfBackend.php
index 614d73e..9926965 100644
--- a/src/Plugin/FillPdfBackend/LocalFillPdfBackend.php
+++ b/src/Plugin/FillPdfBackend/LocalFillPdfBackend.php
@@ -8,11 +8,14 @@ use Drupal\Core\Plugin\PluginBase;
 use Drupal\file\Entity\File;
 use Drupal\fillpdf\FillPdfBackendPluginInterface;
 use Drupal\fillpdf\FillPdfFormInterface;
+use Drupal\fillpdf\FieldMapping\ImageFieldMapping;
+use Drupal\fillpdf\FieldMapping\TextFieldMapping;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 
 /**
  * @Plugin(
  *   id = "local",
+ *   api = 4.5,
  *   label = @Translation("Local PHP/Java-Bridge"),
  *   description = @Translation("Legacy. Use FillPDF LocalServer instead."),
  *   weight = 10
@@ -80,25 +83,22 @@ class LocalFillPdfBackend extends PluginBase implements FillPdfBackendPluginInte
   /**
    * {@inheritdoc}
    */
-  public function populateWithFieldData(FillPdfFormInterface $fillpdf_form, array $field_mapping, array $context) {
+  public function populateWithFieldData(FillPdfFormInterface $fillpdf_form, array $field_mappings, array $context) {
     /** @var \Drupal\file\FileInterface $original_file */
     $original_file = File::load($fillpdf_form->file->target_id);
     $pdf_data = file_get_contents($original_file->getFileUri());
-    $fields = $field_mapping['fields'];
 
     $require = drupal_get_path('module', 'fillpdf') . '/lib/JavaBridge/java/Java.inc';
     require_once DRUPAL_ROOT . '/' . $require;
     try {
       $fillpdf = new \java('com.ocdevel.FillpdfService', base64_encode($pdf_data), 'bytes');
-      foreach ($fields as $key => $field) {
-        if (substr($field, 0, 7) == '{image}') {
-          // Remove {image} marker.
-          $image_filepath = substr($field, 7);
-          $image_realpath = $this->fileSystem->realpath($image_filepath);
-          $fillpdf->image($key, $image_realpath, 'file');
+
+      foreach ($field_mappings as $pdf_key => $mapping) {
+        if ($mapping instanceof TextFieldMapping) {
+          $fillpdf->text($pdf_key, (string) $mapping);
         }
-        else {
-          $fillpdf->text($key, $field);
+        elseif ($mapping instanceof ImageFieldMapping) {
+          $fillpdf->image($pdf_key, $this->fileSystem->realpath($mapping->getUri()), 'file');
         }
       }
     }
diff --git a/src/Plugin/FillPdfBackend/LocalService.php b/src/Plugin/FillPdfBackend/LocalService.php
index 70f3761..271c467 100644
--- a/src/Plugin/FillPdfBackend/LocalService.php
+++ b/src/Plugin/FillPdfBackend/LocalService.php
@@ -2,12 +2,9 @@
 
 namespace Drupal\fillpdf\Plugin\FillPdfBackend;
 
-use Drupal\Core\File\FileSystem;
 use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
 use Drupal\Core\Plugin\PluginBase;
 use Drupal\file\Entity\File;
-use Drupal\fillpdf\FieldMapping\ImageFieldMapping;
-use Drupal\fillpdf\FieldMapping\TextFieldMapping;
 use Drupal\fillpdf\FillPdfBackendPluginInterface;
 use Drupal\fillpdf\FillPdfFormInterface;
 use Drupal\fillpdf\Plugin\BackendServiceManager;
@@ -16,6 +13,7 @@ use Symfony\Component\DependencyInjection\ContainerInterface;
 /**
  * @Plugin(
  *   id = "local_service",
+ *   api = 4.5,
  *   label = @Translation("FillPDF LocalServer"),
  *   description = @Translation("Network-accessible, self-installed PDF API. You will need a VPS or dedicated server."),
  *   weight = 5
@@ -23,13 +21,6 @@ use Symfony\Component\DependencyInjection\ContainerInterface;
  */
 class LocalService extends PluginBase implements FillPdfBackendPluginInterface, ContainerFactoryPluginInterface {
 
-  /**
-   * The file system.
-   *
-   * @var \Drupal\Core\File\FileSystem
-   */
-  protected $fileSystem;
-
   /**
    * The FillPDF backend service manager.
    *
@@ -48,13 +39,10 @@ class LocalService extends PluginBase implements FillPdfBackendPluginInterface,
    *   The plugin implementation definition.
    * @param \Drupal\fillpdf\Plugin\BackendServiceManager $backend_service_manager
    *   The backend service manager.
-   * @param \Drupal\Core\File\FileSystem $file_system
-   *   The file system.
    */
-  public function __construct(array $configuration, $plugin_id, array $plugin_definition, BackendServiceManager $backend_service_manager, FileSystem $file_system) {
+  public function __construct(array $configuration, $plugin_id, array $plugin_definition, BackendServiceManager $backend_service_manager) {
     parent::__construct($configuration, $plugin_id, $plugin_definition);
     $this->backendServiceManager = $backend_service_manager;
-    $this->fileSystem = $file_system;
   }
 
   /**
@@ -65,8 +53,7 @@ class LocalService extends PluginBase implements FillPdfBackendPluginInterface,
       $configuration,
       $plugin_id,
       $plugin_definition,
-      $container->get('plugin.manager.fillpdf_backend_service'),
-      $container->get('file_system')
+      $container->get('plugin.manager.fillpdf_backend_service')
     );
   }
 
@@ -74,43 +61,27 @@ class LocalService extends PluginBase implements FillPdfBackendPluginInterface,
    * {@inheritdoc}
    */
   public function parse(FillPdfFormInterface $fillpdf_form) {
-    /** @var \Drupal\file\FileInterface $file */
-    $file = File::load($fillpdf_form->file->target_id);
-    $pdf = file_get_contents($file->getFileUri());
+    /** @var \Drupal\file\FileInterface $template_file */
+    $template_file = File::load($fillpdf_form->file->target_id);
+    $pdf_content = file_get_contents($template_file->getFileUri());
 
     /** @var \Drupal\fillpdf\Plugin\BackendServiceInterface $backend_service */
     $backend_service = $this->backendServiceManager->createInstance($this->pluginId, $this->configuration);
 
-    return $backend_service->parse($pdf);
+    return $backend_service->parse($pdf_content);
   }
 
   /**
    * {@inheritdoc}
    */
-  public function populateWithFieldData(FillPdfFormInterface $fillpdf_form, array $field_mapping, array $context) {
-    /** @var \Drupal\file\FileInterface $original_file */
-    $original_file = File::load($fillpdf_form->file->target_id);
-    $pdf = file_get_contents($original_file->getFileUri());
-
-    // To use the BackendService, we need to convert the fields into the format
-    // it expects.
-    $mapping_objects = [];
-    foreach ($field_mapping['fields'] as $key => $field) {
-      if (substr($field, 0, 7) === '{image}') {
-        // Remove {image} marker.
-        $image_filepath = substr($field, 7);
-        $image_realpath = $this->fileSystem->realpath($image_filepath);
-        $mapping_objects[$key] = new ImageFieldMapping(file_get_contents($image_realpath), pathinfo($image_filepath, PATHINFO_EXTENSION));
-      }
-      else {
-        $mapping_objects[$key] = new TextFieldMapping($field);
-      }
-    }
+  public function populateWithFieldData(FillPdfFormInterface $fillpdf_form, array $field_mappings, array $context) {
+    $template_file = File::load($fillpdf_form->file->target_id);
+    $pdf_content = file_get_contents($template_file->getFileUri());
 
     /** @var \Drupal\fillpdf\Plugin\BackendServiceInterface $backend_service */
     $backend_service = $this->backendServiceManager->createInstance($this->pluginId, $this->configuration);
 
-    return $backend_service->merge($pdf, $mapping_objects, $context);
+    return $backend_service->merge($pdf_content, $field_mappings, $context);
   }
 
 }
diff --git a/src/Plugin/FillPdfBackend/PdftkFillPdfBackend.php b/src/Plugin/FillPdfBackend/PdftkFillPdfBackend.php
index 935d1b8..3f6de1f 100644
--- a/src/Plugin/FillPdfBackend/PdftkFillPdfBackend.php
+++ b/src/Plugin/FillPdfBackend/PdftkFillPdfBackend.php
@@ -7,6 +7,7 @@ use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
 use Drupal\Core\Plugin\PluginBase;
 use Drupal\file\Entity\File;
 use Drupal\fillpdf\Component\Utility\FillPdf;
+use Drupal\fillpdf\FieldMapping\TextFieldMapping;
 use Drupal\fillpdf\FillPdfAdminFormHelperInterface;
 use Drupal\fillpdf\FillPdfBackendPluginInterface;
 use Drupal\fillpdf\FillPdfFormInterface;
@@ -16,6 +17,7 @@ use Symfony\Component\DependencyInjection\ContainerInterface;
 /**
  * @Plugin(
  *   id = "pdftk",
+ *   api = 4.5,
  *   label = @Translation("pdftk"),
  *   description = @Translation(
  *     "Locally installed pdftk. You will need a VPS or a dedicated server to install pdftk, see <a href=':url'>documentation</a>.",
@@ -155,11 +157,17 @@ class PdftkFillPdfBackend extends PluginBase implements FillPdfBackendPluginInte
   /**
    * {@inheritdoc}
    */
-  public function populateWithFieldData(FillPdfFormInterface $pdf_form, array $field_mapping, array $context) {
+  public function populateWithFieldData(FillPdfFormInterface $pdf_form, array $field_mappings, array $context) {
     /** @var \Drupal\file\FileInterface $original_file */
     $original_file = File::load($pdf_form->file->target_id);
     $filename = $original_file->getFileUri();
-    $fields = $field_mapping['fields'];
+
+    $fields = [];
+    foreach ($field_mappings as $pdf_key => $mapping) {
+      if ($mapping instanceof TextFieldMapping) {
+        $fields[$pdf_key] = (string) $mapping;
+      }
+    }
 
     module_load_include('inc', 'fillpdf', 'xfdf');
     $xfdf_name = $filename . '.xfdf';
diff --git a/src/TokenResolver.php b/src/TokenResolver.php
index 9be46cb..e789c61 100644
--- a/src/TokenResolver.php
+++ b/src/TokenResolver.php
@@ -2,8 +2,16 @@
 
 namespace Drupal\fillpdf;
 
-use Drupal\Core\Utility\Token;
 use Drupal\Component\Render\PlainTextOutput;
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Entity\FieldableEntityInterface;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\Render\BubbleableMetadata;
+use Drupal\Core\Utility\Token;
+use Drupal\file\Entity\File;
+use Drupal\image\Plugin\Field\FieldType\ImageItem;
+use Drupal\fillpdf\FieldMapping\ImageFieldMapping;
+use Drupal\fillpdf\FieldMapping\TextFieldMapping;
 use Drupal\token\TokenEntityMapperInterface;
 
 /**
@@ -27,42 +35,178 @@ class TokenResolver implements TokenResolverInterface {
    */
   protected $tokenEntityMapper;
 
+  /**
+   * The module handler.
+   *
+   * @var \Drupal\Core\Extension\ModuleHandlerInterface
+   */
+  protected $moduleHandler;
+
   /**
    * Constructs a TokenResolver object.
    *
-   * @param \Drupal\Core\Utility\Token $token
+   * @param \Drupal\Core\Utility\Token $token_service
    *   The token service.
    * @param \Drupal\token\TokenEntityMapperInterface $token_entity_mapper
    *   The token entity mapper.
+   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
+   *   The module handler.
    */
-  public function __construct(Token $token_service, TokenEntityMapperInterface $token_entity_mapper) {
+  public function __construct(Token $token_service, TokenEntityMapperInterface $token_entity_mapper, ModuleHandlerInterface $module_handler) {
     $this->tokenService = $token_service;
     $this->tokenEntityMapper = $token_entity_mapper;
+    $this->moduleHandler = $module_handler;
   }
 
   /**
    * {@inheritdoc}
    */
-  public function replace($original, array $entities) {
-    // Whichever entity matches the token last wins.
-    $replaced_string = $original;
-
-    foreach ($entities as $entity_type => $entity_objects) {
-      // Our rule is "last value wins." So use the last entities's values first.
-      $seititne = array_reverse($entity_objects); // Get it?
-
-      foreach ($seititne as $entity_id => $entity) {
-        $replaced_string = $this->tokenService->replace($replaced_string, [
-          $entity_type => $entity,
-        ]);
+  public function replace($text, array $data = [], array $options = []) {
+    // Initialize with defaults.
+    $options += [
+      'content' => '',
+      'replacements' => [],
+      'prefix' => '',
+      'suffix' => '',
+    ];
+
+    $tokens = $this->tokenService->scan($text);
+    if (empty($tokens)) {
+      return new TextFieldMapping(PlainTextOutput::renderFromHtml($text));
+    }
+
+    // Content may be marked as either 'text', 'image' or '' (= unknown).
+    // @todo: Revisit when enforcing FillPdfFields to be one or the other.
+    // See: https://www.drupal.org/project/fillpdf/issues/3049368
+    $maybe_image = ($options['content'] !== 'text');
+    $maybe_text = ($options['content'] !== 'image');
+
+    // Loop through the token types.
+    $bubbleable_metadata = new BubbleableMetadata();
+    $replacements = [];
+    foreach ($tokens as $token_type => $type_tokens) {
+      $token_entity_type = $this->tokenEntityMapper->getEntityTypeForTokenType($token_type, FALSE);
+      if ($token_entity_type && isset($data[$token_entity_type])) {
+        // At least one provided entity matches this token type. If there's
+        // more than one entity of this type, make sure the last one matching
+        // this token wins.
+        foreach (array_reverse($data[$token_entity_type]) as $entity) {
+          // Only fieldable entities may supply image tokens.
+          if ($maybe_image && $entity instanceof FieldableEntityInterface) {
+            if ($token_entity_type === 'webform_submission' && $this->moduleHandler->moduleExists('webform')) {
+              $image_mapping = static::parseImageWebformElementTokens(array_keys($type_tokens), $entity);
+            }
+            elseif ($this->moduleHandler->moduleExists('image')) {
+              $image_mapping = static::parseImageFieldTokens(array_keys($type_tokens), $entity);
+            }
+            if (!empty($image_mapping)) {
+              // Early return if we matched an image token.
+              return $image_mapping;
+            }
+          }
+          if ($maybe_text) {
+            $replacements += $this->tokenService->generate($token_type, $type_tokens, [$token_type => $entity], $options, $bubbleable_metadata);
+          }
+        }
       }
+      elseif ($maybe_text) {
+        // None of the provided entities matches this token type. It may however
+        // still be a global token.
+        $replacements += $this->tokenService->generate($token_type, $type_tokens, [], $options, $bubbleable_metadata);
+      }
+      // Clear any unresolved tokens of this type from the string.
+      $replacements += array_fill_keys($tokens[$token_type], '');
+    }
+
+    // Apply token replacements.
+    $resolved_string = str_replace(array_keys($replacements), array_values($replacements), $text);
+
+    // Replace <br /> occurrences with newlines.
+    $resolved_string = preg_replace('|<br />|', '
+', $resolved_string);
+
+    // Apply transformation replacements.
+    if (isset($options['replacements'][$resolved_string])) {
+      $resolved_string = $options['replacements'][$resolved_string];
     }
 
-    // The entities might not have had values for all the tokens in the pattern.
-    // Ensure that any remaining tokens are cleared from the string so they
-    // don't get sent to the PDF.
-    $clean_replaced_string = $this->tokenService->replace($replaced_string, [], ['clear' => TRUE]);
-    return PlainTextOutput::renderFromHtml($clean_replaced_string);
+    // Apply prefix and suffix, unless empty.
+    if (!empty($resolved_string)) {
+      $resolved_string = $options['prefix'] . $resolved_string . $options['suffix'];
+    }
+
+    return new TextFieldMapping(PlainTextOutput::renderFromHtml($resolved_string));
+  }
+
+  /**
+   * Scans a potential webform image element token.
+   *
+   * This is only called if webform module is installed and the backend supports
+   * image stamping.
+   *
+   * @param string[] $tokens
+   *   List of non-fully qualified webform_submission tokens. These may be
+   *   image element tokens such as 'values:image' or other tokens.
+   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
+   *   Webform submission entity.
+   *
+   * @return \Drupal\fillpdf\FieldMapping\ImageFieldMapping|null
+   *   An ImageFieldMapping, or NULL if the tokens were no image element tokens.
+   */
+  protected static function parseImageWebformElementTokens(array $tokens, ContentEntityInterface $entity) {
+    // Get all non-empty elements.
+    /** @var \Drupal\webform\WebformSubmissionInterface $entity */
+    $elements = $entity->getWebform()->getElementsInitializedFlattenedAndHasValue();
+
+    // Loop through the tokens, starting with the last one.
+    foreach (array_reverse($tokens) as $token) {
+      $name = strtr($token, ['values:' => '']);
+      if (array_key_exists($name, $elements) && isset($elements[$name]['#type']) && $elements[$name]['#type'] === 'webform_image_file') {
+        $file = File::load($entity->getElementData($name));
+        if ($file) {
+          $uri = $file->getFileUri();
+          return new ImageFieldMapping(file_get_contents($uri), NULL, $uri);
+        }
+      }
+    }
+  }
+
+  /**
+   * Scans a potential image field token.
+   *
+   * This is only called if image module is installed and the backend supports
+   * image stamping.
+   *
+   * @param string[] $tokens
+   *   List of non-fully qualified tokens for a given entity type. These may be
+   *   image field tokens such as 'field_image' or 'field_image:thumbnail' or
+   *   other tokens such as 'field_name'.
+   * @param \Drupal\Core\Entity\FieldableEntityInterface $entity
+   *   Fieldable entity.
+   *
+   * @return \Drupal\fillpdf\FieldMapping\ImageFieldMapping|null
+   *   An ImageFieldMapping, or NULL if the tokens were no image field tokens.
+   */
+  protected static function parseImageFieldTokens(array $tokens, FieldableEntityInterface $entity) {
+    // Loop through the tokens, starting with the last one.
+    foreach (array_reverse($tokens) as $token) {
+      // Explode token into its field_name and property parts.
+      list($field_name, $property) = array_pad(explode(':', $token), 2, '');
+
+      if (!$entity->hasField($field_name)) {
+        continue;
+      }
+
+      $item = $entity->get($field_name)->first();
+      if (!empty($item) && $item instanceof ImageItem) {
+        $value = $item->getValue();
+        $file = File::load($value['target_id']);
+        if ($file) {
+          $uri = $file->getFileUri();
+          return new ImageFieldMapping(file_get_contents($uri), NULL, $uri);
+        }
+      }
+    }
   }
 
   /**
diff --git a/src/TokenResolverInterface.php b/src/TokenResolverInterface.php
index d581ba4..46bc92e 100644
--- a/src/TokenResolverInterface.php
+++ b/src/TokenResolverInterface.php
@@ -12,20 +12,29 @@ interface TokenResolverInterface {
   /**
    * Replaces all tokens in a given string with appropriate values.
    *
-   * @param string $original
-   *   The string containing the tokens to replace.
-   * @param \Drupal\Core\Entity\EntityInterface[][] $entities
-   *   Multidimensional array of entities, keyed by entity ID and grouped by
-   *   entity type.
+   * This is basically a replacement for \Drupal\Core\Utility\Token::replace(),
+   * only that it resolves image tokens, applies form and field replacements
+   * after token replacement, and returns FieldMapping objects.
    *
-   * @return string
-   *   The passed-in string after replacing all possible tokens. The default
-   *   implementation of this interface removes any non-matched tokens.
+   * @param string $text
+   *   An plain text string containing replaceable tokens.
+   * @param \Drupal\Core\Entity\EntityInterface[][] $data
+   *   (optional) Multidimensional array of entities, keyed by entity ID and
+   *   grouped by entity type.
+   * @param array $options
+   *   (optional) A keyed array of settings and flags to control the token
+   *   replacement process. Supported options are:
+   *   - langcode: A language code to be used when generating locale-sensitive
+   *     tokens.
+   *   - callback: A callback function that will be used to post-process the
+   *     array of token replacements after they are generated.
+   *
+   * @return \Drupal\fillpdf\FieldMapping
+   *   An instance of a FieldMapping.
    *
    * @see \Drupal\Core\Utility\Token::replace()
-   * @see TokenResolver
    */
-  public function replace($original, array $entities);
+  public function replace($text, array $data = [], array $options = []);
 
   /**
    * Returns the token service.
-- 
GitLab