Chapter 15. Complex Shapes

This chapter looks at three complex topics involving shapes: connecting rectangles, shape composition, and drawing Bezier curves.

15.1 Connecting Two Rectangles

A line can be drawn between two shapes using a LineShape. But it’s much easier to join the shapes with a ConnectorShape, which can be attached precisely by linking its two ends to glue points on the shapes. Glue points are the little blue circles which appear on a shape when you use connectors in Draw. They occur in the middle of the upper, lower, left, and right sides of the shape, although it’s possible to create extra ones.

By default a connector is drawn using multiple horizontal and vertical lines. It’s possible to change this to a curve, a single line, or a connection made up of multiple lines which are mostly horizontal and vertical. Fig. 123 shows the four types linking the same two rectangles.

Different Styles of Connector.

Fig. 123 :Different Styles of Connector.

grouper.py contains code for generating the top-left example in Fig. 123:

# partial main() in grouper.py
def main(self) -> None:
    loader = Lo.load_office(Lo.ConnectPipe())

    try:
        doc = DrawDoc(Draw.create_draw_doc(loader))
        doc.set_visible()
        Lo.delay(1_000)  # need delay or zoom may not occur
        doc.zoom(ZoomKind.ENTIRE_PAGE)

        curr_slide = doc.get_slide(idx=0)

        print()
        print("Connecting rectangles ...")
        g_styles = Info.get_style_container(
            doc=doc.component, family_style_name="graphics"
        )
        # Info.show_container_names("Graphic styles", g_styles)

        self._connect_rectangles(slide=curr_slide, g_styles=g_styles)

        # code for grouping, binding, and combining shape,
        # discussed later

        Lo.delay(1_500)
        msg_result = MsgBox.msgbox(
            "Do you wish to close document?",
            "All done",
            boxtype=MessageBoxType.QUERYBOX,
            buttons=MessageBoxButtonsEnum.BUTTONS_YES_NO,
        )
        if msg_result == MessageBoxResultsEnum.YES:
            doc.close_doc()
            Lo.close_office()
        else:
            print("Keeping document open")
    except Exception:
        Lo.close_office()
        raise

The _connect_rectangles() function creates two labeled rectangles, and links them with a standard connector. The connector starts on the bottom edge of the green rectangle and finishes at the top edge of the blue one (as shown in the top-left of Fig. 123). The method also prints out some information about the glue points of the blue rectangle.

# _connect_rectangles() from grouper.py
def _connect_rectangles(
    self, slide: DrawPage[DrawDoc], g_styles: XNameContainer
) -> None:
    # draw two two labelled rectangles, one green, one blue, and
    #  connect them. Changing the connector to an arrow

    # dark green rectangle with shadow and text
    green_rect = slide.draw_rectangle(x=70, y=180, width=50, height=25)
    green_rect.component.FillColor = CommonColor.DARK_GREEN
    green_rect.component.Shadow = True
    green_rect.add_text(msg="Green Rect")

    # (blue, the default color) rectangle with shadow and text
    blue_rect = slide.draw_rectangle(x=140, y=220, width=50, height=25)
    blue_rect.component.Shadow = True
    blue_rect.add_text(msg="Blue Rect")

    # connect the two rectangles; from the first shape to the second
    conn_shape = slide.add_connector(
        shape1=green_rect.component,
        shape2=blue_rect.component,
        start_conn=GluePointsKind.BOTTOM,
        end_conn=GluePointsKind.TOP,
    )

    conn_shape.set_style(
        graphic_styles=g_styles,
        style_name=GraphicStyleKind.ARROW_LINE,
    )
    # arrow added at the 'from' end of the connector shape
    # and it thickens line and turns it black

    # use GraphicArrowStyleKind to lookup the values for LineStartName and LineEndName.
    # these are the the same names as seen in Draw, Graphic Sytles: Arrow Line dialog box.
    conn_shape.set_property(
        LineWidth=50,
        LineColor=CommonColor.DARK_ORANGE,
        LineStartName=str(GraphicArrowStyleKind.ARROW_SHORT),
        LineStartCenter=False,
        LineEndName=GraphicArrowStyleKind.NONE,
    )
    # Props.show_obj_props("Connector Shape", conn_shape.component)

    # report the glue points for the blue rectangle
    try:
        gps = blue_rect.get_glue_points()
        print("Glue Points for blue rectangle")
        for i, gp in enumerate(gps):
            print(f"  Glue point {i}: ({gp.Position.X}, {gp.Position.Y})")
    except mEx.DrawError:
        pass

