[web] Fix letter spacing for rich paragraphs (#23683)

diff --git a/lib/web_ui/dev/goldens_lock.yaml b/lib/web_ui/dev/goldens_lock.yaml
index c89bd19..f618919 100644
--- a/lib/web_ui/dev/goldens_lock.yaml
+++ b/lib/web_ui/dev/goldens_lock.yaml
@@ -1,2 +1,2 @@
 repository: https://github.com/flutter/goldens.git
-revision: 1e65186b73045bb1034ade9f12b810caabd00462
+revision: ca17cd88e5cc8155672e0b58bb7c0424a9612950
diff --git a/lib/web_ui/lib/src/engine/text/paint_service.dart b/lib/web_ui/lib/src/engine/text/paint_service.dart
index 64debd4..fd8cd81 100644
--- a/lib/web_ui/lib/src/engine/text/paint_service.dart
+++ b/lib/web_ui/lib/src/engine/text/paint_service.dart
@@ -52,7 +52,21 @@
             box.start.index,
             box.end.indexWithoutTrailingNewlines,
           );
-      canvas.fillText(text, x, y, shadows: span.style._shadows);
+      final double? letterSpacing = span.style._letterSpacing;
+      if (letterSpacing == null || letterSpacing == 0.0) {
+        canvas.fillText(text, x, y, shadows: span.style._shadows);
+      } else {
+        // TODO(mdebbar): Implement letter-spacing on canvas more efficiently:
+        //                https://github.com/flutter/flutter/issues/51234
+        double charX = x;
+        final int len = text.length;
+        for (int i = 0; i < len; i++) {
+          final String char = text[i];
+          canvas.fillText(char, charX.roundToDouble(), y,
+              shadows: span.style._shadows);
+          charX += letterSpacing + canvas.measureText(char).width!;
+        }
+      }
 
       // Paint the ellipsis using the same span styles.
       final String? ellipsis = line.ellipsis;
diff --git a/lib/web_ui/test/golden_tests/engine/canvas_paragraph/general_test.dart b/lib/web_ui/test/golden_tests/engine/canvas_paragraph/general_test.dart
index 6871a2a..ba9cb5a 100644
--- a/lib/web_ui/test/golden_tests/engine/canvas_paragraph/general_test.dart
+++ b/lib/web_ui/test/golden_tests/engine/canvas_paragraph/general_test.dart
@@ -146,4 +146,23 @@
 
     return takeScreenshot(canvas, bounds, 'canvas_paragraph_varying_heights');
   });
+
+  test('respects letter-spacing', () {
+    final canvas = BitmapCanvas(bounds, RenderStrategy());
+
+    final CanvasParagraph paragraph = rich(
+      ParagraphStyle(fontFamily: 'Roboto'),
+      (builder) {
+        builder.pushStyle(EngineTextStyle.only(color: blue));
+        builder.addText('Lorem ');
+        builder.pushStyle(EngineTextStyle.only(color: green, letterSpacing: 1));
+        builder.addText('Lorem ');
+        builder.pushStyle(EngineTextStyle.only(color: red, letterSpacing: 3));
+        builder.addText('Lorem');
+      },
+    )..layout(constrain(double.infinity));
+    canvas.drawParagraph(paragraph, Offset.zero);
+
+    return takeScreenshot(canvas, bounds, 'canvas_paragraph_letter_spacing');
+  });
 }
diff --git a/lib/web_ui/test/golden_tests/engine/compositing_golden_test.dart b/lib/web_ui/test/golden_tests/engine/compositing_golden_test.dart
index 0cfc7de..18fd6c7 100644
--- a/lib/web_ui/test/golden_tests/engine/compositing_golden_test.dart
+++ b/lib/web_ui/test/golden_tests/engine/compositing_golden_test.dart
@@ -547,8 +547,8 @@
     'renders clipped text with high quality',
     () async {
       // To reproduce blurriness we need real clipping.
-      final Paragraph paragraph =
-          (ParagraphBuilder(ParagraphStyle(fontFamily: 'Roboto'))..addText('Am I blurry?')).build();
+      final DomParagraph paragraph =
+          (DomParagraphBuilder(ParagraphStyle(fontFamily: 'Roboto'))..addText('Am I blurry?')).build();
       paragraph.layout(const ParagraphConstraints(width: 1000));
 
       final Rect canvasSize = Rect.fromLTRB(