View Javadoc
1   /*
2    * Copyright (c) 2002-2026 Gargoyle Software Inc.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    * https://www.apache.org/licenses/LICENSE-2.0
8    *
9    * Unless required by applicable law or agreed to in writing, software
10   * distributed under the License is distributed on an "AS IS" BASIS,
11   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12   * See the License for the specific language governing permissions and
13   * limitations under the License.
14   */
15  package org.htmlunit.platform.canvas.rendering;
16  
17  import java.awt.AlphaComposite;
18  import java.awt.BasicStroke;
19  import java.awt.Color;
20  import java.awt.Composite;
21  import java.awt.Font;
22  import java.awt.Graphics2D;
23  import java.awt.RenderingHints;
24  import java.awt.Shape;
25  import java.awt.geom.AffineTransform;
26  import java.awt.geom.Arc2D;
27  import java.awt.geom.Path2D;
28  import java.awt.geom.PathIterator;
29  import java.awt.geom.Point2D;
30  import java.awt.image.BufferedImage;
31  import java.awt.image.ImageObserver;
32  import java.io.ByteArrayOutputStream;
33  import java.io.IOException;
34  import java.nio.charset.StandardCharsets;
35  import java.util.ArrayDeque;
36  import java.util.ArrayList;
37  import java.util.Base64;
38  import java.util.Deque;
39  import java.util.HashMap;
40  import java.util.List;
41  import java.util.Locale;
42  import java.util.Map;
43  
44  import javax.imageio.ImageIO;
45  import javax.imageio.ImageReader;
46  
47  import org.apache.commons.logging.Log;
48  import org.apache.commons.logging.LogFactory;
49  import org.htmlunit.platform.image.ImageIOImageData;
50  import org.htmlunit.util.StringUtils;
51  
52  /**
53   * The default implementation of {@link RenderingBackend}.
54   *
55   * @author Ronald Brill
56   */
57  public class AwtRenderingBackend implements RenderingBackend {
58  
59      private static final Log LOG = LogFactory.getLog(AwtRenderingBackend.class);
60      private static int ID_GENERATOR_;
61  
62      private static final Map<String, Color> KNOWN_COLORS = new HashMap<>();
63  
64      private final int id_;
65      private final BufferedImage image_;
66      private final Graphics2D graphics2D_;
67  
68      private AffineTransform transformation_;
69      private float globalAlpha_;
70      private float lineWidth_;
71      private Color fillColor_;
72      private Color strokeColor_;
73  
74      private LineCap lineCap_;
75      private LineJoin lineJoin_;
76      private float miterLimit_;
77  
78      private final List<Path2D> subPaths_;
79      private final Deque<SaveState> savedStates_;
80  
81      static {
82          // see https://developer.mozilla.org/en-US/docs/Web/CSS/color_value
83  
84          // CSS Level 1
85          KNOWN_COLORS.put("black", Color.decode("#000000"));
86          KNOWN_COLORS.put("silver", Color.decode("#c0c0c0"));
87          KNOWN_COLORS.put("gray", Color.decode("#808080"));
88          KNOWN_COLORS.put("white", Color.decode("#ffffff"));
89          KNOWN_COLORS.put("maroon", Color.decode("#800000"));
90          KNOWN_COLORS.put("red", Color.decode("#ff0000"));
91          KNOWN_COLORS.put("purple", Color.decode("#800080"));
92          KNOWN_COLORS.put("fuchsia", Color.decode("#ff00ff"));
93          KNOWN_COLORS.put("green", Color.decode("#008000"));
94          KNOWN_COLORS.put("lime", Color.decode("#00ff00"));
95          KNOWN_COLORS.put("olive", Color.decode("#808000"));
96          KNOWN_COLORS.put("yellow", Color.decode("#ffff00"));
97          KNOWN_COLORS.put("navy", Color.decode("#000080"));
98          KNOWN_COLORS.put("blue", Color.decode("#0000ff"));
99          KNOWN_COLORS.put("teal", Color.decode("#008080"));
100         KNOWN_COLORS.put("aqua", Color.decode("#00ffff"));
101 
102         // CSS Level 2 (Revision 1)
103         KNOWN_COLORS.put("orange", Color.decode("#ffa500"));
104 
105         // CSS Color Module Level 3
106         KNOWN_COLORS.put("aliceblue", Color.decode("#f0f8ff"));
107         KNOWN_COLORS.put("antiquewhite", Color.decode("#faebd7"));
108         KNOWN_COLORS.put("aquamarine", Color.decode("#7fffd4"));
109         KNOWN_COLORS.put("azure", Color.decode("#f0ffff"));
110         KNOWN_COLORS.put("beige", Color.decode("#f5f5dc"));
111         KNOWN_COLORS.put("bisque", Color.decode("#ffe4c4"));
112         KNOWN_COLORS.put("blanchedalmond", Color.decode("#ffebcd"));
113         KNOWN_COLORS.put("blueviolet", Color.decode("#8a2be2"));
114         KNOWN_COLORS.put("brown", Color.decode("#a52a2a"));
115         KNOWN_COLORS.put("burlywood", Color.decode("#deb887"));
116         KNOWN_COLORS.put("cadetblue", Color.decode("#5f9ea0"));
117         KNOWN_COLORS.put("chartreuse", Color.decode("#7fff00"));
118         KNOWN_COLORS.put("chocolate", Color.decode("#d2691e"));
119         KNOWN_COLORS.put("coral", Color.decode("#ff7f50"));
120         KNOWN_COLORS.put("cornflowerblue", Color.decode("#6495ed"));
121         KNOWN_COLORS.put("cornsilk", Color.decode("#fff8dc"));
122         KNOWN_COLORS.put("crimson", Color.decode("#dc143c"));
123         KNOWN_COLORS.put("cyan", Color.decode("#00ffff")); // synonym of aqua
124         KNOWN_COLORS.put("darkblue", Color.decode("#00008b"));
125         KNOWN_COLORS.put("darkcyan", Color.decode("#008b8b"));
126         KNOWN_COLORS.put("darkgoldenrod", Color.decode("#b8860b"));
127         KNOWN_COLORS.put("darkgray", Color.decode("#a9a9a9"));
128         KNOWN_COLORS.put("darkgreen", Color.decode("#006400"));
129         KNOWN_COLORS.put("darkgrey", Color.decode("#a9a9a9"));
130         KNOWN_COLORS.put("darkkhaki", Color.decode("#bdb76b"));
131         KNOWN_COLORS.put("darkmagenta", Color.decode("#8b008b"));
132         KNOWN_COLORS.put("darkolivegreen", Color.decode("#556b2f"));
133         KNOWN_COLORS.put("darkorange", Color.decode("#ff8c00"));
134         KNOWN_COLORS.put("darkorchid", Color.decode("#9932cc"));
135         KNOWN_COLORS.put("darkred", Color.decode("#8b0000"));
136         KNOWN_COLORS.put("darksalmon", Color.decode("#e9967a"));
137         KNOWN_COLORS.put("darkseagreen", Color.decode("#8fbc8f"));
138         KNOWN_COLORS.put("darkslateblue", Color.decode("#483d8b"));
139         KNOWN_COLORS.put("darkslategray", Color.decode("#2f4f4f"));
140         KNOWN_COLORS.put("darkslategrey", Color.decode("#2f4f4f"));
141         KNOWN_COLORS.put("darkturquoise", Color.decode("#00ced1"));
142         KNOWN_COLORS.put("darkviolet", Color.decode("#9400d3"));
143         KNOWN_COLORS.put("deeppink", Color.decode("#ff1493"));
144         KNOWN_COLORS.put("deepskyblue", Color.decode("#00bfff"));
145         KNOWN_COLORS.put("dimgray", Color.decode("#696969"));
146         KNOWN_COLORS.put("dimgrey", Color.decode("#696969"));
147         KNOWN_COLORS.put("dodgerblue", Color.decode("#1e90ff"));
148         KNOWN_COLORS.put("firebrick", Color.decode("#b22222"));
149         KNOWN_COLORS.put("floralwhite", Color.decode("#fffaf0"));
150         KNOWN_COLORS.put("forestgreen", Color.decode("#228b22"));
151         KNOWN_COLORS.put("gainsboro", Color.decode("#dcdcdc"));
152         KNOWN_COLORS.put("ghostwhite", Color.decode("#f8f8ff"));
153         KNOWN_COLORS.put("gold", Color.decode("#ffd700"));
154         KNOWN_COLORS.put("goldenrod", Color.decode("#daa520"));
155         KNOWN_COLORS.put("greenyellow", Color.decode("#adff2f"));
156         KNOWN_COLORS.put("grey", Color.decode("#808080"));
157         KNOWN_COLORS.put("honeydew", Color.decode("#f0fff0"));
158         KNOWN_COLORS.put("hotpink", Color.decode("#ff69b4"));
159         KNOWN_COLORS.put("indianred", Color.decode("#cd5c5c"));
160         KNOWN_COLORS.put("indigo", Color.decode("#4b0082"));
161         KNOWN_COLORS.put("ivory", Color.decode("#fffff0"));
162         KNOWN_COLORS.put("khaki", Color.decode("#f0e68c"));
163         KNOWN_COLORS.put("lavender", Color.decode("#e6e6fa"));
164         KNOWN_COLORS.put("lavenderblush", Color.decode("#fff0f5"));
165         KNOWN_COLORS.put("lawngreen", Color.decode("#7cfc00"));
166         KNOWN_COLORS.put("lemonchiffon", Color.decode("#fffacd"));
167         KNOWN_COLORS.put("lightblue", Color.decode("#add8e6"));
168         KNOWN_COLORS.put("lightcoral", Color.decode("#f08080"));
169         KNOWN_COLORS.put("lightcyan", Color.decode("#e0ffff"));
170         KNOWN_COLORS.put("lightgoldenrodyellow", Color.decode("#fafad2"));
171         KNOWN_COLORS.put("lightgray", Color.decode("#d3d3d3"));
172         KNOWN_COLORS.put("lightgreen", Color.decode("#90ee90"));
173         KNOWN_COLORS.put("lightgrey", Color.decode("#d3d3d3"));
174         KNOWN_COLORS.put("lightpink", Color.decode("#ffb6c1"));
175         KNOWN_COLORS.put("lightsalmon", Color.decode("#ffa07a"));
176         KNOWN_COLORS.put("lightseagreen", Color.decode("#20b2aa"));
177         KNOWN_COLORS.put("lightskyblue", Color.decode("#87cefa"));
178         KNOWN_COLORS.put("lightslategray", Color.decode("#778899"));
179         KNOWN_COLORS.put("lightslategrey", Color.decode("#778899"));
180         KNOWN_COLORS.put("lightsteelblue", Color.decode("#b0c4de"));
181         KNOWN_COLORS.put("lightyellow", Color.decode("#ffffe0"));
182         KNOWN_COLORS.put("limegreen", Color.decode("#32cd32"));
183         KNOWN_COLORS.put("linen", Color.decode("#faf0e6"));
184         KNOWN_COLORS.put("magenta", Color.decode("#ff00ff")); // synonym of fuchsia
185         KNOWN_COLORS.put("mediumaquamarine", Color.decode("#66cdaa"));
186         KNOWN_COLORS.put("mediumblue", Color.decode("#0000cd"));
187         KNOWN_COLORS.put("mediumorchid", Color.decode("#ba55d3"));
188         KNOWN_COLORS.put("mediumpurple", Color.decode("#9370db"));
189         KNOWN_COLORS.put("mediumseagreen", Color.decode("#3cb371"));
190         KNOWN_COLORS.put("mediumslateblue", Color.decode("#7b68ee"));
191         KNOWN_COLORS.put("mediumspringgreen", Color.decode("#00fa9a"));
192         KNOWN_COLORS.put("mediumturquoise", Color.decode("#48d1cc"));
193         KNOWN_COLORS.put("mediumvioletred", Color.decode("#c71585"));
194         KNOWN_COLORS.put("midnightblue", Color.decode("#191970"));
195         KNOWN_COLORS.put("mintcream", Color.decode("#f5fffa"));
196         KNOWN_COLORS.put("mistyrose", Color.decode("#ffe4e1"));
197         KNOWN_COLORS.put("moccasin", Color.decode("#ffe4b5"));
198         KNOWN_COLORS.put("navajowhite", Color.decode("#ffdead"));
199         KNOWN_COLORS.put("oldlace", Color.decode("#fdf5e6"));
200         KNOWN_COLORS.put("olivedrab", Color.decode("#6b8e23"));
201         KNOWN_COLORS.put("orangered", Color.decode("#ff4500"));
202         KNOWN_COLORS.put("orchid", Color.decode("#da70d6"));
203         KNOWN_COLORS.put("palegoldenrod", Color.decode("#eee8aa"));
204         KNOWN_COLORS.put("palegreen", Color.decode("#98fb98"));
205         KNOWN_COLORS.put("paleturquoise", Color.decode("#afeeee"));
206         KNOWN_COLORS.put("palevioletred", Color.decode("#db7093"));
207         KNOWN_COLORS.put("papayawhip", Color.decode("#ffefd5"));
208         KNOWN_COLORS.put("peachpuff", Color.decode("#ffdab9"));
209         KNOWN_COLORS.put("peru", Color.decode("#cd853f"));
210         KNOWN_COLORS.put("pink", Color.decode("#ffc0cb"));
211         KNOWN_COLORS.put("plum", Color.decode("#dda0dd"));
212         KNOWN_COLORS.put("powderblue", Color.decode("#b0e0e6"));
213         KNOWN_COLORS.put("rosybrown", Color.decode("#bc8f8f"));
214         KNOWN_COLORS.put("royalblue", Color.decode("#4169e1"));
215         KNOWN_COLORS.put("saddlebrown", Color.decode("#8b4513"));
216         KNOWN_COLORS.put("salmon", Color.decode("#fa8072"));
217         KNOWN_COLORS.put("sandybrown", Color.decode("#f4a460"));
218         KNOWN_COLORS.put("seagreen", Color.decode("#2e8b57"));
219         KNOWN_COLORS.put("seashell", Color.decode("#fff5ee"));
220         KNOWN_COLORS.put("sienna", Color.decode("#a0522d"));
221         KNOWN_COLORS.put("skyblue", Color.decode("#87ceeb"));
222         KNOWN_COLORS.put("slateblue", Color.decode("#6a5acd"));
223         KNOWN_COLORS.put("slategray", Color.decode("#708090"));
224         KNOWN_COLORS.put("slategrey", Color.decode("#708090"));
225         KNOWN_COLORS.put("snow", Color.decode("#fffafa"));
226         KNOWN_COLORS.put("springgreen", Color.decode("#00ff7f"));
227         KNOWN_COLORS.put("steelblue", Color.decode("#4682b4"));
228         KNOWN_COLORS.put("tan", Color.decode("#d2b48c"));
229         KNOWN_COLORS.put("thistle", Color.decode("#d8bfd8"));
230         KNOWN_COLORS.put("tomato", Color.decode("#ff6347"));
231         KNOWN_COLORS.put("turquoise", Color.decode("#40e0d0"));
232         KNOWN_COLORS.put("violet", Color.decode("#ee82ee"));
233         KNOWN_COLORS.put("wheat", Color.decode("#f5deb3"));
234         KNOWN_COLORS.put("whitesmoke", Color.decode("#f5f5f5"));
235         KNOWN_COLORS.put("yellowgreen", Color.decode("#9acd32"));
236         // CSS Color Module Level 4
237         KNOWN_COLORS.put("rebeccapurple", Color.decode("#663399"));
238     }
239 
240     private static synchronized int nextId() {
241         return ID_GENERATOR_++;
242     }
243 
244     /**
245      * Constructor.
246      * @param imageWidth the width
247      * @param imageHeight the height
248      */
249     public AwtRenderingBackend(final int imageWidth, final int imageHeight) {
250         id_ = nextId();
251         if (LOG.isDebugEnabled()) {
252             LOG.debug("[" + id_ + "] AwtRenderingBackend(" + imageWidth + ", " + imageHeight + ")");
253         }
254 
255         image_ = new BufferedImage(imageWidth, imageHeight, BufferedImage.TYPE_INT_ARGB);
256         graphics2D_ = image_.createGraphics();
257 
258         graphics2D_.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
259         graphics2D_.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
260         graphics2D_.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
261 
262         // without this AWT snaps strokes to pixel boundaries (hence the floor rounding).
263         // With VALUE_STROKE_PURE, strokes are rendered at their exact fractional coordinates,
264         // matching browser sub-pixel behavior.
265         graphics2D_.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE);
266 
267         // reset
268         fillColor_ = Color.black;
269         strokeColor_ = Color.black;
270         lineWidth_ = 1;
271         transformation_ = new AffineTransform();
272         updateGlobalAlpha(1f);
273         graphics2D_.setClip(null);
274 
275         final Font font = new Font("SansSerif", Font.PLAIN, 10);
276         graphics2D_.setFont(font);
277 
278         graphics2D_.setBackground(new Color(0f, 0f, 0f, 0f));
279         graphics2D_.setColor(Color.black);
280         graphics2D_.clearRect(0, 0, imageWidth, imageHeight);
281 
282         lineCap_ = LineCap.BUTT;
283         lineJoin_ = LineJoin.MITER;
284         miterLimit_ = 10.0f;
285 
286         subPaths_ = new ArrayList<>();
287         savedStates_ = new ArrayDeque<>();
288     }
289 
290     /**
291      * {@inheritDoc}
292      */
293     @Override
294     public double getGlobalAlpha() {
295         return globalAlpha_;
296     }
297 
298     /**
299      * {@inheritDoc}
300      */
301     @Override
302     public void setGlobalAlpha(final double globalAlpha) {
303         if (LOG.isDebugEnabled()) {
304             LOG.debug("[" + id_ + "] setGlobalAlpha(" + globalAlpha + ")");
305         }
306 
307         if (globalAlpha >= 0 && globalAlpha <= 1) {
308             updateGlobalAlpha((float) globalAlpha);
309         }
310     }
311 
312     private void updateGlobalAlpha(final float globalAlpha) {
313         globalAlpha_ = globalAlpha;
314         final AlphaComposite composite = AlphaComposite.getInstance(AlphaComposite.SRC_OVER, globalAlpha_);
315         graphics2D_.setComposite(composite);
316     }
317 
318     /**
319      * {@inheritDoc}
320      */
321     @Override
322     public void beginPath() {
323         if (LOG.isDebugEnabled()) {
324             LOG.debug("[" + id_ + "] beginPath()");
325         }
326         subPaths_.clear();
327     }
328 
329     /**
330      * {@inheritDoc}
331      */
332     @Override
333     public void ellipse(final double x, final double y,
334             final double radiusX, final double radiusY,
335             final double rotation, final double startAngle, final double endAngle,
336             final boolean anticlockwise) {
337         if (LOG.isDebugEnabled()) {
338             LOG.debug("[" + id_ + "] ellipse()");
339         }
340 
341         if (startAngle == endAngle) {
342             return;
343         }
344 
345         final Point2D p = transformation_.transform(new Point2D.Double(x, y), null);
346         final AffineTransform transformation = new AffineTransform();
347         transformation.rotate(rotation, p.getX(), p.getY());
348 
349         double startAngleDegree = Math.toDegrees(startAngle);
350         double endAngleDegree = Math.toDegrees(endAngle);
351 
352         double extendAngle = endAngleDegree - startAngleDegree;
353         final Arc2D arc;
354         if (Math.abs(extendAngle) >= 360) {
355             arc = new Arc2D.Double(p.getX() - radiusX, p.getY() - radiusY, radiusX * 2, radiusY * 2,
356                     0, 360, Arc2D.OPEN);
357         }
358         else {
359             startAngleDegree = reverseAngle(startAngleDegree);
360             endAngleDegree = reverseAngle(endAngleDegree);
361 
362             extendAngle = endAngleDegree - startAngleDegree;
363             if (!anticlockwise) {
364                 extendAngle = -reverseAngle(extendAngle);
365             }
366 
367             arc = new Arc2D.Double(p.getX() - radiusX, p.getY() - radiusY, radiusX * 2, radiusY * 2,
368                                             startAngleDegree, extendAngle, Arc2D.OPEN);
369         }
370 
371         // connect=true only if there is already a current point (implicit lineTo behaviour);
372         // connect=false when the subpath is new so we don't get a spurious line from (0,0)
373         // or from the moveTo seed point to the arc's own geometric start.
374         final boolean hasCurrentPoint;
375         if (subPaths_.isEmpty()) {
376             final Path2D subPath = new Path2D.Double();
377             subPaths_.add(subPath);
378             hasCurrentPoint = false;
379         }
380         else {
381             hasCurrentPoint = subPaths_.get(subPaths_.size() - 1).getCurrentPoint() != null;
382         }
383         getCurrentSubPath().append(transformation.createTransformedShape(arc), hasCurrentPoint);
384     }
385 
386     /**
387      * {@inheritDoc}
388      */
389     @Override
390     public void bezierCurveTo(final double cp1x, final double cp1y,
391             final double cp2x, final double cp2y, final double x, final double y) {
392         if (LOG.isDebugEnabled()) {
393             LOG.debug("[" + id_ + "] bezierCurveTo()");
394         }
395 
396         final Point2D cp1 = transformation_.transform(new Point2D.Double(cp1x, cp1y), null);
397         final Point2D cp2 = transformation_.transform(new Point2D.Double(cp2x, cp2y), null);
398         final Point2D p = transformation_.transform(new Point2D.Double(x, y), null);
399 
400         getCurrentSubPath(cp1.getX(), cp1.getY())
401                 .curveTo(cp1.getX(), cp1.getY(), cp2.getX(), cp2.getY(), p.getX(), p.getY());
402     }
403 
404     /**
405      * {@inheritDoc}
406      */
407     @Override
408     public void arc(final double x, final double y, final double radius, final double startAngle,
409             final double endAngle, final boolean anticlockwise) {
410         if (LOG.isDebugEnabled()) {
411             LOG.debug("[" + id_ + "] arc()");
412         }
413 
414         ellipse(x, y, radius, radius, 0, startAngle, endAngle, anticlockwise);
415     }
416 
417     private static double reverseAngle(final double degrees) {
418         return ((-degrees % 360) + 360) % 360;
419     }
420 
421     /**
422      * {@inheritDoc}
423      */
424     @Override
425     public void clearRect(final double x, final double y, final double w, final double h) {
426         if (LOG.isDebugEnabled()) {
427             LOG.debug("[" + id_ + "] clearRect(" + x + ", " + y + ", " + w + ", " + h + ")");
428         }
429 
430         final Composite savedComposite = graphics2D_.getComposite();
431         final Color savedColor = graphics2D_.getColor();
432 
433         try {
434             graphics2D_.setComposite(AlphaComposite.Clear); // force transparent clear
435             graphics2D_.fill(rectPath(x, y, w, h));
436         }
437         finally {
438             graphics2D_.setComposite(savedComposite);
439             graphics2D_.setColor(savedColor);
440         }
441     }
442 
443     /**
444      * {@inheritDoc}
445      */
446     @Override
447     public void drawImage(final org.htmlunit.platform.image.ImageData imageData,
448             final int sx, final int sy, final Integer sWidth, final Integer sHeight,
449             final int dx, final int dy, final Integer dWidth, final Integer dHeight) throws IOException {
450         if (LOG.isDebugEnabled()) {
451             LOG.debug("[" + id_ + "] drawImage(" + sx + ", " + sy + ", " + sWidth + ", " + sHeight
452                     + "," + dx + ", " + dy + ", " + dWidth + ", " + dHeight + ")");
453         }
454 
455         try {
456             final ImageReader imageReader = ((ImageIOImageData) imageData).getImageReader();
457 
458             if (imageReader.getNumImages(true) != 0) {
459                 final BufferedImage img = imageReader.read(0);
460 
461                 final AffineTransform savedTransform = graphics2D_.getTransform();
462                 try {
463                     graphics2D_.setTransform(transformation_);
464                     graphics2D_.setColor(fillColor_);
465 
466                     final int sx2;
467                     if (sWidth == null) {
468                         sx2 = sx + img.getWidth();
469                     }
470                     else {
471                         sx2 = sx + sWidth;
472                     }
473 
474                     final int sy2;
475                     if (sHeight == null) {
476                         sy2 = sy + img.getHeight();
477                     }
478                     else {
479                         sy2 = sy + sHeight;
480                     }
481 
482                     int dx1 = dx;
483                     final int dx2;
484                     if (dWidth == null) {
485                         dx2 = dx + img.getWidth();
486                     }
487                     else {
488                         if (dWidth < 0) {
489                             dx1 = dx1 + dWidth;
490                             dx2 = dx1 - dWidth;
491                         }
492                         else {
493                             dx2 = dx1 + dWidth;
494                         }
495                     }
496 
497                     int dy1 = dy;
498                     final int dy2;
499                     if (dHeight == null) {
500                         dy2 = dy + img.getHeight();
501                     }
502                     else {
503                         if (dHeight < 0) {
504                             dy1 = dy1 + dHeight;
505                             dy2 = dy1 - dHeight;
506                         }
507                         else {
508                             dy2 = dy1 + dHeight;
509                         }
510                     }
511 
512                     final Object done = new Object();
513                     final ImageObserver imageObserver = (img1, flags, x, y, width, height) -> {
514 
515                         if ((flags & ImageObserver.ALLBITS) == ImageObserver.ALLBITS) {
516                             return true;
517                         }
518 
519                         if ((flags & ImageObserver.ABORT) == ImageObserver.ABORT
520                                 || (flags & ImageObserver.ERROR) == ImageObserver.ERROR) {
521                             return true;
522                         }
523 
524                         synchronized (done) {
525                             done.notify();
526                         }
527 
528                         return false;
529                     };
530 
531                     synchronized (done) {
532                         final boolean completelyLoaded =
533                                 graphics2D_.drawImage(img, dx1, dy1, dx2, dy2, sx, sy, sx2, sy2, imageObserver);
534                         if (!completelyLoaded) {
535                             while (true) {
536                                 try {
537                                     done.wait(4 * 1000); // max 4s
538                                     break;
539                                 }
540                                 catch (final InterruptedException e) {
541                                     LOG.error("[" + id_ + "] AwtRenderingBackend interrupted "
542                                             + "while waiting for drawImage to finish.", e);
543 
544                                     // restore interrupted status
545                                     Thread.currentThread().interrupt();
546                                 }
547                             }
548                         }
549                     }
550                 }
551                 finally {
552                     graphics2D_.setTransform(savedTransform);
553                 }
554             }
555         }
556         catch (final ClassCastException e) {
557             LOG.error("[" + id_ + "] drawImage(..) failed", e);
558         }
559     }
560 
561     /**
562      * {@inheritDoc}
563      */
564     @Override
565     public String encodeToString(final String type) throws IOException {
566         String imageType = type;
567         if (imageType != null && imageType.startsWith("image/")) {
568             imageType = imageType.substring(6);
569         }
570         try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
571             ImageIO.write(image_, imageType, bos);
572 
573             final byte[] imageBytes = bos.toByteArray();
574             return new String(Base64.getEncoder().encode(imageBytes), StandardCharsets.US_ASCII);
575         }
576     }
577 
578     /**
579      * {@inheritDoc}
580      */
581     @Override
582     public void fill(final RenderingBackend.WindingRule windingRule) {
583         if (LOG.isDebugEnabled()) {
584             LOG.debug("[" + id_ + "] fill("
585                     + (windingRule == RenderingBackend.WindingRule.EVEN_ODD ? "evenOdd" : "nonZero") + ")");
586         }
587 
588         final int awtRule = (windingRule == RenderingBackend.WindingRule.EVEN_ODD)
589                 ? Path2D.WIND_EVEN_ODD
590                 : Path2D.WIND_NON_ZERO;
591 
592         graphics2D_.setColor(fillColor_);
593         for (final Path2D path2d : subPaths_) {
594             path2d.setWindingRule(awtRule);
595             graphics2D_.fill(path2d);
596         }
597     }
598 
599     /**
600      * {@inheritDoc}
601      */
602     @Override
603     public void fillRect(final double x, final double y, final double w, final double h) {
604         if (LOG.isDebugEnabled()) {
605             LOG.debug("[" + id_ + "] fillRect(" + x + ", "  + y + ", "  + w + ", "  + h + ")");
606         }
607 
608         graphics2D_.setColor(fillColor_);
609         graphics2D_.fill(rectPath(x, y, w, h));
610     }
611 
612     /**
613      * {@inheritDoc}
614      */
615     @Override
616     public void fillText(final String text, final double x, final double y) {
617         if (LOG.isDebugEnabled()) {
618             LOG.debug("[" + id_ + "] fillText('" + text + "', "  + x + ", "  + y + ")");
619         }
620 
621         final AffineTransform savedTransform = graphics2D_.getTransform();
622         try {
623             graphics2D_.setTransform(transformation_);
624             graphics2D_.setColor(fillColor_);
625             graphics2D_.drawString(text, (float) x, (float) y);
626         }
627         finally {
628             graphics2D_.setTransform(savedTransform);
629         }
630     }
631 
632     /**
633      * {@inheritDoc}
634      */
635     @Override
636     public byte[] getBytes(final int width, final int height, final int sx, final int sy) {
637         if (LOG.isDebugEnabled()) {
638             LOG.debug("[" + id_ + "] getBytes(" + width + ", " + height + ", " + sx + ", " + sy + ")");
639         }
640 
641         final byte[] array = new byte[width * height * 4];
642         int index = 0;
643 
644         // Canvas ImageData order is row-major
645         for (int y = sy; y < sy + height; y++) {
646             for (int x = sx; x < sx + width; x++) {
647                 if (x < 0 || x >= image_.getWidth() || y < 0 || y >= image_.getHeight()) {
648                     array[index++] = (byte) 0;
649                     array[index++] = (byte) 0;
650                     array[index++] = (byte) 0;
651                     array[index++] = (byte) 0;
652                 }
653                 else {
654                     final int color = image_.getRGB(x, y);
655                     array[index++] = (byte) ((color >> 16) & 0xFF);  // R
656                     array[index++] = (byte) ((color >> 8) & 0xFF);   // G
657                     array[index++] = (byte) (color & 0xFF);          // B
658                     array[index++] = (byte) ((color >>> 24) & 0xFF); // A
659                 }
660             }
661         }
662 
663         return array;
664     }
665 
666     /**
667      * {@inheritDoc}
668      */
669     @Override
670     public void lineTo(final double x, final double y) {
671         if (LOG.isDebugEnabled()) {
672             LOG.debug("[" + id_ + "] lineTo(" + x + ", " + y + ")");
673         }
674 
675         final Point2D p = transformation_.transform(new Point2D.Double(x, y), null);
676         final Path2D subPath = getCurrentSubPath();
677         if (subPath.getCurrentPoint() == null) {
678             subPath.moveTo(p.getX(), p.getY());
679         }
680         else {
681             subPath.lineTo(p.getX(), p.getY());
682         }
683     }
684 
685     /**
686      * {@inheritDoc}
687      */
688     @Override
689     public void moveTo(final double x, final double y) {
690         if (LOG.isDebugEnabled()) {
691             LOG.debug("[" + id_ + "] moveTo(" + x + ", " + y + ")");
692         }
693 
694         final Point2D p = transformation_.transform(new Point2D.Double(x, y), null);
695 
696         if (!subPaths_.isEmpty()) {
697             final Path2D last = subPaths_.get(subPaths_.size() - 1);
698             if (!hasSegments(last)) {
699                 last.reset();
700                 last.moveTo(p.getX(), p.getY());
701                 return;
702             }
703         }
704 
705         final Path2D subPath = new Path2D.Double();
706         subPath.moveTo(p.getX(), p.getY());
707         subPaths_.add(subPath);
708     }
709 
710     /**
711      * {@inheritDoc}
712      */
713     @Override
714     public void putImageData(final byte[] imageDataBytes,
715             final int imageDataWidth, final int imageDataHeight,
716             final int dx, final int dy, final int dirtyX, final int dirtyY,
717             final int dirtyWidth, final int dirtyHeight) {
718 
719         if (LOG.isDebugEnabled()) {
720             LOG.debug("[" + id_ + "] putImageData()");
721         }
722 
723         if (imageDataBytes == null || imageDataWidth <= 0 || imageDataHeight <= 0) {
724             return;
725         }
726 
727         // Normalize dirty rect (negative width/height)
728         int sx = dirtyX;
729         int sy = dirtyY;
730         int sw = dirtyWidth;
731         int sh = dirtyHeight;
732         if (sw < 0) {
733             sx += sw;
734             sw = -sw;
735         }
736         if (sh < 0) {
737             sy += sh;
738             sh = -sh;
739         }
740         if (sw == 0 || sh == 0) {
741             return;
742         }
743 
744         // Clip dirty rect to source bounds
745         final int srcStartX = Math.max(0, sx);
746         final int srcStartY = Math.max(0, sy);
747         final int srcEndX = Math.min(imageDataWidth, sx + sw);
748         final int srcEndY = Math.min(imageDataHeight, sy + sh);
749         if (srcStartX >= srcEndX || srcStartY >= srcEndY) {
750             return;
751         }
752 
753         final int srcWidth = srcEndX - srcStartX;
754         final int srcHeight = srcEndY - srcStartY;
755 
756         final BufferedImage srcImage = new BufferedImage(srcWidth, srcHeight, BufferedImage.TYPE_INT_ARGB);
757         for (int row = 0; row < srcHeight; row++) {
758             for (int col = 0; col < srcWidth; col++) {
759                 final int byteIdx = ((srcStartY + row) * imageDataWidth + (srcStartX + col)) * 4;
760                 final int r = imageDataBytes[byteIdx]     & 0xFF;
761                 final int g = imageDataBytes[byteIdx + 1] & 0xFF;
762                 final int b = imageDataBytes[byteIdx + 2] & 0xFF;
763                 final int a = imageDataBytes[byteIdx + 3] & 0xFF;
764                 srcImage.setRGB(col, row, (a << 24) | (r << 16) | (g << 8) | b);
765             }
766         }
767 
768         final Composite savedComposite = graphics2D_.getComposite();
769         final AffineTransform savedTransform = graphics2D_.getTransform();
770         try {
771             graphics2D_.setComposite(AlphaComposite.Src);
772             graphics2D_.setTransform(new AffineTransform());
773             graphics2D_.drawImage(srcImage, dx + srcStartX, dy + srcStartY, null);
774         }
775         finally {
776             graphics2D_.setComposite(savedComposite);
777             graphics2D_.setTransform(savedTransform);
778         }
779     }
780 
781     /**
782      * {@inheritDoc}
783      */
784     @Override
785     public void quadraticCurveTo(final double cpx, final double cpy,
786                     final double x, final double y) {
787         if (LOG.isDebugEnabled()) {
788             LOG.debug("[" + id_ + "] quadraticCurveTo()");
789         }
790 
791         final Point2D cp = transformation_.transform(new Point2D.Double(cpx, cpy), null);
792         final Point2D p = transformation_.transform(new Point2D.Double(x, y), null);
793         getCurrentSubPath(cp.getX(), cp.getY()).quadTo(cp.getX(), cp.getY(), p.getX(), p.getY());
794     }
795 
796     /**
797      * {@inheritDoc}
798      */
799     @Override
800     public void rect(final double x, final double y, final double w, final double h) {
801         if (LOG.isDebugEnabled()) {
802             LOG.debug("[" + id_ + "] rect(" + x + ", "  + y + ", "  + w + ", "  + h + ")");
803         }
804 
805         final Point2D p0 = transformation_.transform(new Point2D.Double(x, y), null);
806         subPaths_.add(rectPath(x, y, w, h));
807 
808         // Spec requires a fresh subpath seeded at (x, y) after the closed rect
809         final Path2D nextPath = new Path2D.Double();
810         nextPath.moveTo(p0.getX(), p0.getY());
811         subPaths_.add(nextPath);
812     }
813 
814     /**
815      * {@inheritDoc}
816      */
817     @Override
818     public void setFillStyle(final String fillStyle) {
819         if (LOG.isDebugEnabled()) {
820             LOG.debug("[" + id_ + "] setFillStyle(" + fillStyle + ")");
821         }
822 
823         final Color color = extractColor(fillStyle);
824         if (color != null) {
825             fillColor_ = color;
826         }
827     }
828 
829     /**
830      * {@inheritDoc}
831      */
832     @Override
833     public void setStrokeStyle(final String strokeStyle) {
834         if (LOG.isDebugEnabled()) {
835             LOG.debug("[" + id_ + "] setStrokeStyle(" + strokeStyle + ")");
836         }
837 
838         final Color color = extractColor(strokeStyle);
839         if (color != null) {
840             strokeColor_ = color;
841         }
842     }
843 
844     private static Color extractColor(final String style) {
845         final String tmpStyle = style.replaceAll("\\s", "");
846 
847         Color color = toAwtColor(StringUtils.findColorRGB(tmpStyle));
848         if (color == null) {
849             color = toAwtColor(StringUtils.findColorRGBA(tmpStyle));
850         }
851         if (color == null) {
852             color = toAwtColor(StringUtils.findColorHSL(tmpStyle));
853         }
854 
855         if (color == null) {
856             if (tmpStyle.length() > 0 && tmpStyle.charAt(0) == '#') {
857                 color = toAwtColor(StringUtils.asColorHexadecimal(tmpStyle));
858             }
859             else {
860                 color = KNOWN_COLORS.get(tmpStyle.toLowerCase(Locale.ROOT));
861             }
862         }
863         return color;
864     }
865 
866     /**
867      * {@inheritDoc}
868      */
869     @Override
870     public float getLineWidth() {
871         return lineWidth_;
872     }
873 
874     /**
875      * {@inheritDoc}
876      */
877     @Override
878     public void restore() {
879         if (LOG.isDebugEnabled()) {
880             LOG.debug("[" + id_ + "] restore()");
881         }
882 
883         if (savedStates_.isEmpty()) {
884             return;
885         }
886 
887         savedStates_.pop().applyOn(this);
888     }
889 
890     /**
891      * {@inheritDoc}
892      */
893     @Override
894     public void rotate(final double angle) {
895         if (LOG.isDebugEnabled()) {
896             LOG.debug("[" + id_ + "] rotate()");
897         }
898 
899         transformation_.rotate(angle);
900     }
901 
902     /**
903      * {@inheritDoc}
904      */
905     @Override
906     public void save() {
907         if (LOG.isDebugEnabled()) {
908             LOG.debug("[" + id_ + "] save()");
909         }
910 
911         savedStates_.push(new SaveState(this));
912     }
913 
914     /**
915      * {@inheritDoc}
916      */
917     @Override
918     public void setLineWidth(final float lineWidth) {
919         if (LOG.isDebugEnabled()) {
920             LOG.debug("[" + id_ + "] setLineWidth(" + lineWidth + ")");
921         }
922 
923         lineWidth_ = lineWidth;
924     }
925 
926     /**
927      * {@inheritDoc}
928      */
929     @Override
930     public void setTransform(final double m11, final double m12,
931                     final double m21, final double m22, final double dx, final double dy) {
932         if (LOG.isDebugEnabled()) {
933             LOG.debug("[" + id_ + "] setTransform("
934                         + m11 + ", "  + m12 + ", "  + m21 + ", "  + m22 + ", "  + dx + ", "  + dy + ")");
935         }
936 
937         transformation_ = new AffineTransform(m11, m12, m21, m22, dx, dy);
938     }
939 
940     /**
941      * {@inheritDoc}
942      */
943     @Override
944     public void stroke() {
945         if (LOG.isDebugEnabled()) {
946             LOG.debug("[" + id_ + "] stroke()");
947         }
948 
949         graphics2D_.setStroke(new BasicStroke(getLineWidth(),
950                 toAwtLineCap(lineCap_), toAwtLineJoin(lineJoin_), miterLimit_));
951         graphics2D_.setColor(strokeColor_);
952 
953         for (final Path2D path2d : subPaths_) {
954             if (hasSegments(path2d)) {
955                 graphics2D_.draw(path2d);
956             }
957         }
958     }
959 
960     /**
961      * {@inheritDoc}
962      */
963     @Override
964     public void strokeRect(final double x, final double y, final double w, final double h) {
965         if (LOG.isDebugEnabled()) {
966             LOG.debug("[" + id_ + "] strokeRect(" + x + ", "  + y + ", "  + w + ", "  + h + ")");
967         }
968 
969         graphics2D_.setStroke(new BasicStroke(getLineWidth(),
970                 toAwtLineCap(lineCap_), toAwtLineJoin(lineJoin_), miterLimit_));
971         graphics2D_.setColor(strokeColor_);
972         graphics2D_.draw(rectPath(x, y, w, h));
973     }
974 
975     /**
976      * {@inheritDoc}
977      */
978     @Override
979     public void transform(final double m11, final double m12,
980                     final double m21, final double m22, final double dx, final double dy) {
981         if (LOG.isDebugEnabled()) {
982             LOG.debug("[" + id_ + "] transform()");
983         }
984 
985         transformation_.concatenate(new AffineTransform(m11, m12, m21, m22, dx, dy));
986     }
987 
988     /**
989      * {@inheritDoc}
990      */
991     @Override
992     public void translate(final double x, final double y) {
993         if (LOG.isDebugEnabled()) {
994             LOG.debug("[" + id_ + "] translate()");
995         }
996 
997         transformation_.translate(x, y);
998     }
999 
1000     /**
1001      * {@inheritDoc}
1002      */
1003     @Override
1004     public void clip(final RenderingBackend.WindingRule windingRule,
1005             final org.htmlunit.javascript.host.canvas.Path2D path) {
1006         if (LOG.isDebugEnabled()) {
1007             LOG.debug("[" + id_ + "] clip(" + windingRule + ", " + path + ")");
1008         }
1009 
1010         final Path2D clipPath = new Path2D.Double();
1011 
1012         if (path == null) {
1013             if (subPaths_.isEmpty()) {
1014                 return;
1015             }
1016             for (final Path2D path2d : subPaths_) {
1017                 if (hasSegments(path2d)) {
1018                     clipPath.append(path2d, false);
1019                 }
1020             }
1021         }
1022         else {
1023             // TODO integrate external Path2D host object once available
1024             return;
1025         }
1026 
1027         if (windingRule == WindingRule.NON_ZERO) {
1028             clipPath.setWindingRule(Path2D.WIND_NON_ZERO);
1029         }
1030         else {
1031             clipPath.setWindingRule(Path2D.WIND_EVEN_ODD);
1032         }
1033 
1034         graphics2D_.clip(clipPath);
1035     }
1036 
1037     /**
1038      * {@inheritDoc}
1039      */
1040     @Override
1041     public void closePath() {
1042         if (LOG.isDebugEnabled()) {
1043             LOG.debug("[" + id_ + "] closePath()");
1044         }
1045 
1046         if (subPaths_.isEmpty()) {
1047             return;
1048         }
1049 
1050         final Path2D current = subPaths_.get(subPaths_.size() - 1);
1051 
1052         // Extract the first point of the current subpath — this is where
1053         // the spec requires the new subpath to be seeded after closing.
1054         final double[] coords = new double[6];
1055         final PathIterator it = current.getPathIterator(null);
1056         if (it.isDone()) {
1057             return; // empty path, nothing to close
1058         }
1059         it.currentSegment(coords); // first segment is always SEG_MOVETO
1060         final double firstX = coords[0];
1061         final double firstY = coords[1];
1062 
1063         current.closePath();
1064 
1065         // Spec: "Create a new subpath with the same first point as the
1066         // previous subpath."
1067         final Path2D nextPath = new Path2D.Double();
1068         nextPath.moveTo(firstX, firstY);
1069         subPaths_.add(nextPath);
1070     }
1071 
1072     /**
1073      * {@inheritDoc}
1074      */
1075     @Override
1076     public LineJoin getLineJoin() {
1077         if (LOG.isDebugEnabled()) {
1078             LOG.debug("[" + id_ + "] getLineJoin()");
1079         }
1080         return lineJoin_;
1081     }
1082 
1083     /**
1084      * {@inheritDoc}
1085      */
1086     @Override
1087     public void setLineJoin(final LineJoin lineJoin) {
1088         if (LOG.isDebugEnabled()) {
1089             LOG.debug("[" + id_ + "] setLineJoin(" + lineJoin + ")");
1090         }
1091         lineJoin_ = lineJoin;
1092     }
1093 
1094     /**
1095      * {@inheritDoc}
1096      */
1097     @Override
1098     public LineCap getLineCap() {
1099         if (LOG.isDebugEnabled()) {
1100             LOG.debug("[" + id_ + "] getLineCap()");
1101         }
1102         return lineCap_;
1103     }
1104 
1105     /**
1106      * {@inheritDoc}
1107      */
1108     @Override
1109     public void setLineCap(final LineCap lineCap) {
1110         if (LOG.isDebugEnabled()) {
1111             LOG.debug("[" + id_ + "] setLineCap(" + lineCap + ")");
1112         }
1113         lineCap_ = lineCap;
1114     }
1115 
1116     private Path2D getCurrentSubPath() {
1117         if (subPaths_.isEmpty()) {
1118             final Path2D subPath = new Path2D.Double();
1119             subPaths_.add(subPath);
1120             return subPath;
1121         }
1122         return subPaths_.get(subPaths_.size() - 1);
1123     }
1124 
1125     private Path2D getCurrentSubPath(final double x, final double y) {
1126         if (subPaths_.isEmpty()) {
1127             final Path2D subPath = new Path2D.Double();
1128             subPaths_.add(subPath);
1129             subPath.moveTo(x, y);
1130             return subPath;
1131         }
1132 
1133         final Path2D subPath = subPaths_.get(subPaths_.size() - 1);
1134         if (subPath.getCurrentPoint() == null) {
1135             subPath.moveTo(x, y);
1136         }
1137         return subPath;
1138     }
1139 
1140     private Path2D rectPath(final double x, final double y, final double w, final double h) {
1141         // Build the four corners explicitly from the raw (possibly negative) w/h
1142         // so the winding matches the Canvas spec: (x,y) → (x+w,y) → (x+w,y+h) → (x,y+h)
1143         // Do NOT use Rectangle2D.Double — it normalises negative dimensions.
1144         final Path2D rectPath = new Path2D.Double();
1145         final Point2D p0 = transformation_.transform(new Point2D.Double(x, y), null);
1146         final Point2D p1 = transformation_.transform(new Point2D.Double(x + w, y), null);
1147         final Point2D p2 = transformation_.transform(new Point2D.Double(x + w, y + h), null);
1148         final Point2D p3 = transformation_.transform(new Point2D.Double(x, y + h), null);
1149         rectPath.moveTo(p0.getX(), p0.getY());
1150         rectPath.lineTo(p1.getX(), p1.getY());
1151         rectPath.lineTo(p2.getX(), p2.getY());
1152         rectPath.lineTo(p3.getX(), p3.getY());
1153         rectPath.closePath();
1154 
1155         return rectPath;
1156     }
1157 
1158     private static boolean hasSegments(final Path2D path) {
1159         final PathIterator it = path.getPathIterator(null);
1160         while (!it.isDone()) {
1161             if (it.currentSegment(new double[6]) != PathIterator.SEG_MOVETO) {
1162                 return true;
1163             }
1164             it.next();
1165         }
1166         return false;
1167     }
1168 
1169     private static Color toAwtColor(final org.htmlunit.html.impl.Color color) {
1170         if (color == null) {
1171             return null;
1172         }
1173         return new Color(color.getRed(), color.getGreen(), color.getBlue(), color.getAlpha());
1174     }
1175 
1176     private static int toAwtLineJoin(final LineJoin lineJoin) {
1177         return switch (lineJoin) {
1178             case MITER -> BasicStroke.JOIN_MITER;
1179             case ROUND -> BasicStroke.JOIN_ROUND;
1180             case BEVEL -> BasicStroke.JOIN_BEVEL;
1181         };
1182     }
1183 
1184     private static int toAwtLineCap(final LineCap lineCap) {
1185         return switch (lineCap) {
1186             case ROUND -> BasicStroke.CAP_ROUND;
1187             case SQUARE -> BasicStroke.CAP_SQUARE;
1188             case BUTT -> BasicStroke.CAP_BUTT;
1189         };
1190     }
1191 
1192     private static final class SaveState {
1193         private final AffineTransform transformation_;
1194         private final float globalAlpha_;
1195         private final float lineWidth_;
1196         private final Color fillColor_;
1197         private final Color strokeColor_;
1198         private final Shape clip_;
1199 
1200         private final LineCap lineCap_;
1201         private final LineJoin lineJoin_;
1202         private final float miterLimit_;
1203 
1204         private final Font font_;
1205 
1206         SaveState(final AwtRenderingBackend backend) {
1207             transformation_ = new AffineTransform(backend.transformation_);
1208             globalAlpha_ = backend.globalAlpha_;
1209             lineWidth_ = backend.lineWidth_;
1210             fillColor_ = backend.fillColor_;
1211             strokeColor_ = backend.strokeColor_;
1212 
1213             lineCap_ = backend.lineCap_;
1214             lineJoin_ = backend.lineJoin_;
1215             miterLimit_ = backend.miterLimit_;
1216 
1217             // Defensive copy — getClip() can return a live mutable shape
1218             // on some Graphics2D implementations
1219             final Shape c = backend.graphics2D_.getClip();
1220             clip_ = (c == null) ? null : new Path2D.Double(c);
1221 
1222             // Font is immutable so no copy needed
1223             font_ = backend.graphics2D_.getFont();
1224         }
1225 
1226         void applyOn(final AwtRenderingBackend backend) {
1227             backend.transformation_ = new AffineTransform(transformation_);
1228             backend.lineWidth_ = lineWidth_;
1229             backend.fillColor_ = fillColor_;
1230             backend.strokeColor_ = strokeColor_;
1231 
1232             backend.updateGlobalAlpha(globalAlpha_);
1233 
1234             backend.lineCap_ = lineCap_;
1235             backend.lineJoin_ = lineJoin_;
1236             backend.miterLimit_ = miterLimit_;
1237 
1238             // Restore clip with another defensive copy so this SaveState
1239             // remains reusable if somehow applyOn is called more than once
1240             backend.graphics2D_.setClip((clip_ == null) ? null : new Path2D.Double(clip_));
1241 
1242             backend.graphics2D_.setFont(font_);
1243         }
1244     }
1245 }