summaryrefslogtreecommitdiffstatshomepage
path: root/core/modules/ckeditor5/src/SmartDefaultSettings.php
blob: 3c2d0d9ec2c0475cb64de3f600d8d43c1a52c312 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
<?php

declare(strict_types = 1);

namespace Drupal\ckeditor5;

use Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition;
use Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface;
use Drupal\Component\Assertion\Inspector;
use Drupal\Component\Render\FormattableMarkup;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Url;
use Drupal\editor\EditorInterface;
use Drupal\editor\Entity\Editor;
use Drupal\filter\FilterFormatInterface;
use Psr\Log\LoggerInterface;

/**
 * Generates CKEditor 5 settings for existing text editors/formats.
 *
 * @internal
 *   This class may change at any time. It is not for use outside this module.
 */
final class SmartDefaultSettings {

  use StringTranslationTrait;

  /**
   * The CKEditor 5 plugin manager.
   *
   * @var \Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface
   */
  protected $pluginManager;

  /**
   * The module handler.
   *
   * @var \Drupal\Core\Extension\ModuleHandlerInterface
   */
  protected $moduleHandler;

  /**
   * The current user.
   *
   * @var \Drupal\Core\Session\AccountInterface
   */
  protected $currentUser;

  /**
   * A logger instance.
   *
   * @var \Psr\Log\LoggerInterface
   */
  protected $logger;

  /**
   * Constructs a SmartDefaultSettings object.
   *
   * @param \Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface $plugin_manager
   *   The CKEditor 5 plugin manager.
   * @param \Psr\Log\LoggerInterface $logger
   *   A logger instance.
   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
   *   The module handler.
   * @param \Drupal\Core\Session\AccountInterface $current_user
   *   The current user.
   */
  public function __construct(CKEditor5PluginManagerInterface $plugin_manager, LoggerInterface $logger, ModuleHandlerInterface $module_handler, AccountInterface $current_user) {
    $this->pluginManager = $plugin_manager;
    $this->logger = $logger;
    $this->moduleHandler = $module_handler;
    $this->currentUser = $current_user;
  }