Note that .add_text() method of the shapes, which invokes Draw.add_text(), is used to label the shapes.

slide.add_connector() invokes Draw.add_connector() which links the two rectangles based on glue point names supplied as arguments start_conn and end_conn. These names are defined in the GluePointsKind enum.

Draw.add_connector() creates a ConnectorShape object and sets several of its properties. A simplified inheritance hierarchy for ConnectorShape is shown in Fig. 124, with the parts important for connectors drawn in red.

The Connector Shape Hierarchy

Fig. 124 :The ConnectorShape Hierarchy.

Unlike many shapes, such as the RectangleShape, ConnectorShape doesn’t have a FillProperties class; instead it has ConnectorProperties which holds most of the properties used by Draw.add_connector() which is defined as:

# in Draw Class (simplified)
@classmethod
def add_connector(
    cls,
    slide: XDrawPage,
    shape1: XShape,
    shape2: XShape,
    start_conn: GluePointsKind = None,
    end_conn: GluePointsKind = None,
) -> XShape:
    if start_conn is None:
        start_conn = GluePointsKind.RIGHT
    if end_conn is None:
        end_conn = GluePointsKind.LEFT

    xconnector = cls.add_shape(
        slide=slide, shape_type=DrawingShapeKind.CONNECTOR_SHAPE, x=0, y=0, width=0, height=0
    )
    prop_set = Lo.qi(XPropertySet, xconnector, True)
    prop_set.setPropertyValue("StartShape", shape1)
    prop_set.setPropertyValue("StartGluePointIndex", int(start_conn))

    prop_set.setPropertyValue("EndShape", shape2)
    prop_set.setPropertyValue("EndGluePointIndex", int(end_conn))

    prop_set.setPropertyValue("EdgeKind", ConnectorType.STANDARD)
    return xconnector

Draw.add_shape() is called with a (0,0) position, zero width and height. The real position and dimensions of the connector are set via its properties. StartShape and StartGluePointIndex specify the starting shape and its glue point, and EndShape and EndGluePointIndex define the ending shape and its glue point. EdgeKind specifies one of the connection types from Fig. 123.

grouper.py’s _connect_rectangles() has some code for retrieving an array of glue points for a shape:

# _connect_rectangles() from grouper.py
gps = blue_rect.get_glue_points()

blue_rect.get_glue_points() invokes Draw.get_glue_points() and converts the shape into an XGluePointsSupplier, and calls its getGluePoints() method to retrieves a tuple of GluePoint2 objects. To simplify the access to the points data, this structure is returned as a tuple:

# in Draw Class (simplified)
@staticmethod
def get_glue_points(shape: XShape) -> Tuple[GluePoint2, ...]:
    gp_supp = mLo.Lo.qi(XGluePointsSupplier, shape, True)
    glue_pts = gp_supp.getGluePoints()

    num_gps = glue_pts.getCount()  # should be 4 by default
    if num_gps == 0:
        return ()

    gps: List[GluePoint2] = []
    for i in range(num_gps):
        try:
            gps.append(glue_pts.getByIndex(i))
        except Exception as e:
            mLo.Lo.print(f"Could not access glue point: {i}")
            mLo.Lo.print(f"  {e}")

    return tuple(gps)

_connect_rectangles() doesn’t do much with this data, aside from printing out each glue points coordinate. They’re specified in 1/100 mm units relative to the center of the shape.

Fig. 123 shows that connectors don’t have arrows, but this can be remedied by changing the connector’s graphics style. The graphics style family is obtained by Info.get_style_container(), and passed to _connect_rectangles():

