CoolFace
Apppublic

malepati/custom_template_working

sourceHugging Faceupdated 8mo agoView on Hugging Face
0likes
pptx_presentation_creator.py545 linesDownload Raw Back to services
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