import os from typing import List, Optional import uuid from lxml import etree from pptx import Presentation from pptx.shapes.autoshape import Shape from pptx.slide import Slide from pptx.chart.data import ChartData, BubbleChartData from pptx.chart.chart import Chart from pptx.text.text import _Paragraph, TextFrame, Font, _Run from pptx.enum.chart import ( XL_CHART_TYPE, XL_LEGEND_POSITION, XL_LABEL_POSITION, ) from pptx.opc.constants import RELATIONSHIP_TYPE as RT from lxml.etree import fromstring, tostring from PIL import Image from pptx.util import Pt from graph_processor.models import ( BarGraphDataModel, BubbleChartDataModel, GraphTypeEnum, LineChartDataModel, PieChartDataModel, ) from pptx.dml.color import RGBColor from ppt_generator.models.pptx_models import ( PptxAutoShapeBoxModel, PptxBoxShapeEnum, PptxConnectorModel, PptxFillModel, PptxFontModel, PptxGraphBoxModel, PptxParagraphModel, PptxPictureBoxModel, PptxPositionModel, PptxPresentationModel, PptxShadowModel, PptxSlideModel, PptxSpacingModel, PptxStrokeModel, PptxTextBoxModel, PptxTextRunModel, ) from ppt_generator.utils import ( clip_image, fit_image, round_image_corners, create_circle_image, change_image_color, ) 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._theme = ppt_model.theme # self._watermark = ppt_model.watermark self._ppt = Presentation() self._ppt.slide_width = Pt(1280) self._ppt.slide_height = Pt(720) self._slide_fill = PptxFillModel(color=ppt_model.background_color) def create_ppt(self): # self.set_presentation_theme() 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 self._slide_fill: self.apply_fill_to_shape(slide.background, self._slide_fill) 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 PptxGraphBoxModel: self.add_graph(slide, shape_model) elif model_type is PptxConnectorModel: self.add_connector(slide, shape_model) # if self._watermark: # Adding watermark # self.add_picture(slide, self.get_watermark_box_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) def add_graph(self, slide: Slide, graph_box_model: PptxGraphBoxModel): chart_data = None chart_type = None graph = graph_box_model.graph match (graph.type): case GraphTypeEnum.bar: chart_data = self.get_bar_graph(graph.data) chart_type = XL_CHART_TYPE.COLUMN_CLUSTERED case GraphTypeEnum.scatter: chart_data = self.get_scatter_graph(graph.data) chart_type = XL_CHART_TYPE.XY_SCATTER case GraphTypeEnum.bubble: chart_data = self.get_bubble_graph(graph.data) chart_type = XL_CHART_TYPE.BUBBLE case GraphTypeEnum.line: chart_data = self.get_line_graph(graph.data) chart_type = XL_CHART_TYPE.LINE case GraphTypeEnum.pie: chart_data = self.get_pie_graph(graph.data) chart_type = XL_CHART_TYPE.PIE if chart_data: chart: Chart = slide.shapes.add_chart( chart_type, *graph_box_model.position.to_pt_list(), chart_data ).chart self.apply_graph_styles(chart, graph_box_model) def apply_graph_styles(self, chart, graph_box_model: PptxGraphBoxModel): graph = graph_box_model.graph if graph.type in [GraphTypeEnum.pie, GraphTypeEnum.scatter]: chart.has_legend = True chart.legend.position = XL_LEGEND_POSITION.RIGHT else: chart.has_legend = False if graph_box_model.legend_font: self.apply_font(chart.font, graph_box_model.legend_font) try: category_axis = chart.category_axis if graph_box_model.category_font: font = category_axis.tick_labels.font self.apply_font(font, graph_box_model.category_font) except: print("-" * 20) print("Could not apply category labels style") try: value_axis = chart.value_axis tick_labels = value_axis.tick_labels if graph.postfix: tick_labels.number_format = f'0"{graph.postfix}"' if graph_box_model.value_font: self.apply_font(tick_labels.font, graph_box_model.value_font) except: print("-" * 20) print("Could not apply tick labels style") if graph_box_model.graph.type is GraphTypeEnum.pie: for plot in chart.plots: try: plot.has_data_labels = True plot.data_labels.position = ( XL_LABEL_POSITION.OUTSIDE_END if graph_box_model.graph.type is GraphTypeEnum.bar else XL_LABEL_POSITION.CENTER ) if graph.postfix: plot.data_labels.number_format = f'0"{graph.postfix}"' if graph_box_model.value_font: self.apply_font( plot.data_labels.font, ( graph_box_model.value_font if graph_box_model.graph.type is GraphTypeEnum.bar else PptxFontModel( # size=self._theme.fonts.p2, size=16, bold=True, color="ffffff", ) ), ) except: print("-" * 20) print("Could not apply data labels style") def get_bar_graph(self, graph: BarGraphDataModel): chart_data = ChartData() chart_data.categories = graph.get_categories() for series in graph.series: chart_data.add_series(series.get_name(), series.data) return chart_data def get_bubble_graph(self, graph: BubbleChartDataModel): chart_data = BubbleChartData() for each in graph.series: series = chart_data.add_series(each.get_name()) for point in each.points: series.add_data_point(*point.to_list()) return chart_data def get_line_graph(self, graph: LineChartDataModel): chart_data = ChartData() chart_data.categories = graph.get_categories() for series in graph.series: chart_data.add_series(series.get_name(), series.data) return chart_data def get_pie_graph(self, graph: PieChartDataModel): chart_data = ChartData() chart_data.categories = graph.get_categories() chart_data.add_series("", graph.series[0].data) return chart_data def add_picture(self, slide: Slide, picture_model: PptxPictureBoxModel): image_path = picture_model.picture.path if ( picture_model.clip or picture_model.border_radius or picture_model.overlay or picture_model.object_fit or picture_model.shape ): try: image = Image.open(image_path) except: 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.overlay: image = change_image_color(image, picture_model.overlay) image_path = os.path.join(self._temp_dir, f"{str(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.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_markdown_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_markdown_text_to_text_runs(self, font: PptxFontModel, text: str): text_runs = [] for line in text.split("\n"): current_pos = 0 while current_pos < len(line): # Check for bold and italic (***text***) if ( line[current_pos:].startswith("***") and "***" in line[current_pos + 3 :] ): end_pos = line.find("***", current_pos + 3) text_content = line[current_pos + 3 : end_pos] font_json = font.model_dump() font_json["bold"] = True font_json["italic"] = True text_runs.append( PptxTextRunModel( text=text_content, font=PptxFontModel(**font_json) ) ) current_pos = end_pos + 3 # Check for bold (**text**) elif ( line[current_pos:].startswith("**") and "**" in line[current_pos + 2 :] ): end_pos = line.find("**", current_pos + 2) text_content = line[current_pos + 2 : end_pos] font_json = font.model_dump() font_json["bold"] = True text_runs.append( PptxTextRunModel( text=text_content, font=PptxFontModel(**font_json) ) ) current_pos = end_pos + 2 # Check for italic (*text*) elif ( line[current_pos:].startswith("__") and "__" in line[current_pos + 2 :] ): end_pos = line.find("__", current_pos + 2) text_content = line[current_pos + 2 : end_pos] font_json = font.model_dump() font_json["italic"] = True text_runs.append( PptxTextRunModel( text=text_content, font=PptxFontModel(**font_json) ) ) current_pos = end_pos + 2 else: # Find the next formatting marker or end of line next_marker = float("inf") for marker in ["***", "**", "__"]: pos = line.find(marker, current_pos) if pos != -1: next_marker = min(next_marker, pos) end_pos = next_marker if next_marker != float("inf") else len(line) text_content = line[current_pos:end_pos] if text_content: # Only add non-empty text text_runs.append(PptxTextRunModel(text=text_content, font=font)) current_pos = end_pos # Add newline if not the last line if line != text.split("\n")[-1]: text_runs.append(PptxTextRunModel(text="\n")) return text_runs 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: 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) 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) 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_shadow = effect_list.find("a:outerShdw") if old_shadow: effect_list.remove( old_shadow, namespaces=nsmap ) # Remove the old shadow if not shadow: return if not effect_list: effect_list = etree.SubElement( sp_pr, f"{{{nsmap['a']}}}effectLst", nsmap=nsmap ) outer_shadow = etree.SubElement( effect_list, f"{{{nsmap['a']}}}outerShdw", { "blurRad": f"{Pt(shadow.radius)}", "dir": f"{shadow.angle * 1000}", "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 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.bold = font_model.bold font.italic = font_model.italic font.size = Pt(font_model.size) # def get_watermark_box_model(self): # watermark_asset_path = f"assets/images/{'watermark_dark.png' if self._theme == PresentationTheme.dark else 'watermark.png'}" # return PptxPictureBoxModel( # position=PptxPositionModel(left=1120, top=685, width=140), # clip=False, # picture=PptxPictureModel( # is_network=False, # path=watermark_asset_path, # ), # ) def save(self, path: str): self._ppt.save(path)