# in main() of grouper.py
g_styles = Info.get_style_container(
    doc=doc.component, family_style_name="graphics"
)
self._connect_rectangles(slide=curr_slide, g_styles=g_styles)

The styles reported by Info.get_style_container() are related to the Draw built in styles seen in Fig. 125.

Draw Lines Styles

Fig. 125 :Draw Lines Styles

Inside _connect_rectangles(), the connector’s graphic style is changed to use arrows:

# in _connect_rectangles() of grouper.py
conn_shape.set_style(
    graphic_styles=g_styles,
    style_name=GraphicStyleKind.ARROW_LINE,
)

The GraphicStyleKind.ARROW_LINE style creates black arrows as seen in Fig. 126.

A Connector with an Arrows.

Fig. 126 :A Connector with an Arrows.

The line width can be adjusted by setting the shape’s LineWidth property (which is defined in the LineProperties class), and its color with LineColor. The result can be seen in Fig. 127.

# in _connect_rectangles() of grouper.py
conn_shape.set_property(
    LineWidth=50,
    LineColor=CommonColor.DARK_ORANGE,
    LineStartName=str(GraphicArrowStyleKind.ARROW_SHORT),
    LineStartCenter=False,
    LineEndName=GraphicArrowStyleKind.NONE,
)
An orange line connector with a single arrow.

Fig. 127 :An orange line connector with a single arrow.

The arrow head can be modified by changing the arrow name assigned to the connector’s LineStartName property, and by setting LineStartCenter to false. The place to find names for arrow heads is the Line dialog box in LibreOffice’s “Line and Filling” toolbar. The names appear in the “Start styles” combo-box, as shown in Fig. 128.

The Arrow Styles in Libre Office

Fig. 128 :The Arrow Styles in LibreOffice.

OooDev has GraphicArrowStyleKind for looking up arrow name to make this task much easier.

If the properties are set to:

# in _connect_rectangles() of grouper.py
conn_shape.set_property(
    LineWidth=50,
    LineColor=CommonColor.PURPLE,
    LineStartName=str(GraphicArrowStyleKind.LINE_SHORT),
    LineStartCenter=False,
    LineEndName=GraphicArrowStyleKind.NONE,
)

then the arrow head changes to that shown in Fig. 129.

A Different Arrow

Fig. 129 :A Different Arrow

An arrow can be added to the other end of the connector by adjusting its LineEndCenter and LineEndName properties.

OooDev has GraphicStyleKind that makes it much easier to get the style_name to pass to Draw.set_style(). Styles can be looked up in the following manor:

g_styles = Info.get_style_container(
    doc=doc.component, family_style_name="graphics"
)
Info.show_container_names("Graphic styles", g_styles)

Alternatively, you can browse through the LineProperties class inherited by ConnectorShape (shown in Fig. 124).

15.2 Shape Composition

Office supports three kinds of shape composition for converting multiple shapes into a single shape. The new shape is automatically added to the page, and the old shapes are removed. The three techniques are:

  1. grouping: the shapes form a single shape without being changed in any way. Office has two mechanisms for grouping: the ShapeGroup shape and the deprecated XShapeGrouper interface;

  2. binding: this is similar to grouping, but also draws connector lines between the original shapes;

  3. combining: the new shape is built by changing the original shapes if they overlap each other. Office supports four combination styles, called merging, subtraction, intersection, and combination (the default).

grouper.py illustrates these techniques:

# partial main() in grouper.py
# ...
slide_size = curr_slide.get_size_mm()
width = 40
height = 20
x = round(((slide_size.width * 3) / 4) - (width / 2))
y1 = 20
if self.overlap:
    y2 = 30
else:
    y2 = round((slide_size.height / 2) - (y1 + height))  # so separated

s1 = curr_slide.draw_ellipse(x=x, y=y1, width=width, height=height)
s2 = curr_slide.draw_ellipse(x=x, y=y2, width=width, height=height)

Draw.show_shapes_info(curr_slide.component)