  /**
   * Computes the closest equivalent settings for switching to CKEditor 5.
   *
   * @param \Drupal\editor\EditorInterface|null $text_editor
   *   The editor being reconfigured for CKEditor 5; infer the settings based on
   *   the HTML restrictions.
   * @param \Drupal\filter\FilterFormatInterface $text_format
   *   The text format for which to compute smart default settings.
   *
   * @return array
   *   An array with two values:
   *   1. The cloned config entity with settings modified for CKEditor 5 … or a
   *      completely new config entity if this text format did not yet have one.
   *   2. Messages explaining what conclusions were reached.
   *
   * @throws \InvalidArgumentException
   *   Thrown when computing smart default settings for a new text format, or
   *   when the given text editor and format do not form a pair.
   */
  public function computeSmartDefaultSettings(?EditorInterface $text_editor, FilterFormatInterface $text_format): array {
    if ($text_format->isNew()) {
      throw new \InvalidArgumentException('Smart default settings can only be computed when there is a pre-existing text format.');
    }
    if ($text_editor && $text_editor->id() !== $text_format->id()) {
      throw new \InvalidArgumentException('The given text editor and text format must form a pair.');
    }

    $messages = [];

    // Ensure that unsaved changes to the text format object are also respected.
    if ($text_editor) {
      // Overwrite the Editor config entity object's $filterFormat property, to
      // prevent calls to Editor::hasAssociatedFilterFormat() and
      // Editor::getFilterFormat() from loading the FilterFormat from storage.
      // @todo Remove in https://www.drupal.org/project/drupal/issues/3231347.
      $reflector = new \ReflectionObject($text_editor);
      $property = $reflector->getProperty('filterFormat');
      $property->setValue($text_editor, $text_format);
    }

    // When there is a pre-existing text editor, pass that. Otherwise, generate
    // an empty shell of a text editor config entity — this will then
    // automatically get the default CKEditor 5 settings.
    // @todo Update after https://www.drupal.org/project/drupal/issues/3226673.
    /** @var \Drupal\editor\Entity\Editor $editor */
    $editor = $text_editor !== NULL
      ? clone $text_editor
      : Editor::create([
        'format' => $text_format->id(),
        // @see \Drupal\editor\Entity\Editor::__construct()
        // @see \Drupal\ckeditor5\Plugin\Editor\CKEditor5::getDefaultSettings()
        'editor' => 'ckeditor5',
      ]);
    $editor->setEditor('ckeditor5');

    $source_editing_additions = HTMLRestrictions::emptySet();
    // Compute the appropriate settings based on the HTML restrictions of the
    // text format.
    $old_editor = $editor->id() ? Editor::load($editor->id()) : NULL;
    $old_editor_restrictions = $old_editor ? HTMLRestrictions::fromTextFormat($old_editor->getFilterFormat()) : HTMLRestrictions::emptySet();
    // @todo Remove in https://www.drupal.org/project/drupal/issues/3245351
    if ($old_editor) {
      // When switching from another text editor, use CKEditor 5's default
      // settings.
      // @see \Drupal\ckeditor5\Plugin\Editor\CKEditor5::getDefaultSettings()
      $editor->setSettings(Editor::create(['editor' => 'ckeditor5'])->getSettings());
      $editor->setImageUploadSettings($old_editor->getImageUploadSettings());
    }

    // Add toolbar items based on HTML tags and attributes.
    // NOTE: Helper updates $editor->settings by reference and returns info for
    // the message.
    $result = $this->addToolbarItemsToMatchHtmlElementsInFormat($text_format, $editor);
    if ($result !== NULL) {
      [$enabling_message_content, $enabled_for_attributes_message_content, $missing, $plugins_enabled] = $result;

      // Distinguish between unsupported elements covering only tags or not.
      $missing_attributes = new HTMLRestrictions(array_filter($missing->getAllowedElements()));
      $unsupported = $missing->diff($missing_attributes);

      if ($enabling_message_content) {
        $this->logger->info('The CKEditor 5 migration enabled the following plugins to support tags that are allowed by the %text_format text format: %enabling_message_content. The text format must be saved to make these changes active.',
          [
            '%text_format' => $editor->getFilterFormat()->get('name'),
            '%enabling_message_content' => $enabling_message_content,
          ]
        );
      }

      // Warn user about unsupported tags.
      if (!$unsupported->allowsNothing()) {
        $this->addTagsToSourceEditing($editor, $unsupported);
        $source_editing_additions = $source_editing_additions->merge($unsupported);
        $this->logger->info("The following tags were permitted by the %text_format text format's filter configuration, but no plugin was available that supports them. To ensure the tags remain supported by this text format, the following were added to the Source Editing plugin's <em>Manually editable HTML tags</em>: @unsupported_string. The text format must be saved to make these changes active.", [
          '%text_format' => $editor->getFilterFormat()->get('name'),
          '@unsupported_string' => $unsupported->toFilterHtmlAllowedTagsString(),
        ]);
      }

      if ($enabled_for_attributes_message_content) {
        $this->logger->info('The CKEditor 5 migration process enabled the following plugins to support specific attributes that are allowed by the %text_format text format: %enabled_for_attributes_message_content.',
          [
            '%text_format' => $editor->getFilterFormat()->get('name'),
            '%enabled_for_attributes_message_content' => $enabled_for_attributes_message_content,
          ],
        );
      }
      // Warn user about supported tags but missing attributes.
      if (!$missing_attributes->allowsNothing()) {
        $this->addTagsToSourceEditing($editor, $missing_attributes);
        $source_editing_additions = $source_editing_additions->merge($missing_attributes);
        $this->logger->info("As part of migrating to CKEditor 5, it was found that the %text_format text format's HTML filters includes plugins that support the following tags, but not some of their attributes. To ensure these attributes remain supported, the following were added to the Source Editing plugin's <em>Manually editable HTML tags</em>: @missing_attributes. The text format must be saved to make these changes active.", [
          '%text_format' => $editor->getFilterFormat()->get('name'),
          '@missing_attributes' => $missing_attributes->toFilterHtmlAllowedTagsString(),
        ]);
      }
    }

    $has_html_restrictions = $editor->getFilterFormat()->filters('filter_html')->status;
    $missing_fundamental_tags = HTMLRestrictions::emptySet();
    if ($has_html_restrictions) {
      $fundamental = new HTMLRestrictions($this->pluginManager->getProvidedElements([
        'ckeditor5_essentials',
        'ckeditor5_paragraph',
      ]));
      $filter_html_restrictions = HTMLRestrictions::fromTextFormat($editor->getFilterFormat());
      $missing_fundamental_tags = $fundamental->diff($filter_html_restrictions);
      if (!$missing_fundamental_tags->allowsNothing()) {
        $editor->getFilterFormat()->setFilterConfig('filter_html', $filter_html_restrictions->merge($fundamental)->getAllowedElements());
        $this->logger->warning("As part of migrating the %text_format text format to CKEditor 5, the following tag(s) were added to <em>Limit allowed HTML tags and correct faulty HTML</em>, because they are needed to provide fundamental CKEditor 5 functionality : @missing_tags. The text format must be saved to make these changes active.", [
          '%text_format' => $editor->getFilterFormat()->get('name'),
          '@missing_tags' => $missing_fundamental_tags->toFilterHtmlAllowedTagsString(),
        ]);
      }
    }

    // Finally: for all enabled plugins, find the ones that are configurable,
    // and add their default settings. For enabled plugins with element subsets,
    // compute the appropriate settings to achieve the subset that matches the
    // original text format restrictions.
    $this->addDefaultSettingsForEnabledConfigurablePlugins($editor);

    if ($has_html_restrictions) {
      // Determine what tags/attributes are allowed in this text format that
      // were not allowed previous to the switch.
      $allowed_by_new_plugin_config = new HTMLRestrictions($this->pluginManager->getProvidedElements(array_keys($this->pluginManager->getEnabledDefinitions($editor)), $editor));
      $surplus_tags_attributes = $allowed_by_new_plugin_config->diff($old_editor_restrictions)->diff($missing_fundamental_tags);
      $attributes_to_tag = [];
      $added_tags = [];
      if (!$surplus_tags_attributes->allowsNothing()) {
        $surplus_elements = $surplus_tags_attributes->getAllowedElements();
        $added_tags = array_diff_key($surplus_elements, $old_editor_restrictions->getAllowedElements());
        foreach ($surplus_elements as $tag => $attributes) {
          $the_attributes = is_array($attributes) ? $attributes : [];
          foreach ($the_attributes as $attribute_name => $enabled) {
            if ($enabled) {
              $attributes_to_tag[$attribute_name][] = $tag;
            }
          }
        }
      }

      $help_enabled = $this->moduleHandler->moduleExists('help');
      $can_access_dblog = ($this->currentUser->hasPermission('access site reports') && $this->moduleHandler->moduleExists('dblog'));

      if (!empty($plugins_enabled) || !$source_editing_additions->allowsNothing()) {
        $beginning = $help_enabled ?
          $this->t('To maintain the capabilities of this text format, <a target="_blank" href=":ck_migration_url">the CKEditor 5 migration</a> did the following:', [
            ':ck_migration_url' => Url::fromRoute('help.page', ['name' => 'ckeditor5'], ['fragment' => 'migration-settings'])->toString(),
          ]) :
          $this->t('To maintain the capabilities of this text format, the CKEditor 5 migration did the following:');

        $plugin_info = !empty($plugins_enabled) ?
          $this->t('Enabled these plugins: (%plugins).', [
            '%plugins' => implode(', ', $plugins_enabled),
          ]) : '';

        $source_editing_info = '';
        if (!$source_editing_additions->allowsNothing()) {
          $source_editing_info = $help_enabled ?
            $this->t('Added these tags/attributes to the Source Editing Plugin\'s <a target="_blank" href=":source_edit_url">Manually editable HTML tags</a> setting: @tag_list',
              [
                '@tag_list' => $source_editing_additions->toFilterHtmlAllowedTagsString(),
                ':source_edit_url' => Url::fromRoute('help.page', ['name' => 'ckeditor5'], ['fragment' => 'source-editing'])->toString(),
              ]) :
            $this->t("Added these tags/attributes to the Source Editing Plugin's Manually editable HTML tags setting: @tag_list", ['@tag_list' => $source_editing_additions->toFilterHtmlAllowedTagsString()]);
        }

        $end = $can_access_dblog ?
          $this->t('Additional details are available <a target="_blank" href=":dblog_url">in your logs</a>.',
            [
              ':dblog_url' => Url::fromRoute('dblog.overview')
                ->setOption('query', ['type[]' => 'ckeditor5'])
                ->toString(),
            ]
          ) :
          $this->t('Additional details are available in your logs.');

        $messages[MessengerInterface::TYPE_STATUS][] = new FormattableMarkup('@beginning @plugin_info @source_editing_info. @end', [
          '@beginning' => $beginning,
          '@plugin_info' => $plugin_info,
          '@source_editing_info' => $source_editing_info,
          '@end' => $end,
        ]);
      }

      // Generate warning for:
      // - The addition of <p>/<br> due to them being fundamental tags.
      // - The addition of other tags/attributes previously unsupported by the
      //   format.
      if (!$missing_fundamental_tags->allowsNothing() || !empty($attributes_to_tag) || !empty($added_tags)) {
        $beginning = $this->t('Updating to CKEditor 5 added support for some previously unsupported tags/attributes.');
        $fundamental_tags = '';
        if ($help_enabled && !$missing_fundamental_tags->allowsNothing()) {
          $fundamental_tags = $this->formatPlural(count($missing_fundamental_tags->toCKEditor5ElementsArray()),
            'The @tag tag was added because it is <a target="_blank" href=":fundamental_tag_link">required by CKEditor 5</a>.',
            'The @tag tags were added because they are <a target="_blank" href=":fundamental_tag_link">required by CKEditor 5</a>.',
            [
              '@tag' => implode(', ', $missing_fundamental_tags->toCKEditor5ElementsArray()),
              ':fundamental_tag_link' => URL::fromRoute('help.page', ['name' => 'ckeditor5'], ['fragment' => 'required-tags'])->toString(),
            ]);
        }
        elseif (!$missing_fundamental_tags->allowsNothing()) {
          $fundamental_tags = $this->formatPlural(count($missing_fundamental_tags->toCKEditor5ElementsArray()),
            'The @tag tag was added because it is required by CKEditor 5.',
            'The @tag tags were added because they are required by CKEditor 5.',
            [
              '@tag' => implode(', ', $missing_fundamental_tags->toCKEditor5ElementsArray()),
            ]);
        }

        $added_elements_begin = !empty($attributes_to_tag) || !empty($added_tags) ? $this->t('A plugin introduced support for the following:') : '';
        $added_elements_tags = !empty($added_tags) ? $this->formatPlural(
          count($added_tags),
          'The tag %tags;',
          'The tags %tags;',
          [
            '%tags' => implode(', ', array_map(function ($tag_name) {
              return "<$tag_name>";
            }, array_keys($added_tags))),
          ]) : '';
        $added_elements_attributes = !empty($attributes_to_tag) ? $this->formatPlural(
          count($attributes_to_tag),
          'This attribute: %attributes;',
          'These attributes: %attributes;',
          [
            '%attributes' => rtrim(array_reduce(array_keys($attributes_to_tag), function ($carry, $item) use ($attributes_to_tag) {
              $for_tags = implode(', ', array_map(function ($item) {
                return "<$item>";
              }, $attributes_to_tag[$item]));
              return "$carry $item ({$this->t('for', [],  ['context' => 'Ckeditor 5 tag list'])} $for_tags),";
            }, ''), " ,"),
          ]
        ) : '';
        $end = $can_access_dblog ?
          $this->t('Additional details are available <a target="_blank" href=":dblog_url">in your logs</a>.',
            [
              ':dblog_url' => Url::fromRoute('dblog.overview')
                ->setOption('query', ['type[]' => 'ckeditor5'])
                ->toString(),
            ]
          ) :
          $this->t('Additional details are available in your logs.');

        $messages[MessengerInterface::TYPE_WARNING][] = new FormattableMarkup('@beginning @added_elements_begin @fundamental_tags @added_elements_tags @added_elements_attributes @end',
          [
            '@beginning' => $beginning,
            '@added_elements_begin' => $added_elements_begin,
            '@fundamental_tags' => $fundamental_tags,
            '@added_elements_tags' => $added_elements_tags,
            '@added_elements_attributes' => $added_elements_attributes,
            '@end' => $end,
          ]);
      }
    }

    return [$editor, $messages];
  }

