presenton/electron/servers/fastapi/services/pptx_presentation_creator.py

632 lines
25 KiB
Python

import os
from typing import List, Optional
from lxml import etree
from services.html_to_text_runs_service import (
parse_html_text_to_text_runs as parse_inline_html_to_runs,
)
import tempfile
import zipfile
from pptx import Presentation
from pptx.shapes.autoshape import Shape
from pptx.slide import Slide
from pptx.text.text import _Paragraph, TextFrame, Font, _Run
from pptx.opc.constants import RELATIONSHIP_TYPE as RT
from lxml.etree import fromstring, tostring
from PIL import Image
from pptx.oxml.xmlchemy import OxmlElement
from pptx.util import Pt
from pptx.dml.color import RGBColor
from models.pptx_models import (
PptxAutoShapeBoxModel,
PptxBoxShapeEnum,
PptxConnectorModel,
PptxFillModel,
PptxFontModel,
PptxParagraphModel,
PptxPictureBoxModel,
PptxPositionModel,
PptxPresentationModel,
PptxShadowModel,
PptxSlideModel,
PptxSpacingModel,
PptxStrokeModel,
PptxTextBoxModel,
PptxTextRunModel,
)
from utils.asset_directory_utils import get_images_directory, resolve_image_path_to_filesystem
from utils.download_helpers import download_files
from utils.get_env import get_app_data_directory_env
from utils.image_utils import (
clip_image,
create_circle_image,
fit_image,
invert_image,
round_image_corners,
set_image_opacity,
)
import uuid
BLANK_SLIDE_LAYOUT = 6
class PptxPresentationCreator:
def __init__(self, ppt_model: PptxPresentationModel, temp_dir: str):
self._temp_dir = temp_dir
self._ppt_model = ppt_model
self._slide_models = ppt_model.slides
self._ppt = Presentation()
self._ppt.slide_width = Pt(1280)
self._ppt.slide_height = Pt(720)
def get_sub_element(self, parent, tagname, **kwargs):
"""Helper method to create XML elements"""
element = OxmlElement(tagname)
element.attrib.update(kwargs)
parent.append(element)
return element
def fix_keynote_compatibility(self, pptx_path: str):
"""Patch pptx XML for stricter parsers like Keynote."""
PRESENTATION_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
DRAWING_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
PACKAGE_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
NOTES_MASTER_REL_TYPE = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster"
)
def ensure_grp_sppr_xfrm(slide_path: str):
slide_tree = etree.parse(slide_path)
slide_root = slide_tree.getroot()
grp_sppr_elements = slide_root.findall(
f".//{{{PRESENTATION_NS}}}grpSpPr"
)
changed = False
for grp_sppr in grp_sppr_elements:
xfrm = grp_sppr.find(f"{{{DRAWING_NS}}}xfrm")
if xfrm is None:
xfrm = etree.SubElement(grp_sppr, f"{{{DRAWING_NS}}}xfrm")
etree.SubElement(xfrm, f"{{{DRAWING_NS}}}off", x="0", y="0")
etree.SubElement(xfrm, f"{{{DRAWING_NS}}}ext", cx="0", cy="0")
etree.SubElement(xfrm, f"{{{DRAWING_NS}}}chOff", x="0", y="0")
etree.SubElement(xfrm, f"{{{DRAWING_NS}}}chExt", cx="0", cy="0")
changed = True
if changed:
slide_tree.write(
slide_path,
xml_declaration=True,
encoding="UTF-8",
standalone="yes",
)
with tempfile.TemporaryDirectory() as temp_dir:
extract_dir = os.path.join(temp_dir, "pptx_contents")
os.makedirs(extract_dir, exist_ok=True)
with zipfile.ZipFile(pptx_path, "r") as existing_zip:
existing_zip.extractall(extract_dir)
ppt_dir = os.path.join(extract_dir, "ppt")
slides_dir = os.path.join(ppt_dir, "slides")
if os.path.isdir(slides_dir):
for file_name in os.listdir(slides_dir):
if file_name.endswith(".xml"):
ensure_grp_sppr_xfrm(os.path.join(slides_dir, file_name))
rels_path = os.path.join(ppt_dir, "_rels", "presentation.xml.rels")
presentation_path = os.path.join(ppt_dir, "presentation.xml")
if os.path.exists(rels_path) and os.path.exists(presentation_path):
rels_tree = etree.parse(rels_path)
rels_root = rels_tree.getroot()
rel_tag = f"{{{PACKAGE_REL_NS}}}Relationship"
notes_master_rel = None
existing_ids = set()
for rel in rels_root.findall(rel_tag):
rel_id = rel.get("Id")
if rel_id:
existing_ids.add(rel_id)
if rel.get("Type") == NOTES_MASTER_REL_TYPE:
notes_master_rel = rel
notes_masters_dir = os.path.join(ppt_dir, "notesMasters")
has_notes_master = (
os.path.isdir(notes_masters_dir)
and any(
name.endswith(".xml") for name in os.listdir(notes_masters_dir)
)
)
if has_notes_master and notes_master_rel is None:
next_id = 1
while f"rId{next_id}" in existing_ids:
next_id += 1
notes_master_rel = etree.SubElement(rels_root, rel_tag)
notes_master_rel.set("Id", f"rId{next_id}")
notes_master_rel.set("Type", NOTES_MASTER_REL_TYPE)
notes_master_rel.set(
"Target", "notesMasters/notesMaster1.xml"
)
rels_tree.write(
rels_path,
xml_declaration=True,
encoding="UTF-8",
standalone="yes",
)
if has_notes_master and notes_master_rel is not None:
presentation_tree = etree.parse(presentation_path)
presentation_root = presentation_tree.getroot()
notes_master_id_lst = presentation_root.find(
f"{{{PRESENTATION_NS}}}notesMasterIdLst"
)
if notes_master_id_lst is None:
notes_master_id_lst = etree.Element(
f"{{{PRESENTATION_NS}}}notesMasterIdLst"
)
sld_master_id_lst = presentation_root.find(
f"{{{PRESENTATION_NS}}}sldMasterIdLst"
)
if sld_master_id_lst is not None:
insert_index = list(presentation_root).index(
sld_master_id_lst
) + 1
presentation_root.insert(insert_index, notes_master_id_lst)
else:
presentation_root.insert(0, notes_master_id_lst)
if not notes_master_id_lst.findall(
f"{{{PRESENTATION_NS}}}notesMasterId"
):
notes_master_id = etree.SubElement(
notes_master_id_lst,
f"{{{PRESENTATION_NS}}}notesMasterId",
)
notes_master_id.set(
f"{{{REL_NS}}}id",
notes_master_rel.get("Id"),
)
presentation_tree.write(
presentation_path,
xml_declaration=True,
encoding="UTF-8",
standalone="yes",
)
with zipfile.ZipFile(pptx_path, "w", zipfile.ZIP_DEFLATED) as new_zip:
for root, _, files in os.walk(extract_dir):
for file_name in files:
full_path = os.path.join(root, file_name)
archive_name = os.path.relpath(full_path, extract_dir)
new_zip.write(full_path, archive_name)
async def fetch_network_assets(self):
image_urls = []
models_with_network_asset: List[PptxPictureBoxModel] = []
def _process_image_path(each_shape, image_path):
if not image_path.startswith("http"):
return
if "app_data/" in image_path:
relative_path = image_path.split("app_data/")[1]
app_data_dir = get_app_data_directory_env()
if app_data_dir:
each_shape.picture.path = os.path.join(app_data_dir, relative_path)
else:
each_shape.picture.path = os.path.join("/app_data", relative_path)
each_shape.picture.is_network = False
return
# Resolve HTTP URLs that contain absolute filesystem paths (Mac/Electron)
local_path = resolve_image_path_to_filesystem(image_path)
if local_path:
each_shape.picture.path = local_path
each_shape.picture.is_network = False
return
image_urls.append(image_path)
models_with_network_asset.append(each_shape)
if self._ppt_model.shapes:
for each_shape in self._ppt_model.shapes:
if isinstance(each_shape, PptxPictureBoxModel):
_process_image_path(each_shape, each_shape.picture.path)
for each_slide in self._slide_models:
for each_shape in each_slide.shapes:
if isinstance(each_shape, PptxPictureBoxModel):
_process_image_path(each_shape, each_shape.picture.path)
if image_urls:
image_paths = await download_files(image_urls, self._temp_dir)
for each_shape, each_image_path in zip(
models_with_network_asset, image_paths
):
if each_image_path:
each_shape.picture.path = each_image_path
each_shape.picture.is_network = False
async def create_ppt(self):
await self.fetch_network_assets()
for slide_model in self._slide_models:
# Adding global shapes to slide
if self._ppt_model.shapes:
slide_model.shapes.append(self._ppt_model.shapes)
self.add_and_populate_slide(slide_model)
def set_presentation_theme(self):
slide_master = self._ppt.slide_master
slide_master_part = slide_master.part
theme_part = slide_master_part.part_related_by(RT.THEME)
theme = fromstring(theme_part.blob)
theme_colors = self._theme.colors.theme_color_mapping
nsmap = {"a": "http://schemas.openxmlformats.org/drawingml/2006/main"}
for color_name, hex_value in theme_colors.items():
if color_name:
color_element = theme.xpath(
f"a:themeElements/a:clrScheme/a:{color_name}/a:srgbClr",
namespaces=nsmap,
)[0]
color_element.set("val", hex_value.encode("utf-8"))
theme_part._blob = tostring(theme)
def add_and_populate_slide(self, slide_model: PptxSlideModel):
slide = self._ppt.slides.add_slide(self._ppt.slide_layouts[BLANK_SLIDE_LAYOUT])
if slide_model.background:
self.apply_fill_to_shape(slide.background, slide_model.background)
if slide_model.note:
slide.notes_slide.notes_text_frame.text = slide_model.note
for shape_model in slide_model.shapes:
model_type = type(shape_model)
if model_type is PptxPictureBoxModel:
self.add_picture(slide, shape_model)
elif model_type is PptxAutoShapeBoxModel:
self.add_autoshape(slide, shape_model)
elif model_type is PptxTextBoxModel:
self.add_textbox(slide, shape_model)
elif model_type is PptxConnectorModel:
self.add_connector(slide, shape_model)
def add_connector(self, slide: Slide, connector_model: PptxConnectorModel):
if connector_model.thickness == 0:
return
connector_shape = slide.shapes.add_connector(
connector_model.type, *connector_model.position.to_pt_xyxy()
)
connector_shape.line.width = Pt(connector_model.thickness)
connector_shape.line.color.rgb = RGBColor.from_string(connector_model.color)
self.set_fill_opacity(connector_shape, connector_model.opacity)
def add_picture(self, slide: Slide, picture_model: PptxPictureBoxModel):
image_path = picture_model.picture.path
# Resolve /app_data/... to actual filesystem path (Electron)
if image_path.startswith("/app_data/"):
app_data_dir = get_app_data_directory_env()
if app_data_dir:
relative = image_path[len("/app_data/"):]
image_path = os.path.join(app_data_dir, relative)
if (
picture_model.clip
or picture_model.border_radius
or picture_model.invert
or picture_model.opacity
or picture_model.object_fit
or picture_model.shape
):
try:
image = Image.open(image_path)
except Exception:
print(f"Could not open image: {image_path}")
return
image = image.convert("RGBA")
# ? Applying border radius twice to support both clip and object fit
if picture_model.border_radius:
image = round_image_corners(image, picture_model.border_radius)
if picture_model.object_fit:
image = fit_image(
image,
picture_model.position.width,
picture_model.position.height,
picture_model.object_fit,
)
elif picture_model.clip:
image = clip_image(
image,
picture_model.position.width,
picture_model.position.height,
)
if picture_model.border_radius:
image = round_image_corners(image, picture_model.border_radius)
if picture_model.shape == PptxBoxShapeEnum.CIRCLE:
image = create_circle_image(image)
if picture_model.invert:
image = invert_image(image)
if picture_model.opacity:
image = set_image_opacity(image, picture_model.opacity)
image_path = os.path.join(self._temp_dir, f"{uuid.uuid4()}.png")
image.save(image_path)
margined_position = self.get_margined_position(
picture_model.position, picture_model.margin
)
slide.shapes.add_picture(image_path, *margined_position.to_pt_list())
def add_autoshape(self, slide: Slide, autoshape_box_model: PptxAutoShapeBoxModel):
position = autoshape_box_model.position
if autoshape_box_model.margin:
position = self.get_margined_position(position, autoshape_box_model.margin)
autoshape = slide.shapes.add_shape(
autoshape_box_model.type, *position.to_pt_list()
)
textbox = autoshape.text_frame
textbox.word_wrap = autoshape_box_model.text_wrap
self.apply_fill_to_shape(autoshape, autoshape_box_model.fill)
self.apply_margin_to_text_box(textbox, autoshape_box_model.margin)
self.apply_stroke_to_shape(autoshape, autoshape_box_model.stroke)
self.apply_shadow_to_shape(autoshape, autoshape_box_model.shadow)
self.apply_border_radius_to_shape(autoshape, autoshape_box_model.border_radius)
if autoshape_box_model.paragraphs:
self.add_paragraphs(textbox, autoshape_box_model.paragraphs)
def add_textbox(self, slide: Slide, textbox_model: PptxTextBoxModel):
position = textbox_model.position
textbox_shape = slide.shapes.add_textbox(*position.to_pt_list())
textbox_shape.width += Pt(2)
textbox = textbox_shape.text_frame
textbox.word_wrap = textbox_model.text_wrap
self.apply_fill_to_shape(textbox_shape, textbox_model.fill)
self.apply_margin_to_text_box(textbox, textbox_model.margin)
self.add_paragraphs(textbox, textbox_model.paragraphs)
def add_paragraphs(
self, textbox: TextFrame, paragraph_models: List[PptxParagraphModel]
):
for index, paragraph_model in enumerate(paragraph_models):
paragraph = textbox.add_paragraph() if index > 0 else textbox.paragraphs[0]
self.populate_paragraph(paragraph, paragraph_model)
def populate_paragraph(
self, paragraph: _Paragraph, paragraph_model: PptxParagraphModel
):
if paragraph_model.spacing:
self.apply_spacing_to_paragraph(paragraph, paragraph_model.spacing)
if paragraph_model.line_height:
paragraph.line_spacing = paragraph_model.line_height
if paragraph_model.alignment:
paragraph.alignment = paragraph_model.alignment
if paragraph_model.font:
self.apply_font_to_paragraph(paragraph, paragraph_model.font)
text_runs = []
if paragraph_model.text:
text_runs = self.parse_html_text_to_text_runs(
paragraph_model.font, paragraph_model.text
)
elif paragraph_model.text_runs:
text_runs = paragraph_model.text_runs
for text_run_model in text_runs:
text_run = paragraph.add_run()
self.populate_text_run(text_run, text_run_model)
def parse_html_text_to_text_runs(self, font: Optional[PptxFontModel], text: str):
return parse_inline_html_to_runs(text, font)
def populate_text_run(self, text_run: _Run, text_run_model: PptxTextRunModel):
text_run.text = text_run_model.text
if text_run_model.font:
self.apply_font(text_run.font, text_run_model.font)
def apply_border_radius_to_shape(self, shape: Shape, border_radius: Optional[int]):
if not border_radius:
return
try:
normalized_border_radius = Pt(border_radius) / min(
shape.width, shape.height
)
shape.adjustments[0] = normalized_border_radius
except Exception:
print("Could not apply border radius.")
def apply_fill_to_shape(self, shape: Shape, fill: Optional[PptxFillModel] = None):
if not fill:
shape.fill.background()
else:
shape.fill.solid()
shape.fill.fore_color.rgb = RGBColor.from_string(fill.color)
self.set_fill_opacity(shape.fill, fill.opacity)
def apply_stroke_to_shape(
self, shape: Shape, stroke: Optional[PptxStrokeModel] = None
):
if not stroke or stroke.thickness == 0:
shape.line.fill.background()
else:
shape.line.fill.solid()
shape.line.fill.fore_color.rgb = RGBColor.from_string(stroke.color)
shape.line.width = Pt(stroke.thickness)
self.set_fill_opacity(shape.line.fill, stroke.opacity)
def apply_shadow_to_shape(
self, shape: Shape, shadow: Optional[PptxShadowModel] = None
):
# Access the XML for the shape
sp_element = shape._element
sp_pr = sp_element.xpath("p:spPr")[0] # Shape properties XML element
nsmap = sp_pr.nsmap
# # Remove existing shadow effects if present
effect_list = sp_pr.find("a:effectLst", namespaces=nsmap)
if effect_list:
old_outer_shadow = effect_list.find("a:outerShdw")
if old_outer_shadow:
effect_list.remove(
old_outer_shadow, namespaces=nsmap
) # Remove the old shadow
old_inner_shadow = effect_list.find("a:innerShdw")
if old_inner_shadow:
effect_list.remove(
old_inner_shadow, namespaces=nsmap
) # Remove the old shadow
old_prst_shadow = effect_list.find("a:prstShdw")
if old_prst_shadow:
effect_list.remove(
old_prst_shadow, namespaces=nsmap
) # Remove the old shadow
if not effect_list:
effect_list = etree.SubElement(
sp_pr, f"{{{nsmap['a']}}}effectLst", nsmap=nsmap
)
if shadow is None:
# Apply shadow with zero values when shadow is None
outer_shadow = etree.SubElement(
effect_list,
f"{{{nsmap['a']}}}outerShdw",
{
"blurRad": "0",
"dist": "0",
"dir": "0",
},
nsmap=nsmap,
)
color_element = etree.SubElement(
outer_shadow,
f"{{{nsmap['a']}}}srgbClr",
{"val": "000000"},
nsmap=nsmap,
)
etree.SubElement(
color_element,
f"{{{nsmap['a']}}}alpha",
{"val": "0"},
nsmap=nsmap,
)
else:
# Apply the provided shadow
# dir expects 60000ths of a degree in OOXML
angle_dir = (
int(round((shadow.angle % 360) * 60000))
if shadow.angle is not None
else 0
)
outer_shadow = etree.SubElement(
effect_list,
f"{{{nsmap['a']}}}outerShdw",
{
"blurRad": f"{Pt(shadow.radius)}",
"dir": f"{angle_dir}",
"dist": f"{Pt(shadow.offset)}",
"rotWithShape": "0",
},
nsmap=nsmap,
)
color_element = etree.SubElement(
outer_shadow,
f"{{{nsmap['a']}}}srgbClr",
{"val": f"{shadow.color}"},
nsmap=nsmap,
)
etree.SubElement(
color_element,
f"{{{nsmap['a']}}}alpha",
{"val": f"{int(shadow.opacity * 100000)}"},
nsmap=nsmap,
)
def set_fill_opacity(self, fill, opacity):
if opacity is None or opacity >= 1.0:
return
alpha = int((opacity) * 100000)
try:
ts = fill._xPr.solidFill
sF = ts.get_or_change_to_srgbClr()
self.get_sub_element(sF, "a:alpha", val=str(alpha))
except Exception as e:
print(f"Could not set fill opacity: {e}")
def get_margined_position(
self, position: PptxPositionModel, margin: Optional[PptxSpacingModel]
) -> PptxPositionModel:
if not margin:
return position
left = position.left + margin.left
top = position.top + margin.top
width = max(position.width - margin.left - margin.right, 0)
height = max(position.height - margin.top - margin.bottom, 0)
return PptxPositionModel(left=left, top=top, width=width, height=height)
def apply_margin_to_text_box(
self, text_frame: TextFrame, margin: Optional[PptxSpacingModel]
) -> PptxPositionModel:
text_frame.margin_left = Pt(margin.left if margin else 0)
text_frame.margin_right = Pt(margin.right if margin else 0)
text_frame.margin_top = Pt(margin.top if margin else 0)
text_frame.margin_bottom = Pt(margin.bottom if margin else 0)
def apply_spacing_to_paragraph(
self, paragraph: _Paragraph, spacing: PptxSpacingModel
):
paragraph.space_before = Pt(spacing.top)
paragraph.space_after = Pt(spacing.bottom)
def apply_font_to_paragraph(self, paragraph: _Paragraph, font: PptxFontModel):
self.apply_font(paragraph.font, font)
def apply_font(self, font: Font, font_model: PptxFontModel):
font.name = font_model.name
font.color.rgb = RGBColor.from_string(font_model.color)
font.italic = font_model.italic
font.size = Pt(font_model.size)
font.bold = font_model.font_weight >= 600
if font_model.underline is not None:
font.underline = bool(font_model.underline)
if font_model.strike is not None:
self.apply_strike_to_font(font, font_model.strike)
def apply_strike_to_font(self, font: Font, strike: Optional[bool]):
try:
rPr = font._element
if strike is True:
rPr.set("strike", "sngStrike")
elif strike is False:
rPr.set("strike", "noStrike")
except Exception as e:
print(f"Could not apply strikethrough: {e}")
def save(self, path: str):
self._ppt.save(path)
self.fix_keynote_compatibility(path)