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 Glow
Curved Text
Curve 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.

Leave a Comment