# group, bind, or combine the ellipses
print()
print("Grouping (or binding) ellipses ...")
if self._combine_kind == CombineEllipseKind.GROUP:
    self._group_ellipses(slide=curr_slide, s1=s1.component, s2=s2.component)
elif self._combine_kind == CombineEllipseKind.BIND:
    self._bind_ellipses(slide=curr_slide, s1=s1.component, s2=s2.component)
elif self._combine_kind == CombineEllipseKind.COMBINE:
    self._combine_ellipses(
        slide=curr_slide, s1=s1.component, s2=s2.component
    )
Draw.show_shapes_info(curr_slide.component)

# combine some rectangles
comp_shape = self._combine_rects(slide=curr_slide)
Draw.show_shapes_info(curr_slide.component)

print("Waiting a bit before splitting...")
Lo.delay(3000)  # delay so user can see previous composition
# ...

Two ellipses are created, and positioned at the top-right of the page.

Draw.show_shapes_info() is called to supply information about all the shapes on the page:

Draw Page shapes:
  Shape service: com.sun.star.drawing.RectangleShape; z-order: 0
  Shape service: com.sun.star.drawing.RectangleShape; z-order: 1
  Shape service: com.sun.star.drawing.ConnectorShape; z-order: 2
  Shape service: com.sun.star.drawing.EllipseShape; z-order: 3
  Shape service: com.sun.star.drawing.EllipseShape; z-order: 4

The two rectangles and the connector listed first are the results of calling _connect_rectangles() earlier grouper.py. The two ellipses were just created in the code snipper given above.

15.2.1 Grouping Shapes

grouper.py calls _group_ellipses() to group the two ellipses:

# Grouper.main() of grouper.py
s1 = curr_slide.draw_ellipse(x=x, y=y1, width=width, height=height)
s2 = curr_slide.draw_ellipse(x=x, y=y2, width=width, height=height)
self._group_ellipses(slide=curr_slide, s1=s1.component, s2=s2.component)

_group_ellipses() is:

# in Grouper class of grouper.py
def _group_ellipses(self, slide: DrawPage[DrawDoc], s1: XShape, s2: XShape) -> None:
    shape_group = slide.add_shape(
        shape_type=DrawingShapeKind.GROUP_SHAPE,
        x=0,
        y=0,
        width=0,
        height=0,
    )
    shapes = shape_group.qi(XShapes, True)
    shapes.add(s1)
    shapes.add(s2)

The GroupShape is converted to an XShapes interface so the two ellipses can be added to it. Note that GroupShape has no position or size; they are determined from the added shapes.

An alternative approach for grouping is the deprecated XShapeGrouper, but it requires a few more lines of coding. An example can be found in the Developer’s Guide, at https://wiki.openoffice.org/wiki/Documentation/DevGuide/Drawings/Grouping,_Combining_and_Binding

The on-screen result of _group_ellipses() is that the two ellipses become a single shape, as poorly shown in Fig. 130.

Run the Grouper example with these args.

python -m start -k group
The Grouped Ellipses.

Fig. 130 :The Grouped Ellipses.

There’s no noticeable difference from two ellipses until you click on one of them, which causes both to be selected as a single shape.

The change is better shown by a second call to Draw.show_shapes_info() , which reports:

Draw Page shapes:
  Shape service: com.sun.star.drawing.RectangleShape; z-order: 0
  Shape service: com.sun.star.drawing.RectangleShape; z-order: 1
  Shape service: com.sun.star.drawing.ConnectorShape; z-order: 2
  Shape service: com.sun.star.drawing.GroupShape; z-order: 3

The two ellipses have disappeared, replaced by a single GroupShape.

15.2.2 Binding Shapes

Instead of _group_ellipses(), it’s possible to call _bind_ellipses() in grouper.py:

# Grouper.main() of grouper.py
s1 = curr_slide.draw_ellipse(x=x, y=y1, width=width, height=height)
s2 = curr_slide.draw_ellipse(x=x, y=y2, width=width, height=height)
self._bind_ellipses(slide=curr_slide, s1=s1.component, s2=s2.component)

The function is defined as:

