How to write text along a Bezier Curve?

Here is an abuse of the PathTransition to get text plotted along a Bézier Curve.

The program lets you drag control points around to define a curve, then plot text along that curve. Characters in the text are spaced equidistantly, so it works best if the total length of the curve matches pretty close to the text width with "normal" spacing and it doesn't make adjustments for things like kerning.

The samples below show:

  1. Curved text with a glow effect.
  2. Some curved text with no effect applied.
  3. The control manipulation points used to define the curved path the text without effect was plotted along.

Curved Text With GlowCurved TextCurve Manipulator

The solution was a quick hack based on the answer to the StackOverflow question: CubicCurve JavaFX. I am sure a better solution could be found with more effort, time and skill.

Because the program is based on transitions, it would be very easy to adopt it so that text can be animated to follow the curve, wrapping from right back to left on overflow (like you might see in marquee text or a stock ticker).

Any of the standard JavaFX effects such as glows, shadows, etc and font changes can be applied to get things like the shadowed effect from the paintshop pro text in your question. A glow effect is a nice effect to apply here as it subtly softens the jagged edges around rotated characters.

Also the PathTransition this solution is based on can take any arbitrary shape as input for the path, so the text can follow other kinds of paths, not just cubic curves.

import javafx.animation.*;
import javafx.application.Application;
import javafx.beans.property.DoubleProperty;
import javafx.collections.*;
import javafx.event.*;
import javafx.scene.*;
import javafx.scene.control.ToggleButton;
import javafx.scene.effect.Glow;
import javafx.scene.input.MouseEvent;
import javafx.scene.paint.Color;
import javafx.scene.shape.*;
import javafx.scene.text.Text;
import javafx.stage.Stage;
import javafx.util.Duration;

/**
 * Example of drawing text along a cubic curve.
 * Drag the anchors around to change the curve.
 */
public class BezierTextPlotter extends Application {
    private static final String CURVED_TEXT = "Bézier Curve";

    public static void main(String[] args) throws Exception {
        launch(args);
    }

    @Override
    public void start(final Stage stage) throws Exception {
        final CubicCurve curve = createStartingCurve();

        Line controlLine1 = new BoundLine(curve.controlX1Property(), curve.controlY1Property(), curve.startXProperty(), curve.startYProperty());
        Line controlLine2 = new BoundLine(curve.controlX2Property(), curve.controlY2Property(), curve.endXProperty(), curve.endYProperty());

        Anchor start = new Anchor(Color.PALEGREEN, curve.startXProperty(), curve.startYProperty());
        Anchor control1 = new Anchor(Color.GOLD, curve.controlX1Property(), curve.controlY1Property());
        Anchor control2 = new Anchor(Color.GOLDENROD, curve.controlX2Property(), curve.controlY2Property());
        Anchor end = new Anchor(Color.TOMATO, curve.endXProperty(), curve.endYProperty());

        final Text text = new Text(CURVED_TEXT);
        text.setStyle("-fx-font-size: 40px");
        text.setEffect(new Glow());
        final ObservableList<Text> parts = FXCollections.observableArrayList();
        final ObservableList<PathTransition> transitions = FXCollections.observableArrayList();
        for (char character : text.textProperty().get().toCharArray()) {
            Text part = new Text(character + "");
            part.setEffect(text.getEffect());
            part.setStyle(text.getStyle());
            parts.add(part);
            part.setVisible(false);

            transitions.add(createPathTransition(curve, part));
        }

        final ObservableList<Node> controls = FXCollections.observableArrayList();
        controls.setAll(controlLine1, controlLine2, curve, start, control1, control2, end);

        final ToggleButton plot = new ToggleButton("Plot Text");
        plot.setOnAction(new PlotHandler(plot, parts, transitions, controls));

        Group content = new Group(controlLine1, controlLine2, curve, start, control1, control2, end, plot);
        content.getChildren().addAll(parts);

        stage.setTitle("Cubic Curve Manipulation Sample");
        stage.setScene(new Scene(content, 400, 400, Color.ALICEBLUE));
        stage.show();
    }

    private PathTransition createPathTransition(CubicCurve curve, Text text) {
        final PathTransition transition = new PathTransition(Duration.seconds(10), curve, text);

        transition.setAutoReverse(false);
        transition.setCycleCount(PathTransition.INDEFINITE);
        transition.setOrientation(PathTransition.OrientationType.ORTHOGONAL_TO_TANGENT);
        transition.setInterpolator(Interpolator.LINEAR);

        return transition;
    }

    private CubicCurve createStartingCurve() {
        CubicCurve curve = new CubicCurve();
        curve.setStartX(50);
        curve.setStartY(200);
        curve.setControlX1(150);
        curve.setControlY1(300);
        curve.setControlX2(250);
        curve.setControlY2(50);
        curve.setEndX(350);
        curve.setEndY(150);
        curve.setStroke(Color.FORESTGREEN);
        curve.setStrokeWidth(4);
        curve.setStrokeLineCap(StrokeLineCap.ROUND);
        curve.setFill(Color.CORNSILK.deriveColor(0, 1.2, 1, 0.6));
        return curve;
    }