  private function addTagsToSourceEditing(EditorInterface $editor, HTMLRestrictions $tags): array {
    $messages = [];
    $settings = $editor->getSettings();
    if (!isset($settings['toolbar']['items']) || !in_array('sourceEditing', $settings['toolbar']['items'])) {
      $messages[MessengerInterface::TYPE_STATUS][] = $this->t('The <em>Source Editing</em> plugin was enabled to support tags and/or attributes that are not explicitly supported by any available CKEditor 5 plugins.');
      // Add the "Source Editing" toolbar item in a new group.
      $settings['toolbar']['items'][] = '|';
      $settings['toolbar']['items'][] = 'sourceEditing';
    }
    $allowed_tags_array = $settings['plugins']['ckeditor5_sourceEditing']['allowed_tags'] ?? [];
    $allowed_tags_string = implode(' ', $allowed_tags_array);
    $settings['plugins']['ckeditor5_sourceEditing']['allowed_tags'] = HTMLRestrictions::fromString($allowed_tags_string)->merge($tags)->toCKEditor5ElementsArray();
    $editor->setSettings($settings);
    return $messages;
  }

  /**
   * Computes net new needed elements when considering adding the given plugin.
   *
   * @param \Drupal\ckeditor5\HTMLRestrictions $baseline
   *   The set of HTML restrictions already supported.
   * @param \Drupal\ckeditor5\HTMLRestrictions $needed
   *   The set of HTML restrictions that are needed, that is: in addition to
   *   $baseline.
   * @param \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition $added_plugin
   *   The CKEditor 5 plugin that is being evaluated to check if it would meet
   *   some of the needs.
   *
   * @return array
   *   An array containing two values:
   *   - a set of HTML restrictions that indicates the net new additions that
   *     are needed
   *   - a set of HTML restrictions that indicates the surplus additions (these
   *     are elements that were not needed, but are added by this plugin)
   */
  private static function computeNetNewElementsForPlugin(HTMLRestrictions $baseline, HTMLRestrictions $needed, CKEditor5PluginDefinition $added_plugin): array {
    $plugin_support = HTMLRestrictions::fromString(implode(' ', $added_plugin->getElements()));
    // Do not inspect just $plugin_support, but the union of that with the
    // already supported elements: wildcard restrictions will only resolve
    // if the concrete tags they support are also present.
    $potential_future = $baseline->merge($plugin_support);
    // This is the heart of the operation: intersect the potential future
    // with what we need to achieve, then subtract what is already
    // supported. This yields the net new elements.
    $net_new = $potential_future->intersect($needed)->diff($baseline);
    // But … we may compute too many.
    $surplus_additions = $potential_future->diff($needed)->diff($baseline);
    return [$net_new, $surplus_additions];
  }