# _bind_ellipses() class of grouper.py
def _bind_ellipses(self, slide: DrawPage[DrawDoc], s1: XShape, s2: XShape) -> None:
    shapes = Lo.create_instance_mcf(
        XShapes, "com.sun.star.drawing.ShapeCollection", raise_err=True
    )
    shapes.add(s1)
    shapes.add(s2)
    binder = slide.qi(XShapeBinder, True)
    binder.bind(shapes)

An empty XShapes shape is created, then filled with the component shapes. The shapes inside XShapes are converted into a single object XShapeBinder.bind().

The result is like the grouped ellipses but with a connector linking the shapes, as in Fig. 131.

Run the Grouper example with these args.

python -m start -k bind
The Bound Ellipses.

Fig. 131 :The Bound Ellipses.

The result is also visible in a call to Draw.show_shapes_info():

Draw Page shapes:
  Shape service: com.sun.star.drawing.RectangleShape; z-order: 0
  Shape service: com.sun.star.drawing.RectangleShape; z-order: 1
  Shape service: com.sun.star.drawing.ConnectorShape; z-order: 2
  Shape service: com.sun.star.drawing.ClosedBezierShape; z-order: 3

The two ellipses have been replaced by a closed Bezier shape.

It’s likely easier to link shapes explicitly with connectors, using code like that in _connect_rectangles() from 15.1 Connecting Two Rectangles. If the result needs to be a single shape, then grouping (not binding) can be applied to the shapes and the connector.

15.2.3 Combining Shapes with XShapeCombiner

grouper.py calls _combine_ellipse() to combine the two ellipses:

# in Grouper.main() of grouper.py
s1 = curr_slide.draw_ellipse(x=x, y=y1, width=width, height=height)
s2 = curr_slide.draw_ellipse(x=x, y=y2, width=width, height=height)
self._combine_ellipses(
    slide=curr_slide, s1=s1.component, s2=s2.component
)

_combine_ellipse() employs the XShapeCombiner interface, which is used in the same way as XShapeBinder:

# _combine_ellipses() of grouper.py
def _combine_ellipses(
    self, slide: DrawPage[DrawDoc], s1: XShape, s2: XShape
) -> None:
    shapes = Lo.create_instance_mcf(
        XShapes,
        "com.sun.star.drawing.ShapeCollection",
        raise_err=True,
    )
    shapes.add(s1)
    shapes.add(s2)
    combiner = slide.qi(XShapeCombiner, True)
    combiner.combine(shapes)

The combined shape only differs from grouping if the two ellipses are initially overlapping. Fig. 132 shows that the intersecting areas of the two shapes is removed from the combination.

Run the Grouper example with these args.

python -m start -o -k combine
Combining Shapes with X-Shape-Combiner.

Fig. 132 :Combining Shapes with XShapeCombiner.

The result is also visible in a call to Draw.show_shapes_info():

Draw Page shapes:
  Shape service: com.sun.star.drawing.RectangleShape; z-order: 0
  Shape service: com.sun.star.drawing.RectangleShape; z-order: 1
  Shape service: com.sun.star.drawing.ConnectorShape; z-order: 2
  Shape service: com.sun.star.drawing.ClosedBezierShape; z-order: 3

The two ellipses have again been replaced by a closed Bezier shape .

15.2.4 Richer Shape Combination by Dispatch

The drawback of XShapeCombiner that it only supports combination, not merging, subtraction, or intersection. Those effects had to implemented by using dispatches, as shown in _combine_rects() in grouper.py:

# in grouper.py
def _combine_rects(self, slide: DrawPage[DrawDoc]) -> DrawShape[DrawDoc]:
    print()
    print("Combining rectangles ...")
    r1 = slide.draw_rectangle(x=50, y=20, width=40, height=20)
    r2 = slide.draw_rectangle(x=70, y=25, width=40, height=20)
    shapes = Lo.create_instance_mcf(
        XShapes, "com.sun.star.drawing.ShapeCollection", raise_err=True
    )
    shapes.add(r1.component)
    shapes.add(r2.component)
    comb = slide.owner.combine_shape(
        shapes=shapes, combine_op=ShapeCombKind.COMBINE
    )
    return comb