    class BoundLine extends Line {
        BoundLine(DoubleProperty startX, DoubleProperty startY, DoubleProperty endX, DoubleProperty endY) {
            startXProperty().bind(startX);
            startYProperty().bind(startY);
            endXProperty().bind(endX);
            endYProperty().bind(endY);
            setStrokeWidth(2);
            setStroke(Color.GRAY.deriveColor(0, 1, 1, 0.5));
            setStrokeLineCap(StrokeLineCap.BUTT);
            getStrokeDashArray().setAll(10.0, 5.0);
        }
    }

    // a draggable anchor displayed around a point.
    class Anchor extends Circle {
        Anchor(Color color, DoubleProperty x, DoubleProperty y) {
            super(x.get(), y.get(), 10);
            setFill(color.deriveColor(1, 1, 1, 0.5));
            setStroke(color);
            setStrokeWidth(2);
            setStrokeType(StrokeType.OUTSIDE);

            x.bind(centerXProperty());
            y.bind(centerYProperty());
            enableDrag();
        }

        // make a node movable by dragging it around with the mouse.
        private void enableDrag() {
            final Delta dragDelta = new Delta();
            setOnMousePressed(new EventHandler<MouseEvent>() {
                @Override
                public void handle(MouseEvent mouseEvent) {
                    // record a delta distance for the drag and drop operation.
                    dragDelta.x = getCenterX() - mouseEvent.getX();
                    dragDelta.y = getCenterY() - mouseEvent.getY();
                    getScene().setCursor(Cursor.MOVE);
                }
            });
            setOnMouseReleased(new EventHandler<MouseEvent>() {
                @Override
                public void handle(MouseEvent mouseEvent) {
                    getScene().setCursor(Cursor.HAND);
                }
            });
            setOnMouseDragged(new EventHandler<MouseEvent>() {
                @Override
                public void handle(MouseEvent mouseEvent) {
                    double newX = mouseEvent.getX() + dragDelta.x;
                    if (newX > 0 && newX < getScene().getWidth()) {
                        setCenterX(newX);
                    }
                    double newY = mouseEvent.getY() + dragDelta.y;
                    if (newY > 0 && newY < getScene().getHeight()) {
                        setCenterY(newY);
                    }
                }
            });
            setOnMouseEntered(new EventHandler<MouseEvent>() {
                @Override
                public void handle(MouseEvent mouseEvent) {
                    if (!mouseEvent.isPrimaryButtonDown()) {
                        getScene().setCursor(Cursor.HAND);
                    }
                }
            });
            setOnMouseExited(new EventHandler<MouseEvent>() {
                @Override
                public void handle(MouseEvent mouseEvent) {
                    if (!mouseEvent.isPrimaryButtonDown()) {
                        getScene().setCursor(Cursor.DEFAULT);
                    }
                }
            });
        }

        // records relative x and y co-ordinates.
        private class Delta {
            double x, y;
        }
    }

    // plots text along a path defined by provided bezier control points.
    private static class PlotHandler implements EventHandler<ActionEvent> {
        private final ToggleButton plot;
        private final ObservableList<Text> parts;
        private final ObservableList<PathTransition> transitions;
        private final ObservableList<Node> controls;

        public PlotHandler(ToggleButton plot, ObservableList<Text> parts, ObservableList<PathTransition> transitions, ObservableList<Node> controls) {
            this.plot = plot;
            this.parts = parts;
            this.transitions = transitions;
            this.controls = controls;
        }

        @Override
        public void handle(ActionEvent actionEvent) {
            if (plot.isSelected()) {
                for (int i = 0; i < parts.size(); i++) {
                    parts.get(i).setVisible(true);
                    final Transition transition = transitions.get(i);
                    transition.stop();
                    transition.jumpTo(Duration.seconds(10).multiply((i + 0.5) * 1.0 / parts.size()));
                    // just play a single animation frame to display the curved text, then stop
                    AnimationTimer timer = new AnimationTimer() {
                        int frameCounter = 0;

                        @Override
                        public void handle(long l) {
                            frameCounter++;
                            if (frameCounter == 1) {
                                transition.stop();
                                stop();
                            }
                        }
                    };
                    timer.start();
                    transition.play();
                }
                plot.setText("Show Controls");
            } else {
                plot.setText("Plot Text");
            }

            for (Node control : controls) {
                control.setVisible(!plot.isSelected());
            }

            for (Node part : parts) {
                part.setVisible(plot.isSelected());
            }
        }
    }
}

Another possible solution would be to measure each text character and do the mathematics to interpolate the text location and rotation without using a PathTransition. But PathTransition was already there and worked fine for me (maybe the curve distance measurements for the text advances might challenge me anyway).

Answers to Additional Questions

Do you think it is possible to implement a javafx.scene.effect.Effect by adapting your code?

