Diagram generation: ref-arch-driven procedure + spec validator + KB enrichment
The diagram path now follows a documented standard procedure (lookup the closest Oracle Architecture Center reference → confirm components → author absolute_layout → spec validator → render → visually verify) and ships persistent guardrails so layout regressions can't recur. Persistent procedure changes (apply to all users, all sessions): - tools/diagram_spec_validator.py — geometry checks (CONTAINER_TOO_THIN, CONTAINER_PADDING_VIOLATION, LABEL_OVERFLOW_PARENT) run BEFORE either renderer (drawio + PPTX). Catches the subnet-collapse / label-overflow bugs that the post-render drawio validator missed. - tools/oci_diagram_gen.py + tools/oci_pptx_diagram_gen.py — call the spec validator before emitting any output. Adds mysql / mysql_heatwave type aliases. - tools/archcenter_pattern_lookup.py — scores against cached page descriptions (not just the 1-line summary), supports --queries for multi-fragment composition, and applies synonym expansion via kb/architecture-center/synonyms.yaml so "LB HA cross AD" matches "load balancer high availability availability domain". - kb/architecture-center/synonyms.yaml — canonical synonym table (load balancer, autonomous database, data guard, …) used by the lookup scorer. KB enrichment: - tools/archcenter_description_fetcher.py + 121 cached _description.md under kb/diagram/assets/archcenter-refs/<slug>/. Removes the runtime dependency on docs.oracle.com when authoring specs and feeds the pattern-lookup scorer. - 110+ cached .drawio / .svg / .png references for offline reuse, plus the OCI Toolkit v24.2 import (kb/diagram/assets/oci-toolkit-drawio). Documentation: - docs/skill/output-formats.md — new "Standard diagram-generation procedure (MANDATORY)" + geometry rules + the new validator entry. - SKILL.md option 2 — references the mandatory procedure. - README.md — describes the spec validator, archcenter_pattern_lookup and description fetcher, and updates the KB-health table. Tooling that backs the procedure (cumulative across recent sessions): tools/archcenter_case_runner.py, archcenter_batch_driver.py, archcenter_zip_downloader.py, drawio_visual_validator.py, drawio_fidelity_eval.py, harvest_drawio_icon.py, import_oci_library.py, oci_pptx_diagram_gen.py, oci_pptx_render.py, refresh_pptx_icon_index.py. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
582
tools/oci_pptx_render.py
Normal file
582
tools/oci_pptx_render.py
Normal file
@@ -0,0 +1,582 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Rasterize native OCI PPTX slides without LibreOffice.
|
||||
|
||||
This renderer supports the subset emitted by `tools/oci_pptx_diagram_gen.py`
|
||||
plus the OCI icon groups cloned from `OCI_Icons.pptx`. It is purposely scoped
|
||||
to benchmark/editable architecture slides rather than being a generic
|
||||
PowerPoint renderer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import posixpath
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
import cairosvg
|
||||
from lxml import etree
|
||||
from PIL import Image, ImageColor, ImageDraw, ImageFont
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
EMU_PER_INCH = 914400
|
||||
|
||||
P_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
|
||||
A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
PKG_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
|
||||
NS = {"p": P_NS, "a": A_NS, "r": R_NS}
|
||||
|
||||
DEFAULT_MARKER = "Coordinate-faithful reconstruction"
|
||||
|
||||
|
||||
def _local_name(tag: str) -> str:
|
||||
return tag.split("}", 1)[-1] if "}" in tag else tag
|
||||
|
||||
|
||||
def _font(size_px: int, bold: bool = False) -> ImageFont.ImageFont:
|
||||
candidates = [
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf",
|
||||
]
|
||||
for candidate in candidates:
|
||||
if Path(candidate).is_file():
|
||||
return ImageFont.truetype(candidate, max(size_px, 8))
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def _color(value: str | None, default: str = "#000000") -> tuple[int, int, int, int]:
|
||||
if not value:
|
||||
return ImageColor.getrgb(default) + (255,)
|
||||
if not value.startswith("#"):
|
||||
value = f"#{value}"
|
||||
return ImageColor.getrgb(value) + (255,)
|
||||
|
||||
|
||||
def _safe_int(value: str | None, default: int = 0) -> int:
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Transform:
|
||||
sx: float = 1.0
|
||||
sy: float = 1.0
|
||||
tx: float = 0.0
|
||||
ty: float = 0.0
|
||||
|
||||
def map_point(self, x: float, y: float) -> tuple[float, float]:
|
||||
return (self.tx + x * self.sx, self.ty + y * self.sy)
|
||||
|
||||
def map_rect(self, x: float, y: float, cx: float, cy: float) -> tuple[float, float, float, float]:
|
||||
x1, y1 = self.map_point(x, y)
|
||||
x2, y2 = self.map_point(x + cx, y + cy)
|
||||
return x1, y1, x2, y2
|
||||
|
||||
def compose_group(self, xfrm: etree._Element | None) -> "Transform":
|
||||
if xfrm is None:
|
||||
return self
|
||||
off = xfrm.find("a:off", namespaces=NS)
|
||||
ext = xfrm.find("a:ext", namespaces=NS)
|
||||
ch_off = xfrm.find("a:chOff", namespaces=NS)
|
||||
ch_ext = xfrm.find("a:chExt", namespaces=NS)
|
||||
off_x = _safe_int(off.get("x") if off is not None else None)
|
||||
off_y = _safe_int(off.get("y") if off is not None else None)
|
||||
ext_x = max(_safe_int(ext.get("cx") if ext is not None else None, 1), 1)
|
||||
ext_y = max(_safe_int(ext.get("cy") if ext is not None else None, 1), 1)
|
||||
ch_off_x = _safe_int(ch_off.get("x") if ch_off is not None else None)
|
||||
ch_off_y = _safe_int(ch_off.get("y") if ch_off is not None else None)
|
||||
ch_ext_x = max(_safe_int(ch_ext.get("cx") if ch_ext is not None else None, ext_x), 1)
|
||||
ch_ext_y = max(_safe_int(ch_ext.get("cy") if ch_ext is not None else None, ext_y), 1)
|
||||
child_sx = self.sx * (ext_x / ch_ext_x)
|
||||
child_sy = self.sy * (ext_y / ch_ext_y)
|
||||
child_tx = self.tx + self.sx * (off_x - ch_off_x * (ext_x / ch_ext_x))
|
||||
child_ty = self.ty + self.sy * (off_y - ch_off_y * (ext_y / ch_ext_y))
|
||||
return Transform(sx=child_sx, sy=child_sy, tx=child_tx, ty=child_ty)
|
||||
|
||||
|
||||
def _extract_slide_list(archive: zipfile.ZipFile) -> list[dict]:
|
||||
presentation = etree.fromstring(archive.read("ppt/presentation.xml"))
|
||||
rels = etree.fromstring(archive.read("ppt/_rels/presentation.xml.rels"))
|
||||
rel_map = {
|
||||
rel.get("Id"): rel.get("Target")
|
||||
for rel in rels.findall(f"{{{PKG_REL_NS}}}Relationship")
|
||||
}
|
||||
slide_ids = presentation.find("p:sldIdLst", namespaces=NS)
|
||||
if slide_ids is None:
|
||||
return []
|
||||
slides = []
|
||||
for display_number, slide_id in enumerate(slide_ids.findall("p:sldId", namespaces=NS), start=1):
|
||||
rel_id = slide_id.get(f"{{{R_NS}}}id")
|
||||
target = rel_map.get(rel_id or "")
|
||||
if not target:
|
||||
continue
|
||||
slides.append({
|
||||
"display_number": display_number,
|
||||
"slide_path": f"ppt/{target}",
|
||||
})
|
||||
return slides
|
||||
|
||||
|
||||
def _slide_size(archive: zipfile.ZipFile) -> tuple[int, int]:
|
||||
presentation = etree.fromstring(archive.read("ppt/presentation.xml"))
|
||||
sld_sz = presentation.find("p:sldSz", namespaces=NS)
|
||||
if sld_sz is None:
|
||||
return (12192000, 6858000)
|
||||
return (
|
||||
_safe_int(sld_sz.get("cx"), 12192000),
|
||||
_safe_int(sld_sz.get("cy"), 6858000),
|
||||
)
|
||||
|
||||
|
||||
def _slide_rels_map(archive: zipfile.ZipFile, slide_path: str) -> dict[str, str]:
|
||||
slide_name = Path(slide_path).name
|
||||
rels_path = f"ppt/slides/_rels/{slide_name}.rels"
|
||||
try:
|
||||
root = etree.fromstring(archive.read(rels_path))
|
||||
except KeyError:
|
||||
return {}
|
||||
rels = {}
|
||||
for rel in root.findall(f"{{{PKG_REL_NS}}}Relationship"):
|
||||
rels[rel.get("Id")] = rel.get("Target")
|
||||
return rels
|
||||
|
||||
|
||||
def _resolve_rel_path(slide_path: str, rel_target: str) -> str:
|
||||
base = PurePosixPath(slide_path).parent
|
||||
target = posixpath.normpath((base / rel_target).as_posix())
|
||||
while target.startswith("../"):
|
||||
target = target[3:]
|
||||
if not target.startswith("ppt/"):
|
||||
target = posixpath.normpath(f"ppt/{target}")
|
||||
return target
|
||||
|
||||
|
||||
def _shape_counts(sp_tree: etree._Element) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
for child in sp_tree:
|
||||
tag = _local_name(child.tag)
|
||||
counts[tag] = counts.get(tag, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
def _slide_text(root: etree._Element) -> str:
|
||||
return " ".join(
|
||||
(node.text or "").strip()
|
||||
for node in root.findall(".//a:t", namespaces=NS)
|
||||
if (node.text or "").strip()
|
||||
)
|
||||
|
||||
|
||||
def _pick_slide(archive: zipfile.ZipFile, requested: str, marker: str) -> dict:
|
||||
slides = _extract_slide_list(archive)
|
||||
if requested != "auto":
|
||||
wanted = int(requested)
|
||||
for slide in slides:
|
||||
if slide["display_number"] == wanted:
|
||||
return slide
|
||||
raise ValueError(f"Slide {wanted} not found in PPTX")
|
||||
|
||||
best: dict | None = None
|
||||
best_score = -1.0
|
||||
marker_norm = marker.lower().strip()
|
||||
for slide in slides:
|
||||
root = etree.fromstring(archive.read(slide["slide_path"]))
|
||||
sp_tree = root.find(".//p:spTree", namespaces=NS)
|
||||
if sp_tree is None:
|
||||
continue
|
||||
text = _slide_text(root).lower()
|
||||
counts = _shape_counts(sp_tree)
|
||||
score = 0.0
|
||||
if marker_norm and marker_norm in text:
|
||||
score += 100.0
|
||||
if "coordinate-faithful reconstruction" in text:
|
||||
score += 60.0
|
||||
if "oci region" in text:
|
||||
score += 25.0
|
||||
if "availability domain" in text:
|
||||
score += 10.0
|
||||
score += counts.get("grpSp", 0) * 2.0
|
||||
score += counts.get("pic", 0) * 2.5
|
||||
score += counts.get("cxnSp", 0) * 1.5
|
||||
score += min(counts.get("sp", 0), 60) * 0.1
|
||||
if score > best_score:
|
||||
best = slide
|
||||
best_score = score
|
||||
if best is None:
|
||||
raise ValueError("Could not detect an architecture slide")
|
||||
return best
|
||||
|
||||
|
||||
class PPTXSlideRenderer:
|
||||
def __init__(self, pptx_path: Path, output_width: int | None = None):
|
||||
self.pptx_path = Path(pptx_path)
|
||||
self.output_width = output_width
|
||||
self.archive = zipfile.ZipFile(self.pptx_path)
|
||||
self.slide_cx, self.slide_cy = _slide_size(self.archive)
|
||||
self.width_px = output_width or 1600
|
||||
self.height_px = max(1, int(round(self.width_px * self.slide_cy / self.slide_cx)))
|
||||
self.scale_x = self.width_px / self.slide_cx
|
||||
self.scale_y = self.height_px / self.slide_cy
|
||||
self.image = Image.new("RGBA", (self.width_px, self.height_px), (255, 255, 255, 255))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
self._media_cache: dict[str, Image.Image] = {}
|
||||
self.skipped_media: list[str] = []
|
||||
|
||||
def close(self) -> None:
|
||||
self.archive.close()
|
||||
|
||||
def render(self, slide_number: str = "auto", marker: str = DEFAULT_MARKER) -> dict:
|
||||
slide = _pick_slide(self.archive, slide_number, marker)
|
||||
slide_root = etree.fromstring(self.archive.read(slide["slide_path"]))
|
||||
sp_tree = slide_root.find(".//p:spTree", namespaces=NS)
|
||||
if sp_tree is None:
|
||||
raise ValueError(f"{slide['slide_path']} has no shape tree")
|
||||
rel_map = _slide_rels_map(self.archive, slide["slide_path"])
|
||||
slide["shape_counts"] = _shape_counts(sp_tree)
|
||||
slide["text_sample"] = _slide_text(slide_root)[:800]
|
||||
slide["skipped_media"] = []
|
||||
for child in sp_tree:
|
||||
self._render_node(child, Transform(sx=self.scale_x, sy=self.scale_y), rel_map, slide["slide_path"])
|
||||
slide["skipped_media"] = list(self.skipped_media)
|
||||
slide["output_size"] = [self.width_px, self.height_px]
|
||||
return slide
|
||||
|
||||
def save(self, path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.image.convert("RGB").save(path)
|
||||
|
||||
def _render_node(self, node: etree._Element, transform: Transform, rel_map: dict[str, str], slide_path: str) -> None:
|
||||
tag = _local_name(node.tag)
|
||||
if tag == "grpSp":
|
||||
group_xfrm = node.find("p:grpSpPr/a:xfrm", namespaces=NS)
|
||||
child_transform = transform.compose_group(group_xfrm)
|
||||
for child in node:
|
||||
if _local_name(child.tag) in {"grpSpPr", "nvGrpSpPr"}:
|
||||
continue
|
||||
self._render_node(child, child_transform, rel_map, slide_path)
|
||||
return
|
||||
if tag == "sp":
|
||||
self._render_shape(node, transform)
|
||||
return
|
||||
if tag == "pic":
|
||||
self._render_picture(node, transform, rel_map, slide_path)
|
||||
return
|
||||
if tag == "cxnSp":
|
||||
self._render_connector(node, transform)
|
||||
|
||||
def _shape_rect(self, xfrm: etree._Element | None, transform: Transform) -> tuple[int, int, int, int] | None:
|
||||
if xfrm is None:
|
||||
return None
|
||||
off = xfrm.find("a:off", namespaces=NS)
|
||||
ext = xfrm.find("a:ext", namespaces=NS)
|
||||
if off is None or ext is None:
|
||||
return None
|
||||
x1, y1, x2, y2 = transform.map_rect(
|
||||
_safe_int(off.get("x")),
|
||||
_safe_int(off.get("y")),
|
||||
_safe_int(ext.get("cx")),
|
||||
_safe_int(ext.get("cy")),
|
||||
)
|
||||
return (
|
||||
int(round(min(x1, x2))),
|
||||
int(round(min(y1, y2))),
|
||||
int(round(max(x1, x2))),
|
||||
int(round(max(y1, y2))),
|
||||
)
|
||||
|
||||
def _render_shape(self, node: etree._Element, transform: Transform) -> None:
|
||||
sp_pr = node.find("p:spPr", namespaces=NS)
|
||||
if sp_pr is None:
|
||||
return
|
||||
xfrm = sp_pr.find("a:xfrm", namespaces=NS)
|
||||
rect = self._shape_rect(xfrm, transform)
|
||||
if rect is None:
|
||||
return
|
||||
geom = sp_pr.find("a:prstGeom", namespaces=NS)
|
||||
geom_type = geom.get("prst") if geom is not None else "rect"
|
||||
fill = self._fill_color(sp_pr)
|
||||
line = self._line_style(sp_pr)
|
||||
if geom_type == "ellipse":
|
||||
if fill:
|
||||
self.draw.ellipse(rect, fill=fill)
|
||||
if line:
|
||||
self.draw.ellipse(rect, outline=line["color"], width=line["width"])
|
||||
elif geom_type == "roundRect":
|
||||
radius = max(4, int(min(rect[2] - rect[0], rect[3] - rect[1]) * 0.08))
|
||||
if fill:
|
||||
self.draw.rounded_rectangle(rect, radius=radius, fill=fill)
|
||||
if line:
|
||||
self.draw.rounded_rectangle(rect, radius=radius, outline=line["color"], width=line["width"])
|
||||
elif geom_type == "line":
|
||||
self._render_line_like(xfrm, transform, line or {"color": _color("312D2A"), "width": 2, "dashed": False}, arrow=False)
|
||||
else:
|
||||
if fill:
|
||||
self.draw.rectangle(rect, fill=fill)
|
||||
if line:
|
||||
self.draw.rectangle(rect, outline=line["color"], width=line["width"])
|
||||
self._render_text(node, rect)
|
||||
|
||||
def _render_picture(self, node: etree._Element, transform: Transform, rel_map: dict[str, str], slide_path: str) -> None:
|
||||
xfrm = node.find("p:spPr/a:xfrm", namespaces=NS)
|
||||
rect = self._shape_rect(xfrm, transform)
|
||||
if rect is None:
|
||||
return
|
||||
blip = node.find(".//a:blip", namespaces=NS)
|
||||
rid = blip.get(f"{{{R_NS}}}embed") if blip is not None else None
|
||||
target = rel_map.get(rid or "")
|
||||
if not target:
|
||||
return
|
||||
media_path = _resolve_rel_path(slide_path, target)
|
||||
image = self._media(media_path)
|
||||
if image is None:
|
||||
return
|
||||
width = max(rect[2] - rect[0], 1)
|
||||
height = max(rect[3] - rect[1], 1)
|
||||
resized = image.resize((width, height), Image.Resampling.LANCZOS)
|
||||
self.image.alpha_composite(resized, (rect[0], rect[1]))
|
||||
|
||||
def _render_connector(self, node: etree._Element, transform: Transform) -> None:
|
||||
sp_pr = node.find("p:spPr", namespaces=NS)
|
||||
if sp_pr is None:
|
||||
return
|
||||
line = self._line_style(sp_pr)
|
||||
if not line:
|
||||
return
|
||||
xfrm = sp_pr.find("a:xfrm", namespaces=NS)
|
||||
self._render_line_like(xfrm, transform, line, arrow=True)
|
||||
|
||||
def _render_line_like(self, xfrm: etree._Element | None, transform: Transform, line: dict, arrow: bool) -> 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 = [0, 0]
|
||||
end_local = [cx, cy]
|
||||
if flip_h:
|
||||
start_local[0], end_local[0] = cx, 0
|
||||
if flip_v:
|
||||
start_local[1], end_local[1] = cy, 0
|
||||
start = transform.map_point(x + start_local[0], y + start_local[1])
|
||||
end = transform.map_point(x + end_local[0], y + end_local[1])
|
||||
start_xy = (int(round(start[0])), int(round(start[1])))
|
||||
end_xy = (int(round(end[0])), int(round(end[1])))
|
||||
if line.get("dashed"):
|
||||
self._draw_dashed_line(start_xy, end_xy, line["color"], line["width"])
|
||||
else:
|
||||
self.draw.line([start_xy, end_xy], fill=line["color"], width=line["width"])
|
||||
if arrow and line.get("arrow"):
|
||||
self._draw_arrowhead(start_xy, end_xy, line["color"], line["width"])
|
||||
|
||||
def _draw_dashed_line(self, start: tuple[int, int], end: tuple[int, int], color, width: int) -> None:
|
||||
dx = end[0] - start[0]
|
||||
dy = end[1] - start[1]
|
||||
distance = math.hypot(dx, dy)
|
||||
if distance < 1:
|
||||
return
|
||||
dash = 12
|
||||
gap = 7
|
||||
vx = dx / distance
|
||||
vy = dy / distance
|
||||
pos = 0.0
|
||||
while pos < distance:
|
||||
seg_end = min(pos + dash, distance)
|
||||
p1 = (int(round(start[0] + vx * pos)), int(round(start[1] + vy * pos)))
|
||||
p2 = (int(round(start[0] + vx * seg_end)), int(round(start[1] + vy * seg_end)))
|
||||
self.draw.line([p1, p2], fill=color, width=width)
|
||||
pos += dash + gap
|
||||
|
||||
def _draw_arrowhead(self, start: tuple[int, int], end: tuple[int, int], color, width: int) -> None:
|
||||
angle = math.atan2(end[1] - start[1], end[0] - start[0])
|
||||
size = max(8, width * 3)
|
||||
left = (
|
||||
end[0] - size * math.cos(angle - math.pi / 6),
|
||||
end[1] - size * math.sin(angle - math.pi / 6),
|
||||
)
|
||||
right = (
|
||||
end[0] - size * math.cos(angle + math.pi / 6),
|
||||
end[1] - size * math.sin(angle + math.pi / 6),
|
||||
)
|
||||
self.draw.polygon([end, left, right], fill=color)
|
||||
|
||||
def _fill_color(self, sp_pr: etree._Element):
|
||||
fill = sp_pr.find("a:solidFill", namespaces=NS)
|
||||
if fill is None or fill.find("a:srgbClr", namespaces=NS) is None:
|
||||
return None
|
||||
return _color(fill.find("a:srgbClr", namespaces=NS).get("val"))
|
||||
|
||||
def _line_style(self, sp_pr: etree._Element):
|
||||
line = sp_pr.find("a:ln", namespaces=NS)
|
||||
if line is None or line.find("a:noFill", namespaces=NS) is not None:
|
||||
return None
|
||||
solid = line.find("a:solidFill/a:srgbClr", namespaces=NS)
|
||||
color = _color(solid.get("val") if solid is not None else None, "#312D2A")
|
||||
width = max(1, int(round(_safe_int(line.get("w"), 12700) * self.scale_x / 12700)))
|
||||
dash = line.find("a:prstDash", namespaces=NS)
|
||||
return {
|
||||
"color": color,
|
||||
"width": width,
|
||||
"dashed": dash is not None and dash.get("val") not in {None, "solid"},
|
||||
"arrow": line.find("a:tailEnd", namespaces=NS) is not None or line.find("a:headEnd", namespaces=NS) is not None,
|
||||
}
|
||||
|
||||
def _render_text(self, node: etree._Element, rect: tuple[int, int, int, int]) -> None:
|
||||
tx_body = node.find("p:txBody", namespaces=NS)
|
||||
if tx_body is None:
|
||||
return
|
||||
body_pr = tx_body.find("a:bodyPr", namespaces=NS)
|
||||
paragraphs = []
|
||||
for paragraph in tx_body.findall("a:p", namespaces=NS):
|
||||
parts = []
|
||||
run_pr = None
|
||||
for child in paragraph:
|
||||
tag = _local_name(child.tag)
|
||||
if tag in {"r", "fld"}:
|
||||
if run_pr is None:
|
||||
run_pr = child.find("a:rPr", namespaces=NS)
|
||||
text_parts = [
|
||||
(node.text or "")
|
||||
for node in child.findall(".//a:t", namespaces=NS)
|
||||
]
|
||||
parts.append("".join(text_parts))
|
||||
elif tag == "br":
|
||||
parts.append("\n")
|
||||
text = "".join(parts).strip()
|
||||
if not text:
|
||||
continue
|
||||
p_pr = paragraph.find("a:pPr", namespaces=NS)
|
||||
align = (p_pr.get("algn") if p_pr is not None else None) or "l"
|
||||
font_size = _safe_int(run_pr.get("sz") if run_pr is not None else None, 1100)
|
||||
fill = run_pr.find("a:solidFill/a:srgbClr", namespaces=NS) if run_pr is not None else None
|
||||
color = _color(fill.get("val") if fill is not None else None, "#312D2A")
|
||||
paragraphs.append({
|
||||
"text": text,
|
||||
"align": align,
|
||||
"size_px": max(8, int(round((font_size / 100.0) * self.height_px / ((self.slide_cy / EMU_PER_INCH) * 72)))),
|
||||
"bold": run_pr is not None and run_pr.get("b") == "1",
|
||||
"color": color,
|
||||
})
|
||||
if not paragraphs:
|
||||
return
|
||||
inset_l = int(round(_safe_int(body_pr.get("lIns") if body_pr is not None else None, 18000) * self.scale_x))
|
||||
inset_r = int(round(_safe_int(body_pr.get("rIns") if body_pr is not None else None, 18000) * self.scale_x))
|
||||
inset_t = int(round(_safe_int(body_pr.get("tIns") if body_pr is not None else None, 0) * self.scale_y))
|
||||
inset_b = int(round(_safe_int(body_pr.get("bIns") if body_pr is not None else None, 0) * self.scale_y))
|
||||
anchor = (body_pr.get("anchor") if body_pr is not None else None) or "t"
|
||||
text_left = rect[0] + inset_l
|
||||
text_top = rect[1] + inset_t
|
||||
text_right = rect[2] - inset_r
|
||||
text_bottom = rect[3] - inset_b
|
||||
box_w = max(text_right - text_left, 1)
|
||||
layouts = []
|
||||
total_h = 0
|
||||
for paragraph in paragraphs:
|
||||
font = _font(paragraph["size_px"], paragraph["bold"])
|
||||
bbox = self.draw.multiline_textbbox((0, 0), paragraph["text"], font=font, spacing=2, align="left")
|
||||
width = bbox[2] - bbox[0]
|
||||
height = bbox[3] - bbox[1]
|
||||
layouts.append((paragraph, font, width, height))
|
||||
total_h += height
|
||||
total_h += max(0, len(layouts) - 1) * 2
|
||||
if anchor == "ctr":
|
||||
cursor_y = text_top + max((text_bottom - text_top - total_h) // 2, 0)
|
||||
elif anchor == "b":
|
||||
cursor_y = max(text_bottom - total_h, text_top)
|
||||
else:
|
||||
cursor_y = text_top
|
||||
for paragraph, font, text_w, text_h in layouts:
|
||||
if paragraph["align"] == "ctr":
|
||||
cursor_x = text_left + max((box_w - text_w) // 2, 0)
|
||||
elif paragraph["align"] == "r":
|
||||
cursor_x = max(text_right - text_w, text_left)
|
||||
else:
|
||||
cursor_x = text_left
|
||||
self.draw.multiline_text(
|
||||
(cursor_x, cursor_y),
|
||||
paragraph["text"],
|
||||
fill=paragraph["color"],
|
||||
font=font,
|
||||
spacing=2,
|
||||
align="left",
|
||||
)
|
||||
cursor_y += text_h + 2
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def _media(self, media_path: str) -> Image.Image | None:
|
||||
try:
|
||||
media_bytes = self.archive.read(media_path)
|
||||
except KeyError:
|
||||
return None
|
||||
suffix = Path(media_path).suffix.lower()
|
||||
try:
|
||||
if suffix == ".svg":
|
||||
media_bytes = cairosvg.svg2png(bytestring=media_bytes)
|
||||
image = Image.open(io.BytesIO(media_bytes))
|
||||
image.load()
|
||||
return image.convert("RGBA")
|
||||
except Exception:
|
||||
self.skipped_media.append(media_path)
|
||||
return None
|
||||
|
||||
|
||||
def render_pptx_to_png(
|
||||
pptx_path: Path,
|
||||
output_path: Path,
|
||||
slide_number: str = "auto",
|
||||
marker: str = DEFAULT_MARKER,
|
||||
width: int | None = None,
|
||||
) -> dict:
|
||||
renderer = PPTXSlideRenderer(Path(pptx_path), output_width=width)
|
||||
try:
|
||||
meta = renderer.render(slide_number=slide_number, marker=marker)
|
||||
renderer.save(output_path)
|
||||
meta["output_path"] = str(output_path)
|
||||
return meta
|
||||
finally:
|
||||
renderer.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Render a PPTX slide to PNG without LibreOffice.")
|
||||
parser.add_argument("--pptx", required=True, type=Path)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
parser.add_argument("--slide-number", default="auto", help="'auto' or a 1-based slide number")
|
||||
parser.add_argument("--marker", default=DEFAULT_MARKER)
|
||||
parser.add_argument("--width", type=int, default=None)
|
||||
parser.add_argument("--json", type=Path, default=None)
|
||||
args = parser.parse_args()
|
||||
|
||||
meta = render_pptx_to_png(
|
||||
args.pptx,
|
||||
args.output,
|
||||
slide_number=args.slide_number,
|
||||
marker=args.marker,
|
||||
width=args.width,
|
||||
)
|
||||
if args.json:
|
||||
args.json.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.json.write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8")
|
||||
print(json.dumps(meta, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user