The dispatching is performed by Draw.combine_shape(), which is passed an array of XShapes and a constant representing one of the four combining techniques.

Fig. 133 shows the results when the two rectangles created in _combine_rects() are combined in the different ways.

The Four Ways of Combining Shapes.

Fig. 133 :The Four Ways of Combining Shapes.

The merging change in Fig. 133 is a bit subtle – notice that there’s no black outline between the rectangles after merging; the merged object is a single shape.

When _combine_rects() returns, Draw.show_shapes_info() reports:

Draw Page shapes:
  Shape service: com.sun.star.drawing.RectangleShape; z-order: 0
  Shape service: com.sun.star.drawing.RectangleShape; z-order: 1
  Shape service: com.sun.star.drawing.ConnectorShape; z-order: 2
  Shape service: com.sun.star.drawing.ClosedBezierShape; z-order: 3
  Shape service: com.sun.star.drawing.PolyPolygonShape; z-order: 4

The combined shape is a PolyPolygonShape, which means that the shape is created from multiple polygons.

One tricky aspect of combining shapes with dispatches is that the shapes must be selected prior to the dispatch being sent. After the dispatch has been processed, the selection will have been changed to contain only the single new shape. This approach is implemented in Draw.combine_shape():

# in Draw class (simplified)
@staticmethod
def combine_shape(doc: XComponent, shapes: XShapes, combine_op: ShapeCombKind) -> XShape:

    sel_supp = Lo.qi(XSelectionSupplier, GUI.get_current_controller(doc), True)
    sel_supp.select(shapes)

    if combine_op == ShapeCombKind.INTERSECT:
        Lo.dispatch_cmd("Intersect")
    elif combine_op == ShapeCombKind.SUBTRACT:
        Lo.dispatch_cmd("Substract")  # misspelt!
    elif combine_op == ShapeCombKind.COMBINE:
        Lo.dispatch_cmd("Combine")
    else:
        Lo.dispatch_cmd("Merge")

    Lo.delay(500)  # give time for dispatches to arrive and be processed

    # extract the new single shape from the modified selection
    xs = Lo.qi(XShapes, sel_supp.getSelection(), True)
    combined_shape = Lo.qi(XShape, xs.getByIndex(0), True)
    return combined_shape

The shapes are selected by adding them to an XSelectionSupplier. The requested dispatch is sent to the selection, and then the function briefly sleeps to ensure that the dispatch has been processed. An XShapes object is obtained from the changed selection, and the new PolyPolygonShape is extracted and returned.

15.3 Undoing a Grouping/Binding/Combining

Any shapes which have been grouped, bound, or combined can be ungrouped, unbound, or uncombined. On screen the separated shapes will look the same as before, but may not have the same shape types as the originals.

The main() function of grouper.py shows how the combination of the two rectangles can be undone:

# in Grouper.main() of grouper.py
# ...
comp_shape = self._combine_rects(slide=curr_slide)
# ...
combiner = curr_slide.qi(XShapeCombiner, True)
combiner.split(comp_shape.component)
Draw.show_shapes_info(curr_slide.component)

The combined rectangles shape is passed to XShapeCombiner.split() which removes the combined shape from the slide, replacing it by its components. Draw.show_shapes_info() shows this result:

Draw Page shapes:
  Shape service: com.sun.star.drawing.RectangleShape; z-order: 0
  Shape service: com.sun.star.drawing.RectangleShape; z-order: 1
  Shape service: com.sun.star.drawing.ConnectorShape; z-order: 2
  Shape service: com.sun.star.drawing.ClosedBezierShape; z-order: 3
  Shape service: com.sun.star.drawing.PolyPolygonShape; z-order: 4
  Shape service: com.sun.star.drawing.PolyPolygonShape; z-order: 5

The last two shapes listed are the separated rectangles, but represented now by two PolyPolygonShape.

XShapeCombiner.split() only works on shapes that were combined using a COMBINE dispatch. Shapes that were composed using merging, subtraction, or intersection, can not be separated.