  /**
   * Computes a score for the given surplus compared to the given need.
   *
   * @param \Drupal\ckeditor5\HTMLRestrictions $surplus
   *   A surplus compared to what is needed.
   * @param \Drupal\ckeditor5\HTMLRestrictions $needed
   *   Exactly what is needed.
   *
   * @return int
   *   A surplus score. Lower is better. Scores are a positive integer.
   *
   * @see https://www.drupal.org/project/drupal/issues/3231328#comment-14444987
   */
  private static function computeSurplusScore(HTMLRestrictions $surplus, HTMLRestrictions $needed): int {
    // Compute a score for surplus elements, while taking into account how much
    // impact each surplus element has:
    $surplus_score = 0;
    foreach ($surplus->getAllowedElements() as $tag_name => $attributes_config) {
      // 10^6 per surplus tag.
      if (!isset($needed->getAllowedElements()[$tag_name])) {
        $surplus_score += pow(10, 6);
      }

      // 10^5 per surplus "any attributes allowed".
      if ($attributes_config === TRUE) {
        $surplus_score += pow(10, 5);
      }

      if (!is_array($attributes_config)) {
        continue;
      }

      foreach ($attributes_config as $attribute_name => $attribute_config) {
        // 10^4 per surplus wildcard attribute.
        if (str_contains($attribute_name, '*')) {
          $surplus_score += pow(10, 4);
        }
        // 10^3 per surplus attribute.
        else {
          $surplus_score += pow(10, 3);
        }

        // 10^2 per surplus "any attribute values allowed".
        if ($attribute_config === TRUE) {
          $surplus_score += pow(10, 2);
        }

        if (!is_array($attribute_config)) {
          continue;
        }

        foreach ($attribute_config as $allowed_attribute_value => $allowed_attribute_value_config) {
          // 10^1 per surplus wildcard attribute value.
          if (str_contains($allowed_attribute_value, '*')) {
            $surplus_score += pow(10, 1);
          }
          // 10^0 per surplus attribute value.
          else {
            $surplus_score += pow(10, 0);
          }
        }
      }
    }
    return $surplus_score;
  }

