PPTX connectors: emit native bentConnector2/3/5 instead of disjoint lines
Spec authors hand the renderer a polyline like ``points: [[210,280],[400,280],[700,130]]``. The previous code emitted one ``prst="line"`` shape per segment, so the elbow was two separate straight lines that PowerPoint cannot recognise as a connected path. The OCI template (kb/diagram/assets/OCI_Icons.pptx) uses native PowerPoint bent connectors instead — 98 ``straightConnector1`` plus 22 ``bentConnector2/3/5`` plus 15 ``line`` across 48 slides. Match that convention: • 2 points → straightConnector1 (one segment, unchanged) • 3 points → bentConnector2 (one elbow) • 4 points → bentConnector3 (S-shape, two elbows) • 5+ points → bentConnector5 (four elbows) The rasterizer (oci_pptx_render.py) now also understands bentConnectorN so the local PNG preview matches what real PowerPoint draws — without this it rendered every connector as one straight diagonal regardless of the prst. Persistent: applies to every absolute_layout connector for every spec, not a per-case patch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -511,12 +511,15 @@ class NativePPTXDiagramRenderer:
|
||||
(source["x"] + int(source["cx"] / 2), source["y"] + int(source["cy"] / 2)),
|
||||
(target["x"] + int(target["cx"] / 2), target["y"] + int(target["cy"] / 2)),
|
||||
]
|
||||
for idx in range(len(scaled) - 1):
|
||||
fragments.append(self._arrow_shape(
|
||||
allocate_id(), scaled[idx][0], scaled[idx][1], scaled[idx + 1][0], scaled[idx + 1][1],
|
||||
self.bark, width_emu=12700, dashed=bool(conn.get("dashed")),
|
||||
arrow=idx == len(scaled) - 2,
|
||||
))
|
||||
# Render the polyline as a SINGLE native bent connector
|
||||
# (OCI_Icons.pptx convention) instead of multiple disjoint
|
||||
# straight `line` shapes.
|
||||
bent = self._bent_connector_shape(
|
||||
allocate_id(), scaled, self.bark,
|
||||
width_emu=12700, dashed=bool(conn.get("dashed")), arrow=True,
|
||||
)
|
||||
if bent is not None:
|
||||
fragments.append(bent)
|
||||
if conn.get("flow_order"):
|
||||
bx = scaled[0][0] + int((scaled[-1][0] - scaled[0][0]) * float(conn.get("badge_t", 0.25))) - sl(9)
|
||||
by = scaled[0][1] + int((scaled[-1][1] - scaled[0][1]) * float(conn.get("badge_t", 0.25))) - sl(9)
|
||||
@@ -1306,6 +1309,70 @@ class NativePPTXDiagramRenderer:
|
||||
"""
|
||||
return etree.fromstring(xml.encode("utf-8"))
|
||||
|
||||
def _bent_connector_shape(self, shape_id: int, points: list[tuple[int, int]],
|
||||
color: str, width_emu: int = 12700,
|
||||
dashed: bool = False, arrow: bool = True):
|
||||
"""Render a polyline as a SINGLE OOXML bent connector.
|
||||
|
||||
OCI_Icons.pptx (the template) uses ``bentConnector2/3/5`` for
|
||||
elbow paths, NOT a sequence of separate straight ``line``
|
||||
shapes. The latter renders as disjoint segments at the bends —
|
||||
what Diego flagged: "la distribucion/forma de las flechas me
|
||||
gusta mas como quedo en drawio que en pptx" (2026-04-25).
|
||||
|
||||
We use:
|
||||
• 2 points (1 segment) → straightConnector1 (single line)
|
||||
• 3 points (1 elbow) → bentConnector2 (1 bend)
|
||||
• 4 points (2 elbows) → bentConnector3 (2 bends, S-shape)
|
||||
• 5+ points → bentConnector5 (4 bends)
|
||||
|
||||
``flipH``/``flipV`` are derived from the path's overall
|
||||
direction so the bent connector lays out source→target the way
|
||||
the spec authored it.
|
||||
"""
|
||||
if len(points) < 2:
|
||||
return None
|
||||
x_first, y_first = points[0]
|
||||
x_last, y_last = points[-1]
|
||||
xs = [p[0] for p in points]
|
||||
ys = [p[1] for p in points]
|
||||
x = min(xs)
|
||||
y = min(ys)
|
||||
cx = max(max(xs) - x, 1)
|
||||
cy = max(max(ys) - y, 1)
|
||||
flip_h = ' flipH="1"' if x_last < x_first else ""
|
||||
flip_v = ' flipV="1"' if y_last < y_first else ""
|
||||
n = len(points)
|
||||
if n == 2:
|
||||
prst = "straightConnector1"
|
||||
elif n == 3:
|
||||
prst = "bentConnector2"
|
||||
elif n == 4:
|
||||
prst = "bentConnector3"
|
||||
else:
|
||||
prst = "bentConnector5"
|
||||
dash = "<a:prstDash val=\"dash\"/>" if dashed else "<a:prstDash val=\"solid\"/>"
|
||||
arrow_xml = '<a:tailEnd type="triangle" w="sm" len="sm"/>' if arrow else ""
|
||||
xml = f"""
|
||||
<p:cxnSp xmlns:p="{P_NS}" xmlns:a="{A_NS}">
|
||||
<p:nvCxnSpPr>
|
||||
<p:cNvPr id="{shape_id}" name="Connector"/>
|
||||
<p:cNvCxnSpPr/>
|
||||
<p:nvPr/>
|
||||
</p:nvCxnSpPr>
|
||||
<p:spPr>
|
||||
<a:xfrm{flip_h}{flip_v}><a:off x="{x}" y="{y}"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm>
|
||||
<a:prstGeom prst="{prst}"><a:avLst/></a:prstGeom>
|
||||
<a:ln w="{width_emu}">
|
||||
<a:solidFill><a:srgbClr val="{color}"/></a:solidFill>
|
||||
{dash}
|
||||
{arrow_xml}
|
||||
</a:ln>
|
||||
</p:spPr>
|
||||
</p:cxnSp>
|
||||
"""
|
||||
return etree.fromstring(xml.encode("utf-8"))
|
||||
|
||||
def _badge_shape(self, shape_id: int, x: int, y: int, size: int, text: str):
|
||||
xml = f"""
|
||||
<p:sp xmlns:p="{P_NS}" xmlns:a="{A_NS}">
|
||||
|
||||
@@ -355,8 +355,60 @@ class PPTXSlideRenderer:
|
||||
if not line:
|
||||
return
|
||||
xfrm = sp_pr.find("a:xfrm", namespaces=NS)
|
||||
geom = sp_pr.find("a:prstGeom", namespaces=NS)
|
||||
prst = geom.get("prst") if geom is not None else "line"
|
||||
# bentConnectorN: PowerPoint draws an elbow path inside the
|
||||
# bbox. Approximate visually so the rasterizer preview matches
|
||||
# what real PowerPoint shows. (The previous code rendered every
|
||||
# connector as a single diagonal which hid bent elbows during
|
||||
# local visual review — Diego flagged 2026-04-25.)
|
||||
if prst.startswith("bentConnector"):
|
||||
self._render_bent_connector(xfrm, transform, line, prst)
|
||||
return
|
||||
self._render_line_like(xfrm, transform, line, arrow=True)
|
||||
|
||||
def _render_bent_connector(self, xfrm: etree._Element | None, transform: Transform,
|
||||
line: dict, prst: str) -> None:
|
||||
if xfrm is None:
|
||||
return
|
||||
off = xfrm.find("a:off", namespaces=NS)
|
||||
ext = xfrm.find("a:ext", namespaces=NS)
|
||||
if off is None or ext is None:
|
||||
return
|
||||
x = _safe_int(off.get("x"))
|
||||
y = _safe_int(off.get("y"))
|
||||
cx = _safe_int(ext.get("cx"))
|
||||
cy = _safe_int(ext.get("cy"))
|
||||
flip_h = xfrm.get("flipH") == "1"
|
||||
flip_v = xfrm.get("flipV") == "1"
|
||||
start_local = (cx if flip_h else 0, cy if flip_v else 0)
|
||||
end_local = (0 if flip_h else cx, 0 if flip_v else cy)
|
||||
sx_, sy_ = transform.map_point(x + start_local[0], y + start_local[1])
|
||||
ex, ey = transform.map_point(x + end_local[0], y + end_local[1])
|
||||
# Build the elbow waypoints. bentConnector2 = single bend at
|
||||
# the source's projection on the target. bentConnector3 = two
|
||||
# bends (S-shape). bentConnector5 = four bends.
|
||||
if prst == "bentConnector2":
|
||||
mid = (ex, sy_)
|
||||
pts = [(sx_, sy_), mid, (ex, ey)]
|
||||
elif prst == "bentConnector3":
|
||||
half_x = (sx_ + ex) / 2
|
||||
pts = [(sx_, sy_), (half_x, sy_), (half_x, ey), (ex, ey)]
|
||||
else: # bentConnector5 or anything larger
|
||||
half_x = (sx_ + ex) / 2
|
||||
half_y = (sy_ + ey) / 2
|
||||
pts = [(sx_, sy_), (half_x, sy_), (half_x, half_y),
|
||||
(ex, half_y), (ex, ey)]
|
||||
ipts = [(int(round(p[0])), int(round(p[1]))) for p in pts]
|
||||
for i in range(len(ipts) - 1):
|
||||
a, b = ipts[i], ipts[i + 1]
|
||||
if line.get("dashed"):
|
||||
self._draw_dashed_line(a, b, line["color"], line["width"])
|
||||
else:
|
||||
self.draw.line([a, b], fill=line["color"], width=line["width"])
|
||||
if line.get("arrow"):
|
||||
self._draw_arrowhead(ipts[-2], ipts[-1], line["color"], line["width"])
|
||||
|
||||
def _render_line_like(self, xfrm: etree._Element | None, transform: Transform, line: dict, arrow: bool) -> None:
|
||||
if xfrm is None:
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user