""" Creation of YouTube Audit Reports from a PPTX template and audit data which will be inserted into the PPTX template, either in text placeholders or in charts. """ import dataclasses import re from io import BytesIO from pathlib import Path from typing import Iterable from pptx import Presentation, presentation from pptx.enum.shapes import MSO_SHAPE_TYPE from pptx.shapes.graphfrm import GraphicFrame from .... import logger from ....constants import FileExtensions from ....utils import pdf from ....utils import pptx as pptx_helpers from ....utils.classes import AccessTracker from ..report.models import ImageData, PlaceholderDataSRAT from .charts import render_chart from .config import ( IMAGE_REGEX, PLACEHOLDER_DELIMITERS_END, PLACEHOLDER_DELIMITERS_START, PLACEHOLDER_REGEX, ) from .io import save_to_disk from .table_of_contents import TOCHandler logger = logger.new_logger(__name__) class PlaceholderReplacer: """Class for replacing placeholders in a string with data from a data object. Used for replacing placeholders in one or more pieces of text with data from a data object. It also tracks which placeholders have been seen during text processing, so that we can check if all -or which- placeholders have been replaced at the end of the process. """ def __init__(self, data: PlaceholderDataSRAT): """Initialize the PlaceholderReplacer with a data object. Args: data: The data object to use for replacing placeholders. It must be a dataclass instance which contains the placeholder texts as attributes and the replacement values as their values. """ self._data: PlaceholderDataSRAT = data self._placeholder_names = {field.name for field in dataclasses.fields(data)} def replace(self, runs: Iterable): """Replace placeholders in a sequence of runs with their values from the data object. Args: runs: Sequence of runs to replace placeholders in. Returns: The replaced text. """ runs_text = "".join(run.text for run in runs) if not runs_text.strip(): return self._check_placeholders_balanced(runs_text) detected_placeholders = set( ph[1] for ph in PLACEHOLDER_REGEX.findall(runs_text) ) if not detected_placeholders: return for run in runs: replaced_text = run.text if not replaced_text.strip(): # Skip empty runs for performance. continue for placeholder in detected_placeholders: replaced_text = replaced_text.replace( placeholder, self._replace_one(placeholder) ) run.text = replaced_text.replace(PLACEHOLDER_DELIMITERS_START, "").replace( PLACEHOLDER_DELIMITERS_END, "" ) @staticmethod def _check_placeholders_balanced(text: str) -> None: """Check if placeholders are balanced in the text. Args: text: Text to check for balanced placeholders. """ def find_positions(pattern: str) -> list[int]: return [match.start() for match in re.finditer(pattern, text)] # Ensure every placeholder has a start and an end delimiter, in the # correct order. start_placeholders_positions = find_positions(PLACEHOLDER_DELIMITERS_START) end_placeholders_positions = find_positions(PLACEHOLDER_DELIMITERS_END) # Check if there's an equal number of start and end placeholders if len(start_placeholders_positions) != len(end_placeholders_positions): raise ValueError("Unbalanced number of start and end placeholders.") # Sort the positions, though they should ideally already be in order. start_placeholders_positions.sort() end_placeholders_positions.sort() # Track the last end position to ensure there's no overlap or nesting. last_end_pos = -1 for start_pos, end_pos in zip( start_placeholders_positions, end_placeholders_positions ): # Ensure the start position comes before the end position if start_pos >= end_pos: raise ValueError( "Start placeholder cannot be after or at the same position " "as its end placeholder." ) # Ensure there's no overlapping or nesting if start_pos < last_end_pos: raise ValueError( "Overlapping or nested placeholders detected. Each placeholder " "must be properly closed before a new one begins." ) last_end_pos = end_pos # Update the last end position def _replace_one(self, placeholder_name: str) -> str: """Replace a single placeholder with its value from the data object. Args: placeholder_name: Name of the placeholder to replace. Returns: Placeholder replacement value casted as string. """ try: placeholder_value = getattr(self._data, placeholder_name.lower()) except AttributeError as ex: raise ValueError( f"Placeholder `{placeholder_name}` is used in source but is not " f"defined in data object." ) from ex return str(placeholder_value) async def new_report( placeholders: PlaceholderDataSRAT, images: ImageData, template: str = "report_sr_at", output_path: str = None, as_pdf: bool = False, ) -> BytesIO: """Create a new report from a template and data. Args: placeholders: Data to be inserted into the placeholders in the template. All provided placeholders must be defined in the template, otherwise a ValueError will be raised. images: Data to be inserted into the image placeholders in the template. template: Template to use. Defaults to "report_sr_at". The template is a PPTX file in the `templates` directory which must be appropriately formatted with placeholders so that the data can be inserted into it. output_path: Path to save the report to. Defaults to None; in such case, the report will not be saved to disk and will only be returned as bytes. as_pdf: Whether to output the report as PDF (both the returned bytes and the saved file, if `output_path` is specified). Defaults to False. Returns: BytesIO object containing the report data bytes in the specified format (PPTX or PDF). """ source = Presentation( Path(__file__).parent / "templates" / f"{template}{FileExtensions.PPTX}" ) placeholders_names: set[str] = set(dataclasses.asdict(placeholders).keys()) images_names: set[str] = set(dataclasses.asdict(images).keys()) placeholders = AccessTracker(placeholders) images = AccessTracker(images) placeholder_replacer = PlaceholderReplacer(placeholders) # Warning: always edit text in the `run` objects so to not lose formatting! def handle_shape_placeholders(shape) -> None: """Handle placeholders in a shape.""" if shape.has_text_frame: for paragraph in shape.text_frame.paragraphs: placeholder_replacer.replace(paragraph.runs) # Collect found charts while paginating slides so that we can replace their data # later. charts: list[GraphicFrame] = [] for i, slide in enumerate(source.slides): logger.debug(f"Processing slide {i + 1} ...") for shape in slide.shapes or (): shape_type = shape.shape_type if shape_type == 19: # Hande complex tables which are not PPTX standard tables # (i.e. Google Shapes, probably generated by GSuite Slides # and then imported into or saved as PPTX). if hasattr(shape, "table"): for row in shape.table.rows: for cell in filter(lambda x: x.text_frame, row.cells): placeholder_replacer.replace( cell.text_frame.paragraphs[0].runs ) elif hasattr(shape, "shapes"): # A group shape for s in shape.shapes: handle_shape_placeholders(s) elif shape.has_text_frame: handle_shape_placeholders(shape) elif (shape_type == MSO_SHAPE_TYPE.TABLE) or ( hasattr(shape, "has_table") and shape.has_table ): # A table for row in shape.table.rows: for cell in filter(lambda x: x.text_frame, row.cells): handle_shape_placeholders(cell.text_frame) elif shape_type == MSO_SHAPE_TYPE.CHART: charts.append(shape) _handle_chart_placeholders(charts, placeholders) _handle_image_placeholders(source, images) # Debug checks for unused placeholders and images if not_seen_placeholders := placeholders_names.difference( placeholders.attrs_accessed.keys() ): logger.debug( f"{len(not_seen_placeholders)} defined placeholder(s) not seen in " f"the processed template: {', '.join(not_seen_placeholders)}" ) if not_seen_images := images_names.difference(images.attrs_accessed.keys()): logger.debug( f"{len(not_seen_images)} defined image(s) not seen in " f"the processed template: {', '.join(not_seen_images)}" ) # Decide whether to remove the "Next Steps" slide based on the number of # actionable conflicts. _handle_next_steps_slide(source, placeholders) # Dynamically generate Table of Contents slide items and also dynamically # assign page numbers to the content slides. See TOCHandler implementation # for more details. TOCHandler(source, toc_page=2).run() buffer = _save_to_buffer(source) if as_pdf: # Perform final adjustments to the PDF file, such as updating the title in the # EXIF metadata (so that it isn't the default "PowerPoint Presentation") and # compressing the file to reduce its final size. title = "YouTube Monetization Audit Report - " + placeholders.label_name buffer = pdf.adjust_pdf_file( pdf_bytes=await pdf.convert_pptx_to_pdf(buffer), new_exif_title=title ) if output_path is not None: save_to_disk( as_pdf=as_pdf, buffer=buffer, output_path=output_path, source=source ) return buffer def _save_to_buffer(source) -> BytesIO: """Save a PPTX presentation to a BytesIO buffer.""" buffer = BytesIO() source.save(buffer) buffer.seek(0) return buffer def _handle_next_steps_slide(source, placeholders) -> None: """Remove the "Next Steps" slide if there are no actionable conflicts. This is an in-place operation. """ placeholders = placeholders.target # Unwrap the AccessTracker to not track accesses if ( placeholders.sr_actionable_conflict_count == 0 and placeholders.sr_actionable_attached_conflict_count == 0 ): pptx_helpers.remove_slide(source, 5) # Remove "Next Steps" slide def _handle_chart_placeholders( charts: list[GraphicFrame], placeholders: PlaceholderDataSRAT ) -> None: """Handle chart placeholders in a PPTX presentation. This is an in-place operation. Args: charts: List of chart objects. placeholders: Data to be inserted into the chart placeholders in the template. """ before_after = ("Before", "After") # Chart: YouTube Monetization Before and After Audit render_chart( charts[0], before_after, [ ("", (None, None)), ( "Enabled for Monetization", ( placeholders.sr_before_monetizing_track_pct / 100, placeholders.sr_after_monetizing_track_pct / 100, ), ), ( "Not Enabled for Monetization", ( placeholders.sr_before_not_monetizing_track_pct / 100, placeholders.sr_after_not_monetizing_track_pct / 100, ), ), ], ) # Chart: Improvement Results After Audit render_chart( charts[1], ("Monetizing", "Ownership", "References", "Policies", "Not Monetizing"), [ ( "Percentages", ( float(placeholders.sr_mt) / 100, float(placeholders.sr_agg_updated_ownership_pct) / 100, float(placeholders.sr_rr) / 100, float(placeholders.sr_mp) / 100, float(placeholders.sr_nm) / 100, ), ), ], ) # Chart: Art Tracks Status Before and After Audit render_chart( charts[2], before_after, [ ( "Available on Correct Artist Page", ( placeholders.at_before_monetizing_track_pct / 100, placeholders.at_after_monetizing_track_pct / 100, ), ), ( "Not Available on Correct Artist Page", ( placeholders.at_before_not_monetizing_track_pct / 100, placeholders.at_after_not_monetizing_track_pct / 100, ), ), ], ) def _handle_image_placeholders( source: presentation.Presentation, images: ImageData, ) -> None: """Handle image placeholders in a PPTX presentation. This is an in-place operation. Args: source: PPTX Presentation object. images: Data to be inserted into the image placeholders in the template. """ # Add images for slide in source.slides: for shape in slide.shapes: if shape.has_text_frame and shape.text_frame.text: text = shape.text_frame.text if match := IMAGE_REGEX.fullmatch(text): # Remove placeholder image using workaround as there's no # direct method in PPTX API. pptx_helpers.remove_shape(shape) # If no bytes, then just remove the placeholder shape without # adding an image. if image_bytes := getattr(images, match.group(2).lower()): # TODO test no bytes / image is None slide.shapes.add_picture( image_bytes, shape.left, shape.top, shape.width, shape.height, )