  /**
   * Finds candidates for the still needed restrictions among disabled plugins.
   *
   * @param \Drupal\ckeditor5\HTMLRestrictions $provided
   *   The already provided HTML restrictions, thanks to already enabled
   *   CKEditor 5 plugins.
   * @param \Drupal\ckeditor5\HTMLRestrictions $still_needed
   *   The still needed HTML restrictions, unmet by the already enabled CKEditor
   *   5 plugins.
   * @param \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition[] $disabled_plugin_definitions
   *   The list of not yet enabled CKEditor 5 plugin definitions, amongst which
   *   candidates must be found.
   * @param \Drupal\editor\EditorInterface $prospective_editor
   *   The in-progress prospective editor to be generated by this class.
   *
   * @return array
   *   A nested array with a tree structure covering:
   *   1. tag name
   *   2. concrete attribute name, `-attribute-none-` (meaning no attributes
   *      allowed on this tag) or `-attribute-any-` (meaning any attribute
   *      allowed on this tag).
   *   3. (optional) attribute value (if concrete attribute name in previous
   *      level), `TRUE` or `FALSE`
   *   4. (optional) attribute value restriction
   *   5. candidate CKEditor 5 plugin ID for the HTML elements in the hierarchy
   *   and the surplus score as the value. In other words: the leaf of this is
   *   always a leaf, and a selected CKEditor 5 plugin ID is always the parent
   *   of a leaf.
   */
  private function getCandidates(HTMLRestrictions $provided, HTMLRestrictions $still_needed, array $disabled_plugin_definitions, EditorInterface $prospective_editor): array {
    $plugin_candidates = [];
    if (!$still_needed->allowsNothing()) {
      foreach ($disabled_plugin_definitions as $definition) {
        // Only proceed if the plugin has configured elements and the plugin
        // does not have conditions, or only conditions that are met. In the
        // future we could add support for automatically enabling filters, but
        // for now we assume that the filter configuration cannot be modified.
        if (!$definition->hasElements()) {
          continue;
        }
        if (!$definition->hasConditions()) {
          // Any plugin that has no conditions is a viable candidate.
          $is_viable_candidate = TRUE;
        }
        else {
          $is_viable_candidate = TRUE;
          foreach ($definition->getConditions() as $condition_type => $required_value) {
            // @see \Drupal\ckeditor5\Plugin\CKEditor5PluginManager::isPluginDisabled()
            // @see \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition::validateDrupalAspects()
            $is_viable_candidate = match ($condition_type) {
              // If this requires a toolbar item that is provided by this
              // plugin, then this is a viable candidate: placing this plugin's
              // toolbar item suffices.
              'toolbarItem' => array_key_exists($definition->getConditions()['toolbarItem'], $definition->getToolbarItems()),
              // The image upload status is not modified, so if the current
              // value matches the condition, then this is a viable candidate.
              'imageUploadStatus' => ($prospective_editor->getImageUploadSettings()['status'] ?? FALSE) === $required_value,
              // The set of filters is not modified, so if the required filter
              // is already enabled, then this is a viable candidate.
              // @todo Simplify in https://www.drupal.org/project/drupal/issues/2385047
              'filter' => $prospective_editor->getFilterFormat()->filters()->has($required_value) && $prospective_editor->getFilterFormat()->filters()->get($required_value)->status,
              // The default configuration is used for each plugin that would be
              // enabled, so if the default configuration contains the required
              // configuration, then this is a viable candidate.
              'requiresConfiguration' =>  array_intersect($this->pluginManager->createInstance($definition->id())->defaultConfiguration(), $required_value) === $required_value,
              // If this requires plugins already in the current prospective
              // editor, then this is a viable candidate.
              'plugins' => array_intersect($required_value, array_keys($this->pluginManager->getEnabledDefinitions($prospective_editor))) === $required_value,
            };
            if (!$is_viable_candidate) {
              break;
            }
          }
        }
        if ($is_viable_candidate) {
          [$net_new, $surplus_additions] = self::computeNetNewElementsForPlugin($provided, $still_needed, $definition);
          if (!$net_new->allowsNothing()) {
            $plugin_id = $definition->id();
            $creatable_elements = HTMLRestrictions::fromString(implode(' ', $definition->getCreatableElements()));
            $surplus_score = static::computeSurplusScore($surplus_additions, $still_needed);
            foreach ($net_new->getAllowedElements() as $tag_name => $attributes_config) {
              // Non-specific attribute restrictions: `FALSE` or `TRUE`.
              // TRICKY: PHP does not support boolean array keys, so map these
              // to a string. The string must not be a valid attribute name, so
              // use a leading and trailing dash.
              if (!is_array($attributes_config)) {
                if ($attributes_config === FALSE && !array_key_exists($tag_name, $creatable_elements->getAllowedElements())) {
                  // If this plugin is not able to create the plain tag, then
                  // cannot be a candidate for the tag without attributes.
                  continue;
                }
                $non_specific_attribute = $attributes_config ? '-attributes-any-' : '-attributes-none-';
                $plugin_candidates[$tag_name][$non_specific_attribute][$plugin_id] = $surplus_score;
                continue;
              }

              // With specific attribute restrictions: array.
              foreach ($attributes_config as $attribute_name => $attribute_config) {
                if (!is_array($attribute_config)) {
                  $plugin_candidates[$tag_name][$attribute_name][$attribute_config][$plugin_id] = $surplus_score;
                }
                else {
                  foreach ($attribute_config as $allowed_attribute_value => $allowed_attribute_value_config) {
                    $plugin_candidates[$tag_name][$attribute_name][$allowed_attribute_value][$allowed_attribute_value_config][$plugin_id] = $surplus_score;
                  }
                }
              }

              // If this plugin supports unneeded attributes, it still makes a
              // valid candidate for supporting the HTML tag.
              $plugin_candidates[$tag_name]['-attributes-none-'][$plugin_id] = $surplus_score;
            }
          }
        }
      }
    }

    return $plugin_candidates;
  }