For grouped and bound shapes, the methods for breaking apart a shape are XShapeGrouper.ungroup() and XShapeBinder.unbind(). For example:

grouper = curr_slide.qi(XShapeGrouper, True)
grouper.ungroup(comp_shape)

15.4 Bezier Curves

The simplest Bezier curve is defined using four coordinates, as in Fig. 134.

A Cubic Bezier Curve.

Fig. 134 :A Cubic Bezier Curve.

P0 and P3 are the start and end points of the curve (also called nodes or anchors), and P1 and P2 are control points, which specify how the curve bends between the start and finish. A curve using four points in this way is a cubic Bezier curve, the default type in Office.

The code for generating Fig. 134 is in _draw_curve() in bezier_builder.py:

# in bezier_builder.py
def _draw_curve(
    self, slide: DrawPage[DrawDoc]
) -> OpenBezierShape[DrawDoc]:
    # sample data, same as bpts3.txt
    path_pts: List[Point] = []
    path_flags: List[PolygonFlags] = []

    path_pts.append(Point(1_000, 2_500))
    path_flags.append(PolygonFlags.NORMAL)

    path_pts.append(Point(1_000, 1_000))  # control point
    path_flags.append(PolygonFlags.CONTROL)

    path_pts.append(Point(4_000, 1_000))  # control point
    path_flags.append(PolygonFlags.CONTROL)

    path_pts.append(Point(4_000, 2_500))
    path_flags.append(PolygonFlags.NORMAL)

    return slide.draw_bezier_open(
        pts=path_pts, flags=path_flags
    )

Most of the curve generation is done by Draw.draw_bezier(), but the programmer must still define two list and a boolean. The path_pts[] list holds the four coordinates, and path_flags[] specify their types. The final boolean argument of Draw.draw_bezier() indicates whether the generated curve is to be open or closed.

Fig. 135 shows how the curve is rendered.

The Drawn Bezier Curve

Fig. 135 :The Drawn Bezier Curve.

Draw.draw_bezier() uses the is_open boolean to decide whether to create an OpenBezierShape or a ClosedBezierShape. Then it fills a PolyPolygonBezierCoords data structure with the coordinates and flags before assigning the structure to the shape’s PolyPolygonBezier property:

# in the Draw class (simplified)
@classmethod
def draw_bezier(
    cls,
    slide: XDrawPage,
    pts: Sequence[Point],
    flags: Sequence[PolygonFlags],
    is_open: bool
) -> XShape:

    if len(pts) != len(flags):
        raise IndexError("pts and flags must be the same length")

    bezier_type = "OpenBezierShape" if is_open else "ClosedBezierShape"
    bezier_poly = cls.add_shape(
        slide=slide, shape_type=bezier_type, x=0, y=0, width=0, height=0
    )
    # create space for one bezier shape
    coords = PolyPolygonBezierCoords()
    coords.Coordinates = (pts,)
    coords.Flags = (flags,)

    Props.set(bezier_poly, PolyPolygonBezier=coords)
    return bezier_poly

A PolyPolygonBezierCoords object can store multiple Bezier curves, but Draw.draw_bezier() only assigns one curve to it. Each curve is defined by a list of coordinates and a set of flags.

15.4.1 Drawing a Simple Bezier

The hard part of writing _draw_curve() in bezier_builder.py is knowing what coordinates to put into path_pts[]. Probably the ‘easiest’ solution is to use a SVG editor to draw the curve by hand, and then extract the coordinates from the generated file.

As the quotes around ‘easiest’ suggest, this isn’t actually that easy since a curve can be much more complex than my example. A real example may be composed from multiple curves, straight lines, quadratic Bezier sub-curves (i.e. ones which use only a single control point between anchors), arcs, and smoothing. The official specification can be found at https://www.w3.org/TR/SVG/paths.html, and there are many tutorials on the topic, such as https://www.w3schools.com/graphics/svg_path.asp.

Even if you’re careful and only draw curves, the generated SVG is not quite the same as the coordinates used by Office’s PolyPolygonBezierCoords. However, the translation is fairly straightforward, once you’ve done one or two.

