diff --git a/tools/oci_pptx_diagram_gen.py b/tools/oci_pptx_diagram_gen.py index 42a8522..b01f072 100644 --- a/tools/oci_pptx_diagram_gen.py +++ b/tools/oci_pptx_diagram_gen.py @@ -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 = "" if dashed else "" + arrow_xml = '' if arrow else "" + xml = f""" + + + + + + + + + + + + {dash} + {arrow_xml} + + + + """ + return etree.fromstring(xml.encode("utf-8")) + def _badge_shape(self, shape_id: int, x: int, y: int, size: int, text: str): xml = f""" diff --git a/tools/oci_pptx_render.py b/tools/oci_pptx_render.py index eccff6c..ca8bad1 100644 --- a/tools/oci_pptx_render.py +++ b/tools/oci_pptx_render.py @@ -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