  /**
   * Selects best candidate for each of the still needed restrictions.
   *
   * @param array $candidates
   *   The output of ::getCandidates().
   * @param \Drupal\ckeditor5\HTMLRestrictions $still_needed
   *   The still needed HTML restrictions, unmet by the already enabled CKEditor
   *   5 plugins.
   * @param string[] $already_supported_tags
   *   A list of already supported HTML tags, necessary to select the best
   *   matching candidate for elements still needed in $still_needed.
   *
   * @return array
   *   A nested array with a tree structure, with each key a selected CKEditor 5
   *   plugin ID and its values expressing the reason it was enabled.
   */
  private static function selectCandidate(array $candidates, HTMLRestrictions $still_needed, array $already_supported_tags): array {
    assert(Inspector::assertAllStrings($already_supported_tags));

    // Make a selection in the candidates: minimize the surplus count, to
    // avoid generating surplus additions whenever possible.
    $selected_plugins = [];
    foreach ($still_needed->getAllowedElements() as $tag_name => $attributes_config) {
      if (!isset($candidates[$tag_name])) {
        // Sadly no plugin found for this tag.
        continue;
      }

      // Non-specific attribute restrictions for tag.
      if (is_bool($attributes_config)) {
        $key = $attributes_config ? '-attributes-any-' : '-attributes-none-';
        if (!isset($candidates[$tag_name][$key])) {
          // Sadly no plugin found for this tag + unspecific attribute.
          continue;
        }
        asort($candidates[$tag_name][$key]);
        $selected_plugin_id = array_keys($candidates[$tag_name][$key])[0];
        $selected_plugins[$selected_plugin_id][$key][$tag_name] = NULL;
        continue;
      }

      // Specific attribute restrictions for tag.
      foreach ($attributes_config as $attribute_name => $attribute_config) {
        if (!isset($candidates[$tag_name][$attribute_name])) {
          // Sadly no plugin found for this tag + attribute.
          continue;
        }
        if (!is_array($attribute_config)) {
          if (!isset($candidates[$tag_name][$attribute_name][$attribute_config])) {
            // Sadly no plugin found for this tag + attribute + config.
            continue;
          }
          asort($candidates[$tag_name][$attribute_name][$attribute_config]);
          $selected_plugin_id = array_keys($candidates[$tag_name][$attribute_name][$attribute_config])[0];
          $selected_plugins[$selected_plugin_id][$attribute_name][$tag_name] = $attribute_config;
          continue;
        }
        else {
          foreach ($attribute_config as $allowed_attribute_value => $allowed_attribute_value_config) {
            if (!isset($candidates[$tag_name][$attribute_name][$allowed_attribute_value][$allowed_attribute_value_config])) {
              // Sadly no plugin found for this tag + attr + value + config.
              continue;
            }
            asort($candidates[$tag_name][$attribute_name][$allowed_attribute_value][$allowed_attribute_value_config]);
            $selected_plugin_id = array_keys($candidates[$tag_name][$attribute_name][$allowed_attribute_value][$allowed_attribute_value_config])[0];
            $selected_plugins[$selected_plugin_id][$attribute_name][$tag_name][$allowed_attribute_value] = $allowed_attribute_value_config;
            continue;
          }
        }
      }

      // If we got to this point, no exact match was found. But selecting a
      // plugin to support the tag at all (when it is not yet supported) is
      // crucial to meet the user's expectations.
      // For example: when `<blockquote cite>` is needed, select at least the
      // plugin that can support `<blockquote>`, then only the `cite` attribute
      // needs to be made possible using the `SourceEditing` plugin.
      if (!in_array($tag_name, $already_supported_tags, TRUE) && isset($candidates[$tag_name]['-attributes-none-'])) {
        asort($candidates[$tag_name]['-attributes-none-']);
        $selected_plugin_id = array_keys($candidates[$tag_name]['-attributes-none-'])[0];
        $selected_plugins[$selected_plugin_id]['-attributes-none-'][$tag_name] = NULL;
      }
    }

    // The above selects all exact matches. It's possible the same plugin is
    // selected for multiple reasons: for supporting the tag at all, but also
    // for supporting more attributes on the tag. Whenever that scenario
    // occurs, keep only the "tag" reason, since that is the most relevant one
    // for the end user. Otherwise a single plugin being selected (and enabled)
    // could generate multiple messages, which would be confusing and
    // overwhelming for the user.
    // For example: when `<a href>` is needed, supporting `<a>` is more
    // relevant to be informed about as an end user than the plugin also being
    // enabled to support the `href` attribute.
    foreach ($selected_plugins as $selected_plugin_id => $reason) {
      if (count($reason) > 1 && isset($reason['-attributes-none-'])) {
        $selected_plugins[$selected_plugin_id] = array_intersect_key($reason, ['-attributes-none-' => TRUE]);
      }
    }

    return $selected_plugins;
  }

