diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..df2d8fbf --- /dev/null +++ b/package-lock.json @@ -0,0 +1,24 @@ +{ + "name": "presenton", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "uuid": "^11.1.0" + } + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/esm/bin/uuid" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..220d763d --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "uuid": "^11.1.0" + } +} diff --git a/servers/fastapi/models/pptx_models.py b/servers/fastapi/models/pptx_models.py index 768d31c1..dd42fb2a 100644 --- a/servers/fastapi/models/pptx_models.py +++ b/servers/fastapi/models/pptx_models.py @@ -61,11 +61,13 @@ class PptxFontModel(BaseModel): class PptxFillModel(BaseModel): color: str + opacity: float = 1.0 class PptxStrokeModel(BaseModel): color: str thickness: float + opacity: float = 1.0 class PptxShadowModel(BaseModel): @@ -85,6 +87,7 @@ class PptxParagraphModel(BaseModel): spacing: Optional[PptxSpacingModel] = None alignment: Optional[PP_ALIGN] = None font: Optional[PptxFontModel] = None + line_height: Optional[float] = None text: Optional[str] = None text_runs: Optional[List[PptxTextRunModel]] = None @@ -141,6 +144,7 @@ class PptxConnectorModel(PptxShapeModel): position: PptxPositionModel thickness: float = 0.5 color: str = "000000" + opacity: float = 1.0 class PptxSlideModel(BaseModel): diff --git a/servers/fastapi/services/pptx_presentation_creator.py b/servers/fastapi/services/pptx_presentation_creator.py index 53bd6fb8..1bbde2a5 100644 --- a/servers/fastapi/services/pptx_presentation_creator.py +++ b/servers/fastapi/services/pptx_presentation_creator.py @@ -10,6 +10,7 @@ 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 @@ -55,6 +56,13 @@ class PptxPresentationCreator: 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 + async def fetch_network_assets(self): image_urls = [] models_with_network_asset: List[PptxPictureBoxModel] = [] @@ -158,6 +166,8 @@ class PptxPresentationCreator: ) connector_shape.line.width = Pt(connector_model.thickness) connector_shape.line.color.rgb = RGBColor.from_string(connector_model.color) + # Set line opacity using XML manipulation for better reliability + self.set_line_opacity(connector_shape, connector_model.opacity) def add_picture(self, slide: Slide, picture_model: PptxPictureBoxModel): image_path = picture_model.picture.path @@ -252,6 +262,9 @@ class PptxPresentationCreator: 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 @@ -365,6 +378,7 @@ class PptxPresentationCreator: 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 @@ -375,6 +389,7 @@ class PptxPresentationCreator: 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 @@ -427,6 +442,19 @@ class PptxPresentationCreator: 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: diff --git a/servers/nextjs/app/api/presentation_to_pptx_model/route.ts b/servers/nextjs/app/api/presentation_to_pptx_model/route.ts index 02da924f..f855ac44 100644 --- a/servers/nextjs/app/api/presentation_to_pptx_model/route.ts +++ b/servers/nextjs/app/api/presentation_to_pptx_model/route.ts @@ -4,6 +4,9 @@ import puppeteer, { Browser, ElementHandle } from "puppeteer"; import { ElementAttributes, SlideAttributesResult } from "@/types/element_attibutes"; import { convertElementAttributesToPptxSlides } from "@/utils/pptx_models_utils"; import { PptxPresentationModel } from "@/types/pptx_models"; +import fs from "fs"; +import path from "path"; +import crypto from "crypto"; // Interface for getAllChildElementsAttributes function arguments interface GetAllChildElementsAttributesArgs { @@ -57,9 +60,13 @@ async function getPresentationId(request: NextRequest) { } async function getSlidesAttributes(slides: ElementHandle[]) { - const slideResults = await Promise.all(slides.map(async (slide) => { - return await getAllChildElementsAttributes({ element: slide }); - })); + const slideResults: SlideAttributesResult[] = []; + //? Can't use Promise.all because of the screenshot + //? taking screenshot with mess up position of elements + for (const slide of slides) { + const result = await getAllChildElementsAttributes({ element: slide }); + slideResults.push(result); + } const elements = slideResults.map(result => result.elements); const backgroundColors = slideResults.map(result => result.backgroundColor); @@ -93,9 +100,10 @@ async function getPresentationPage(browser: Browser, id: string) { page.on('console', (msg) => { const type = msg.type(); const text = msg.text(); + console.log(`${type}: ${text}`); }); - await page.setViewport({ width: 1640, height: 720, deviceScaleFactor: 1 }); + await page.setViewport({ width: 1920, height: 1080, deviceScaleFactor: 1 }); await page.goto(`http://localhost/presentation?id=${id}`, { waitUntil: "networkidle0", timeout: 60000, @@ -116,6 +124,42 @@ async function getAllChildElementsAttributes({ element, rootRect = null, depth = }; }); + // Check if this element is SVG or canvas or table + const tagName = await element.evaluate((el) => el.tagName.toLowerCase()); + + if (tagName === 'svg' || tagName === 'canvas' || tagName === 'table') { + return { + elements: [], + backgroundColor: undefined + }; + // // Take screenshot of SVG/canvas element + // const screenshotPath = await takeElementScreenshot(element); + + // // Get basic attributes for the element + // const attributes = await getElementAttributes(element); + + // // Update image source to point to the screenshot + // if (screenshotPath) { + // attributes.imageSrc = screenshotPath; + // } + + // // Adjust position relative to root + // if (attributes.position && attributes.position.left !== undefined && attributes.position.top !== undefined) { + // attributes.position = { + // left: attributes.position.left - currentRootRect.left, + // top: attributes.position.top - currentRootRect.top, + // width: attributes.position.width, + // height: attributes.position.height, + // }; + // } + + // // Return early without processing children for SVG/canvas elements + // return { + // elements: [attributes], + // backgroundColor: undefined + // }; + } + // Get direct children only (not all descendants) const directChildElementHandles = await element.$$(':scope > *'); @@ -232,7 +276,6 @@ async function getAllChildElementsAttributes({ element, rootRect = null, depth = } -// Do not edit this function, it is used to get the attributes of an element async function getElementAttributes(element: ElementHandle): Promise { const attributes = await element.evaluate((el: Element) => { @@ -333,6 +376,7 @@ async function getElementAttributes(element: ElementHandle): Promise): Promise): Promise singleLineHeight * 2; // Allow some tolerance + const hasOverflow = htmlEl.scrollHeight > htmlEl.clientHeight; + + const isMultiline = hasExplicitLineBreaks || hasTextWrapping || hasOverflow; + + // Only return line height if text is multiline + if (isMultiline && lineHeight && lineHeight !== 'normal') { + const parsedLineHeight = parseFloat(lineHeight); + if (!isNaN(parsedLineHeight)) { + return parsedLineHeight; + } + } + + return undefined; + } + function parseMargin(computedStyles: CSSStyleDeclaration) { const marginTop = parseFloat(computedStyles.marginTop); const marginBottom = parseFloat(computedStyles.marginBottom); @@ -657,6 +732,8 @@ async function getElementAttributes(element: ElementHandle): Promise): Promise): Promise): Promise { + try { + // Validate environment configuration + const tempDir = process.env.TEMP_DIRECTORY; + if (!tempDir) { + console.warn('TEMP_DIRECTORY environment variable not set, skipping screenshot'); + return undefined; + } + + // Check element visibility and dimensions + const elementInfo = await element.evaluate((el) => { + const rect = el.getBoundingClientRect(); + const styles = window.getComputedStyle(el); + + return { + isVisible: styles.visibility !== 'hidden' && + styles.display !== 'none' && + styles.opacity !== '0', + hasValidDimensions: rect.width > 0 && rect.height > 0, + isInViewport: rect.top < window.innerHeight && + rect.bottom > 0 && + rect.left < window.innerWidth && + rect.right > 0, + dimensions: { + width: rect.width, + height: rect.height, + top: rect.top, + left: rect.left + } + }; + }).catch((error) => { + console.warn('Failed to evaluate element visibility:', error.message); + return { isVisible: false, hasValidDimensions: false, isInViewport: false, dimensions: null }; + }); + + if (!elementInfo.isVisible || !elementInfo.hasValidDimensions) { + console.warn('Element is not visible or has invalid dimensions, skipping screenshot', { + visible: elementInfo.isVisible, + validDimensions: elementInfo.hasValidDimensions, + dimensions: elementInfo.dimensions + }); + return undefined; + } + + // Scroll element into viewport if not visible + if (!elementInfo.isInViewport) { + try { + await element.evaluate((el) => { + el.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'center' }); + }); + + // Wait a brief moment for scrolling to complete + await new Promise(resolve => setTimeout(resolve, 200)); + + console.log('Element scrolled into viewport for screenshot'); + } catch (scrollError: any) { + console.warn('Failed to scroll element into view:', scrollError.message); + // Continue with screenshot attempt even if scrolling fails + } + } + + // Ensure screenshots directory exists + const screenshotsDir = path.join(tempDir, 'screenshots'); + if (!fs.existsSync(screenshotsDir)) { + fs.mkdirSync(screenshotsDir, { recursive: true }); + } + + // Generate unique filename + const uuid = crypto.randomUUID(); + const filename = `${uuid}.png`; + const filePath = path.join(screenshotsDir, filename); + + // Take screenshot of the element + await element.screenshot({ + path: filePath as `${string}.png`, + type: 'png', + omitBackground: false + }); + + console.log(`Screenshot saved: ${filePath}`); + return filePath; + + } catch (error) { + console.error('Error taking element screenshot:', error); + return undefined; + } +} \ No newline at end of file diff --git a/servers/nextjs/package-lock.json b/servers/nextjs/package-lock.json index 480621bc..11b32e27 100644 --- a/servers/nextjs/package-lock.json +++ b/servers/nextjs/package-lock.json @@ -64,6 +64,7 @@ "@types/puppeteer": "^5.4.7", "@types/react": "^18", "@types/react-dom": "^18", + "@types/uuid": "^10.0.0", "cypress": "^14.3.3", "tailwindcss": "^3.4.1", "typescript": "^5" @@ -152,6 +153,15 @@ "node": ">= 6" } }, + "node_modules/@cypress/request/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@cypress/xvfb": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz", @@ -2386,6 +2396,12 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -7381,16 +7397,6 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true, - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/verror": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", diff --git a/servers/nextjs/package.json b/servers/nextjs/package.json index 27bc483e..80516abf 100644 --- a/servers/nextjs/package.json +++ b/servers/nextjs/package.json @@ -67,6 +67,7 @@ "@types/puppeteer": "^5.4.7", "@types/react": "^18", "@types/react-dom": "^18", + "@types/uuid": "^10.0.0", "cypress": "^14.3.3", "tailwindcss": "^3.4.1", "typescript": "^5" diff --git a/servers/nextjs/types/element_attibutes.ts b/servers/nextjs/types/element_attibutes.ts index 89e22a7e..df113fc1 100644 --- a/servers/nextjs/types/element_attibutes.ts +++ b/servers/nextjs/types/element_attibutes.ts @@ -10,6 +10,7 @@ export interface ElementAttributes { border?: { color?: string; width?: number; + opacity?: number; }; shadow?: { offset?: [number, number]; @@ -47,6 +48,7 @@ export interface ElementAttributes { }; zIndex?: number; textAlign?: 'left' | 'center' | 'right' | 'justify'; + lineHeight?: number; borderRadius?: number[]; imageSrc?: string; objectFit?: 'contain' | 'cover' | 'fill'; diff --git a/servers/nextjs/types/pptx_models.ts b/servers/nextjs/types/pptx_models.ts index 8b61701b..caefb0a4 100644 --- a/servers/nextjs/types/pptx_models.ts +++ b/servers/nextjs/types/pptx_models.ts @@ -236,11 +236,13 @@ export interface PptxFontModel { export interface PptxFillModel { color: string; + opacity: number; } export interface PptxStrokeModel { color: string; thickness: number; + opacity: number; } export interface PptxShadowModel { @@ -260,6 +262,7 @@ export interface PptxParagraphModel { spacing?: PptxSpacingModel; alignment?: PptxAlignment; font?: PptxFontModel; + line_height?: number; text?: string; text_runs?: PptxTextRunModel[]; } @@ -313,6 +316,7 @@ export interface PptxConnectorModel extends PptxShapeModel { position: PptxPositionModel; thickness: number; color: string; + opacity: number; } export interface PptxSlideModel { diff --git a/servers/nextjs/utils/pptx_models_utils.ts b/servers/nextjs/utils/pptx_models_utils.ts index 9186b2b9..35466f86 100644 --- a/servers/nextjs/utils/pptx_models_utils.ts +++ b/servers/nextjs/utils/pptx_models_utils.ts @@ -40,6 +40,28 @@ function convertTextAlignToPptxAlignment(textAlign?: string): PptxAlignment | un } } +/** + * Converts line height from pixels to relative format (e.g., 1.5) + * If lineHeight is already a relative number (less than 10), return as is + * Otherwise, convert from pixels to relative by dividing by font size + */ +function convertLineHeightToRelative(lineHeight?: number, fontSize?: number): number | undefined { + if (!lineHeight) return undefined; + + let calculatedLineHeight = 1.2; + // If lineHeight is already a relative number (typically between 1.0 and 3.0) + if (lineHeight < 10) { + calculatedLineHeight = lineHeight; + } + + // If we have font size, convert from pixels to relative + if (fontSize && fontSize > 0) { + calculatedLineHeight = Math.round((lineHeight / fontSize) * 100) / 100; // Round to 2 decimal places + } + + return calculatedLineHeight - 0.4 + (fontSize ?? 16) * 0.004; +} + /** * Converts ElementAttributes[][] to PptxSlideModel[] * Each inner array represents elements on a slide @@ -60,7 +82,8 @@ export function convertElementAttributesToPptxSlides( // Add background color if available if (backgroundColors && backgroundColors[index]) { slide.background = { - color: backgroundColors[index]! + color: backgroundColors[index]!, + opacity: 1.0 }; } @@ -80,7 +103,7 @@ function convertElementToPptxShape( } // Check if it's an image element - if (element.tagName === 'img' || (element.className && typeof element.className === 'string' && element.className.includes('image'))) { + if (element.tagName === 'img' || (element.className && typeof element.className === 'string' && element.className.includes('image')) || element.imageSrc) { return convertToPictureBox(element); } @@ -109,7 +132,10 @@ function convertToTextBox(element: ElementAttributes): PptxTextBoxModel { height: Math.round(element.position?.height ?? 0) }; - const fill: PptxFillModel | undefined = element.background?.color ? { color: element.background.color } : undefined; + const fill: PptxFillModel | undefined = element.background?.color ? { + color: element.background.color, + opacity: element.background.opacity ?? 1.0 + } : undefined; const font: PptxFontModel | undefined = element.font ? { name: element.font.name ?? "Inter", @@ -123,6 +149,7 @@ function convertToTextBox(element: ElementAttributes): PptxTextBoxModel { spacing: undefined, alignment: convertTextAlignToPptxAlignment(element.textAlign), font, + line_height: convertLineHeightToRelative(element.lineHeight, element.font?.size), text: element.innerText }; @@ -145,11 +172,15 @@ function convertToAutoShapeBox(element: ElementAttributes): PptxAutoShapeBoxMode width: Math.round(element.position?.width ?? 0), height: Math.round(element.position?.height ?? 0) }; - const fill: PptxFillModel | undefined = element.background?.color ? { color: element.background.color } : undefined; + const fill: PptxFillModel | undefined = element.background?.color ? { + color: element.background.color, + opacity: element.background.opacity ?? 1.0 + } : undefined; const stroke: PptxStrokeModel | undefined = element.border?.color ? { color: element.border.color, - thickness: element.border.width ?? 1 // float - keep as number + thickness: element.border.width ?? 1, // float - keep as number + opacity: element.border.opacity ?? 1.0 } : undefined; const shadow: PptxShadowModel | undefined = element.shadow?.color ? { @@ -171,6 +202,7 @@ function convertToAutoShapeBox(element: ElementAttributes): PptxAutoShapeBoxMode italic: element.font.italic ?? false, color: element.font.color ?? "000000" } : undefined, + line_height: convertLineHeightToRelative(element.lineHeight, element.font?.size), text: element.innerText }] : undefined; @@ -235,6 +267,7 @@ function convertToConnector(element: ElementAttributes): PptxConnectorModel { type: PptxConnectorType.STRAIGHT, // Default to straight connector position, thickness: element.border?.width ?? 0.5, // float - keep as number - color: element.border?.color || element.background?.color || '000000' + color: element.border?.color || element.background?.color || '000000', + opacity: element.border?.opacity ?? 1.0 }; }