malepati/custom_template_working
0
1import os2from typing import List, Optional3from lxml import etree4from services.html_to_text_runs_service import (5 parse_html_text_to_text_runs as parse_inline_html_to_runs,6)7 8from pptx import Presentation9from pptx.shapes.autoshape import Shape10from pptx.slide import Slide11from pptx.text.text import _Paragraph, TextFrame, Font, _Run12from pptx.opc.constants import RELATIONSHIP_TYPE as RT13from lxml.etree import fromstring, tostring14from PIL import Image15from pptx.oxml.xmlchemy import OxmlElement16 17from pptx.util import Pt18from pptx.dml.color import RGBColor19 20from pptx.chart.data import CategoryChartData, ChartData21from pptx.enum.chart import XL_CHART_TYPE22from models.pptx_models import (23 PptxAutoShapeBoxModel,24 PptxBoxShapeEnum,25 PptxChartModel, # Added26 PptxConnectorModel,27 PptxFillModel,28 PptxFontModel,29 PptxParagraphModel,30 PptxPictureBoxModel,31 PptxPositionModel,32 PptxPresentationModel,33 PptxShadowModel,34 PptxSlideModel,35 PptxSpacingModel,36 PptxStrokeModel,37 PptxTextBoxModel,38 PptxTextRunModel,39)40from utils.download_helpers import download_files41from utils.get_env import get_app_data_directory_env42from utils.image_utils import (43 clip_image,44 create_circle_image,45 fit_image,46 invert_image,47 round_image_corners,48 set_image_opacity,49)50import uuid51 52BLANK_SLIDE_LAYOUT = 653 54 55class PptxPresentationCreator:56 def __init__(self, ppt_model: PptxPresentationModel, temp_dir: str):57 self._temp_dir = temp_dir58 59 self._ppt_model = ppt_model60 self._slide_models = ppt_model.slides61 62 self._ppt = Presentation()63 self._ppt.slide_width = Pt(1280)64 self._ppt.slide_height = Pt(720)65 66 def get_sub_element(self, parent, tagname, **kwargs):67 """Helper method to create XML elements"""68 element = OxmlElement(tagname)69 element.attrib.update(kwargs)70 parent.append(element)71 return element72 73 async def fetch_network_assets(self):74 image_urls = []75 models_with_network_asset: List[PptxPictureBoxModel] = []76 app_data_dir = get_app_data_directory_env()77 78 if self._ppt_model.shapes:79 for each_shape in self._ppt_model.shapes:80 if isinstance(each_shape, PptxPictureBoxModel):81 image_path = each_shape.picture.path82 if image_path:83 # Handle relative web paths84 if image_path.startswith("/app_data"): 85 # Convert /app_data/foo to C:/.../app_data/foo86 # But app_data_dir is the path TO app_data.87 # image_path is /app_data/images/...88 # remove leading / and or app_data if needed89 # get_app_data_directory_env returns absolute path to 'app_data' folder90 91 # rel path: /app_data/images/foo.png92 # we want: C:/.../app_data/images/foo.png93 # so strip "/app_data" from start94 clean_rel = image_path.replace("/app_data", "", 1).lstrip("/").lstrip("\\") 95 abs_path = os.path.join(app_data_dir, clean_rel)96 each_shape.picture.path = abs_path97 each_shape.picture.is_network = False98 continue99 100 # Handle absolute paths that were mistakenly treated as network? 101 # usually network check is startswith http102 if image_path.startswith("http"):103 # If we have http localhost that maps to app_data104 if "app_data" in image_path:105 # Fallback logic if needed106 pass107 image_urls.append(image_path)108 models_with_network_asset.append(each_shape)109 110 for each_slide in self._slide_models:111 for each_shape in each_slide.shapes:112 if isinstance(each_shape, PptxPictureBoxModel):113 image_path = each_shape.picture.path114 if image_path:115 # Handle relative web paths116 if image_path.startswith("/app_data"):117 clean_rel = image_path.replace("/app_data", "", 1).lstrip("/").lstrip("\\") 118 abs_path = os.path.join(app_data_dir, clean_rel)119 each_shape.picture.path = abs_path120 each_shape.picture.is_network = False121 continue122 123 if image_path.startswith("http"):124 image_urls.append(image_path)125 models_with_network_asset.append(each_shape)126 127 if image_urls:128 image_paths = await download_files(image_urls, self._temp_dir)129 130 for each_shape, each_image_path in zip(131 models_with_network_asset, image_paths132 ):133 if each_image_path:134 each_shape.picture.path = each_image_path135 each_shape.picture.is_network = False136 137 async def create_ppt(self):138 await self.fetch_network_assets()139 140 for slide_model in self._slide_models:141 # Adding global shapes to slide142 if self._ppt_model.shapes:143 slide_model.shapes.append(self._ppt_model.shapes)144 145 self.add_and_populate_slide(slide_model)146 147 def set_presentation_theme(self):148 slide_master = self._ppt.slide_master149 slide_master_part = slide_master.part150 151 theme_part = slide_master_part.part_related_by(RT.THEME)152 theme = fromstring(theme_part.blob)153 154 theme_colors = self._theme.colors.theme_color_mapping155 nsmap = {"a": "http://schemas.openxmlformats.org/drawingml/2006/main"}156 157 for color_name, hex_value in theme_colors.items():158 if color_name:159 color_element = theme.xpath(160 f"a:themeElements/a:clrScheme/a:{color_name}/a:srgbClr",161 namespaces=nsmap,162 )[0]163 color_element.set("val", hex_value.encode("utf-8"))164 165 theme_part._blob = tostring(theme)166 167 def add_and_populate_slide(self, slide_model: PptxSlideModel):168 slide = self._ppt.slides.add_slide(self._ppt.slide_layouts[BLANK_SLIDE_LAYOUT])169 170 if slide_model.background:171 self.apply_fill_to_shape(slide.background, slide_model.background)172 173 if slide_model.note:174 slide.notes_slide.notes_text_frame.text = slide_model.note175 176 for shape_model in slide_model.shapes:177 model_type = type(shape_model)178 179 if model_type is PptxPictureBoxModel:180 self.add_picture(slide, shape_model)181 182 elif model_type is PptxAutoShapeBoxModel:183 self.add_autoshape(slide, shape_model)184 185 elif model_type is PptxTextBoxModel:186 self.add_textbox(slide, shape_model)187 188 elif model_type is PptxConnectorModel:189 self.add_connector(slide, shape_model)190 191 elif model_type is PptxChartModel:192 self.add_chart(slide, shape_model)193 194 def add_chart(self, slide: Slide, chart_model: PptxChartModel):195 # Map our chart type to python-pptx chart type196 chart_type_map = {197 "BAR": XL_CHART_TYPE.COLUMN_CLUSTERED,198 "PIE": XL_CHART_TYPE.PIE,199 "LINE": XL_CHART_TYPE.LINE,200 "SCATTER": XL_CHART_TYPE.XY_SCATTER,201 }202 pptx_chart_type = chart_type_map.get(chart_model.chart_type, XL_CHART_TYPE.COLUMN_CLUSTERED)203 204 chart_data = CategoryChartData()205 chart_data.categories = [d.label for d in chart_model.data]206 chart_data.add_series(chart_model.title, [d.value for d in chart_model.data])207 208 position = chart_model.position209 if chart_model.margin:210 position = self.get_margined_position(position, chart_model.margin)211 212 graphic_frame = slide.shapes.add_chart(213 pptx_chart_type,214 *position.to_pt_list(),215 chart_data216 )217 218 chart = graphic_frame.chart219 if chart_model.chart_type in ["BAR", "PIE"]:220 try:221 chart.plots[0].vary_by_categories = True222 except Exception as e:223 print(f"Warning: Could not set vary_by_categories: {e}")224 225 def add_connector(self, slide: Slide, connector_model: PptxConnectorModel):226 if connector_model.thickness == 0:227 return228 connector_shape = slide.shapes.add_connector(229 connector_model.type, *connector_model.position.to_pt_xyxy()230 )231 connector_shape.line.width = Pt(connector_model.thickness)232 connector_shape.line.color.rgb = RGBColor.from_string(connector_model.color)233 self.set_fill_opacity(connector_shape, connector_model.opacity)234 235 def add_picture(self, slide: Slide, picture_model: PptxPictureBoxModel):236 image_path = picture_model.picture.path237 if (238 picture_model.clip239 or picture_model.border_radius240 or picture_model.invert241 or picture_model.opacity242 or picture_model.object_fit243 or picture_model.shape244 ):245 try:246 image = Image.open(image_path)247 except Exception:248 print(f"Could not open image: {image_path}")249 return250 251 image = image.convert("RGBA")252 # ? Applying border radius twice to support both clip and object fit253 if picture_model.border_radius:254 image = round_image_corners(image, picture_model.border_radius)255 if picture_model.object_fit:256 image = fit_image(257 image,258 picture_model.position.width,259 picture_model.position.height,260 picture_model.object_fit,261 )262 elif picture_model.clip:263 image = clip_image(264 image,265 picture_model.position.width,266 picture_model.position.height,267 )268 if picture_model.border_radius:269 image = round_image_corners(image, picture_model.border_radius)270 if picture_model.shape == PptxBoxShapeEnum.CIRCLE:271 image = create_circle_image(image)272 if picture_model.invert:273 image = invert_image(image)274 if picture_model.opacity:275 image = set_image_opacity(image, picture_model.opacity)276 image_path = os.path.join(self._temp_dir, f"{uuid.uuid4()}.png")277 image.save(image_path)278 279 margined_position = self.get_margined_position(280 picture_model.position, picture_model.margin281 )282 283 slide.shapes.add_picture(image_path, *margined_position.to_pt_list())284 285 def add_autoshape(self, slide: Slide, autoshape_box_model: PptxAutoShapeBoxModel):286 position = autoshape_box_model.position287 if autoshape_box_model.margin:288 position = self.get_margined_position(position, autoshape_box_model.margin)289 290 autoshape = slide.shapes.add_shape(291 autoshape_box_model.type, *position.to_pt_list()292 )293 294 textbox = autoshape.text_frame295 textbox.word_wrap = autoshape_box_model.text_wrap296 297 self.apply_fill_to_shape(autoshape, autoshape_box_model.fill)298 self.apply_margin_to_text_box(textbox, autoshape_box_model.margin)299 self.apply_stroke_to_shape(autoshape, autoshape_box_model.stroke)300 self.apply_shadow_to_shape(autoshape, autoshape_box_model.shadow)301 self.apply_border_radius_to_shape(autoshape, autoshape_box_model.border_radius)302 303 if autoshape_box_model.paragraphs:304 self.add_paragraphs(textbox, autoshape_box_model.paragraphs)305 306 def add_textbox(self, slide: Slide, textbox_model: PptxTextBoxModel):307 position = textbox_model.position308 textbox_shape = slide.shapes.add_textbox(*position.to_pt_list())309 textbox_shape.width += Pt(2)310 311 textbox = textbox_shape.text_frame312 textbox.word_wrap = textbox_model.text_wrap313 314 self.apply_fill_to_shape(textbox_shape, textbox_model.fill)315 self.apply_margin_to_text_box(textbox, textbox_model.margin)316 self.add_paragraphs(textbox, textbox_model.paragraphs)317 318 def add_paragraphs(319 self, textbox: TextFrame, paragraph_models: List[PptxParagraphModel]320 ):321 for index, paragraph_model in enumerate(paragraph_models):322 paragraph = textbox.add_paragraph() if index > 0 else textbox.paragraphs[0]323 self.populate_paragraph(paragraph, paragraph_model)324 325 def populate_paragraph(326 self, paragraph: _Paragraph, paragraph_model: PptxParagraphModel327 ):328 if paragraph_model.spacing:329 self.apply_spacing_to_paragraph(paragraph, paragraph_model.spacing)330 331 if paragraph_model.line_height:332 paragraph.line_spacing = paragraph_model.line_height333 334 if paragraph_model.alignment:335 paragraph.alignment = paragraph_model.alignment336 337 if paragraph_model.font:338 self.apply_font_to_paragraph(paragraph, paragraph_model.font)339 340 text_runs = []341 if paragraph_model.text:342 text_runs = self.parse_html_text_to_text_runs(343 paragraph_model.font, paragraph_model.text344 )345 elif paragraph_model.text_runs:346 text_runs = paragraph_model.text_runs347 348 for text_run_model in text_runs:349 text_run = paragraph.add_run()350 self.populate_text_run(text_run, text_run_model)351 352 def parse_html_text_to_text_runs(self, font: Optional[PptxFontModel], text: str):353 return parse_inline_html_to_runs(text, font)354 355 def populate_text_run(self, text_run: _Run, text_run_model: PptxTextRunModel):356 text_run.text = text_run_model.text357 if text_run_model.font:358 self.apply_font(text_run.font, text_run_model.font)359 360 def apply_border_radius_to_shape(self, shape: Shape, border_radius: Optional[int]):361 if not border_radius:362 return363 try:364 normalized_border_radius = Pt(border_radius) / min(365 shape.width, shape.height366 )367 shape.adjustments[0] = normalized_border_radius368 except Exception:369 print("Could not apply border radius.")370 371 def apply_fill_to_shape(self, shape: Shape, fill: Optional[PptxFillModel] = None):372 if not fill:373 shape.fill.background()374 else:375 shape.fill.solid()376 shape.fill.fore_color.rgb = RGBColor.from_string(fill.color)377 self.set_fill_opacity(shape.fill, fill.opacity)378 379 def apply_stroke_to_shape(380 self, shape: Shape, stroke: Optional[PptxStrokeModel] = None381 ):382 if not stroke or stroke.thickness == 0:383 shape.line.fill.background()384 else:385 shape.line.fill.solid()386 shape.line.fill.fore_color.rgb = RGBColor.from_string(stroke.color)387 shape.line.width = Pt(stroke.thickness)388 self.set_fill_opacity(shape.line.fill, stroke.opacity)389 390 def apply_shadow_to_shape(391 self, shape: Shape, shadow: Optional[PptxShadowModel] = None392 ):393 # Access the XML for the shape394 sp_element = shape._element395 sp_pr = sp_element.xpath("p:spPr")[0] # Shape properties XML element396 397 nsmap = sp_pr.nsmap398 399 # # Remove existing shadow effects if present400 effect_list = sp_pr.find("a:effectLst", namespaces=nsmap)401 if effect_list:402 old_outer_shadow = effect_list.find("a:outerShdw")403 if old_outer_shadow:404 effect_list.remove(405 old_outer_shadow, namespaces=nsmap406 ) # Remove the old shadow407 old_inner_shadow = effect_list.find("a:innerShdw")408 if old_inner_shadow:409 effect_list.remove(410 old_inner_shadow, namespaces=nsmap411 ) # Remove the old shadow412 old_prst_shadow = effect_list.find("a:prstShdw")413 if old_prst_shadow:414 effect_list.remove(415 old_prst_shadow, namespaces=nsmap416 ) # Remove the old shadow417 418 if not effect_list:419 effect_list = etree.SubElement(420 sp_pr, f"{{{nsmap['a']}}}effectLst", nsmap=nsmap421 )422 423 if shadow is None:424 # Apply shadow with zero values when shadow is None425 outer_shadow = etree.SubElement(426 effect_list,427 f"{{{nsmap['a']}}}outerShdw",428 {429 "blurRad": "0",430 "dist": "0",431 "dir": "0",432 },433 nsmap=nsmap,434 )435 color_element = etree.SubElement(436 outer_shadow,437 f"{{{nsmap['a']}}}srgbClr",438 {"val": "000000"},439 nsmap=nsmap,440 )441 etree.SubElement(442 color_element,443 f"{{{nsmap['a']}}}alpha",444 {"val": "0"},445 nsmap=nsmap,446 )447 else:448 # Apply the provided shadow449 # dir expects 60000ths of a degree in OOXML450 angle_dir = (451 int(round((shadow.angle % 360) * 60000))452 if shadow.angle is not None453 else 0454 )455 outer_shadow = etree.SubElement(456 effect_list,457 f"{{{nsmap['a']}}}outerShdw",458 {459 "blurRad": f"{Pt(shadow.radius)}",460 "dir": f"{angle_dir}",461 "dist": f"{Pt(shadow.offset)}",462 "rotWithShape": "0",463 },464 nsmap=nsmap,465 )466 color_element = etree.SubElement(467 outer_shadow,468 f"{{{nsmap['a']}}}srgbClr",469 {"val": f"{shadow.color}"},470 nsmap=nsmap,471 )472 etree.SubElement(473 color_element,474 f"{{{nsmap['a']}}}alpha",475 {"val": f"{int(shadow.opacity * 100000)}"},476 nsmap=nsmap,477 )478 479 def set_fill_opacity(self, fill, opacity):480 if opacity is None or opacity >= 1.0:481 return482 483 alpha = int((opacity) * 100000)484 485 try:486 ts = fill._xPr.solidFill487 sF = ts.get_or_change_to_srgbClr()488 self.get_sub_element(sF, "a:alpha", val=str(alpha))489 except Exception as e:490 print(f"Could not set fill opacity: {e}")491 492 def get_margined_position(493 self, position: PptxPositionModel, margin: Optional[PptxSpacingModel]494 ) -> PptxPositionModel:495 if not margin:496 return position497 498 left = position.left + margin.left499 top = position.top + margin.top500 width = max(position.width - margin.left - margin.right, 0)501 height = max(position.height - margin.top - margin.bottom, 0)502 503 return PptxPositionModel(left=left, top=top, width=width, height=height)504 505 def apply_margin_to_text_box(506 self, text_frame: TextFrame, margin: Optional[PptxSpacingModel]507 ) -> PptxPositionModel:508 text_frame.margin_left = Pt(margin.left if margin else 0)509 text_frame.margin_right = Pt(margin.right if margin else 0)510 text_frame.margin_top = Pt(margin.top if margin else 0)511 text_frame.margin_bottom = Pt(margin.bottom if margin else 0)512 513 def apply_spacing_to_paragraph(514 self, paragraph: _Paragraph, spacing: PptxSpacingModel515 ):516 paragraph.space_before = Pt(spacing.top)517 paragraph.space_after = Pt(spacing.bottom)518 519 def apply_font_to_paragraph(self, paragraph: _Paragraph, font: PptxFontModel):520 self.apply_font(paragraph.font, font)521 522 def apply_font(self, font: Font, font_model: PptxFontModel):523 font.name = font_model.name524 font.color.rgb = RGBColor.from_string(font_model.color)525 font.italic = font_model.italic526 font.size = Pt(font_model.size)527 font.bold = font_model.font_weight >= 600528 if font_model.underline is not None:529 font.underline = bool(font_model.underline)530 if font_model.strike is not None:531 self.apply_strike_to_font(font, font_model.strike)532 533 def apply_strike_to_font(self, font: Font, strike: Optional[bool]):534 try:535 rPr = font._element536 if strike is True:537 rPr.set("strike", "sngStrike")538 elif strike is False:539 rPr.set("strike", "noStrike")540 except Exception as e:541 print(f"Could not apply strikethrough: {e}")542 543 def save(self, path: str):544 self._ppt.save(path)545 