  /**
   * Adds CKEditor 5 toolbar items to match the format's HTML elements.
   *
   * @param \Drupal\filter\FilterFormatInterface $format
   *   The text format for which to compute smart default settings.
   * @param \Drupal\editor\EditorInterface $editor
   *   The text editor config entity to update.
   *
   * @return array|null
   *   NULL when nothing happened, otherwise an array with four values:
   *   1. a description (for use in a message) of which CKEditor 5 plugins were
   *      enabled to match the HTML tags allowed by the text format.
   *   2. a description (for use in a message) of which CKEditor 5 plugins were
   *      enabled to match the HTML attributes allowed by the text format.
   *   3. the unsupported elements, in an HTMLRestrictions value object.
   *   4. the list of enabled plugin labels.
   */
  private function addToolbarItemsToMatchHtmlElementsInFormat(FilterFormatInterface $format, EditorInterface $editor): ?array {
    $html_restrictions_needed_elements = $format->getHtmlRestrictions();
    if ($html_restrictions_needed_elements === FALSE) {
      // There are no HTML restrictions, so configure CKEditor 5 to allow
      // arbitrary markup to be entered.
      $editor_settings_to_update = $editor->getSettings();
      // Create new group for all the added toolbar items.
      $editor_settings_to_update['toolbar']['items'][] = '|';
      $editor_settings_to_update['toolbar']['items'][] = 'sourceEditing';
      $editor_settings_to_update['plugins']['ckeditor5_sourceEditing']['allowed_tags'] = [];
      $editor->setSettings($editor_settings_to_update);
      return [
        NULL,
        NULL,
        HTMLRestrictions::emptySet(),
        $this->pluginManager->getDefinition('ckeditor5_sourceEditing'),
      ];
    }

    $all_definitions = $this->pluginManager->getDefinitions();
    $enabled_definitions = $this->pluginManager->getEnabledDefinitions($editor);
    $disabled_definitions = array_diff_key($all_definitions, $enabled_definitions);
    $enabled_plugins = array_keys($enabled_definitions);
    $provided_elements = $this->pluginManager->getProvidedElements($enabled_plugins, $editor);
    $provided = new HTMLRestrictions($provided_elements);
    $needed = HTMLRestrictions::fromTextFormat($format);
    // Plugins only supporting <tag attr> cannot create the tag. For that, they
    // must support plain <tag> too. With this being the case, break down what
    // is needed based on what is currently provided.
    // @see \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition::getCreatableElements()
    // TRICKY: the HTMLRestrictions value object can only convey complete
    // restrictions: merging <foo> and <foo bar> results in just <foo bar>. The
    // list of already provided plain tags must hence be constructed separately.
    $provided_plain_tags = new HTMLRestrictions(
      $this->pluginManager->getProvidedElements($enabled_plugins, NULL, FALSE, TRUE)
    );

    // Determine the still needed plain tags, the still needed attributes, and
    // the union of both.
    $still_needed_plain_tags = $needed->extractPlainTagsSubset()->diff($provided_plain_tags);
    $still_needed_attributes = $needed->diff($provided)->diff($still_needed_plain_tags);
    $still_needed = $still_needed_plain_tags->merge($still_needed_attributes);

    if (!$still_needed->allowsNothing()) {
      // Select plugins for supporting the still needed plain tags.
      $prospective_editor = clone $editor;
      $plugin_candidates_plain_tags = self::getCandidates($provided_plain_tags, $still_needed_plain_tags, $disabled_definitions, $prospective_editor);
      $selected_plugins_plain_tags = self::selectCandidate($plugin_candidates_plain_tags, $still_needed_plain_tags, array_keys($provided_plain_tags->getAllowedElements()));

      // Select plugins for supporting the still needed attributes.
      $prospective_editor_settings = $prospective_editor->getSettings();
      foreach (array_keys($selected_plugins_plain_tags) as $plugin_id) {
        $plugin_definition = $this->pluginManager->getDefinition($plugin_id);
        assert($plugin_definition instanceof CKEditor5PluginDefinition);
        if ($plugin_definition->hasToolbarItems()) {
          $prospective_editor_settings['toolbar']['items'] = [...$prospective_editor_settings['toolbar']['items'], ...array_keys($plugin_definition->getToolbarItems())];
        }
        if ($plugin_definition->isConfigurable()) {
          $prospective_editor_settings['plugins'][$plugin_id] = $this->pluginManager->createInstance($plugin_id)->defaultConfiguration();
        }
      }
      $prospective_editor->setSettings($prospective_editor_settings);
      $plugin_candidates_attributes = self::getCandidates($provided, $still_needed_attributes, array_diff_key($disabled_definitions, $selected_plugins_plain_tags), $prospective_editor);
      $selected_plugins_attributes = self::selectCandidate($plugin_candidates_attributes, $still_needed, array_keys($provided->getAllowedElements()));

      // Combine the selection.
      $selected_plugins = array_merge_recursive($selected_plugins_plain_tags, $selected_plugins_attributes);

      // If additional plugins need to be enabled to support attribute config,
      // loop through the list to enable the plugins and build a UI message that
      // will convey this plugin-enabling to the user.
      if (!empty($selected_plugins)) {
        $enabled_for_tags_message_content = '';
        $enabled_for_attributes_message_content = '';
        $editor_settings_to_update = $editor->getSettings();
        // Create new group for all the added toolbar items.
        $editor_settings_to_update['toolbar']['items'][] = '|';
        foreach ($selected_plugins as $plugin_id => $reason_why_enabled) {
          $plugin_definition = $this->pluginManager->getDefinition($plugin_id);
          $label = $plugin_definition->label();
          $plugins_enabled[] = $label;
          [$net_new] = self::computeNetNewElementsForPlugin($provided, $still_needed, $plugin_definition);
          // Track remaining elements/attributes that are still needed.
          $still_needed = $still_needed->diff($net_new);

          // Fulfill the purpose of this method: generate the settings to add
          // this plugin's toolbar item.
          if ($plugin_definition->hasToolbarItems()) {
            $editor_settings_to_update['toolbar']['items'] = array_merge($editor_settings_to_update['toolbar']['items'], array_keys($plugin_definition->getToolbarItems()));
            foreach ($reason_why_enabled as $attribute_name => $attribute_config) {
              // Plugin was selected for tag.
              if (in_array($attribute_name, ['-attributes-none-', '-attributes-any-'], TRUE)) {
                $tags = array_reduce(array_keys($net_new->getAllowedElements()), function ($carry, $item) {
                  return $carry . "<$item>";
                });
                $enabled_for_tags_message_content .= "$label (for tags: $tags) ";
                // This plugin does not add attributes: continue to next plugin.
                continue;
              }
              // Plugin was selected for attribute.
              $enabled_for_attributes_message_content .= "$label (";
              foreach ($attribute_config as $tag_name => $attribute_value_config) {
                $enabled_for_attributes_message_content .= " for tag: <$tag_name> to support: $attribute_name";
                if (is_array($attribute_value_config)) {
                  $enabled_for_attributes_message_content .= " with value(s): ";
                  foreach (array_keys($attribute_value_config) as $allowed_value) {
                    $enabled_for_attributes_message_content .= " $allowed_value,";
                  }
                  $enabled_for_attributes_message_content = substr($enabled_for_attributes_message_content, 0, -1) . '), ';
                }
              }
            }
          }
        }
        $editor->setSettings($editor_settings_to_update);
        // Some plugins enabled, maybe some missing tags or attributes.
        return [
          substr($enabled_for_tags_message_content, 0, -1),
          substr($enabled_for_attributes_message_content, 0, -2),
          $still_needed,
          $plugins_enabled,
        ];
      }
      else {
        // No plugins enabled, maybe some missing tags or attributes.
        return [
          NULL,
          NULL,
          $still_needed,
          NULL,
        ];
      }
    }
    else {
      return NULL;
    }
  }

  /**
   * Adds default settings for all enabled CKEditor 5 plugins.
   *
   * @param \Drupal\editor\EditorInterface $editor
   *   The text editor config entity to update.
   */
  private function addDefaultSettingsForEnabledConfigurablePlugins(EditorInterface $editor): void {
    $settings = $editor->getSettings();
    $update_settings = FALSE;
    $enabled_definitions = $this->pluginManager->getEnabledDefinitions($editor);
    $configurable_definitions = array_filter($enabled_definitions, function (CKEditor5PluginDefinition $definition): bool {
      return $definition->isConfigurable();
    });

    foreach ($configurable_definitions as $plugin_name => $definition) {
      $default_plugin_configuration = $this->pluginManager->getPlugin($plugin_name, NULL)->defaultConfiguration();
      // Skip plugins with an empty default configuration, the plugin
      // configuration is most likely stored elsewhere. Also skip any plugin
      // that already has configuration data as default values are not needed.
      if ($default_plugin_configuration === [] || isset($settings['plugins'][$plugin_name])) {
        continue;
      }
      $update_settings = TRUE;
      $settings['plugins'][$plugin_name] = $default_plugin_configuration;
    }

    if ($update_settings) {
      $editor->setSettings($settings);
    }
  }

}