No. Implementing an effect would require performing the mathematics for displaying the text along the bezier curve, which my answer doesn't provide (as it just adopts the existing PathTransition to do this).

Besides, there is no public API in JavaFX 2.2 for implementing your own custom effect.

There is an existing DisplacementMap effect which could perhaps be used to get something similar. However, I feel that using the DisplacementMap effect (and perhaps any effect to adjust the text layout) would likely distort the text.

IMO, writing text along a Bezier curve is more layout related than effect related - it is best to adjust the layout and rotation of the characters rather than to use an effect to move them around.

Or may be there is a better way for integrating it properly in the JFX framework ?

You could subclass Pane and create a custom PathLayout that is similar to a FlowPane, but lays out nodes out along a path rather than a straight line. The nodes to be laid out are formed by a Text node for each character, similar to what I have done in my answer. But even then, you aren't really accurately rendering the text because you want to take into account things like proportionally spaced letters, kerning etc. So, for total fidelity and accuracy, you would need implement your own low level text layout algorithm. If it were me, I'd only go to that effort if the "good enough" solution provided in this answer using PathTransitions turned out not to be high enough quality for you.


You could use the WebView and some html to display a svg. Here is an example:

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.layout.StackPane;
import javafx.scene.web.WebView;
import javafx.stage.Stage;

public class CurvedText extends Application {

  public static void main(String[] args) {
    launch(args);
  }

  @Override
  public void start(Stage primaryStage) throws Exception {
    StackPane root = new StackPane();
    WebView view = new WebView();
    view.getEngine().loadContent("<!DOCTYPE html>\n" +
            "<html xmlns=\"http://www.w3.org/1999/xhtml\">\n" +
            "  <body>\n" +
            "<embed width=\"100\" height=\"100\" type=\"image/svg+xml\" src=\"path.svg\">\n" +
            "  <svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">" +
            "<defs>\n" +
            "  <path id=\"textPath\" d=\"M10 50 C10 0 90 0 90 50\"/>\n" +
            "</defs>\n"+
            "<text fill=\"red\">\n" +
            "  <textPath xlink:href=\"#textPath\">Text on a Path</textPath>\n" +
            "</text>" +
            "</svg>\n" +
            "</embed>" +
            "  </body>\n" +
            "</html>");
    root.getChildren().add(view);
    Scene scene = new Scene(root, 500, 500);
    primaryStage.setScene(scene);
    primaryStage.show();
  }
}

Result:

enter image description here

This is no optimal solution since the JavaFX WebView behaves a bit touchy when it should behave like a label in my experience, but it is something to start with.

EDIT

Since you don't want to use WebView directly, you could use a single instance of a WebView to render the scene with html and then take a snapshot of it to produce an ImageView. See this example:

import javafx.animation.AnimationTimer;
import javafx.application.Application;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.concurrent.Worker;
import javafx.scene.Scene;
import javafx.scene.image.ImageView;
import javafx.scene.image.WritableImage;
import javafx.scene.layout.HBox;
import javafx.scene.web.WebView;
import javafx.stage.Stage;

public class CurvedText extends Application {

  public static void main(String[] args) {
    launch(args);
  }

  @Override
  public void start(Stage primaryStage) throws Exception {
    final HBox root = new HBox();
    final WebView view = new WebView();
    view.getEngine().loadContent("<!DOCTYPE html>\n" +
            "<html xmlns=\"http://www.w3.org/1999/xhtml\">\n" +
            "  <body>\n" +
            "<embed width=\"100\" height=\"100\" type=\"image/svg+xml\" src=\"path.svg\">\n" +
            "  <svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">" +
            "<defs>\n" +
            "  <path id=\"textPath\" d=\"M10 50 C10 0 90 0 90 50\"/>\n" +
            "</defs>\n"+
            "<text fill=\"red\">\n" +
            "  <textPath xlink:href=\"#textPath\">Text on a Path</textPath>\n" +
            "</text>" +
            "</svg>\n" +
            "</embed>" +
            "  </body>\n" +
            "</html>");
    root.getChildren().add(view);
    view.getEngine().getLoadWorker().stateProperty().addListener(new ChangeListener<Worker.State>() {
      @Override
      public void changed(ObservableValue<? extends Worker.State> arg0, Worker.State oldState, Worker.State newState) {
        if (newState == Worker.State.SUCCEEDED) {
          // workaround for https://javafx-jira.kenai.com/browse/RT-23265
          AnimationTimer waitForViewToBeRendered = new AnimationTimer(){
            private int frames = 0;
            @Override
            public void handle(long now) {
              if (frames++ > 3){
                WritableImage snapshot = view.snapshot(null, null);
                ImageView imageView = new ImageView(snapshot);
                root.getChildren().add(imageView);
                this.stop();
              }
            }
          };
          waitForViewToBeRendered.start();
        }
      }
    });
    Scene scene = new Scene(root, 500, 500);
    primaryStage.setScene(scene);
    primaryStage.show();
  }
}