One good online site for drawing simple curves is https://blogs.sitepointstatic.com/examples/tech/svg-curves/cubic-curve.html, developed by Craig Buckler. It restricts you to manipulating a curve made up of two anchors and two controls, like mine, and displays the corresponding SVG path data, as in Fig. 136.

The Drawn Bezier Curve

Fig. 136 :Drawing a Curve Online

Fig. 136 is a bit small – the path data at the top-right is: The path contains two operations: M and C. M moves the drawing point to a specified coordinate (in this case (100, 250)). The C is followed by three coordinates: (100, 100), (400, 100), and (400, 250). The first two are the control points and the last is the end point of the curve. There’s no start point since the result of the M operation is used by default.

Translating this to Office coordinates means using the M coordinate as the start point, and applying some scaling of the values to make the curve visible on the page. Remember that Office uses 1/100 mm units for drawing. A simple scale factor is to multiply all the numbers by 10, producing: (1000, 2500), (1000, 1000), (4000, 1000), and (4000, 2500). These are the numbers in Fig. 134, and utilized by _draw_curve() in bezier_builder.py.

15.4.2 Drawing a Complicated Bezier Curve

What if you want to draw a curve of more than four points? I use Office’s Draw application to draw the curve manually, save it as an SVG file, and then extract the path coordinates from that file.

Recommend using Draw because it generates path coordinates using 1/100 mm units, which saves me from having to do any scaling.

You might be thinking that if Draw can generate SVG data then why not just import that data as a Bezier curve into the code? Unfortunately, this isn’t quite possible at present – it’s true that you can import an SVG file into Office, but it’s stored as an image. In particular, it’s available as a GraphicObjectShape not a OpenBezierShape or a ClosedBezierShape. This means that you cannot examine or change its points.

As an example, consider the complex curve in Fig. 137 which was created in Draw and exported as an SVG file.

A Complex Bezier Curve, manually produced in Draw.

Fig. 137 :A Complex Bezier Curve, manually produced in Draw.

Details on how to draw Bezier curves are explained in the Draw user guide, at the end of section 11 on advanced techniques.

The SVG file format is XML-based, so the saved file can be opened by a text editor.

The coordinate information for this OpenBezierShape is near the end of the file:

<g class="com.sun.star.drawing.OpenBezierShape">
    <g id="id3">
        <path fill="none" stroke="rgb(0,0,0)" d="M 5586,13954 C
        5713,13954 4443,2905 8253,7477 12063,12049 8634,19415 15619,10906
        22604,2397 11682,1381 10285,6334 8888,11287 21207,21447 8253,17002 -
        4701,12557 11174,15986 11174,15986"/>
    </g>
</g>

The path consists of a single M operation, and a long C operation, which should be read as a series of cubic Bezier curves. Each curve in the C list is made from three coordinates, since the start point is implicitly given by the initial M move or the end point of the previous curve in the list.

Copy the data and save it as two lines in a text file (e.g. in bpts2.txt):

M 5586,13954

C 5713,13954 4443,2905 8253,7477 12063,12049 8634,19415 15619,10906
22604,2397 11682,1381 10285,6334 8888,11287 21207,21447 8253,17002 -
4701,12557 11174,15986 11174,15986

Run the Draw Bezier Curve with these args.

python -m start 2

the curve shown in Fig. 138 appears on a page.

The Curve Drawn by bezier builder python file

Fig. 138 :The Curve Drawn by bezier_builder.py

The bezier_builder.py a data-reading functions can only handle a single M and C operation. If the curve you draw has straight lines, arcs, smoothing, or multiple parts, then the SVG file will contain operations that are not able to be processed by that code.

However, the data-reading functions do recognize the Z operation, which specifies that the curve should be closed. If Z is added as a new line at the end of the bpts2.txt, then the closed Bezier curve in Fig. 139 is generated.

The Closed Curve Drawn by bezier builder python file

Fig. 139 :The Closed Curve Drawn by bezier_builder.py